From cee015e87a46d29f05dc4ab2ea7121156c3fec0e Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Wed, 21 Aug 2024 16:09:34 -0400 Subject: [PATCH 01/15] implement basic episode functionality --- spotify_player/src/cli/client.rs | 8 +- spotify_player/src/client/handlers.rs | 39 ++- spotify_player/src/client/mod.rs | 88 +++++-- spotify_player/src/client/request.rs | 1 + spotify_player/src/command.rs | 22 +- spotify_player/src/event/mod.rs | 181 +++++++++++--- spotify_player/src/event/page.rs | 18 ++ spotify_player/src/event/popup.rs | 3 + spotify_player/src/event/window.rs | 44 +++- spotify_player/src/media_control.rs | 32 ++- spotify_player/src/state/model.rs | 84 ++++++- spotify_player/src/state/player.rs | 10 +- spotify_player/src/state/ui/page.rs | 8 +- spotify_player/src/state/ui/popup.rs | 6 + spotify_player/src/streaming.rs | 61 +++-- spotify_player/src/ui/page.rs | 37 ++- spotify_player/src/ui/playback.rs | 340 ++++++++++++++++++-------- spotify_player/src/utils.rs | 9 + 18 files changed, 775 insertions(+), 216 deletions(-) diff --git a/spotify_player/src/cli/client.rs b/spotify_player/src/cli/client.rs index dcf9798b..1a61b52d 100644 --- a/spotify_player/src/cli/client.rs +++ b/spotify_player/src/cli/client.rs @@ -339,27 +339,27 @@ async fn handle_playback_request( let tracks = client.radio_tracks(sid.uri()).await?; PlayerRequest::StartPlayback( - Playback::URIs(tracks.into_iter().map(|t| t.id).collect(), None), + Playback::URIs(tracks.into_iter().map(|t| t.id.into()).collect(), None), None, ) } Command::StartLikedTracks { limit, random } => { // get a list of liked tracks' ids - let mut ids: Vec<_> = if let Some(ref state) = state { + let mut ids: Vec = if let Some(ref state) = state { state .data .read() .user_data .saved_tracks .values() - .map(|t| t.id.to_owned()) + .map(|t| t.id.to_owned().into()) .collect() } else { client .current_user_saved_tracks() .await? .into_iter() - .map(|t| t.id) + .map(|t| t.id.into()) .collect() }; diff --git a/spotify_player/src/client/handlers.rs b/spotify_player/src/client/handlers.rs index a659ec70..f9dfd573 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -46,25 +46,36 @@ fn handle_playback_change_event( handler_state: &mut PlayerEventHandlerState, ) -> anyhow::Result<()> { let player = state.player.read(); - let (playback, track) = match ( + let (playback, id, name, duration) = match ( player.buffered_playback.as_ref(), - player.current_playing_track(), + player.currently_playing(), ) { - (Some(playback), Some(track)) => (playback, track), + (Some(playback), Some(PlayableItem::Track(track))) => ( + playback, + PlayableId::Track(track.id.clone().unwrap()), + track.name.clone(), + track.duration, + ), + (Some(playback), Some(PlayableItem::Episode(episode))) => ( + playback, + PlayableId::Episode(episode.id.clone()), + episode.name.clone(), + episode.duration, + ), _ => return Ok(()), }; if let Some(progress) = player.playback_progress() { // update the playback when the current track ends - if progress >= track.duration && playback.is_playing { + if progress >= duration && playback.is_playing { client_pub.send(ClientRequest::GetCurrentPlayback)?; } } if let Some(queue) = player.queue.as_ref() { // queue needs to be updated if its playing track is different from actual playback's playing track - if let Some(PlayableItem::Track(queue_track)) = queue.currently_playing.as_ref() { - if queue_track.id != track.id { + if let Some(queue_track) = queue.currently_playing.as_ref() { + if queue_track.id().unwrap().uri() != id.uri() { client_pub.send(ClientRequest::GetCurrentUserQueue)?; } } @@ -77,16 +88,24 @@ fn handle_playback_change_event( if let Some(progress) = player.playback_progress() { // re-queue the current track if it's about to end while // ensuring that only one `AddTrackToQueue` request is made - if progress + chrono::TimeDelta::seconds(5) >= track.duration + if progress + chrono::TimeDelta::seconds(5) >= duration && playback.is_playing && handler_state.add_track_to_queue_req_timer.elapsed() > std::time::Duration::from_secs(10) { tracing::info!( "fake track repeat mode is enabled, add the current track ({}) to queue", - track.name + name ); - client_pub.send(ClientRequest::AddTrackToQueue(track.id.clone().unwrap()))?; + match id { + PlayableId::Track(id) => { + client_pub.send(ClientRequest::AddTrackToQueue(id))?; + } + + PlayableId::Episode(id) => { + client_pub.send(ClientRequest::AddEpisodeToQueue(id))?; + } + } handler_state.add_track_to_queue_req_timer = std::time::Instant::now(); } } @@ -148,7 +167,7 @@ fn handle_page_change_event( artists, scroll_offset, } => { - if let Some(current_track) = state.player.read().current_playing_track() { + if let Some(current_track) = state.player.read().currently_playing() { if current_track.name != *track { tracing::info!("Current playing track \"{}\" is different from the track \"{track}\" shown up in the lyric page. Updating the track and fetching its lyric...", current_track.name); track.clone_from(¤t_track.name); diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index f585f4c0..4ec2ec0e 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -447,6 +447,10 @@ impl Client { self.add_item_to_queue(PlayableId::Track(track_id), None) .await? } + ClientRequest::AddEpisodeToQueue(episode_id) => { + self.add_item_to_queue(PlayableId::Episode(episode_id), None) + .await? + } ClientRequest::AddTrackToPlaylist(playlist_id, track_id) => { self.add_track_to_playlist(state, playlist_id, track_id) .await?; @@ -880,14 +884,15 @@ impl Client { /// Search for items (tracks, artists, albums, playlists) matching a given query pub async fn search(&self, query: &str) -> Result { - let (track_result, artist_result, album_result, playlist_result) = tokio::try_join!( + let (track_result, artist_result, album_result, playlist_result, episode_result) = tokio::try_join!( self.search_specific_type(query, rspotify_model::SearchType::Track), self.search_specific_type(query, rspotify_model::SearchType::Artist), self.search_specific_type(query, rspotify_model::SearchType::Album), - self.search_specific_type(query, rspotify_model::SearchType::Playlist) + self.search_specific_type(query, rspotify_model::SearchType::Playlist), + self.search_specific_type(query, rspotify_model::SearchType::Episode) )?; - let (tracks, artists, albums, playlists) = ( + let (tracks, artists, albums, playlists, episodes) = ( match track_result { rspotify_model::SearchResult::Tracks(p) => p .items @@ -916,6 +921,12 @@ impl Client { } _ => anyhow::bail!("expect a playlist search result"), }, + match episode_result { + rspotify_model::SearchResult::Episodes(p) => { + p.items.into_iter().map(|i| i.into()).collect() + } + _ => anyhow::bail!("expect a episode search result"), + }, ); Ok(SearchResults { @@ -923,6 +934,7 @@ impl Client { artists, albums, playlists, + episodes, }) } @@ -1369,18 +1381,24 @@ impl Client { let playback = self.current_playback(None, None::>).await?; let mut player = state.player.write(); - let prev_track_name = player - .current_playing_track() - .map(|t| t.name.to_owned()) - .unwrap_or_default(); + let prev_track = player.currently_playing(); + + let prev_track_name = match prev_track { + Some(rspotify_model::PlayableItem::Track(track)) => track.name.to_owned(), + Some(rspotify_model::PlayableItem::Episode(episode)) => episode.name.to_owned(), + None => String::new(), + }; player.playback = playback; player.playback_last_updated_time = Some(std::time::Instant::now()); - let curr_track_name = player - .current_playing_track() - .map(|t| t.name.to_owned()) - .unwrap_or_default(); + let curr_track = player.currently_playing(); + + let curr_track_name = match curr_track { + Some(rspotify_model::PlayableItem::Track(track)) => track.name.to_owned(), + Some(rspotify_model::PlayableItem::Episode(episode)) => episode.name.to_owned(), + None => String::new(), + }; let new_track = prev_track_name != curr_track_name && !curr_track_name.is_empty(); // check if we need to update the buffered playback @@ -1422,23 +1440,45 @@ impl Client { async fn handle_new_track_event(&self, state: &SharedState) -> Result<()> { let configs = config::get_config(); - let track = match state.player.read().current_playing_track() { - None => return Ok(()), - Some(track) => track.clone(), + let track_or_episode = { + let player = state.player.read(); + let Some(track_or_episode) = player.currently_playing().clone() else { + return Ok(()); + }; + track_or_episode.clone() }; - let url = match crate::utils::get_track_album_image_url(&track) { - Some(url) => url, - None => return Ok(()), + let url = match track_or_episode { + rspotify_model::PlayableItem::Track(ref track) => { + crate::utils::get_track_album_image_url(&track) + .ok_or(anyhow::anyhow!("missing image"))? + } + rspotify_model::PlayableItem::Episode(ref episode) => { + crate::utils::get_episode_show_image_url(&episode) + .ok_or(anyhow::anyhow!("missing image"))? + } }; - let filename = format!( - "{}-{}-cover-{}.jpg", - track.album.name, - track.album.artists.first().unwrap().name, - // first 6 characters of the album's id - &track.album.id.as_ref().unwrap().id()[..6] - ) + let filename = (match track_or_episode { + rspotify_model::PlayableItem::Track(ref track) => { + format!( + "{}-{}-cover-{}.jpg", + track.album.name, + track.album.artists.first().unwrap().name, + // first 6 characters of the album's id + &track.album.id.as_ref().unwrap().id()[..6] + ) + } + rspotify_model::PlayableItem::Episode(ref episode) => { + format!( + "{}-{}-cover-{}.jpg", + episode.show.name, + episode.show.publisher, + // first 6 characters of the album's id + &episode.show.id.as_ref().id()[..6] + ) + } + }) .replace('/', ""); // remove invalid characters from the file's name let path = configs.cache_folder.join("image").join(filename); diff --git a/spotify_player/src/client/request.rs b/spotify_player/src/client/request.rs index 763ba0bc..a85289c3 100644 --- a/spotify_player/src/client/request.rs +++ b/spotify_player/src/client/request.rs @@ -38,6 +38,7 @@ pub enum ClientRequest { }, Search(String), AddTrackToQueue(TrackId<'static>), + AddEpisodeToQueue(EpisodeId<'static>), AddAlbumToQueue(AlbumId<'static>), AddTrackToPlaylist(PlaylistId<'static>, TrackId<'static>), DeleteTrackFromPlaylist(PlaylistId<'static>, TrackId<'static>), diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs index 908ee17c..8d764666 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -1,5 +1,5 @@ use crate::state::{ - Album, Artist, DataReadGuard, Playlist, PlaylistFolder, PlaylistFolderItem, Track, + Album, Artist, DataReadGuard, Episode, Playlist, PlaylistFolder, PlaylistFolderItem, Track, }; use serde::Deserialize; @@ -107,6 +107,7 @@ pub enum ActionContext { Album(Album), Artist(Artist), Playlist(Playlist), + Episode(Episode), #[allow(dead_code)] // TODO: support actions for playlist folders PlaylistFolder(PlaylistFolder), @@ -148,6 +149,12 @@ impl From for ActionContext { } } +impl From for ActionContext { + fn from(v: Episode) -> Self { + Self::Episode(v) + } +} + impl From for ActionContext { fn from(value: PlaylistFolderItem) -> Self { match value { @@ -164,6 +171,7 @@ impl ActionContext { Self::Album(album) => construct_album_actions(album, data), Self::Artist(artist) => construct_artist_actions(artist, data), Self::Playlist(playlist) => construct_playlist_actions(playlist, data), + Self::Episode(episode) => construct_episode_actions(episode, data), // TODO: support actions for playlist folders Self::PlaylistFolder(_) => vec![], } @@ -243,6 +251,18 @@ pub fn construct_playlist_actions(playlist: &Playlist, data: &DataReadGuard) -> actions } +/// constructs a list of actions on an episode +pub fn construct_episode_actions(_episode: &Episode, _data: &DataReadGuard) -> Vec { + vec![ + //TODO: implement the below + //Action::GoToShow + //Action::ShowActionsOnShow, + Action::CopyLink, + //Action::AddToPlaylist, ? + Action::AddToQueue, + ] +} + impl Command { pub fn desc(&self) -> &'static str { match self { diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index a80b53c4..a1418c16 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -61,11 +61,12 @@ fn handle_mouse_event( if event.row == rect.y { // calculate the seek position (in ms) based on the mouse click position, // the progress bar's width and the track's duration (in ms) - let duration = state - .player - .read() - .current_playing_track() - .map(|t| t.duration); + let player = state.player.read(); + let duration = match player.currently_playing() { + Some(rspotify_model::PlayableItem::Track(track)) => Some(track.duration), + Some(rspotify_model::PlayableItem::Episode(episode)) => Some(episode.duration), + None => None, + }; if let Some(duration) = duration { let position_ms = (duration.num_milliseconds()) * (event.column as i64) / (rect.width as i64); @@ -346,6 +347,96 @@ pub fn handle_action_in_context( } _ => Ok(false), }, + ActionContext::Episode(episode) => match action { + //Action::GoToShow => { + // handle_go_to_artist(track.artists, ui); + // Ok(true) + //} + Action::AddToQueue => { + client_pub.send(ClientRequest::AddEpisodeToQueue(episode.id))?; + ui.popup = None; + Ok(true) + } + Action::CopyLink => { + let episode_url = format!("https://open.spotify.com/episode/{}", episode.id.id()); + execute_copy_command(episode_url)?; + ui.popup = None; + Ok(true) + } + //Action::AddToPlaylist => { + // client_pub.send(ClientRequest::GetUserPlaylists)?; + // ui.popup = Some(PopupState::UserPlaylistList( + // PlaylistPopupAction::AddTrack { + // folder_id: 0, + // track_id: track.id, + // }, + // ListState::default(), + // )); + // Ok(true) + //} + //Action::ToggleLiked => { + // if data.user_data.is_liked_track(&track) { + // client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?; + // } else { + // client_pub.send(ClientRequest::AddToLibrary(Item::Track(track)))?; + // } + // ui.popup = None; + // Ok(true) + //} + //Action::AddToLiked => { + // client_pub.send(ClientRequest::AddToLibrary(Item::Track(track)))?; + // ui.popup = None; + // Ok(true) + //} + //Action::DeleteFromLiked => { + // client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?; + // ui.popup = None; + // Ok(true) + //} + //Action::GoToRadio => { + // let uri = track.id.uri(); + // let name = track.name; + // ui.new_radio_page(&uri); + // client_pub.send(ClientRequest::GetRadioTracks { + // seed_uri: uri, + // seed_name: name, + // })?; + // Ok(true) + //} + //Action::ShowActionsOnArtist => { + // handle_show_actions_on_artist(track.artists, data, ui); + // Ok(true) + //} + //Action::ShowActionsOnAlbum => { + // if let Some(album) = track.album { + // let context = ActionContext::Album(album.clone()); + // ui.popup = Some(PopupState::ActionList( + // Box::new(ActionListItem::Album( + // album, + // context.get_available_actions(data), + // )), + // ListState::default(), + // )); + // return Ok(true); + // } + // Ok(false) + //} + //Action::DeleteFromPlaylist => { + // if let PageState::Context { + // id: Some(ContextId::Playlist(playlist_id)), + // .. + // } = ui.current_page() + // { + // client_pub.send(ClientRequest::DeleteTrackFromPlaylist( + // playlist_id.clone_static(), + // track.id, + // ))?; + // } + // ui.popup = None; + // Ok(true) + //} + _ => Ok(false), + }, // TODO: support actions for playlist folders ActionContext::PlaylistFolder(_) => Ok(false), } @@ -401,15 +492,28 @@ fn handle_global_action( let player = state.player.read(); let data = state.data.read(); - if let Some(currently_playing) = player.current_playing_track() { - if let Some(track) = Track::try_from_full_track(currently_playing.clone()) { - return handle_action_in_context( - action, - ActionContext::Track(track), - client_pub, - &data, - ui, - ); + if let Some(currently_playing) = player.currently_playing() { + match currently_playing { + rspotify_model::PlayableItem::Track(track) => { + if let Some(track) = Track::try_from_full_track(track.clone()) { + return handle_action_in_context( + action, + ActionContext::Track(track), + client_pub, + &data, + ui, + ); + } + } + rspotify_model::PlayableItem::Episode(episode) => { + return handle_action_in_context( + action, + ActionContext::Episode(episode.clone().into()), + client_pub, + &data, + ui, + ); + } } } }; @@ -494,14 +598,27 @@ fn handle_global_command( client_pub.send(ClientRequest::GetCurrentPlayback)?; } Command::ShowActionsOnCurrentTrack => { - if let Some(track) = state.player.read().current_playing_track() { - if let Some(track) = Track::try_from_full_track(track.clone()) { - let data = state.data.read(); - let actions = command::construct_track_actions(&track, &data); - ui.popup = Some(PopupState::ActionList( - Box::new(ActionListItem::Track(track, actions)), - ListState::default(), - )); + if let Some(currently_playing) = state.player.read().currently_playing() { + match currently_playing { + rspotify_model::PlayableItem::Track(track) => { + if let Some(track) = Track::try_from_full_track(track.clone()) { + let data = state.data.read(); + let actions = command::construct_track_actions(&track, &data); + ui.popup = Some(PopupState::ActionList( + Box::new(ActionListItem::Track(track, actions)), + ListState::default(), + )); + } + } + rspotify_model::PlayableItem::Episode(episode) => { + let episode = episode.clone().into(); + let data = state.data.read(); + let actions = command::construct_episode_actions(&episode, &data); + ui.popup = Some(PopupState::ActionList( + Box::new(ActionListItem::Episode(episode, actions)), + ListState::default(), + )); + } } } } @@ -596,7 +713,7 @@ fn handle_global_command( "track" => { let id = TrackId::from_id(id)?.into_static(); client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( - Playback::URIs(vec![id], None), + Playback::URIs(vec![id.into()], None), None, )))?; } @@ -633,7 +750,7 @@ fn handle_global_command( } #[cfg(feature = "lyric-finder")] Command::LyricPage => { - if let Some(track) = state.player.read().current_playing_track() { + if let Some(track) = state.player.read().currently_playing() { let artists = map_join(&track.artists, |a| &a.name, ", "); ui.new_page(PageState::Lyric { track: track.name.clone(), @@ -688,13 +805,13 @@ fn handle_global_command( }); } Command::JumpToCurrentTrackInContext => { - let track_id = match state - .player - .read() - .current_playing_track() - .and_then(|track| track.id.clone()) - { - Some(id) => id, + let track_id = match state.player.read().currently_playing() { + Some(rspotify_model::PlayableItem::Track(track)) => { + PlayableId::Track(track.id.clone().unwrap()) + } + Some(rspotify_model::PlayableItem::Episode(episode)) => { + PlayableId::Episode(episode.id.clone()) + } None => return Ok(false), }; @@ -707,7 +824,7 @@ fn handle_global_command( .data .read() .context_tracks(context_id) - .and_then(|tracks| tracks.iter().position(|t| t.id == track_id)); + .and_then(|tracks| tracks.iter().position(|t| t.id.uri() == track_id.uri())); if let Some(p) = context_track_pos { ui.current_page_mut().select(p); diff --git a/spotify_player/src/event/page.rs b/spotify_player/src/event/page.rs index 87800612..7ae6d09b 100644 --- a/spotify_player/src/event/page.rs +++ b/spotify_player/src/event/page.rs @@ -254,6 +254,24 @@ fn handle_key_sequence_for_search_page( _ => Ok(false), } } + SearchFocusState::Episodes => { + let episodes = match search_results { + Some(s) => s.episodes.iter().collect(), + None => Vec::new(), + }; + + match found_keymap { + CommandOrAction::Command(command) => { + window::handle_command_for_episode_list_window( + command, client_pub, episodes, &data, ui, + ) + } + CommandOrAction::Action(action, ActionTarget::SelectedItem) => { + window::handle_action_for_selected_item(action, episodes, &data, ui, client_pub) + } + _ => Ok(false), + } + } } } diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs index ea78c0c3..45e04233 100644 --- a/spotify_player/src/event/popup.rs +++ b/spotify_player/src/event/popup.rs @@ -501,5 +501,8 @@ pub fn handle_item_action( ActionListItem::Playlist(playlist, actions) => { handle_action_in_context(actions[n], playlist.into(), client_pub, &data, ui) } + ActionListItem::Episode(episode, actions) => { + handle_action_in_context(actions[n], episode.into(), client_pub, &data, ui) + } } } diff --git a/spotify_player/src/event/window.rs b/spotify_player/src/event/window.rs index 49d89925..ecadab5c 100644 --- a/spotify_player/src/event/window.rs +++ b/spotify_player/src/event/window.rs @@ -309,7 +309,10 @@ fn handle_command_for_track_table_window( let base_playback = if let Some(context_id) = context_id { Playback::Context(context_id, None) } else { - Playback::URIs(tracks.iter().map(|t| t.id.clone_static()).collect(), None) + Playback::URIs( + tracks.iter().map(|t| t.id.clone_static().into()).collect(), + None, + ) }; client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( @@ -358,7 +361,7 @@ pub fn handle_command_for_track_list_window( // `ChooseSelected` by starting a `URIs` playback // containing all the tracks in the table. client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( - Playback::URIs(vec![tracks[id].id.clone()], None), + Playback::URIs(vec![tracks[id].id.clone().into()], None), None, )))?; } @@ -503,3 +506,40 @@ pub fn handle_command_for_playlist_list_window( } Ok(true) } + +pub fn handle_command_for_episode_list_window( + command: Command, + client_pub: &flume::Sender, + episodes: Vec<&Episode>, + data: &DataReadGuard, + ui: &mut UIStateGuard, +) -> Result { + let id = ui.current_page_mut().selected().unwrap_or_default(); + if id >= episodes.len() { + return Ok(false); + } + + if handle_navigation_command(command, ui.current_page_mut(), id, episodes.len()) { + return Ok(true); + } + match command { + Command::ChooseSelected => { + client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( + Playback::URIs(vec![episodes[id].id.clone().into()], None), + None, + )))?; + } + Command::ShowActionsOnSelectedItem => { + let actions = command::construct_episode_actions(episodes[id], data); + ui.popup = Some(PopupState::ActionList( + Box::new(ActionListItem::Episode(episodes[id].clone(), actions)), + ListState::default(), + )); + } + //Command::AddSelectedItemToQueue => { + // client_pub.send(ClientRequest::AddEpisodeToQueue(episodes[id].id.clone()))?; + //} + _ => return Ok(false), + } + Ok(true) +} diff --git a/spotify_player/src/media_control.rs b/spotify_player/src/media_control.rs index 94783d04..30a59df3 100644 --- a/spotify_player/src/media_control.rs +++ b/spotify_player/src/media_control.rs @@ -1,4 +1,5 @@ #![allow(unused_imports)] +use rspotify::model as rspotify_model; use souvlaki::MediaPosition; use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig}; @@ -16,9 +17,9 @@ fn update_control_metadata( ) -> Result<(), souvlaki::Error> { let player = state.player.read(); - match player.current_playing_track() { + match player.currently_playing() { None => {} - Some(track) => { + Some(rspotify_model::PlayableItem::Track(track)) => { if let Some(ref playback) = player.playback { let progress = player .playback_progress() @@ -45,6 +46,33 @@ fn update_control_metadata( *prev_track_info = track_info; } } + Some(rspotify_model::PlayableItem::Episode(episode)) => { + if let Some(ref playback) = player.playback { + let progress = player + .playback_progress() + .and_then(|p| Some(MediaPosition(p.to_std().ok()?))); + + if playback.is_playing { + controls.set_playback(MediaPlayback::Playing { progress })?; + } else { + controls.set_playback(MediaPlayback::Paused { progress })?; + } + } + + // only update metadata when the episode information is changed + let episode_info = format!("{}/{}", episode.name, episode.show.name); + if episode_info != *prev_track_info { + controls.set_metadata(MediaMetadata { + title: Some(&episode.name), + album: Some(&episode.show.name), + artist: Some(&episode.show.publisher), + duration: episode.duration.to_std().ok(), + cover_url: utils::get_episode_show_image_url(episode), + })?; + + *prev_track_info = episode_info; + } + } } Ok(()) diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index 61754edb..c4e8770e 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -1,6 +1,8 @@ pub use rspotify::model as rspotify_model; use rspotify::model::CurrentPlaybackContext; -pub use rspotify::model::{AlbumId, AlbumType, ArtistId, Id, PlaylistId, TrackId, UserId}; +pub use rspotify::model::{ + AlbumId, AlbumType, ArtistId, EpisodeId, Id, PlayableId, PlaylistId, TrackId, UserId, +}; use crate::utils::map_join; use html_escape::decode_html_entities; @@ -46,7 +48,6 @@ pub enum ContextId { Tracks(TracksId), } -#[derive(Clone, Debug)] /// Data used to start a new playback. /// There are two ways to start a new playback: /// - Specify the playing context ID with an offset @@ -55,7 +56,42 @@ pub enum ContextId { /// An offset can be either a track's URI or its absolute offset in the context pub enum Playback { Context(ContextId, Option), - URIs(Vec>, Option), + URIs(Vec>, Option), +} + +impl std::fmt::Debug for Playback { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Context(context_id, offset) => f + .debug_tuple("Playback::Context") + .field(context_id) + .field(offset) + .finish(), + Self::URIs(playable_ids, offset) => { + write!(f, "Playback::URIs([")?; + for id in playable_ids.iter() { + match id { + PlayableId::Track(track_id) => write!(f, "{}, ", track_id)?, + PlayableId::Episode(episode_id) => write!(f, "{}, ", episode_id)?, + } + } + write!(f, "], ")?; + write!(f, "{:?})", offset) + } + } + } +} + +impl Clone for Playback { + fn clone(&self) -> Self { + match self { + Self::Context(context_id, offset) => Self::Context(context_id.clone(), offset.clone()), + Self::URIs(playable_ids, offset) => Self::URIs( + playable_ids.into_iter().map(|x| x.clone_static()).collect(), + offset.clone(), + ), + } + } } #[derive(Default, Clone, Debug, Deserialize, Serialize)] @@ -65,6 +101,7 @@ pub struct SearchResults { pub artists: Vec, pub albums: Vec, pub playlists: Vec, + pub episodes: Vec, } #[derive(Debug)] @@ -160,6 +197,15 @@ pub struct Playlist { pub current_folder_id: usize, } +#[derive(Deserialize, Serialize, Debug, Clone)] +/// A Spotify playlist +pub struct Episode { + pub id: EpisodeId<'static>, + pub name: String, + pub description: String, + pub duration: std::time::Duration, +} + #[derive(Deserialize, Serialize, Debug, Clone)] /// A playlist folder, not related to Spotify API yet pub struct PlaylistFolder { @@ -475,12 +521,40 @@ impl From for Playlist { } } +impl From for Episode { + fn from(episode: rspotify_model::SimplifiedEpisode) -> Self { + Self { + id: episode.id, + name: episode.name, + description: episode.description, + duration: episode.duration.to_std().expect("valid chrono duration"), + } + } +} + +impl From for Episode { + fn from(episode: rspotify_model::FullEpisode) -> Self { + Self { + id: episode.id, + name: episode.name, + description: episode.description, + duration: episode.duration.to_std().expect("valid chrono duration"), + } + } +} + impl std::fmt::Display for Playlist { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} • {}", self.name, self.owner.0) } } +impl std::fmt::Display for Episode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name,) + } +} + impl std::fmt::Display for PlaylistFolder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}/", self.name) @@ -533,7 +607,7 @@ impl Playback { } Playback::URIs(ids, _) => { let ids = if ids.len() < limit { - ids.clone() + ids.into_iter().map(|x| x.clone_static()).collect() } else { let pos = ids .iter() @@ -545,7 +619,7 @@ impl Playback { // API request, we restrict the range of tracks to be played, which is based on the // playing track's position (if any) and the application's limit (`app_config.tracks_playback_limit`). // Related issue: https://github.com/aome510/spotify-player/issues/78 - ids[l..r].to_vec() + ids[l..r].into_iter().map(|x| x.clone_static()).collect() }; Playback::URIs(ids, Some(rspotify_model::Offset::Uri(uri))) diff --git a/spotify_player/src/state/player.rs b/spotify_player/src/state/player.rs index 34356a0a..3a2958fb 100644 --- a/spotify_player/src/state/player.rs +++ b/spotify_player/src/state/player.rs @@ -46,14 +46,8 @@ impl PlayerState { Some(playback) } - pub fn current_playing_track(&self) -> Option<&rspotify_model::FullTrack> { - match self.playback { - None => None, - Some(ref playback) => match playback.item { - Some(rspotify::model::PlayableItem::Track(ref track)) => Some(track), - _ => None, - }, - } + pub fn currently_playing(&self) -> Option<&rspotify_model::PlayableItem> { + self.playback.as_ref().map(|p| p.item.as_ref()).flatten() } pub fn playback_progress(&self) -> Option { diff --git a/spotify_player/src/state/ui/page.rs b/spotify_player/src/state/ui/page.rs index 0e4e783d..263de06f 100644 --- a/spotify_player/src/state/ui/page.rs +++ b/spotify_player/src/state/ui/page.rs @@ -60,6 +60,7 @@ pub struct SearchPageUIState { pub album_list: ListState, pub artist_list: ListState, pub playlist_list: ListState, + pub episode_list: ListState, pub focus: SearchFocusState, } @@ -109,6 +110,7 @@ pub enum SearchFocusState { Albums, Artists, Playlists, + Episodes, } #[derive(Clone, Debug)] @@ -182,6 +184,7 @@ impl PageState { album_list, artist_list, playlist_list, + episode_list, focus, }, .. @@ -191,6 +194,7 @@ impl PageState { SearchFocusState::Albums => Some(MutableWindowState::List(album_list)), SearchFocusState::Artists => Some(MutableWindowState::List(artist_list)), SearchFocusState::Playlists => Some(MutableWindowState::List(playlist_list)), + SearchFocusState::Episodes => Some(MutableWindowState::List(episode_list)), }, Self::Context { state, .. } => state.as_mut().map(|state| match state { ContextPageUIState::Tracks { track_table } => { @@ -247,6 +251,7 @@ impl SearchPageUIState { album_list: ListState::default(), artist_list: ListState::default(), playlist_list: ListState::default(), + episode_list: ListState::default(), focus: SearchFocusState::Input, } } @@ -410,5 +415,6 @@ impl_focusable!( [Tracks, Albums], [Albums, Artists], [Artists, Playlists], - [Playlists, Input] + [Playlists, Episodes], + [Episodes, Input] ); diff --git a/spotify_player/src/state/ui/popup.rs b/spotify_player/src/state/ui/popup.rs index af774ddd..9b3ad836 100644 --- a/spotify_player/src/state/ui/popup.rs +++ b/spotify_player/src/state/ui/popup.rs @@ -32,6 +32,7 @@ pub enum ActionListItem { Artist(Artist, Vec), Album(Album, Vec), Playlist(Playlist, Vec), + Episode(Episode, Vec), } /// An action on an item in a playlist popup list @@ -106,6 +107,7 @@ impl ActionListItem { ActionListItem::Artist(.., actions) => actions.len(), ActionListItem::Album(.., actions) => actions.len(), ActionListItem::Playlist(.., actions) => actions.len(), + ActionListItem::Episode(.., actions) => actions.len(), } } @@ -115,6 +117,7 @@ impl ActionListItem { ActionListItem::Artist(artist, ..) => &artist.name, ActionListItem::Album(album, ..) => &album.name, ActionListItem::Playlist(playlist, ..) => &playlist.name, + ActionListItem::Episode(episode, ..) => &episode.name, } } @@ -132,6 +135,9 @@ impl ActionListItem { ActionListItem::Playlist(.., actions) => { actions.iter().map(|a| format!("{a:?}")).collect::>() } + ActionListItem::Episode(.., actions) => { + actions.iter().map(|a| format!("{a:?}")).collect::>() + } } } } diff --git a/spotify_player/src/streaming.rs b/spotify_player/src/streaming.rs index 5d159ac5..63638e31 100644 --- a/spotify_player/src/streaming.rs +++ b/spotify_player/src/streaming.rs @@ -11,8 +11,7 @@ use librespot_playback::{ mixer::{self, Mixer}, player, }; -use rspotify::model::TrackId; -use serde::Serialize; +use rspotify::model::{EpisodeId, Id, PlayableId, TrackId}; #[cfg(not(any( feature = "rodio-backend", @@ -36,24 +35,23 @@ compile_error!("Streaming feature is enabled but no audio backend has been selec For more information, visit https://github.com/aome510/spotify-player?tab=readme-ov-file#streaming "); -#[derive(Debug, Serialize)] enum PlayerEvent { Changed { - old_track_id: TrackId<'static>, - new_track_id: TrackId<'static>, + old_playable_id: PlayableId<'static>, + new_playable_id: PlayableId<'static>, }, Playing { - track_id: TrackId<'static>, + playable_id: PlayableId<'static>, position_ms: u32, duration_ms: u32, }, Paused { - track_id: TrackId<'static>, + playable_id: PlayableId<'static>, position_ms: u32, duration_ms: u32, }, EndOfTrack { - track_id: TrackId<'static>, + playable_id: PlayableId<'static>, }, } @@ -62,43 +60,52 @@ impl PlayerEvent { pub fn args(&self) -> Vec { match self { PlayerEvent::Changed { - old_track_id, - new_track_id, + old_playable_id, + new_playable_id, } => vec![ "Changed".to_string(), - old_track_id.to_string(), - new_track_id.to_string(), + old_playable_id.uri(), + new_playable_id.uri(), ], PlayerEvent::Playing { - track_id, + playable_id, position_ms, duration_ms, } => vec![ "Playing".to_string(), - track_id.to_string(), + playable_id.uri(), position_ms.to_string(), duration_ms.to_string(), ], PlayerEvent::Paused { - track_id, + playable_id, position_ms, duration_ms, } => vec![ "Paused".to_string(), - track_id.to_string(), + playable_id.uri(), position_ms.to_string(), duration_ms.to_string(), ], - PlayerEvent::EndOfTrack { track_id } => { - vec!["EndOfTrack".to_string(), track_id.to_string()] + PlayerEvent::EndOfTrack { playable_id } => { + vec!["EndOfTrack".to_string(), playable_id.uri()] } } } } -fn spotify_id_to_track_id(id: spotify_id::SpotifyId) -> anyhow::Result> { - let uri = id.to_uri()?; - Ok(TrackId::from_uri(&uri)?.into_static()) +fn spotify_id_to_playable_id(id: spotify_id::SpotifyId) -> anyhow::Result> { + match id.audio_type { + spotify_id::SpotifyAudioType::Track => { + let uri = id.to_uri()?; + Ok(TrackId::from_uri(&uri)?.into_static().into()) + } + spotify_id::SpotifyAudioType::Podcast => { + let uri = id.to_uri()?; + Ok(EpisodeId::from_uri(&uri)?.into_static().into()) + } + _ => anyhow::bail!("unexpected spotify_id {:?}", id), + } } impl PlayerEvent { @@ -108,8 +115,8 @@ impl PlayerEvent { old_track_id, new_track_id, } => Some(PlayerEvent::Changed { - old_track_id: spotify_id_to_track_id(old_track_id)?, - new_track_id: spotify_id_to_track_id(new_track_id)?, + old_playable_id: spotify_id_to_playable_id(old_track_id)?, + new_playable_id: spotify_id_to_playable_id(new_track_id)?, }), player::PlayerEvent::Playing { track_id, @@ -117,7 +124,7 @@ impl PlayerEvent { duration_ms, .. } => Some(PlayerEvent::Playing { - track_id: spotify_id_to_track_id(track_id)?, + playable_id: spotify_id_to_playable_id(track_id)?, position_ms, duration_ms, }), @@ -127,12 +134,12 @@ impl PlayerEvent { duration_ms, .. } => Some(PlayerEvent::Paused { - track_id: spotify_id_to_track_id(track_id)?, + playable_id: spotify_id_to_playable_id(track_id)?, position_ms, duration_ms, }), player::PlayerEvent::EndOfTrack { track_id, .. } => Some(PlayerEvent::EndOfTrack { - track_id: spotify_id_to_track_id(track_id)?, + playable_id: spotify_id_to_playable_id(track_id)?, }), _ => None, }) @@ -207,7 +214,7 @@ pub async fn new_connection(client: Client, state: SharedState) -> Spirc { tracing::warn!("Failed to convert a `librespot` player event into `spotify_player` player event: {err:#}"); } Ok(Some(event)) => { - tracing::info!("Got a new player event: {event:?}"); + tracing::info!("Got a new player event"); match event { PlayerEvent::Playing { .. } => { let mut player = state.player.write(); diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index d88ce461..c55fbed6 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -47,7 +47,7 @@ pub fn render_search_page( let rect = chunks[1]; // track/album/artist/playlist search results layout (2x2 table) - let chunks = Layout::vertical([Constraint::Ratio(1, 2); 2]) + let chunks = Layout::vertical([Constraint::Ratio(1, 3); 3]) .split(rect) .iter() .flat_map(|rect| { @@ -75,6 +75,15 @@ pub fn render_search_page( ); let playlist_rect = construct_and_render_block("Playlists", &ui.theme, Borders::TOP, frame, chunks[3]); + let _show_rect = construct_and_render_block( + "Shows", + &ui.theme, + Borders::TOP | Borders::RIGHT, + frame, + chunks[4], + ); + let episode_rect = + construct_and_render_block("Episodes", &ui.theme, Borders::TOP, frame, chunks[5]); // 3. Construct the page's widgets let (track_list, n_tracks) = { @@ -142,6 +151,23 @@ pub fn render_search_page( utils::construct_list_widget(&ui.theme, playlist_items, is_active) }; + // TODO: show + + let (episode_list, n_episodes) = { + let episode_items = search_results + .map(|s| { + s.episodes + .iter() + .map(|e| (format!("{}", e.to_string()), false)) + .collect::>() + }) + .unwrap_or_default(); + + let is_active = is_active && focus_state == SearchFocusState::Episodes; + + utils::construct_list_widget(&ui.theme, episode_items, is_active) + }; + // 4. Render the page's widgets // Render the query input box frame.render_widget( @@ -183,6 +209,13 @@ pub fn render_search_page( n_playlists, &mut page_state.playlist_list, ); + utils::render_list_window( + frame, + episode_list, + episode_rect, + n_episodes, + &mut page_state.episode_list, + ); } pub fn render_context_page( @@ -634,7 +667,7 @@ pub fn render_queue_page( .map(|a| a.name.as_str()) .collect::>() .join(", "), - PlayableItem::Episode(FullEpisode { .. }) => String::new(), + PlayableItem::Episode(FullEpisode { ref show, .. }) => show.publisher.clone(), } } fn get_playable_duration(item: &PlayableItem) -> String { diff --git a/spotify_player/src/ui/playback.rs b/spotify_player/src/ui/playback.rs index 4db72f6b..b8b1a3b1 100644 --- a/spotify_player/src/ui/playback.rs +++ b/spotify_player/src/ui/playback.rs @@ -16,102 +16,224 @@ pub fn render_playback_window( let player = state.player.read(); if let Some(ref playback) = player.playback { - if let Some(rspotify::model::PlayableItem::Track(ref track)) = playback.item { - let (metadata_rect, progress_bar_rect) = { - // allocate the progress bar rect - let (rect, progress_bar_rect) = { - let chunks = - Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]).split(rect); - - (chunks[0], chunks[1]) + match playback.item { + Some(rspotify::model::PlayableItem::Track(ref track)) => { + let (metadata_rect, progress_bar_rect) = { + // allocate the progress bar rect + let (rect, progress_bar_rect) = { + let chunks = Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]) + .split(rect); + + (chunks[0], chunks[1]) + }; + + let metadata_rect = { + // Render the track's cover image if `image` feature is enabled + #[cfg(feature = "image")] + { + let configs = config::get_config(); + // Split the allocated rectangle into `metadata_rect` and `cover_img_rect` + let (metadata_rect, cover_img_rect) = { + let hor_chunks = Layout::horizontal([ + Constraint::Length(configs.app_config.cover_img_length as u16), + Constraint::Fill(0), // metadata_rect + ]) + .spacing(1) + .split(rect); + let ver_chunks = Layout::vertical([ + Constraint::Length(configs.app_config.cover_img_width as u16), // cover_img_rect + Constraint::Fill(0), // empty space + ]) + .split(hor_chunks[0]); + + (hor_chunks[1], ver_chunks[0]) + }; + + let url = + crate::utils::get_track_album_image_url(track).map(String::from); + if let Some(url) = url { + let needs_clear = if ui.last_cover_image_render_info.url != url + || ui.last_cover_image_render_info.render_area != cover_img_rect + { + ui.last_cover_image_render_info = ImageRenderInfo { + url, + render_area: cover_img_rect, + rendered: false, + }; + true + } else { + false + }; + + if needs_clear { + // clear the image's both new and old areas to ensure no remaining artifacts before rendering the image + // See: https://github.com/aome510/spotify-player/issues/389 + clear_area(frame, ui.last_cover_image_render_info.render_area); + clear_area(frame, cover_img_rect); + } else { + if !ui.last_cover_image_render_info.rendered { + if let Err(err) = render_playback_cover_image(state, ui) { + tracing::error!( + "Failed to render playback's cover image: {err:#}" + ); + } + } + + // set the `skip` state of cells in the cover image area + // to prevent buffer from overwriting the image's rendered area + // NOTE: `skip` should not be set when clearing the render area. + // Otherwise, nothing will be clear as the buffer doesn't handle cells with `skip=true`. + for x in cover_img_rect.left()..cover_img_rect.right() { + for y in cover_img_rect.top()..cover_img_rect.bottom() { + frame.buffer_mut().get_mut(x, y).set_skip(true); + } + } + } + } + + metadata_rect + } + + #[cfg(not(feature = "image"))] + { + rect + } + }; + + (metadata_rect, progress_bar_rect) }; - let metadata_rect = { - // Render the track's cover image if `image` feature is enabled - #[cfg(feature = "image")] - { - let configs = config::get_config(); - // Split the allocated rectangle into `metadata_rect` and `cover_img_rect` - let (metadata_rect, cover_img_rect) = { - let hor_chunks = Layout::horizontal([ - Constraint::Length(configs.app_config.cover_img_length as u16), - Constraint::Fill(0), // metadata_rect - ]) - .spacing(1) + if let Some(ref playback) = player.buffered_playback { + let playback_text = construct_playback_text( + ui, + &rspotify_model::PlayableItem::Track(track.clone()), + playback, + ); + let playback_desc = Paragraph::new(playback_text); + frame.render_widget(playback_desc, metadata_rect); + } + + let progress = std::cmp::min( + player.playback_progress().expect("non-empty playback"), + track.duration, + ); + render_playback_progress_bar( + frame, + ui, + progress, + track.duration, + progress_bar_rect, + ); + } + Some(rspotify::model::PlayableItem::Episode(ref episode)) => { + let (metadata_rect, progress_bar_rect) = { + // allocate the progress bar rect + let (rect, progress_bar_rect) = { + let chunks = Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]) .split(rect); - let ver_chunks = Layout::vertical([ - Constraint::Length(configs.app_config.cover_img_width as u16), // cover_img_rect - Constraint::Fill(0), // empty space - ]) - .split(hor_chunks[0]); - - (hor_chunks[1], ver_chunks[0]) - }; - - let url = crate::utils::get_track_album_image_url(track).map(String::from); - if let Some(url) = url { - let needs_clear = if ui.last_cover_image_render_info.url != url - || ui.last_cover_image_render_info.render_area != cover_img_rect - { - ui.last_cover_image_render_info = ImageRenderInfo { - url, - render_area: cover_img_rect, - rendered: false, - }; - true - } else { - false + + (chunks[0], chunks[1]) + }; + + let metadata_rect = { + // Render the track's cover image if `image` feature is enabled + #[cfg(feature = "image")] + { + let configs = config::get_config(); + // Split the allocated rectangle into `metadata_rect` and `cover_img_rect` + let (metadata_rect, cover_img_rect) = { + let hor_chunks = Layout::horizontal([ + Constraint::Length(configs.app_config.cover_img_length as u16), + Constraint::Fill(0), // metadata_rect + ]) + .spacing(1) + .split(rect); + let ver_chunks = Layout::vertical([ + Constraint::Length(configs.app_config.cover_img_width as u16), // cover_img_rect + Constraint::Fill(0), // empty space + ]) + .split(hor_chunks[0]); + + (hor_chunks[1], ver_chunks[0]) }; - if needs_clear { - // clear the image's both new and old areas to ensure no remaining artifacts before rendering the image - // See: https://github.com/aome510/spotify-player/issues/389 - clear_area(frame, ui.last_cover_image_render_info.render_area); - clear_area(frame, cover_img_rect); - } else { - if !ui.last_cover_image_render_info.rendered { - if let Err(err) = render_playback_cover_image(state, ui) { - tracing::error!( - "Failed to render playback's cover image: {err:#}" - ); + let url = + crate::utils::get_episode_show_image_url(episode).map(String::from); + if let Some(url) = url { + let needs_clear = if ui.last_cover_image_render_info.url != url + || ui.last_cover_image_render_info.render_area != cover_img_rect + { + ui.last_cover_image_render_info = ImageRenderInfo { + url, + render_area: cover_img_rect, + rendered: false, + }; + true + } else { + false + }; + + if needs_clear { + // clear the image's both new and old areas to ensure no remaining artifacts before rendering the image + // See: https://github.com/aome510/spotify-player/issues/389 + clear_area(frame, ui.last_cover_image_render_info.render_area); + clear_area(frame, cover_img_rect); + } else { + if !ui.last_cover_image_render_info.rendered { + if let Err(err) = render_playback_cover_image(state, ui) { + tracing::error!( + "Failed to render playback's cover image: {err:#}" + ); + } } - } - // set the `skip` state of cells in the cover image area - // to prevent buffer from overwriting the image's rendered area - // NOTE: `skip` should not be set when clearing the render area. - // Otherwise, nothing will be clear as the buffer doesn't handle cells with `skip=true`. - for x in cover_img_rect.left()..cover_img_rect.right() { - for y in cover_img_rect.top()..cover_img_rect.bottom() { - frame.buffer_mut().get_mut(x, y).set_skip(true); + // set the `skip` state of cells in the cover image area + // to prevent buffer from overwriting the image's rendered area + // NOTE: `skip` should not be set when clearing the render area. + // Otherwise, nothing will be clear as the buffer doesn't handle cells with `skip=true`. + for x in cover_img_rect.left()..cover_img_rect.right() { + for y in cover_img_rect.top()..cover_img_rect.bottom() { + frame.buffer_mut().get_mut(x, y).set_skip(true); + } } } } + + metadata_rect } - metadata_rect - } + #[cfg(not(feature = "image"))] + { + rect + } + }; - #[cfg(not(feature = "image"))] - { - rect - } + (metadata_rect, progress_bar_rect) }; - (metadata_rect, progress_bar_rect) - }; + if let Some(ref playback) = player.buffered_playback { + let playback_text = construct_playback_text( + ui, + &rspotify_model::PlayableItem::Episode(episode.clone()), + playback, + ); + let playback_desc = Paragraph::new(playback_text); + frame.render_widget(playback_desc, metadata_rect); + } - if let Some(ref playback) = player.buffered_playback { - let playback_text = construct_playback_text(ui, track, playback); - let playback_desc = Paragraph::new(playback_text); - frame.render_widget(playback_desc, metadata_rect); + let progress = std::cmp::min( + player.playback_progress().expect("non-empty playback"), + episode.duration, + ); + render_playback_progress_bar( + frame, + ui, + progress, + episode.duration, + progress_bar_rect, + ); } - - let progress = std::cmp::min( - player.playback_progress().expect("non-empty playback"), - track.duration, - ); - render_playback_progress_bar(frame, ui, progress, track, progress_bar_rect); + None => (), } } else { // Previously rendered image can result in a weird rendering text, @@ -149,7 +271,7 @@ fn clear_area(frame: &mut Frame, rect: Rect) { fn construct_playback_text( ui: &UIStateGuard, - track: &rspotify_model::FullTrack, + playable: &rspotify_model::PlayableItem, playback: &PlaybackMetadata, ) -> Text<'static> { // Construct a "styled" text (`playback_text`) from playback's data @@ -189,19 +311,42 @@ fn construct_playback_text( .to_owned(), ui.theme.playback_status(), ), - "{track}" => ( - if track.explicit { - format!("{} (E)", track.name) - } else { - track.name.clone() - }, - ui.theme.playback_track(), - ), - "{artists}" => ( - crate::utils::map_join(&track.artists, |a| &a.name, ", "), - ui.theme.playback_artists(), - ), - "{album}" => (track.album.name.to_owned(), ui.theme.playback_album()), + "{track}" => match playable { + rspotify_model::PlayableItem::Track(track) => ( + if track.explicit { + format!("{} (E)", track.name) + } else { + track.name.clone() + }, + ui.theme.playback_track(), + ), + rspotify_model::PlayableItem::Episode(episode) => ( + if episode.explicit { + format!("{} (E)", episode.name) + } else { + episode.name.clone() + }, + ui.theme.playback_track(), + ), + }, + "{artists}" => match playable { + rspotify_model::PlayableItem::Track(track) => ( + crate::utils::map_join(&track.artists, |a| &a.name, ", "), + ui.theme.playback_artists(), + ), + rspotify_model::PlayableItem::Episode(episode) => ( + episode.show.publisher.to_owned(), + ui.theme.playback_artists(), + ), + }, + "{album}" => match playable { + rspotify_model::PlayableItem::Track(track) => { + (track.album.name.to_owned(), ui.theme.playback_album()) + } + rspotify_model::PlayableItem::Episode(episode) => { + (episode.show.name.to_owned(), ui.theme.playback_album()) + } + }, "{metadata}" => ( format!( "repeat: {} | shuffle: {} | volume: {} | device: {}", @@ -238,13 +383,12 @@ fn render_playback_progress_bar( frame: &mut Frame, ui: &mut UIStateGuard, progress: chrono::Duration, - track: &rspotify_model::FullTrack, + duration: chrono::Duration, rect: Rect, ) { // Negative numbers can sometimes appear from progress.num_seconds() so this stops // them coming through into the ratios - let ratio = - (progress.num_seconds() as f64 / track.duration.num_seconds() as f64).clamp(0.0, 1.0); + let ratio = (progress.num_seconds() as f64 / duration.num_seconds() as f64).clamp(0.0, 1.0); match config::get_config().app_config.progress_bar_type { config::ProgressBarType::Line => frame.render_widget( @@ -256,7 +400,7 @@ fn render_playback_progress_bar( format!( "{}/{}", crate::utils::format_duration(&progress), - crate::utils::format_duration(&track.duration), + crate::utils::format_duration(&duration), ), Style::default().add_modifier(Modifier::BOLD), )), @@ -270,7 +414,7 @@ fn render_playback_progress_bar( format!( "{}/{}", crate::utils::format_duration(&progress), - crate::utils::format_duration(&track.duration), + crate::utils::format_duration(&duration), ), Style::default().add_modifier(Modifier::BOLD), )), diff --git a/spotify_player/src/utils.rs b/spotify_player/src/utils.rs index 894d445d..a8d905d1 100644 --- a/spotify_player/src/utils.rs +++ b/spotify_player/src/utils.rs @@ -28,6 +28,15 @@ pub fn get_track_album_image_url(track: &rspotify::model::FullTrack) -> Option<& } } +#[allow(dead_code)] +pub fn get_episode_show_image_url(episode: &rspotify::model::FullEpisode) -> Option<&str> { + if episode.show.images.is_empty() { + None + } else { + Some(&episode.show.images[0].url) + } +} + pub fn parse_uri(uri: &str) -> Cow { let parts = uri.split(':').collect::>(); // The below URI probably has a format of `spotify:user:{user_id}:{type}:{id}`, From 5ac28ee73032722b2b13a87ae5c55ae33daf7419 Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Thu, 22 Aug 2024 13:46:19 -0400 Subject: [PATCH 02/15] implement AddToPlaylist --- spotify_player/src/client/mod.rs | 20 +++++++-------- spotify_player/src/client/request.rs | 1 + spotify_player/src/command.rs | 2 +- spotify_player/src/event/mod.rs | 22 ++++++++--------- spotify_player/src/event/popup.rs | 37 ++++++++++++++++++++++++++++ spotify_player/src/state/ui/popup.rs | 4 +++ spotify_player/src/ui/popup.rs | 3 +++ 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index 4ec2ec0e..1c28de75 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -452,7 +452,11 @@ impl Client { .await? } ClientRequest::AddTrackToPlaylist(playlist_id, track_id) => { - self.add_track_to_playlist(state, playlist_id, track_id) + self.add_item_to_playlist(state, playlist_id, PlayableId::Track(track_id)) + .await?; + } + ClientRequest::AddEpisodeToPlaylist(playlist_id, episode_id) => { + self.add_item_to_playlist(state, playlist_id, PlayableId::Episode(episode_id)) .await?; } ClientRequest::AddAlbumToQueue(album_id) => { @@ -951,26 +955,22 @@ impl Client { } /// Add a track to a playlist - pub async fn add_track_to_playlist( + pub async fn add_item_to_playlist( &self, state: &SharedState, playlist_id: PlaylistId<'_>, - track_id: TrackId<'_>, + playable_id: PlayableId<'_>, ) -> Result<()> { // remove all the occurrences of the track to ensure no duplication in the playlist self.playlist_remove_all_occurrences_of_items( playlist_id.as_ref(), - [PlayableId::Track(track_id.as_ref())], + [playable_id.as_ref()], None, ) .await?; - self.playlist_add_items( - playlist_id.as_ref(), - [PlayableId::Track(track_id.as_ref())], - None, - ) - .await?; + self.playlist_add_items(playlist_id.as_ref(), [playable_id.as_ref()], None) + .await?; // After adding a new track to a playlist, remove the cache of that playlist to force refetching new data state.data.write().caches.context.remove(&playlist_id.uri()); diff --git a/spotify_player/src/client/request.rs b/spotify_player/src/client/request.rs index a85289c3..aba852a7 100644 --- a/spotify_player/src/client/request.rs +++ b/spotify_player/src/client/request.rs @@ -41,6 +41,7 @@ pub enum ClientRequest { AddEpisodeToQueue(EpisodeId<'static>), AddAlbumToQueue(AlbumId<'static>), AddTrackToPlaylist(PlaylistId<'static>, TrackId<'static>), + AddEpisodeToPlaylist(PlaylistId<'static>, EpisodeId<'static>), DeleteTrackFromPlaylist(PlaylistId<'static>, TrackId<'static>), ReorderPlaylistItems { playlist_id: PlaylistId<'static>, diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs index 8d764666..ed38aa11 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -258,7 +258,7 @@ pub fn construct_episode_actions(_episode: &Episode, _data: &DataReadGuard) -> V //Action::GoToShow //Action::ShowActionsOnShow, Action::CopyLink, - //Action::AddToPlaylist, ? + Action::AddToPlaylist, Action::AddToQueue, ] } diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index a1418c16..48f51812 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -363,17 +363,17 @@ pub fn handle_action_in_context( ui.popup = None; Ok(true) } - //Action::AddToPlaylist => { - // client_pub.send(ClientRequest::GetUserPlaylists)?; - // ui.popup = Some(PopupState::UserPlaylistList( - // PlaylistPopupAction::AddTrack { - // folder_id: 0, - // track_id: track.id, - // }, - // ListState::default(), - // )); - // Ok(true) - //} + Action::AddToPlaylist => { + client_pub.send(ClientRequest::GetUserPlaylists)?; + ui.popup = Some(PopupState::UserPlaylistList( + PlaylistPopupAction::AddEpisode { + folder_id: 0, + episode_id: episode.id, + }, + ListState::default(), + )); + Ok(true) + } //Action::ToggleLiked => { // if data.user_data.is_liked_track(&track) { // client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?; diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs index 45e04233..6a7cfa99 100644 --- a/spotify_player/src/event/popup.rs +++ b/spotify_player/src/event/popup.rs @@ -164,6 +164,43 @@ pub fn handle_key_sequence_for_popup( }, ) } + PlaylistPopupAction::AddEpisode { + folder_id, + episode_id, + } => { + let episode_id = episode_id.clone(); + let data = state.data.read(); + let items = data.user_data.modifiable_playlist_items(Some(*folder_id)); + + handle_command_for_list_popup( + command, + ui, + items.len(), + |_, _| {}, + |ui: &mut UIStateGuard, id: usize| -> Result<()> { + ui.popup = match items[id] { + PlaylistFolderItem::Folder(f) => Some(PopupState::UserPlaylistList( + PlaylistPopupAction::AddEpisode { + folder_id: f.target_id, + episode_id, + }, + ListState::default(), + )), + PlaylistFolderItem::Playlist(p) => { + client_pub.send(ClientRequest::AddEpisodeToPlaylist( + p.id.clone(), + episode_id, + ))?; + None + } + }; + Ok(()) + }, + |ui: &mut UIStateGuard| { + ui.popup = None; + }, + ) + } }, PopupState::UserFollowedArtistList(_) => { let artist_uris = state diff --git a/spotify_player/src/state/ui/popup.rs b/spotify_player/src/state/ui/popup.rs index 9b3ad836..3b1a0920 100644 --- a/spotify_player/src/state/ui/popup.rs +++ b/spotify_player/src/state/ui/popup.rs @@ -45,6 +45,10 @@ pub enum PlaylistPopupAction { folder_id: usize, track_id: TrackId<'static>, }, + AddEpisode { + folder_id: usize, + episode_id: EpisodeId<'static>, + }, } /// An action on an item in an artist popup list diff --git a/spotify_player/src/ui/popup.rs b/spotify_player/src/ui/popup.rs index fa85fa87..7c6296d0 100644 --- a/spotify_player/src/ui/popup.rs +++ b/spotify_player/src/ui/popup.rs @@ -112,6 +112,9 @@ pub fn render_popup( PlaylistPopupAction::AddTrack { folder_id, .. } => { data.user_data.modifiable_playlist_items(Some(*folder_id)) } + PlaylistPopupAction::AddEpisode { folder_id, .. } => { + data.user_data.modifiable_playlist_items(Some(*folder_id)) + } }; let items = items.into_iter().map(|p| (p.to_string(), false)).collect(); From 1b3f96ab942faac0a5ac0baf8ad6d4787a7012dd Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Wed, 21 Aug 2024 15:27:34 -0400 Subject: [PATCH 03/15] implement shows --- spotify_player/src/client/handlers.rs | 1 + spotify_player/src/client/mod.rs | 51 +++++++++- spotify_player/src/command.rs | 18 +++- spotify_player/src/event/mod.rs | 9 ++ spotify_player/src/event/page.rs | 15 +++ spotify_player/src/event/popup.rs | 3 + spotify_player/src/event/window.rs | 126 +++++++++++++++++++++++- spotify_player/src/state/data.rs | 6 ++ spotify_player/src/state/model.rs | 60 ++++++++++-- spotify_player/src/state/ui/page.rs | 21 +++- spotify_player/src/state/ui/popup.rs | 6 ++ spotify_player/src/ui/page.rs | 135 +++++++++++++++++++++++++- 12 files changed, 434 insertions(+), 17 deletions(-) diff --git a/spotify_player/src/client/handlers.rs b/spotify_player/src/client/handlers.rs index f9dfd573..00d6b7f0 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -143,6 +143,7 @@ fn handle_page_change_event( ContextId::Artist(_) => ContextPageUIState::new_artist(), ContextId::Playlist(_) => ContextPageUIState::new_playlist(), ContextId::Tracks(_) => ContextPageUIState::new_tracks(), + ContextId::Show(_) => ContextPageUIState::new_show(), }); } None => { diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index 1c28de75..bf23ad8e 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -403,6 +403,7 @@ impl Client { "`GetContext` request for `tracks` context is not supported!" ); } + ContextId::Show(show_id) => self.show_context(show_id).await?, }; state @@ -815,6 +816,9 @@ impl Client { ContextId::Tracks(_) => { anyhow::bail!("`StartPlayback` request for `tracks` context is not supported") } + ContextId::Show(_) => { + anyhow::bail!("`StartPlayback` request for `show` context is not supported") + } }, Playback::URIs(track_ids, offset) => { self.start_uris_playback( @@ -888,15 +892,23 @@ impl Client { /// Search for items (tracks, artists, albums, playlists) matching a given query pub async fn search(&self, query: &str) -> Result { - let (track_result, artist_result, album_result, playlist_result, episode_result) = tokio::try_join!( + let ( + track_result, + artist_result, + album_result, + playlist_result, + show_result, + episode_result, + ) = tokio::try_join!( self.search_specific_type(query, rspotify_model::SearchType::Track), self.search_specific_type(query, rspotify_model::SearchType::Artist), self.search_specific_type(query, rspotify_model::SearchType::Album), self.search_specific_type(query, rspotify_model::SearchType::Playlist), + self.search_specific_type(query, rspotify_model::SearchType::Show), self.search_specific_type(query, rspotify_model::SearchType::Episode) )?; - let (tracks, artists, albums, playlists, episodes) = ( + let (tracks, artists, albums, playlists, shows, episodes) = ( match track_result { rspotify_model::SearchResult::Tracks(p) => p .items @@ -925,6 +937,12 @@ impl Client { } _ => anyhow::bail!("expect a playlist search result"), }, + match show_result { + rspotify_model::SearchResult::Shows(p) => { + p.items.into_iter().map(|i| i.into()).collect() + } + _ => anyhow::bail!("expect a show search result"), + }, match episode_result { rspotify_model::SearchResult::Episodes(p) => { p.items.into_iter().map(|i| i.into()).collect() @@ -938,6 +956,7 @@ impl Client { artists, albums, playlists, + shows, episodes, }) } @@ -1115,6 +1134,9 @@ impl Client { } } } + Item::Show(_show) => { + // TODO: implement add_to_library + } } Ok(()) } @@ -1157,6 +1179,9 @@ impl Client { }); self.playlist_unfollow(id).await?; } + ItemId::Show(_id) => { + // TODO: implement unfollow + } } Ok(()) } @@ -1282,6 +1307,28 @@ impl Client { }) } + /// Get a show context data + pub async fn show_context(&self, show_id: ShowId<'_>) -> Result { + let show_uri = show_id.uri(); + tracing::info!("Get show context: {}", show_uri); + + let show = self.get_a_show(show_id, None).await?; + let first_page = show.episodes.clone(); + + // converts `rspotify_model::FullShow` into `state::Show` + let show: Show = show.into(); + + // get the show's tracks + let episodes = self + .all_paging_items(first_page, &Query::new()) + .await? + .into_iter() + .map(|e| e.into()) + .collect::>(); + + Ok(Context::Show { show, episodes }) + } + /// Make a GET HTTP request to the Spotify server async fn http_get(&self, url: &str, payload: &Query<'_>) -> Result where diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs index ed38aa11..123074b7 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -1,5 +1,6 @@ use crate::state::{ - Album, Artist, DataReadGuard, Episode, Playlist, PlaylistFolder, PlaylistFolderItem, Track, + Album, Artist, DataReadGuard, Episode, Playlist, PlaylistFolder, PlaylistFolderItem, Show, + Track, }; use serde::Deserialize; @@ -111,6 +112,7 @@ pub enum ActionContext { #[allow(dead_code)] // TODO: support actions for playlist folders PlaylistFolder(PlaylistFolder), + Show(Show), } #[derive(Debug, PartialEq, Clone, Deserialize, Default, Copy)] @@ -149,6 +151,12 @@ impl From for ActionContext { } } +impl From for ActionContext { + fn from(v: Show) -> Self { + Self::Show(v) + } +} + impl From for ActionContext { fn from(v: Episode) -> Self { Self::Episode(v) @@ -174,6 +182,7 @@ impl ActionContext { Self::Episode(episode) => construct_episode_actions(episode, data), // TODO: support actions for playlist folders Self::PlaylistFolder(_) => vec![], + Self::Show(show) => construct_show_actions(show, data), } } } @@ -250,6 +259,13 @@ pub fn construct_playlist_actions(playlist: &Playlist, data: &DataReadGuard) -> } actions } +/// +/// constructs a list of actions on a show +pub fn construct_show_actions(show: &Show, data: &DataReadGuard) -> Vec { + let mut actions = vec![Action::CopyLink]; + // TODO: add move actions + actions +} /// constructs a list of actions on an episode pub fn construct_episode_actions(_episode: &Episode, _data: &DataReadGuard) -> Vec { diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index 48f51812..0dde9aaf 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -347,6 +347,15 @@ pub fn handle_action_in_context( } _ => Ok(false), }, + ActionContext::Show(show) => match action { + Action::CopyLink => { + let show_url = format!("https://open.spotify.com/show/{}", show.id.id()); + execute_copy_command(show_url)?; + ui.popup = None; + Ok(true) + } + _ => Ok(false), + }, ActionContext::Episode(episode) => match action { //Action::GoToShow => { // handle_go_to_artist(track.artists, ui); diff --git a/spotify_player/src/event/page.rs b/spotify_player/src/event/page.rs index 7ae6d09b..c82a5633 100644 --- a/spotify_player/src/event/page.rs +++ b/spotify_player/src/event/page.rs @@ -254,6 +254,21 @@ fn handle_key_sequence_for_search_page( _ => Ok(false), } } + SearchFocusState::Shows => { + let shows = search_results + .map(|s| s.shows.iter().collect()) + .unwrap_or_default(); + + match found_keymap { + CommandOrAction::Command(command) => { + window::handle_command_for_show_list_window(command, shows, &data, ui) + } + CommandOrAction::Action(action, ActionTarget::SelectedItem) => { + window::handle_action_for_selected_item(action, shows, &data, ui, client_pub) + } + _ => Ok(false), + } + } SearchFocusState::Episodes => { let episodes = match search_results { Some(s) => s.episodes.iter().collect(), diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs index 6a7cfa99..5e722a03 100644 --- a/spotify_player/src/event/popup.rs +++ b/spotify_player/src/event/popup.rs @@ -538,6 +538,9 @@ pub fn handle_item_action( ActionListItem::Playlist(playlist, actions) => { handle_action_in_context(actions[n], playlist.into(), client_pub, &data, ui) } + ActionListItem::Show(show, actions) => { + handle_action_in_context(actions[n], show.into(), client_pub, &data, ui) + } ActionListItem::Episode(episode, actions) => { handle_action_in_context(actions[n], episode.into(), client_pub, &data, ui) } diff --git a/spotify_player/src/event/window.rs b/spotify_player/src/event/window.rs index ecadab5c..6da9269c 100644 --- a/spotify_player/src/event/window.rs +++ b/spotify_player/src/event/window.rs @@ -1,7 +1,10 @@ use super::page::handle_navigation_command; use super::*; use crate::{ - command::{construct_album_actions, construct_artist_actions, construct_playlist_actions}, + command::{ + construct_album_actions, construct_artist_actions, construct_playlist_actions, + construct_show_actions, + }, state::UIStateGuard, }; use command::Action; @@ -79,6 +82,7 @@ pub fn handle_action_for_focused_context_page( ui, client_pub, ), + Some(Context::Show { .. }) => todo!("handle_action_for_focused_context_page"), None => Ok(false), } } @@ -201,6 +205,9 @@ pub fn handle_command_for_focused_context_window( Context::Tracks { tracks, .. } => { handle_command_for_track_table_window(command, client_pub, None, tracks, &data, ui) } + Context::Show { episodes, .. } => handle_command_for_episode_table_window( + command, client_pub, None, episodes, &data, ui, + ), }, None => Ok(false), } @@ -338,6 +345,89 @@ fn handle_command_for_track_table_window( Ok(true) } +fn handle_command_for_episode_table_window( + command: Command, + client_pub: &flume::Sender, + context_id: Option, + episodes: &[Episode], + data: &DataReadGuard, + ui: &mut UIStateGuard, +) -> Result { + let id = ui.current_page_mut().selected().unwrap_or_default(); + let filtered_episodes = ui.search_filtered_items(episodes); + if id >= filtered_episodes.len() { + return Ok(false); + } + + //if let Some(ContextId::Playlist(ref playlist_id)) = context_id { + // let modifiable = + // data.user_data.modifiable_playlist_items(None).iter().any( + // |item| matches!(item, PlaylistFolderItem::Playlist(p) if p.id.eq(playlist_id)), + // ); + // if modifiable + // && handle_playlist_modify_command( + // id, + // playlist_id, + // command, + // client_pub, + // &filtered_episodes, + // data, + // ui, + // )? + // { + // return Ok(true); + // } + //} + + if handle_navigation_command(command, ui.current_page_mut(), id, filtered_episodes.len()) { + return Ok(true); + } + + match command { + Command::PlayRandom | Command::ChooseSelected => { + let uri = if command == Command::PlayRandom { + episodes[rand::thread_rng().gen_range(0..episodes.len())] + .id + .uri() + } else { + filtered_episodes[id].id.uri() + }; + + let base_playback = if let Some(context_id) = context_id { + Playback::Context(context_id, None) + } else { + Playback::URIs( + episodes + .iter() + .map(|t| t.id.clone_static().into()) + .collect(), + None, + ) + }; + + client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( + base_playback + .uri_offset(uri, config::get_config().app_config.tracks_playback_limit), + None, + )))?; + } + Command::ShowActionsOnSelectedItem => { + let actions = command::construct_episode_actions(filtered_episodes[id], data); + ui.popup = Some(PopupState::ActionList( + Box::new(ActionListItem::Episode(episodes[id].clone(), actions)), + ListState::default(), + )); + } + Command::AddSelectedItemToQueue => { + client_pub.send(ClientRequest::AddEpisodeToQueue( + filtered_episodes[id].id.clone(), + ))?; + } + _ => return Ok(false), + } + Ok(true) +} + pub fn handle_command_for_track_list_window( command: Command, client_pub: &flume::Sender, @@ -506,6 +596,40 @@ pub fn handle_command_for_playlist_list_window( } Ok(true) } +pub fn handle_command_for_show_list_window( + command: Command, + shows: Vec<&Show>, + data: &DataReadGuard, + ui: &mut UIStateGuard, +) -> Result { + let id = ui.current_page_mut().selected().unwrap_or_default(); + if id >= shows.len() { + return Ok(false); + } + + if handle_navigation_command(command, ui.current_page_mut(), id, shows.len()) { + return Ok(true); + } + match command { + Command::ChooseSelected => { + let context_id = ContextId::Show(shows[id].id.clone()); + ui.new_page(PageState::Context { + id: None, + context_page_type: ContextPageType::Browsing(context_id), + state: None, + }); + } + Command::ShowActionsOnSelectedItem => { + let actions = construct_show_actions(shows[id], data); + ui.popup = Some(PopupState::ActionList( + Box::new(ActionListItem::Show(shows[id].clone(), actions)), + ListState::default(), + )); + } + _ => return Ok(false), + } + Ok(true) +} pub fn handle_command_for_episode_list_window( command: Command, diff --git a/spotify_player/src/state/data.rs b/spotify_player/src/state/data.rs index 9fb56aa4..0bd6e92b 100644 --- a/spotify_player/src/state/data.rs +++ b/spotify_player/src/state/data.rs @@ -87,6 +87,9 @@ impl AppData { top_tracks: tracks, .. } => tracks, Context::Tracks { tracks, .. } => tracks, + Context::Show { .. } => { + todo!("implement context_tracks_mut"); + } }) } @@ -98,6 +101,9 @@ impl AppData { top_tracks: tracks, .. } => tracks, Context::Tracks { tracks, .. } => tracks, + Context::Show { .. } => { + todo!("implement context_tracks"); + } }) } } diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index c4e8770e..64b69a7a 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -1,7 +1,7 @@ pub use rspotify::model as rspotify_model; use rspotify::model::CurrentPlaybackContext; pub use rspotify::model::{ - AlbumId, AlbumType, ArtistId, EpisodeId, Id, PlayableId, PlaylistId, TrackId, UserId, + AlbumId, AlbumType, ArtistId, EpisodeId, Id, PlayableId, PlaylistId, ShowId, TrackId, UserId, }; use crate::utils::map_join; @@ -31,6 +31,10 @@ pub enum Context { tracks: Vec, desc: String, }, + Show { + show: Show, + episodes: Vec, + }, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -46,6 +50,7 @@ pub enum ContextId { Album(AlbumId<'static>), Artist(ArtistId<'static>), Tracks(TracksId), + Show(ShowId<'static>), } /// Data used to start a new playback. @@ -101,6 +106,7 @@ pub struct SearchResults { pub artists: Vec, pub albums: Vec, pub playlists: Vec, + pub shows: Vec, pub episodes: Vec, } @@ -121,6 +127,7 @@ pub enum Item { Album(Album), Artist(Artist), Playlist(Playlist), + Show(Show), } #[derive(Debug, Clone)] @@ -129,6 +136,7 @@ pub enum ItemId { Album(AlbumId<'static>), Artist(ArtistId<'static>), Playlist(PlaylistId<'static>), + Show(ShowId<'static>), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -198,7 +206,14 @@ pub struct Playlist { } #[derive(Deserialize, Serialize, Debug, Clone)] -/// A Spotify playlist +/// A Spotify show (podcast) +pub struct Show { + pub id: ShowId<'static>, + pub name: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +/// A Spotify episode (podcast episode) pub struct Episode { pub id: EpisodeId<'static>, pub name: String, @@ -271,6 +286,10 @@ impl Context { } Context::Artist { ref artist, .. } => artist.name.to_string(), Context::Tracks { desc, tracks } => format!("{} | {} songs", desc, tracks.len()), + Context::Show { + ref show, + ref episodes, + } => format!("{} | {} episodes", show.name, episodes.len()), } } } @@ -282,6 +301,7 @@ impl ContextId { Self::Artist(id) => id.uri(), Self::Playlist(id) => id.uri(), Self::Tracks(id) => id.uri.to_owned(), + Self::Show(id) => id.uri(), } } } @@ -521,6 +541,36 @@ impl From for Playlist { } } +impl std::fmt::Display for Playlist { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} • {}", self.name, self.owner.0) + } +} + +impl From for Show { + fn from(show: rspotify_model::SimplifiedShow) -> Self { + Self { + id: show.id, + name: show.name, + } + } +} + +impl From for Show { + fn from(show: rspotify_model::FullShow) -> Self { + Self { + id: show.id, + name: show.name, + } + } +} + +impl std::fmt::Display for Show { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + impl From for Episode { fn from(episode: rspotify_model::SimplifiedEpisode) -> Self { Self { @@ -543,12 +593,6 @@ impl From for Episode { } } -impl std::fmt::Display for Playlist { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} • {}", self.name, self.owner.0) - } -} - impl std::fmt::Display for Episode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.name,) diff --git a/spotify_player/src/state/ui/page.rs b/spotify_player/src/state/ui/page.rs index 263de06f..a5faf8f7 100644 --- a/spotify_player/src/state/ui/page.rs +++ b/spotify_player/src/state/ui/page.rs @@ -60,6 +60,7 @@ pub struct SearchPageUIState { pub album_list: ListState, pub artist_list: ListState, pub playlist_list: ListState, + pub show_list: ListState, pub episode_list: ListState, pub focus: SearchFocusState, } @@ -87,6 +88,9 @@ pub enum ContextPageUIState { Tracks { track_table: TableState, }, + Show { + episode_table: TableState, + }, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -110,6 +114,7 @@ pub enum SearchFocusState { Albums, Artists, Playlists, + Shows, Episodes, } @@ -184,6 +189,7 @@ impl PageState { album_list, artist_list, playlist_list, + show_list, episode_list, focus, }, @@ -194,6 +200,7 @@ impl PageState { SearchFocusState::Albums => Some(MutableWindowState::List(album_list)), SearchFocusState::Artists => Some(MutableWindowState::List(artist_list)), SearchFocusState::Playlists => Some(MutableWindowState::List(playlist_list)), + SearchFocusState::Shows => Some(MutableWindowState::List(show_list)), SearchFocusState::Episodes => Some(MutableWindowState::List(episode_list)), }, Self::Context { state, .. } => state.as_mut().map(|state| match state { @@ -216,6 +223,9 @@ impl PageState { MutableWindowState::List(related_artist_list) } }, + ContextPageUIState::Show { episode_table } => { + MutableWindowState::Table(episode_table) + } }), Self::Browse { state } => match state { BrowsePageUIState::CategoryList { state } => Some(MutableWindowState::List(state)), @@ -251,6 +261,7 @@ impl SearchPageUIState { album_list: ListState::default(), artist_list: ListState::default(), playlist_list: ListState::default(), + show_list: ListState::default(), episode_list: ListState::default(), focus: SearchFocusState::Input, } @@ -266,6 +277,7 @@ impl ContextPageType { ContextId::Album(_) => String::from("Album"), ContextId::Artist(_) => String::from("Artist"), ContextId::Tracks(id) => id.kind.to_owned(), + ContextId::Show(_) => String::from("Show"), }, } } @@ -298,6 +310,12 @@ impl ContextPageUIState { track_table: TableState::default(), } } + + pub fn new_show() -> Self { + Self::Show { + episode_table: TableState::default(), + } + } } impl<'a> MutableWindowState<'a> { @@ -415,6 +433,7 @@ impl_focusable!( [Tracks, Albums], [Albums, Artists], [Artists, Playlists], - [Playlists, Episodes], + [Playlists, Shows], + [Shows, Episodes], [Episodes, Input] ); diff --git a/spotify_player/src/state/ui/popup.rs b/spotify_player/src/state/ui/popup.rs index 3b1a0920..6284bd44 100644 --- a/spotify_player/src/state/ui/popup.rs +++ b/spotify_player/src/state/ui/popup.rs @@ -32,6 +32,7 @@ pub enum ActionListItem { Artist(Artist, Vec), Album(Album, Vec), Playlist(Playlist, Vec), + Show(Show, Vec), Episode(Episode, Vec), } @@ -111,6 +112,7 @@ impl ActionListItem { ActionListItem::Artist(.., actions) => actions.len(), ActionListItem::Album(.., actions) => actions.len(), ActionListItem::Playlist(.., actions) => actions.len(), + ActionListItem::Show(.., actions) => actions.len(), ActionListItem::Episode(.., actions) => actions.len(), } } @@ -121,6 +123,7 @@ impl ActionListItem { ActionListItem::Artist(artist, ..) => &artist.name, ActionListItem::Album(album, ..) => &album.name, ActionListItem::Playlist(playlist, ..) => &playlist.name, + ActionListItem::Show(show, ..) => &show.name, ActionListItem::Episode(episode, ..) => &episode.name, } } @@ -139,6 +142,9 @@ impl ActionListItem { ActionListItem::Playlist(.., actions) => { actions.iter().map(|a| format!("{a:?}")).collect::>() } + ActionListItem::Show(.., actions) => { + actions.iter().map(|a| format!("{a:?}")).collect::>() + } ActionListItem::Episode(.., actions) => { actions.iter().map(|a| format!("{a:?}")).collect::>() } diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index c55fbed6..1ea6f2ea 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -75,7 +75,7 @@ pub fn render_search_page( ); let playlist_rect = construct_and_render_block("Playlists", &ui.theme, Borders::TOP, frame, chunks[3]); - let _show_rect = construct_and_render_block( + let show_rect = construct_and_render_block( "Shows", &ui.theme, Borders::TOP | Borders::RIGHT, @@ -151,7 +151,19 @@ pub fn render_search_page( utils::construct_list_widget(&ui.theme, playlist_items, is_active) }; - // TODO: show + let (show_list, n_shows) = { + let show_items = search_results + .map(|s| { + s.shows + .iter() + .map(|a| (a.to_string(), false)) + .collect::>() + }) + .unwrap_or_default(); + let is_active = is_active && focus_state == SearchFocusState::Shows; + + utils::construct_list_widget(&ui.theme, show_items, is_active) + }; let (episode_list, n_episodes) = { let episode_items = search_results @@ -209,6 +221,13 @@ pub fn render_search_page( n_playlists, &mut page_state.playlist_list, ); + utils::render_list_window( + frame, + show_list, + show_rect, + n_shows, + &mut page_state.show_list, + ); utils::render_list_window( frame, episode_list, @@ -330,6 +349,17 @@ pub fn render_context_page( &data, ); } + Context::Show { episodes, .. } => { + render_episode_table( + frame, + rect, + is_active, + state, + ui.search_filtered_items(episodes), + ui, + &data, + ); + } } } None => { @@ -951,14 +981,111 @@ fn render_track_table( state: Some(state), .. } = ui.current_page_mut() { - let track_table_state = match state { + let playable_table_state = match state { + ContextPageUIState::Artist { + top_track_table, .. + } => top_track_table, + ContextPageUIState::Playlist { track_table } => track_table, + ContextPageUIState::Album { track_table } => track_table, + ContextPageUIState::Tracks { track_table } => track_table, + ContextPageUIState::Show { episode_table } => episode_table, + }; + utils::render_table_window(frame, track_table, rect, n_tracks, playable_table_state); + } +} + +fn render_episode_table( + frame: &mut Frame, + rect: Rect, + is_active: bool, + state: &SharedState, + episodes: Vec<&Episode>, + ui: &mut UIStateGuard, + data: &DataReadGuard, +) { + let configs = config::get_config(); + // get the current playing track's URI to decorate such track (if exists) in the track table + let mut playing_episode_uri = "".to_string(); + let mut playing_id = ""; + if let Some(ref playback) = state.player.read().playback { + if let Some(rspotify_model::PlayableItem::Episode(ref episode)) = playback.item { + playing_episode_uri = episode.id.uri(); + + playing_id = if playback.is_playing { + &configs.app_config.play_icon + } else { + &configs.app_config.pause_icon + }; + } + } + + let n_episodes = episodes.len(); + let rows = episodes + .into_iter() + .enumerate() + .map(|(id, t)| { + let (id, style) = if playing_episode_uri == t.id.uri() { + (playing_id.to_string(), ui.theme.current_playing()) + } else { + ((id + 1).to_string(), Style::default()) + }; + Row::new(vec![ + //if data.user_data.is_liked_track(t) { + // Cell::from(&configs.app_config.liked_icon as &str).style(ui.theme.like()) + //} else { + Cell::from(""), + //}, + Cell::from(id), + Cell::from(t.name.clone()), + //Cell::from(t.artists_info()), + //Cell::from(t.album_info()), + Cell::from(format!( + "{}:{:02}", + t.duration.as_secs() / 60, + t.duration.as_secs() % 60, + )), + ]) + .style(style) + }) + .collect::>(); + let episode_table = Table::new( + rows, + [ + Constraint::Length(configs.app_config.liked_icon.chars().count() as u16), + Constraint::Length(4), + Constraint::Fill(4), + //Constraint::Fill(3), + //Constraint::Fill(5), + Constraint::Fill(1), + ], + ) + .header( + Row::new(vec![ + Cell::from(""), + Cell::from("#"), + Cell::from("Title"), + //Cell::from("Artists"), + //Cell::from("Album"), + Cell::from("Duration"), + ]) + .style(ui.theme.table_header()), + ) + .column_spacing(2) + .highlight_style(ui.theme.selection(is_active)); + + if let PageState::Context { + state: Some(state), .. + } = ui.current_page_mut() + { + let playable_table_state = match state { ContextPageUIState::Artist { top_track_table, .. } => top_track_table, ContextPageUIState::Playlist { track_table } => track_table, ContextPageUIState::Album { track_table } => track_table, ContextPageUIState::Tracks { track_table } => track_table, + ContextPageUIState::Show { episode_table } => episode_table, }; - utils::render_table_window(frame, track_table, rect, n_tracks, track_table_state); + utils::render_table_window(frame, episode_table, rect, n_episodes, playable_table_state); } } From c0a00fa7ed171a92b3155cdfe42176860d5244c3 Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Thu, 22 Aug 2024 22:36:42 -0400 Subject: [PATCH 04/15] convert unwraps to expects --- spotify_player/src/client/handlers.rs | 9 +++++++-- spotify_player/src/event/mod.rs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/spotify_player/src/client/handlers.rs b/spotify_player/src/client/handlers.rs index 00d6b7f0..b3ed4035 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -52,7 +52,7 @@ fn handle_playback_change_event( ) { (Some(playback), Some(PlayableItem::Track(track))) => ( playback, - PlayableId::Track(track.id.clone().unwrap()), + PlayableId::Track(track.id.clone().expect("all non-local tracks have ids")), track.name.clone(), track.duration, ), @@ -75,7 +75,12 @@ fn handle_playback_change_event( if let Some(queue) = player.queue.as_ref() { // queue needs to be updated if its playing track is different from actual playback's playing track if let Some(queue_track) = queue.currently_playing.as_ref() { - if queue_track.id().unwrap().uri() != id.uri() { + if queue_track + .id() + .expect("all non-local tracks have ids") + .uri() + != id.uri() + { client_pub.send(ClientRequest::GetCurrentUserQueue)?; } } diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index 0dde9aaf..62c7638d 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -816,7 +816,7 @@ fn handle_global_command( Command::JumpToCurrentTrackInContext => { let track_id = match state.player.read().currently_playing() { Some(rspotify_model::PlayableItem::Track(track)) => { - PlayableId::Track(track.id.clone().unwrap()) + PlayableId::Track(track.id.clone().expect("all non-local tracks have ids")) } Some(rspotify_model::PlayableItem::Episode(episode)) => { PlayableId::Episode(episode.id.clone()) From 865268009cf98989a850021002b4b4993393825b Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Thu, 22 Aug 2024 22:50:36 -0400 Subject: [PATCH 05/15] impl GoToShow --- spotify_player/src/command.rs | 12 ++++++++---- spotify_player/src/event/mod.rs | 18 ++++++++++++++---- spotify_player/src/state/model.rs | 3 +++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs index 123074b7..38c52489 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -87,6 +87,7 @@ pub enum Action { GoToArtist, GoToAlbum, GoToRadio, + GoToShow, AddToLibrary, AddToPlaylist, AddToQueue, @@ -268,15 +269,18 @@ pub fn construct_show_actions(show: &Show, data: &DataReadGuard) -> Vec } /// constructs a list of actions on an episode -pub fn construct_episode_actions(_episode: &Episode, _data: &DataReadGuard) -> Vec { - vec![ +pub fn construct_episode_actions(episode: &Episode, _data: &DataReadGuard) -> Vec { + let mut actions = vec![ //TODO: implement the below - //Action::GoToShow //Action::ShowActionsOnShow, Action::CopyLink, Action::AddToPlaylist, Action::AddToQueue, - ] + ]; + if episode.show.is_some() { + actions.push(Action::GoToShow) + } + actions } impl Command { diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index 62c7638d..3bacde73 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -357,10 +357,20 @@ pub fn handle_action_in_context( _ => Ok(false), }, ActionContext::Episode(episode) => match action { - //Action::GoToShow => { - // handle_go_to_artist(track.artists, ui); - // Ok(true) - //} + Action::GoToShow => { + if let Some(show) = episode.show { + let context_id = ContextId::Show( + ShowId::from_uri(&parse_uri(&show.id.uri()))?.into_static(), + ); + ui.new_page(PageState::Context { + id: None, + context_page_type: ContextPageType::Browsing(context_id), + state: None, + }); + return Ok(true); + } + Ok(false) + } Action::AddToQueue => { client_pub.send(ClientRequest::AddEpisodeToQueue(episode.id))?; ui.popup = None; diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index 64b69a7a..b17326e4 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -219,6 +219,7 @@ pub struct Episode { pub name: String, pub description: String, pub duration: std::time::Duration, + pub show: Option, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -578,6 +579,7 @@ impl From for Episode { name: episode.name, description: episode.description, duration: episode.duration.to_std().expect("valid chrono duration"), + show: None, } } } @@ -589,6 +591,7 @@ impl From for Episode { name: episode.name, description: episode.description, duration: episode.duration.to_std().expect("valid chrono duration"), + show: Some(episode.show.into()), } } } From fc98151d1057c4dcaa7a6e0b8f2adeb80252438c Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Thu, 22 Aug 2024 22:58:07 -0400 Subject: [PATCH 06/15] impl ShowActionsOnShow --- README.md | 1 + spotify_player/src/command.rs | 12 ++++-------- spotify_player/src/event/mod.rs | 28 ++++++++++++++-------------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6d58976e..9e26b8c9 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,7 @@ List of available actions: - `DeleteFromPlaylist` - `ShowActionsOnAlbum` - `ShowActionsOnArtist` +- `ShowActionsOnShow` - `ToggleLiked` - `CopyLink` - `Follow` diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs index 38c52489..881e48a3 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -97,6 +97,7 @@ pub enum Action { DeleteFromPlaylist, ShowActionsOnAlbum, ShowActionsOnArtist, + ShowActionsOnShow, ToggleLiked, CopyLink, Follow, @@ -270,15 +271,10 @@ pub fn construct_show_actions(show: &Show, data: &DataReadGuard) -> Vec /// constructs a list of actions on an episode pub fn construct_episode_actions(episode: &Episode, _data: &DataReadGuard) -> Vec { - let mut actions = vec![ - //TODO: implement the below - //Action::ShowActionsOnShow, - Action::CopyLink, - Action::AddToPlaylist, - Action::AddToQueue, - ]; + let mut actions = vec![Action::CopyLink, Action::AddToPlaylist, Action::AddToQueue]; if episode.show.is_some() { - actions.push(Action::GoToShow) + actions.push(Action::ShowActionsOnShow); + actions.push(Action::GoToShow); } actions } diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index 3bacde73..30e9dfdf 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -426,20 +426,20 @@ pub fn handle_action_in_context( // handle_show_actions_on_artist(track.artists, data, ui); // Ok(true) //} - //Action::ShowActionsOnAlbum => { - // if let Some(album) = track.album { - // let context = ActionContext::Album(album.clone()); - // ui.popup = Some(PopupState::ActionList( - // Box::new(ActionListItem::Album( - // album, - // context.get_available_actions(data), - // )), - // ListState::default(), - // )); - // return Ok(true); - // } - // Ok(false) - //} + Action::ShowActionsOnShow => { + if let Some(show) = episode.show { + let context = ActionContext::Show(show.clone()); + ui.popup = Some(PopupState::ActionList( + Box::new(ActionListItem::Show( + show, + context.get_available_actions(data), + )), + ListState::default(), + )); + return Ok(true); + } + Ok(false) + } //Action::DeleteFromPlaylist => { // if let PageState::Context { // id: Some(ContextId::Playlist(playlist_id)), From 3ffeaf6adce0175e9e441685799c71b8d183d5f8 Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Thu, 22 Aug 2024 23:05:42 -0400 Subject: [PATCH 07/15] add release_date to episode --- spotify_player/src/state/model.rs | 3 +++ spotify_player/src/ui/page.rs | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index b17326e4..b4dc3ce7 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -220,6 +220,7 @@ pub struct Episode { pub description: String, pub duration: std::time::Duration, pub show: Option, + pub release_date: String, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -580,6 +581,7 @@ impl From for Episode { description: episode.description, duration: episode.duration.to_std().expect("valid chrono duration"), show: None, + release_date: episode.release_date, } } } @@ -592,6 +594,7 @@ impl From for Episode { description: episode.description, duration: episode.duration.to_std().expect("valid chrono duration"), show: Some(episode.show.into()), + release_date: episode.release_date, } } } diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index 1ea6f2ea..94344c01 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -1037,7 +1037,7 @@ fn render_episode_table( //}, Cell::from(id), Cell::from(t.name.clone()), - //Cell::from(t.artists_info()), + Cell::from(t.release_date.clone()), //Cell::from(t.album_info()), Cell::from(format!( "{}:{:02}", @@ -1054,7 +1054,7 @@ fn render_episode_table( Constraint::Length(configs.app_config.liked_icon.chars().count() as u16), Constraint::Length(4), Constraint::Fill(4), - //Constraint::Fill(3), + Constraint::Fill(3), //Constraint::Fill(5), Constraint::Fill(1), ], @@ -1064,7 +1064,7 @@ fn render_episode_table( Cell::from(""), Cell::from("#"), Cell::from("Title"), - //Cell::from("Artists"), + Cell::from("Date"), //Cell::from("Album"), Cell::from("Duration"), ]) From 396ebe93d7ed1694f8bdcdbf163fae63ecbc6356 Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Sat, 7 Sep 2024 20:33:18 -0400 Subject: [PATCH 08/15] combine playable queue actions --- Cargo.lock | 16 ++++++++-------- spotify_player/Cargo.toml | 3 +-- spotify_player/src/client/handlers.rs | 10 +--------- spotify_player/src/client/mod.rs | 9 ++------- spotify_player/src/client/request.rs | 3 +-- spotify_player/src/event/mod.rs | 4 ++-- spotify_player/src/event/window.rs | 12 +++++++----- spotify_player/src/state/model.rs | 4 ++-- 8 files changed, 24 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc0f19e7..1d99d42f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4033,9 +4033,9 @@ dependencies = [ [[package]] name = "rspotify" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b2487556b568b6471cbbdcca0d23e1eff885e993cf057254813dc46c3837922" +checksum = "71aa4a990ef1bacbed874fbab621e16c61a8b5a56854ada6b2bcccf19acb5795" dependencies = [ "async-stream", "async-trait", @@ -4057,9 +4057,9 @@ dependencies = [ [[package]] name = "rspotify-http" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9424544350d142db73534967503205030162c4e650b2f17fe1729f5dc5c8edff" +checksum = "a193f73ee55ab66aeb0337170d120bc73ec4963b150d9c66d68b28d14bc5ac5f" dependencies = [ "async-trait", "log", @@ -4071,15 +4071,15 @@ dependencies = [ [[package]] name = "rspotify-macros" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "475cd14f84b46cc8d96389e0c892940cd2bd32a9240d1ba6fefcdfd8432ab9d1" +checksum = "b78387b0ebb8da6d4c72e728496b09701b7054c0ef88ea2f4f40e46b9107a6de" [[package]] name = "rspotify-model" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "496322604d8dfe61c10a37a88bb4ecac955e2d66fdbe148b2c44a844048b1ca2" +checksum = "1b6ce0f0ecf4eb3b0b8ab7c6932328d03040dd77169b1c533a3ead1308985af6" dependencies = [ "chrono", "enum_dispatch", diff --git a/spotify_player/Cargo.toml b/spotify_player/Cargo.toml index c9299563..6f44d12a 100644 --- a/spotify_player/Cargo.toml +++ b/spotify_player/Cargo.toml @@ -22,7 +22,7 @@ log = "0.4.22" chrono = "0.4.38" reqwest = { version = "0.12.5", features = ["json"] } rpassword = "7.3.1" -rspotify = "0.13.2" +rspotify = "0.13.3" serde = { version = "1.0.204", features = ["derive"] } tokio = { version = "1.38.0", features = ["rt", "rt-multi-thread", "macros", "time"] } toml = "0.8.14" @@ -89,4 +89,3 @@ default = ["rodio-backend", "media-control"] [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/{ name }_{ target }{ archive-suffix }" - diff --git a/spotify_player/src/client/handlers.rs b/spotify_player/src/client/handlers.rs index b3ed4035..79cdc29b 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -102,15 +102,7 @@ fn handle_playback_change_event( "fake track repeat mode is enabled, add the current track ({}) to queue", name ); - match id { - PlayableId::Track(id) => { - client_pub.send(ClientRequest::AddTrackToQueue(id))?; - } - - PlayableId::Episode(id) => { - client_pub.send(ClientRequest::AddEpisodeToQueue(id))?; - } - } + client_pub.send(ClientRequest::AddPlayableToQueue(id.into()))?; handler_state.add_track_to_queue_req_timer = std::time::Instant::now(); } } diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index bf23ad8e..52e8263d 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -444,13 +444,8 @@ impl Client { ); } } - ClientRequest::AddTrackToQueue(track_id) => { - self.add_item_to_queue(PlayableId::Track(track_id), None) - .await? - } - ClientRequest::AddEpisodeToQueue(episode_id) => { - self.add_item_to_queue(PlayableId::Episode(episode_id), None) - .await? + ClientRequest::AddPlayableToQueue(playable_id) => { + self.add_item_to_queue(playable_id, None).await? } ClientRequest::AddTrackToPlaylist(playlist_id, track_id) => { self.add_item_to_playlist(state, playlist_id, PlayableId::Track(track_id)) diff --git a/spotify_player/src/client/request.rs b/spotify_player/src/client/request.rs index aba852a7..5caae8d4 100644 --- a/spotify_player/src/client/request.rs +++ b/spotify_player/src/client/request.rs @@ -37,8 +37,7 @@ pub enum ClientRequest { seed_name: String, }, Search(String), - AddTrackToQueue(TrackId<'static>), - AddEpisodeToQueue(EpisodeId<'static>), + AddPlayableToQueue(PlayableId<'static>), AddAlbumToQueue(AlbumId<'static>), AddTrackToPlaylist(PlaylistId<'static>, TrackId<'static>), AddEpisodeToPlaylist(PlaylistId<'static>, EpisodeId<'static>), diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index 30e9dfdf..e8909815 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -160,7 +160,7 @@ pub fn handle_action_in_context( Ok(true) } Action::AddToQueue => { - client_pub.send(ClientRequest::AddTrackToQueue(track.id))?; + client_pub.send(ClientRequest::AddPlayableToQueue(track.id.into()))?; ui.popup = None; Ok(true) } @@ -372,7 +372,7 @@ pub fn handle_action_in_context( Ok(false) } Action::AddToQueue => { - client_pub.send(ClientRequest::AddEpisodeToQueue(episode.id))?; + client_pub.send(ClientRequest::AddPlayableToQueue(episode.id.into()))?; ui.popup = None; Ok(true) } diff --git a/spotify_player/src/event/window.rs b/spotify_player/src/event/window.rs index 6da9269c..1e93652e 100644 --- a/spotify_player/src/event/window.rs +++ b/spotify_player/src/event/window.rs @@ -336,8 +336,8 @@ fn handle_command_for_track_table_window( )); } Command::AddSelectedItemToQueue => { - client_pub.send(ClientRequest::AddTrackToQueue( - filtered_tracks[id].id.clone(), + client_pub.send(ClientRequest::AddPlayableToQueue( + filtered_tracks[id].id.clone().into(), ))?; } _ => return Ok(false), @@ -419,8 +419,8 @@ fn handle_command_for_episode_table_window( )); } Command::AddSelectedItemToQueue => { - client_pub.send(ClientRequest::AddEpisodeToQueue( - filtered_episodes[id].id.clone(), + client_pub.send(ClientRequest::AddPlayableToQueue( + filtered_episodes[id].id.clone().into(), ))?; } _ => return Ok(false), @@ -463,7 +463,9 @@ pub fn handle_command_for_track_list_window( )); } Command::AddSelectedItemToQueue => { - client_pub.send(ClientRequest::AddTrackToQueue(tracks[id].id.clone()))?; + client_pub.send(ClientRequest::AddPlayableToQueue( + tracks[id].id.clone().into(), + ))?; } _ => return Ok(false), } diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index b4dc3ce7..ee2a1d79 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -357,7 +357,7 @@ impl Track { pub fn try_from_simplified_track(track: rspotify_model::SimplifiedTrack) -> Option { if track.is_playable.unwrap_or(true) { let id = match track.linked_from { - Some(d) => d.id, + Some(d) => d.id?, None => track.id?, }; Some(Self { @@ -378,7 +378,7 @@ impl Track { pub fn try_from_full_track(track: rspotify_model::FullTrack) -> Option { if track.is_playable.unwrap_or(true) { let id = match track.linked_from { - Some(d) => d.id, + Some(d) => d.id?, None => track.id?, }; Some(Self { From b696da3507b1f073bdd99fd6fae4109bc626081b Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Sat, 7 Sep 2024 20:37:54 -0400 Subject: [PATCH 09/15] combine playable playlist actions --- spotify_player/src/client/mod.rs | 8 ++------ spotify_player/src/client/request.rs | 3 +-- spotify_player/src/event/popup.rs | 8 ++++---- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index 52e8263d..1e07b48b 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -447,12 +447,8 @@ impl Client { ClientRequest::AddPlayableToQueue(playable_id) => { self.add_item_to_queue(playable_id, None).await? } - ClientRequest::AddTrackToPlaylist(playlist_id, track_id) => { - self.add_item_to_playlist(state, playlist_id, PlayableId::Track(track_id)) - .await?; - } - ClientRequest::AddEpisodeToPlaylist(playlist_id, episode_id) => { - self.add_item_to_playlist(state, playlist_id, PlayableId::Episode(episode_id)) + ClientRequest::AddPlayableToPlaylist(playlist_id, playable_id) => { + self.add_item_to_playlist(state, playlist_id, playable_id) .await?; } ClientRequest::AddAlbumToQueue(album_id) => { diff --git a/spotify_player/src/client/request.rs b/spotify_player/src/client/request.rs index 5caae8d4..e2be606c 100644 --- a/spotify_player/src/client/request.rs +++ b/spotify_player/src/client/request.rs @@ -39,8 +39,7 @@ pub enum ClientRequest { Search(String), AddPlayableToQueue(PlayableId<'static>), AddAlbumToQueue(AlbumId<'static>), - AddTrackToPlaylist(PlaylistId<'static>, TrackId<'static>), - AddEpisodeToPlaylist(PlaylistId<'static>, EpisodeId<'static>), + AddPlayableToPlaylist(PlaylistId<'static>, PlayableId<'static>), DeleteTrackFromPlaylist(PlaylistId<'static>, TrackId<'static>), ReorderPlaylistItems { playlist_id: PlaylistId<'static>, diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs index 5e722a03..0958764c 100644 --- a/spotify_player/src/event/popup.rs +++ b/spotify_player/src/event/popup.rs @@ -150,9 +150,9 @@ pub fn handle_key_sequence_for_popup( ListState::default(), )), PlaylistFolderItem::Playlist(p) => { - client_pub.send(ClientRequest::AddTrackToPlaylist( + client_pub.send(ClientRequest::AddPlayableToPlaylist( p.id.clone(), - track_id, + track_id.into(), ))?; None } @@ -187,9 +187,9 @@ pub fn handle_key_sequence_for_popup( ListState::default(), )), PlaylistFolderItem::Playlist(p) => { - client_pub.send(ClientRequest::AddEpisodeToPlaylist( + client_pub.send(ClientRequest::AddPlayableToPlaylist( p.id.clone(), - episode_id, + episode_id.into(), ))?; None } From e30d9fc03627bb96c9257e7fe65dbbc02363ca50 Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Sat, 7 Sep 2024 21:23:18 -0400 Subject: [PATCH 10/15] revert some changes now that we have Debug + Clone --- spotify_player/src/event/mod.rs | 47 ------------------------------ spotify_player/src/event/window.rs | 29 +++++++++--------- spotify_player/src/state/model.rs | 40 ++----------------------- spotify_player/src/streaming.rs | 4 ++- 4 files changed, 20 insertions(+), 100 deletions(-) diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index e8909815..72d39227 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -393,39 +393,6 @@ pub fn handle_action_in_context( )); Ok(true) } - //Action::ToggleLiked => { - // if data.user_data.is_liked_track(&track) { - // client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?; - // } else { - // client_pub.send(ClientRequest::AddToLibrary(Item::Track(track)))?; - // } - // ui.popup = None; - // Ok(true) - //} - //Action::AddToLiked => { - // client_pub.send(ClientRequest::AddToLibrary(Item::Track(track)))?; - // ui.popup = None; - // Ok(true) - //} - //Action::DeleteFromLiked => { - // client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?; - // ui.popup = None; - // Ok(true) - //} - //Action::GoToRadio => { - // let uri = track.id.uri(); - // let name = track.name; - // ui.new_radio_page(&uri); - // client_pub.send(ClientRequest::GetRadioTracks { - // seed_uri: uri, - // seed_name: name, - // })?; - // Ok(true) - //} - //Action::ShowActionsOnArtist => { - // handle_show_actions_on_artist(track.artists, data, ui); - // Ok(true) - //} Action::ShowActionsOnShow => { if let Some(show) = episode.show { let context = ActionContext::Show(show.clone()); @@ -440,20 +407,6 @@ pub fn handle_action_in_context( } Ok(false) } - //Action::DeleteFromPlaylist => { - // if let PageState::Context { - // id: Some(ContextId::Playlist(playlist_id)), - // .. - // } = ui.current_page() - // { - // client_pub.send(ClientRequest::DeleteTrackFromPlaylist( - // playlist_id.clone_static(), - // track.id, - // ))?; - // } - // ui.popup = None; - // Ok(true) - //} _ => Ok(false), }, // TODO: support actions for playlist folders diff --git a/spotify_player/src/event/window.rs b/spotify_player/src/event/window.rs index 1e93652e..6cdb3acb 100644 --- a/spotify_player/src/event/window.rs +++ b/spotify_player/src/event/window.rs @@ -82,7 +82,13 @@ pub fn handle_action_for_focused_context_page( ui, client_pub, ), - Some(Context::Show { .. }) => todo!("handle_action_for_focused_context_page"), + Some(Context::Show { episodes, .. }) => handle_action_for_selected_item( + action, + ui.search_filtered_items(episodes), + &data, + ui, + client_pub, + ), None => Ok(false), } } @@ -316,10 +322,7 @@ fn handle_command_for_track_table_window( let base_playback = if let Some(context_id) = context_id { Playback::Context(context_id, None) } else { - Playback::URIs( - tracks.iter().map(|t| t.id.clone_static().into()).collect(), - None, - ) + Playback::URIs(tracks.iter().map(|t| t.id.clone().into()).collect(), None) }; client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( @@ -396,13 +399,7 @@ fn handle_command_for_episode_table_window( let base_playback = if let Some(context_id) = context_id { Playback::Context(context_id, None) } else { - Playback::URIs( - episodes - .iter() - .map(|t| t.id.clone_static().into()) - .collect(), - None, - ) + Playback::URIs(episodes.iter().map(|t| t.id.clone().into()).collect(), None) }; client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( @@ -662,9 +659,11 @@ pub fn handle_command_for_episode_list_window( ListState::default(), )); } - //Command::AddSelectedItemToQueue => { - // client_pub.send(ClientRequest::AddEpisodeToQueue(episodes[id].id.clone()))?; - //} + Command::AddSelectedItemToQueue => { + client_pub.send(ClientRequest::AddPlayableToQueue( + episodes[id].id.clone().into(), + ))?; + } _ => return Ok(false), } Ok(true) diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index ee2a1d79..133bf265 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -59,46 +59,12 @@ pub enum ContextId { /// - Specify the list of track IDs with an offset /// /// An offset can be either a track's URI or its absolute offset in the context +#[derive(Clone, Debug)] pub enum Playback { Context(ContextId, Option), URIs(Vec>, Option), } -impl std::fmt::Debug for Playback { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Context(context_id, offset) => f - .debug_tuple("Playback::Context") - .field(context_id) - .field(offset) - .finish(), - Self::URIs(playable_ids, offset) => { - write!(f, "Playback::URIs([")?; - for id in playable_ids.iter() { - match id { - PlayableId::Track(track_id) => write!(f, "{}, ", track_id)?, - PlayableId::Episode(episode_id) => write!(f, "{}, ", episode_id)?, - } - } - write!(f, "], ")?; - write!(f, "{:?})", offset) - } - } - } -} - -impl Clone for Playback { - fn clone(&self) -> Self { - match self { - Self::Context(context_id, offset) => Self::Context(context_id.clone(), offset.clone()), - Self::URIs(playable_ids, offset) => Self::URIs( - playable_ids.into_iter().map(|x| x.clone_static()).collect(), - offset.clone(), - ), - } - } -} - #[derive(Default, Clone, Debug, Deserialize, Serialize)] /// Data returned when searching a query using Spotify APIs. pub struct SearchResults { @@ -657,7 +623,7 @@ impl Playback { } Playback::URIs(ids, _) => { let ids = if ids.len() < limit { - ids.into_iter().map(|x| x.clone_static()).collect() + ids.clone() } else { let pos = ids .iter() @@ -669,7 +635,7 @@ impl Playback { // API request, we restrict the range of tracks to be played, which is based on the // playing track's position (if any) and the application's limit (`app_config.tracks_playback_limit`). // Related issue: https://github.com/aome510/spotify-player/issues/78 - ids[l..r].into_iter().map(|x| x.clone_static()).collect() + ids[l..r].to_vec() }; Playback::URIs(ids, Some(rspotify_model::Offset::Uri(uri))) diff --git a/spotify_player/src/streaming.rs b/spotify_player/src/streaming.rs index 63638e31..566b7857 100644 --- a/spotify_player/src/streaming.rs +++ b/spotify_player/src/streaming.rs @@ -12,6 +12,7 @@ use librespot_playback::{ player, }; use rspotify::model::{EpisodeId, Id, PlayableId, TrackId}; +use serde::Serialize; #[cfg(not(any( feature = "rodio-backend", @@ -35,6 +36,7 @@ compile_error!("Streaming feature is enabled but no audio backend has been selec For more information, visit https://github.com/aome510/spotify-player?tab=readme-ov-file#streaming "); +#[derive(Debug, Serialize)] enum PlayerEvent { Changed { old_playable_id: PlayableId<'static>, @@ -214,7 +216,7 @@ pub async fn new_connection(client: Client, state: SharedState) -> Spirc { tracing::warn!("Failed to convert a `librespot` player event into `spotify_player` player event: {err:#}"); } Ok(Some(event)) => { - tracing::info!("Got a new player event"); + tracing::info!("Got a new player event: {event:?}"); match event { PlayerEvent::Playing { .. } => { let mut player = state.player.write(); From db616d414059fef87c57e48b7aa9f2992c79816e Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Sat, 7 Sep 2024 21:51:05 -0400 Subject: [PATCH 11/15] implement show following --- spotify_player/src/client/mod.rs | 20 ++++++++++++++++---- spotify_player/src/command.rs | 6 +++++- spotify_player/src/event/mod.rs | 10 ++++++++++ spotify_player/src/state/data.rs | 4 ++++ spotify_player/src/ui/page.rs | 12 ------------ 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index 1e07b48b..ed4ae29a 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -1125,8 +1125,13 @@ impl Client { } } } - Item::Show(_show) => { - // TODO: implement add_to_library + Item::Show(show) => { + let follows = self.check_users_saved_shows([show.id.as_ref()]).await?; + if !follows[0] { + self.save_shows([show.id.as_ref()]).await?; + // update the in-memory `user_data` + state.data.write().user_data.saved_shows.insert(0, show); + } } } Ok(()) @@ -1170,8 +1175,15 @@ impl Client { }); self.playlist_unfollow(id).await?; } - ItemId::Show(_id) => { - // TODO: implement unfollow + ItemId::Show(id) => { + state + .data + .write() + .user_data + .saved_shows + .retain(|s| s.id != id); + self.remove_users_saved_shows([id], Some(Market::FromToken)) + .await?; } } Ok(()) diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs index 881e48a3..90596cd1 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -265,7 +265,11 @@ pub fn construct_playlist_actions(playlist: &Playlist, data: &DataReadGuard) -> /// constructs a list of actions on a show pub fn construct_show_actions(show: &Show, data: &DataReadGuard) -> Vec { let mut actions = vec![Action::CopyLink]; - // TODO: add move actions + if data.user_data.saved_shows.iter().any(|s| s.id == show.id) { + actions.push(Action::DeleteFromLibrary); + } else { + actions.push(Action::AddToLibrary); + } actions } diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index 72d39227..14a206a8 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -354,6 +354,16 @@ pub fn handle_action_in_context( ui.popup = None; Ok(true) } + Action::AddToLibrary => { + client_pub.send(ClientRequest::AddToLibrary(Item::Show(show)))?; + ui.popup = None; + Ok(true) + } + Action::DeleteFromLibrary => { + client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Show(show.id)))?; + ui.popup = None; + Ok(true) + } _ => Ok(false), }, ActionContext::Episode(episode) => match action { diff --git a/spotify_player/src/state/data.rs b/spotify_player/src/state/data.rs index 0bd6e92b..055f2934 100644 --- a/spotify_player/src/state/data.rs +++ b/spotify_player/src/state/data.rs @@ -13,6 +13,7 @@ pub enum FileCacheKey { Playlists, PlaylistFolders, FollowedArtists, + SavedShows, SavedAlbums, SavedTracks, } @@ -35,6 +36,7 @@ pub struct UserData { pub playlists: Vec, pub playlist_folder_node: Option, pub followed_artists: Vec, + pub saved_shows: Vec, pub saved_albums: Vec, pub saved_tracks: HashMap, } @@ -124,6 +126,8 @@ impl UserData { cache_folder, ) .unwrap_or_default(), + saved_shows: load_data_from_file_cache(FileCacheKey::SavedShows, cache_folder) + .unwrap_or_default(), saved_albums: load_data_from_file_cache(FileCacheKey::SavedAlbums, cache_folder) .unwrap_or_default(), saved_tracks: load_data_from_file_cache(FileCacheKey::SavedTracks, cache_folder) diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index 94344c01..66f5116b 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -357,7 +357,6 @@ pub fn render_context_page( state, ui.search_filtered_items(episodes), ui, - &data, ); } } @@ -1001,7 +1000,6 @@ fn render_episode_table( state: &SharedState, episodes: Vec<&Episode>, ui: &mut UIStateGuard, - data: &DataReadGuard, ) { let configs = config::get_config(); // get the current playing track's URI to decorate such track (if exists) in the track table @@ -1030,15 +1028,9 @@ fn render_episode_table( ((id + 1).to_string(), Style::default()) }; Row::new(vec![ - //if data.user_data.is_liked_track(t) { - // Cell::from(&configs.app_config.liked_icon as &str).style(ui.theme.like()) - //} else { - Cell::from(""), - //}, Cell::from(id), Cell::from(t.name.clone()), Cell::from(t.release_date.clone()), - //Cell::from(t.album_info()), Cell::from(format!( "{}:{:02}", t.duration.as_secs() / 60, @@ -1051,21 +1043,17 @@ fn render_episode_table( let episode_table = Table::new( rows, [ - Constraint::Length(configs.app_config.liked_icon.chars().count() as u16), Constraint::Length(4), Constraint::Fill(4), Constraint::Fill(3), - //Constraint::Fill(5), Constraint::Fill(1), ], ) .header( Row::new(vec![ - Cell::from(""), Cell::from("#"), Cell::from("Title"), Cell::from("Date"), - //Cell::from("Album"), Cell::from("Duration"), ]) .style(ui.theme.table_header()), From c5d6600f62e680332a2dde7ecdc0c8c4174c34a9 Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Sat, 7 Sep 2024 22:33:15 -0400 Subject: [PATCH 12/15] load saved shows on start --- spotify_player/src/client/mod.rs | 20 ++++++++++++++++++++ spotify_player/src/client/request.rs | 1 + spotify_player/src/main.rs | 1 + 3 files changed, 22 insertions(+) diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index ed4ae29a..eae6918e 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -337,6 +337,16 @@ impl Client { .context("store user's saved albums into the cache folder")?; state.data.write().user_data.saved_albums = albums; } + ClientRequest::GetUserSavedShows => { + let shows = self.current_user_saved_shows().await?; + store_data_into_file_cache( + FileCacheKey::SavedShows, + &config::get_config().cache_folder, + &shows, + ) + .context("store user's saved shows into the cache folder")?; + state.data.write().user_data.saved_shows = shows; + } ClientRequest::GetUserTopTracks => { let uri = &USER_TOP_TRACKS_ID.uri; if !state.data.read().caches.context.contains_key(uri) { @@ -750,6 +760,16 @@ impl Client { Ok(albums.into_iter().map(|a| a.album.into()).collect()) } + /// Get all saved shows of the current user + pub async fn current_user_saved_shows(&self) -> Result> { + let first_page = self.get_saved_show_manual(Some(50), None).await?; + + let shows = self.all_paging_items(first_page, &Query::new()).await?; + + // converts `rspotify_model::SavedAlbum` into `state::Album` + Ok(shows.into_iter().map(|s| s.show.into()).collect()) + } + /// Get all albums of an artist pub async fn artist_albums(&self, artist_id: ArtistId<'_>) -> Result> { let payload = market_query(); diff --git a/spotify_player/src/client/request.rs b/spotify_player/src/client/request.rs index e2be606c..e78f1711 100644 --- a/spotify_player/src/client/request.rs +++ b/spotify_player/src/client/request.rs @@ -26,6 +26,7 @@ pub enum ClientRequest { GetBrowseCategoryPlaylists(Category), GetUserPlaylists, GetUserSavedAlbums, + GetUserSavedShows, GetUserFollowedArtists, GetUserSavedTracks, GetUserTopTracks, diff --git a/spotify_player/src/main.rs b/spotify_player/src/main.rs index 5d60b10b..451de112 100644 --- a/spotify_player/src/main.rs +++ b/spotify_player/src/main.rs @@ -32,6 +32,7 @@ async fn init_spotify( client_pub.send(client::ClientRequest::GetUserFollowedArtists)?; client_pub.send(client::ClientRequest::GetUserSavedAlbums)?; client_pub.send(client::ClientRequest::GetUserSavedTracks)?; + client_pub.send(client::ClientRequest::GetUserSavedShows)?; Ok(()) } From d2a5009e9e90fda03e5ed4c98df4980e8f8d682b Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Sun, 8 Sep 2024 09:02:22 -0400 Subject: [PATCH 13/15] fix check with features enabled --- spotify_player/src/client/handlers.rs | 4 +++- spotify_player/src/client/mod.rs | 28 ++++++++++++++++++++++----- spotify_player/src/event/mod.rs | 4 +++- spotify_player/src/state/player.rs | 3 +++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/spotify_player/src/client/handlers.rs b/spotify_player/src/client/handlers.rs index 79cdc29b..eab4655d 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -165,7 +165,9 @@ fn handle_page_change_event( artists, scroll_offset, } => { - if let Some(current_track) = state.player.read().currently_playing() { + if let Some(rspotify_model::PlayableItem::Track(current_track)) = + state.player.read().currently_playing() + { if current_track.name != *track { tracing::info!("Current playing track \"{}\" is different from the track \"{track}\" shown up in the lyric page. Updating the track and fetching its lyric...", current_track.name); track.clone_from(¤t_track.name); diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index eae6918e..7f772fd4 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -1574,7 +1574,7 @@ impl Client { if configs.app_config.enable_notify && (!configs.app_config.notify_streaming_only || self.stream_conn.lock().is_some()) { - Self::notify_new_track(track, &path)?; + Self::notify_new_track(track_or_episode, &path)?; } Ok(()) @@ -1617,7 +1617,7 @@ impl Client { #[cfg(feature = "notify")] /// Create a notification for a new track fn notify_new_track( - track: rspotify_model::FullTrack, + playable: rspotify_model::PlayableItem, cover_img_path: &std::path::Path, ) -> Result<()> { let mut n = notify_rust::Notification::new(); @@ -1639,11 +1639,29 @@ impl Client { } ptr = e; match m.as_str() { - "{track}" => text += &track.name, + "{track}" => { + let name = match playable { + rspotify_model::PlayableItem::Track(ref track) => &track.name, + rspotify_model::PlayableItem::Episode(ref episode) => &episode.name, + }; + text += &name + } "{artists}" => { - text += &crate::utils::map_join(&track.artists, |a| &a.name, ", ") + let artists_or_show = match playable { + rspotify_model::PlayableItem::Track(ref track) => { + crate::utils::map_join(&track.artists, |a| &a.name, ", ") + } + rspotify_model::PlayableItem::Episode(ref episode) => { + episode.show.name.clone() + } + }; + text += &artists_or_show + } + "{album}" => { + if let rspotify_model::PlayableItem::Track(ref track) = playable { + text += &track.album.name + } } - "{album}" => text += &track.album.name, _ => continue, } } diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index 14a206a8..ac8a72c1 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -732,7 +732,9 @@ fn handle_global_command( } #[cfg(feature = "lyric-finder")] Command::LyricPage => { - if let Some(track) = state.player.read().currently_playing() { + if let Some(rspotify_model::PlayableItem::Track(track)) = + state.player.read().currently_playing() + { let artists = map_join(&track.artists, |a| &a.name, ", "); ui.new_page(PageState::Lyric { track: track.name.clone(), diff --git a/spotify_player/src/state/player.rs b/spotify_player/src/state/player.rs index 3a2958fb..90b491d0 100644 --- a/spotify_player/src/state/player.rs +++ b/spotify_player/src/state/player.rs @@ -83,6 +83,9 @@ impl PlayerState { rspotify_model::Type::Artist => Some(ContextId::Artist( ArtistId::from_uri(&uri).ok()?.into_static(), )), + rspotify_model::Type::Show => { + Some(ContextId::Show(ShowId::from_uri(&uri).ok()?.into_static())) + } _ => None, } } From aeafb929a581c7c9f709798df421cf46b8dc0e3d Mon Sep 17 00:00:00 2001 From: Sebastian Rollen Date: Sun, 8 Sep 2024 09:09:38 -0400 Subject: [PATCH 14/15] fix clippy with features enabled --- spotify_player/src/client/handlers.rs | 2 +- spotify_player/src/client/mod.rs | 8 ++++---- spotify_player/src/state/player.rs | 2 +- spotify_player/src/ui/page.rs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spotify_player/src/client/handlers.rs b/spotify_player/src/client/handlers.rs index eab4655d..dd656844 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -102,7 +102,7 @@ fn handle_playback_change_event( "fake track repeat mode is enabled, add the current track ({}) to queue", name ); - client_pub.send(ClientRequest::AddPlayableToQueue(id.into()))?; + client_pub.send(ClientRequest::AddPlayableToQueue(id))?; handler_state.add_track_to_queue_req_timer = std::time::Instant::now(); } } diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index 7f772fd4..dd006746 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -1512,7 +1512,7 @@ impl Client { let track_or_episode = { let player = state.player.read(); - let Some(track_or_episode) = player.currently_playing().clone() else { + let Some(track_or_episode) = player.currently_playing() else { return Ok(()); }; track_or_episode.clone() @@ -1520,11 +1520,11 @@ impl Client { let url = match track_or_episode { rspotify_model::PlayableItem::Track(ref track) => { - crate::utils::get_track_album_image_url(&track) + crate::utils::get_track_album_image_url(track) .ok_or(anyhow::anyhow!("missing image"))? } rspotify_model::PlayableItem::Episode(ref episode) => { - crate::utils::get_episode_show_image_url(&episode) + crate::utils::get_episode_show_image_url(episode) .ok_or(anyhow::anyhow!("missing image"))? } }; @@ -1644,7 +1644,7 @@ impl Client { rspotify_model::PlayableItem::Track(ref track) => &track.name, rspotify_model::PlayableItem::Episode(ref episode) => &episode.name, }; - text += &name + text += name } "{artists}" => { let artists_or_show = match playable { diff --git a/spotify_player/src/state/player.rs b/spotify_player/src/state/player.rs index 90b491d0..16ff6056 100644 --- a/spotify_player/src/state/player.rs +++ b/spotify_player/src/state/player.rs @@ -47,7 +47,7 @@ impl PlayerState { } pub fn currently_playing(&self) -> Option<&rspotify_model::PlayableItem> { - self.playback.as_ref().map(|p| p.item.as_ref()).flatten() + self.playback.as_ref().and_then(|p| p.item.as_ref()) } pub fn playback_progress(&self) -> Option { diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index 66f5116b..c36ddb31 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -170,7 +170,7 @@ pub fn render_search_page( .map(|s| { s.episodes .iter() - .map(|e| (format!("{}", e.to_string()), false)) + .map(|e| (format!("{}", e), false)) .collect::>() }) .unwrap_or_default(); From 9782715db20eb7178cab00b0be0b38d859d45a11 Mon Sep 17 00:00:00 2001 From: Thang Pham Date: Sat, 26 Oct 2024 22:54:15 -0400 Subject: [PATCH 15/15] wip: code review 1st pass --- spotify_player/src/client/handlers.rs | 11 +- spotify_player/src/client/mod.rs | 62 +++--- spotify_player/src/command.rs | 2 +- spotify_player/src/event/page.rs | 7 +- spotify_player/src/event/window.rs | 123 +---------- spotify_player/src/media_control.rs | 90 ++++---- spotify_player/src/state/data.rs | 13 +- spotify_player/src/state/model.rs | 6 +- spotify_player/src/ui/page.rs | 83 +++----- spotify_player/src/ui/playback.rs | 292 ++++++++------------------ 10 files changed, 209 insertions(+), 480 deletions(-) diff --git a/spotify_player/src/client/handlers.rs b/spotify_player/src/client/handlers.rs index dd656844..e7b6a5b4 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -53,13 +53,13 @@ fn handle_playback_change_event( (Some(playback), Some(PlayableItem::Track(track))) => ( playback, PlayableId::Track(track.id.clone().expect("all non-local tracks have ids")), - track.name.clone(), + &track.name, track.duration, ), (Some(playback), Some(PlayableItem::Episode(episode))) => ( playback, PlayableId::Episode(episode.id.clone()), - episode.name.clone(), + &episode.name, episode.duration, ), _ => return Ok(()), @@ -75,12 +75,7 @@ fn handle_playback_change_event( if let Some(queue) = player.queue.as_ref() { // queue needs to be updated if its playing track is different from actual playback's playing track if let Some(queue_track) = queue.currently_playing.as_ref() { - if queue_track - .id() - .expect("all non-local tracks have ids") - .uri() - != id.uri() - { + if queue_track.id().expect("all non-local tracks have ids") != id { client_pub.send(ClientRequest::GetCurrentUserQueue)?; } } diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index dd006746..866ec472 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -763,10 +763,7 @@ impl Client { /// Get all saved shows of the current user pub async fn current_user_saved_shows(&self) -> Result> { let first_page = self.get_saved_show_manual(Some(50), None).await?; - let shows = self.all_paging_items(first_page, &Query::new()).await?; - - // converts `rspotify_model::SavedAlbum` into `state::Album` Ok(shows.into_iter().map(|s| s.show.into()).collect()) } @@ -984,7 +981,7 @@ impl Client { .await?) } - /// Add a track to a playlist + /// Add a playable item to a playlist pub async fn add_item_to_playlist( &self, state: &SharedState, @@ -1341,7 +1338,7 @@ impl Client { // converts `rspotify_model::FullShow` into `state::Show` let show: Show = show.into(); - // get the show's tracks + // get the show's episodes let episodes = self .all_paging_items(first_page, &Query::new()) .await? @@ -1446,14 +1443,14 @@ impl Client { state: &SharedState, reset_buffered_playback: bool, ) -> Result<()> { - let new_track = { + let new_playback = { // update the playback state let playback = self.current_playback(None, None::>).await?; let mut player = state.player.write(); - let prev_track = player.currently_playing(); + let prev_item = player.currently_playing(); - let prev_track_name = match prev_track { + let prev_name = match prev_item { Some(rspotify_model::PlayableItem::Track(track)) => track.name.to_owned(), Some(rspotify_model::PlayableItem::Episode(episode)) => episode.name.to_owned(), None => String::new(), @@ -1462,18 +1459,18 @@ impl Client { player.playback = playback; player.playback_last_updated_time = Some(std::time::Instant::now()); - let curr_track = player.currently_playing(); + let curr_item = player.currently_playing(); - let curr_track_name = match curr_track { + let curr_name = match curr_item { Some(rspotify_model::PlayableItem::Track(track)) => track.name.to_owned(), Some(rspotify_model::PlayableItem::Episode(episode)) => episode.name.to_owned(), None => String::new(), }; - let new_track = prev_track_name != curr_track_name && !curr_track_name.is_empty(); + let new_playback = prev_name != curr_name && !curr_name.is_empty(); // check if we need to update the buffered playback let needs_update = match (&player.buffered_playback, &player.playback) { - (Some(bp), Some(p)) => bp.device_id != p.device.id || new_track, + (Some(bp), Some(p)) => bp.device_id != p.device.id || new_playback, (None, None) => false, _ => true, }; @@ -1495,22 +1492,22 @@ impl Client { }); } - new_track + new_playback }; - if !new_track { + if !new_playback { return Ok(()); } - self.handle_new_track_event(state).await?; + self.handle_new_playback_event(state).await?; Ok(()) } // Handle new track event - async fn handle_new_track_event(&self, state: &SharedState) -> Result<()> { + async fn handle_new_playback_event(&self, state: &SharedState) -> Result<()> { let configs = config::get_config(); - let track_or_episode = { + let curr_item = { let player = state.player.read(); let Some(track_or_episode) = player.currently_playing() else { return Ok(()); @@ -1518,7 +1515,7 @@ impl Client { track_or_episode.clone() }; - let url = match track_or_episode { + let url = match curr_item { rspotify_model::PlayableItem::Track(ref track) => { crate::utils::get_track_album_image_url(track) .ok_or(anyhow::anyhow!("missing image"))? @@ -1529,7 +1526,7 @@ impl Client { } }; - let filename = (match track_or_episode { + let filename = (match curr_item { rspotify_model::PlayableItem::Track(ref track) => { format!( "{}-{}-cover-{}.jpg", @@ -1544,7 +1541,7 @@ impl Client { "{}-{}-cover-{}.jpg", episode.show.name, episode.show.publisher, - // first 6 characters of the album's id + // first 6 characters of the show's id &episode.show.id.as_ref().id()[..6] ) } @@ -1574,7 +1571,7 @@ impl Client { if configs.app_config.enable_notify && (!configs.app_config.notify_streaming_only || self.stream_conn.lock().is_some()) { - Self::notify_new_track(track_or_episode, &path)?; + Self::notify_new_playback(curr_item, &path)?; } Ok(()) @@ -1615,8 +1612,8 @@ impl Client { } #[cfg(feature = "notify")] - /// Create a notification for a new track - fn notify_new_track( + /// Create a notification for a new playback + fn notify_new_playback( playable: rspotify_model::PlayableItem, cover_img_path: &std::path::Path, ) -> Result<()> { @@ -1647,21 +1644,16 @@ impl Client { text += name } "{artists}" => { - let artists_or_show = match playable { - rspotify_model::PlayableItem::Track(ref track) => { - crate::utils::map_join(&track.artists, |a| &a.name, ", ") - } - rspotify_model::PlayableItem::Episode(ref episode) => { - episode.show.name.clone() - } - }; - text += &artists_or_show - } - "{album}" => { if let rspotify_model::PlayableItem::Track(ref track) = playable { - text += &track.album.name + text += &crate::utils::map_join(&track.artists, |a| &a.name, ", "); } } + "{album}" => match playable { + rspotify_model::PlayableItem::Track(ref track) => text += &track.album.name, + rspotify_model::PlayableItem::Episode(ref episode) => { + text += &episode.show.name + } + }, _ => continue, } } diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs index 90596cd1..2caa344f 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -261,7 +261,7 @@ pub fn construct_playlist_actions(playlist: &Playlist, data: &DataReadGuard) -> } actions } -/// + /// constructs a list of actions on a show pub fn construct_show_actions(show: &Show, data: &DataReadGuard) -> Vec { let mut actions = vec![Action::CopyLink]; diff --git a/spotify_player/src/event/page.rs b/spotify_player/src/event/page.rs index c82a5633..da36f36a 100644 --- a/spotify_player/src/event/page.rs +++ b/spotify_player/src/event/page.rs @@ -276,10 +276,9 @@ fn handle_key_sequence_for_search_page( }; match found_keymap { - CommandOrAction::Command(command) => { - window::handle_command_for_episode_list_window( - command, client_pub, episodes, &data, ui, - ) + CommandOrAction::Command(_) => { + // implement command handler for episode list window similar to track list window + Ok(false) } CommandOrAction::Action(action, ActionTarget::SelectedItem) => { window::handle_action_for_selected_item(action, episodes, &data, ui, client_pub) diff --git a/spotify_player/src/event/window.rs b/spotify_player/src/event/window.rs index 6cdb3acb..7941d722 100644 --- a/spotify_player/src/event/window.rs +++ b/spotify_player/src/event/window.rs @@ -211,9 +211,10 @@ pub fn handle_command_for_focused_context_window( Context::Tracks { tracks, .. } => { handle_command_for_track_table_window(command, client_pub, None, tracks, &data, ui) } - Context::Show { episodes, .. } => handle_command_for_episode_table_window( - command, client_pub, None, episodes, &data, ui, - ), + Context::Show { .. } => { + // TODO: implement command handler for episode table window similar to track table window + Ok(false) + } }, None => Ok(false), } @@ -348,83 +349,6 @@ fn handle_command_for_track_table_window( Ok(true) } -fn handle_command_for_episode_table_window( - command: Command, - client_pub: &flume::Sender, - context_id: Option, - episodes: &[Episode], - data: &DataReadGuard, - ui: &mut UIStateGuard, -) -> Result { - let id = ui.current_page_mut().selected().unwrap_or_default(); - let filtered_episodes = ui.search_filtered_items(episodes); - if id >= filtered_episodes.len() { - return Ok(false); - } - - //if let Some(ContextId::Playlist(ref playlist_id)) = context_id { - // let modifiable = - // data.user_data.modifiable_playlist_items(None).iter().any( - // |item| matches!(item, PlaylistFolderItem::Playlist(p) if p.id.eq(playlist_id)), - // ); - // if modifiable - // && handle_playlist_modify_command( - // id, - // playlist_id, - // command, - // client_pub, - // &filtered_episodes, - // data, - // ui, - // )? - // { - // return Ok(true); - // } - //} - - if handle_navigation_command(command, ui.current_page_mut(), id, filtered_episodes.len()) { - return Ok(true); - } - - match command { - Command::PlayRandom | Command::ChooseSelected => { - let uri = if command == Command::PlayRandom { - episodes[rand::thread_rng().gen_range(0..episodes.len())] - .id - .uri() - } else { - filtered_episodes[id].id.uri() - }; - - let base_playback = if let Some(context_id) = context_id { - Playback::Context(context_id, None) - } else { - Playback::URIs(episodes.iter().map(|t| t.id.clone().into()).collect(), None) - }; - - client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( - base_playback - .uri_offset(uri, config::get_config().app_config.tracks_playback_limit), - None, - )))?; - } - Command::ShowActionsOnSelectedItem => { - let actions = command::construct_episode_actions(filtered_episodes[id], data); - ui.popup = Some(PopupState::ActionList( - Box::new(ActionListItem::Episode(episodes[id].clone(), actions)), - ListState::default(), - )); - } - Command::AddSelectedItemToQueue => { - client_pub.send(ClientRequest::AddPlayableToQueue( - filtered_episodes[id].id.clone().into(), - ))?; - } - _ => return Ok(false), - } - Ok(true) -} - pub fn handle_command_for_track_list_window( command: Command, client_pub: &flume::Sender, @@ -629,42 +553,3 @@ pub fn handle_command_for_show_list_window( } Ok(true) } - -pub fn handle_command_for_episode_list_window( - command: Command, - client_pub: &flume::Sender, - episodes: Vec<&Episode>, - data: &DataReadGuard, - ui: &mut UIStateGuard, -) -> Result { - let id = ui.current_page_mut().selected().unwrap_or_default(); - if id >= episodes.len() { - return Ok(false); - } - - if handle_navigation_command(command, ui.current_page_mut(), id, episodes.len()) { - return Ok(true); - } - match command { - Command::ChooseSelected => { - client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( - Playback::URIs(vec![episodes[id].id.clone().into()], None), - None, - )))?; - } - Command::ShowActionsOnSelectedItem => { - let actions = command::construct_episode_actions(episodes[id], data); - ui.popup = Some(PopupState::ActionList( - Box::new(ActionListItem::Episode(episodes[id].clone(), actions)), - ListState::default(), - )); - } - Command::AddSelectedItemToQueue => { - client_pub.send(ClientRequest::AddPlayableToQueue( - episodes[id].id.clone().into(), - ))?; - } - _ => return Ok(false), - } - Ok(true) -} diff --git a/spotify_player/src/media_control.rs b/spotify_player/src/media_control.rs index 30a59df3..9f7996ee 100644 --- a/spotify_player/src/media_control.rs +++ b/spotify_player/src/media_control.rs @@ -13,65 +13,55 @@ use crate::{ fn update_control_metadata( state: &SharedState, controls: &mut MediaControls, - prev_track_info: &mut String, + prev_info: &mut String, ) -> Result<(), souvlaki::Error> { let player = state.player.read(); match player.currently_playing() { None => {} - Some(rspotify_model::PlayableItem::Track(track)) => { - if let Some(ref playback) = player.playback { - let progress = player - .playback_progress() - .and_then(|p| Some(MediaPosition(p.to_std().ok()?))); + Some(item) => { + let progress = player + .playback_progress() + .and_then(|p| Some(MediaPosition(p.to_std().ok()?))); - if playback.is_playing { - controls.set_playback(MediaPlayback::Playing { progress })?; - } else { - controls.set_playback(MediaPlayback::Paused { progress })?; - } + if player.playback.as_ref().expect("playback").is_playing { + controls.set_playback(MediaPlayback::Playing { progress })?; + } else { + controls.set_playback(MediaPlayback::Paused { progress })?; } - // only update metadata when the track information is changed - let track_info = format!("{}/{}", track.name, track.album.name); - if track_info != *prev_track_info { - controls.set_metadata(MediaMetadata { - title: Some(&track.name), - album: Some(&track.album.name), - artist: Some(&map_join(&track.artists, |a| &a.name, ", ")), - duration: track.duration.to_std().ok(), - cover_url: utils::get_track_album_image_url(track), - })?; - - *prev_track_info = track_info; - } - } - Some(rspotify_model::PlayableItem::Episode(episode)) => { - if let Some(ref playback) = player.playback { - let progress = player - .playback_progress() - .and_then(|p| Some(MediaPosition(p.to_std().ok()?))); + match item { + rspotify_model::PlayableItem::Track(track) => { + // only update metadata when the track information is changed + let track_info = format!("{}/{}", track.name, track.album.name); + if track_info != *prev_info { + controls.set_metadata(MediaMetadata { + title: Some(&track.name), + album: Some(&track.album.name), + artist: Some(&map_join(&track.artists, |a| &a.name, ", ")), + duration: track.duration.to_std().ok(), + cover_url: utils::get_track_album_image_url(track), + })?; - if playback.is_playing { - controls.set_playback(MediaPlayback::Playing { progress })?; - } else { - controls.set_playback(MediaPlayback::Paused { progress })?; + *prev_info = track_info; + } } - } - - // only update metadata when the episode information is changed - let episode_info = format!("{}/{}", episode.name, episode.show.name); - if episode_info != *prev_track_info { - controls.set_metadata(MediaMetadata { - title: Some(&episode.name), - album: Some(&episode.show.name), - artist: Some(&episode.show.publisher), - duration: episode.duration.to_std().ok(), - cover_url: utils::get_episode_show_image_url(episode), - })?; + rspotify_model::PlayableItem::Episode(episode) => { + // only update metadata when the episode information is changed + let episode_info = format!("{}/{}", episode.name, episode.show.name); + if episode_info != *prev_info { + controls.set_metadata(MediaMetadata { + title: Some(&episode.name), + album: Some(&episode.show.name), + artist: Some(&episode.show.publisher), + duration: episode.duration.to_std().ok(), + cover_url: utils::get_episode_show_image_url(episode), + })?; - *prev_track_info = episode_info; - } + *prev_info = episode_info; + } + } + }; } } @@ -153,9 +143,9 @@ pub fn start_event_watcher( // handler provided by the souvlaki library, which only handles an event every 1s. // [1]: https://github.com/Sinono3/souvlaki/blob/b4d47bb2797ffdd625c17192df640510466762e1/src/platform/linux/mod.rs#L450 let refresh_duration = std::time::Duration::from_millis(1000); - let mut track_info = String::new(); + let mut info = String::new(); loop { - update_control_metadata(&state, &mut controls, &mut track_info)?; + update_control_metadata(&state, &mut controls, &mut info)?; std::thread::sleep(refresh_duration); // this must be run repeatedly to ensure that diff --git a/spotify_player/src/state/data.rs b/spotify_player/src/state/data.rs index 055f2934..cdb8ca9a 100644 --- a/spotify_player/src/state/data.rs +++ b/spotify_player/src/state/data.rs @@ -82,7 +82,9 @@ impl AppData { /// Get a list of tracks inside a given context pub fn context_tracks_mut(&mut self, id: &ContextId) -> Option<&mut Vec> { - self.caches.context.get_mut(&id.uri()).map(|c| match c { + let c = self.caches.context.get_mut(&id.uri())?; + + Some(match c { Context::Album { tracks, .. } => tracks, Context::Playlist { tracks, .. } => tracks, Context::Artist { @@ -90,13 +92,15 @@ impl AppData { } => tracks, Context::Tracks { tracks, .. } => tracks, Context::Show { .. } => { - todo!("implement context_tracks_mut"); + // TODO: handle context_tracks_mut for Show + return None; } }) } pub fn context_tracks(&self, id: &ContextId) -> Option<&Vec> { - self.caches.context.get(&id.uri()).map(|c| match c { + let c = self.caches.context.get(&id.uri())?; + Some(match c { Context::Album { tracks, .. } => tracks, Context::Playlist { tracks, .. } => tracks, Context::Artist { @@ -104,7 +108,8 @@ impl AppData { } => tracks, Context::Tracks { tracks, .. } => tracks, Context::Show { .. } => { - todo!("implement context_tracks"); + // TODO: handle context_tracks for Show + return None; } }) } diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index 133bf265..2f12d938 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -567,7 +567,11 @@ impl From for Episode { impl std::fmt::Display for Episode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.name,) + if let Some(s) = &self.show { + write!(f, "{} • {}", self.name, s.name) + } else { + write!(f, "{}", self.name) + } } } diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index c36ddb31..8d21cb72 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -1,4 +1,7 @@ -use std::collections::{btree_map::Entry, BTreeMap}; +use std::{ + collections::{btree_map::Entry, BTreeMap}, + fmt::Display, +}; use crate::utils::format_duration; @@ -46,7 +49,7 @@ pub fn render_search_page( let search_input_rect = chunks[0]; let rect = chunks[1]; - // track/album/artist/playlist search results layout (2x2 table) + // track/album/artist/playlist/show/episode search results layout (3x2 table) let chunks = Layout::vertical([Constraint::Ratio(1, 3); 3]) .split(rect) .iter() @@ -85,20 +88,14 @@ pub fn render_search_page( let episode_rect = construct_and_render_block("Episodes", &ui.theme, Borders::TOP, frame, chunks[5]); + fn search_items(items: &[T]) -> Vec<(String, bool)> { + items.iter().map(|i| (i.to_string(), false)).collect() + } + // 3. Construct the page's widgets let (track_list, n_tracks) = { let track_items = search_results - .map(|s| { - s.tracks - .iter() - .map(|a| { - ( - format!("{} • {}", a.display_name(), a.artists_info()), - false, - ) - }) - .collect::>() - }) + .map(|s| search_items(&s.tracks)) .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Tracks; @@ -108,12 +105,7 @@ pub fn render_search_page( let (album_list, n_albums) = { let album_items = search_results - .map(|s| { - s.albums - .iter() - .map(|a| (a.to_string(), false)) - .collect::>() - }) + .map(|s| search_items(&s.albums)) .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Albums; @@ -123,12 +115,7 @@ pub fn render_search_page( let (artist_list, n_artists) = { let artist_items = search_results - .map(|s| { - s.artists - .iter() - .map(|a| (a.to_string(), false)) - .collect::>() - }) + .map(|s| search_items(&s.artists)) .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Artists; @@ -138,12 +125,7 @@ pub fn render_search_page( let (playlist_list, n_playlists) = { let playlist_items = search_results - .map(|s| { - s.playlists - .iter() - .map(|a| (a.to_string(), false)) - .collect::>() - }) + .map(|s| search_items(&s.playlists)) .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Playlists; @@ -153,12 +135,7 @@ pub fn render_search_page( let (show_list, n_shows) = { let show_items = search_results - .map(|s| { - s.shows - .iter() - .map(|a| (a.to_string(), false)) - .collect::>() - }) + .map(|s| search_items(&s.shows)) .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Shows; @@ -167,12 +144,7 @@ pub fn render_search_page( let (episode_list, n_episodes) = { let episode_items = search_results - .map(|s| { - s.episodes - .iter() - .map(|e| (format!("{}", e), false)) - .collect::>() - }) + .map(|s| search_items(&s.episodes)) .unwrap_or_default(); let is_active = is_active && focus_state == SearchFocusState::Episodes; @@ -987,7 +959,9 @@ fn render_track_table( ContextPageUIState::Playlist { track_table } => track_table, ContextPageUIState::Album { track_table } => track_table, ContextPageUIState::Tracks { track_table } => track_table, - ContextPageUIState::Show { episode_table } => episode_table, + ContextPageUIState::Show { .. } => { + unreachable!("show's episode table should be handled by render_episode_table") + } }; utils::render_table_window(frame, track_table, rect, n_tracks, playable_table_state); } @@ -1002,7 +976,7 @@ fn render_episode_table( ui: &mut UIStateGuard, ) { let configs = config::get_config(); - // get the current playing track's URI to decorate such track (if exists) in the track table + // get the current playing episode's URI to decorate such episode (if exists) in the episode table let mut playing_episode_uri = "".to_string(); let mut playing_id = ""; if let Some(ref playback) = state.player.read().playback { @@ -1021,20 +995,20 @@ fn render_episode_table( let rows = episodes .into_iter() .enumerate() - .map(|(id, t)| { - let (id, style) = if playing_episode_uri == t.id.uri() { + .map(|(id, e)| { + let (id, style) = if playing_episode_uri == e.id.uri() { (playing_id.to_string(), ui.theme.current_playing()) } else { ((id + 1).to_string(), Style::default()) }; Row::new(vec![ Cell::from(id), - Cell::from(t.name.clone()), - Cell::from(t.release_date.clone()), + Cell::from(e.name.clone()), + Cell::from(e.release_date.clone()), Cell::from(format!( "{}:{:02}", - t.duration.as_secs() / 60, - t.duration.as_secs() % 60, + e.duration.as_secs() / 60, + e.duration.as_secs() % 60, )), ]) .style(style) @@ -1066,13 +1040,8 @@ fn render_episode_table( } = ui.current_page_mut() { let playable_table_state = match state { - ContextPageUIState::Artist { - top_track_table, .. - } => top_track_table, - ContextPageUIState::Playlist { track_table } => track_table, - ContextPageUIState::Album { track_table } => track_table, - ContextPageUIState::Tracks { track_table } => track_table, ContextPageUIState::Show { episode_table } => episode_table, + s => unreachable!("unexpected state: {s:?}"), }; utils::render_table_window(frame, episode_table, rect, n_episodes, playable_table_state); } diff --git a/spotify_player/src/ui/playback.rs b/spotify_player/src/ui/playback.rs index b8b1a3b1..36089ed3 100644 --- a/spotify_player/src/ui/playback.rs +++ b/spotify_player/src/ui/playback.rs @@ -16,224 +16,114 @@ pub fn render_playback_window( let player = state.player.read(); if let Some(ref playback) = player.playback { - match playback.item { - Some(rspotify::model::PlayableItem::Track(ref track)) => { - let (metadata_rect, progress_bar_rect) = { - // allocate the progress bar rect - let (rect, progress_bar_rect) = { - let chunks = Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]) - .split(rect); - - (chunks[0], chunks[1]) - }; - - let metadata_rect = { - // Render the track's cover image if `image` feature is enabled - #[cfg(feature = "image")] - { - let configs = config::get_config(); - // Split the allocated rectangle into `metadata_rect` and `cover_img_rect` - let (metadata_rect, cover_img_rect) = { - let hor_chunks = Layout::horizontal([ - Constraint::Length(configs.app_config.cover_img_length as u16), - Constraint::Fill(0), // metadata_rect - ]) - .spacing(1) - .split(rect); - let ver_chunks = Layout::vertical([ - Constraint::Length(configs.app_config.cover_img_width as u16), // cover_img_rect - Constraint::Fill(0), // empty space - ]) - .split(hor_chunks[0]); - - (hor_chunks[1], ver_chunks[0]) - }; - - let url = - crate::utils::get_track_album_image_url(track).map(String::from); - if let Some(url) = url { - let needs_clear = if ui.last_cover_image_render_info.url != url - || ui.last_cover_image_render_info.render_area != cover_img_rect - { - ui.last_cover_image_render_info = ImageRenderInfo { - url, - render_area: cover_img_rect, - rendered: false, - }; - true - } else { - false - }; - - if needs_clear { - // clear the image's both new and old areas to ensure no remaining artifacts before rendering the image - // See: https://github.com/aome510/spotify-player/issues/389 - clear_area(frame, ui.last_cover_image_render_info.render_area); - clear_area(frame, cover_img_rect); - } else { - if !ui.last_cover_image_render_info.rendered { - if let Err(err) = render_playback_cover_image(state, ui) { - tracing::error!( - "Failed to render playback's cover image: {err:#}" - ); - } - } - - // set the `skip` state of cells in the cover image area - // to prevent buffer from overwriting the image's rendered area - // NOTE: `skip` should not be set when clearing the render area. - // Otherwise, nothing will be clear as the buffer doesn't handle cells with `skip=true`. - for x in cover_img_rect.left()..cover_img_rect.right() { - for y in cover_img_rect.top()..cover_img_rect.bottom() { - frame.buffer_mut().get_mut(x, y).set_skip(true); - } - } - } - } - - metadata_rect - } - - #[cfg(not(feature = "image"))] - { - rect - } - }; - - (metadata_rect, progress_bar_rect) + if let Some(item) = &playback.item { + let (metadata_rect, progress_bar_rect) = { + // allocate the progress bar rect + let (rect, progress_bar_rect) = { + let chunks = + Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]).split(rect); + + (chunks[0], chunks[1]) }; - if let Some(ref playback) = player.buffered_playback { - let playback_text = construct_playback_text( - ui, - &rspotify_model::PlayableItem::Track(track.clone()), - playback, - ); - let playback_desc = Paragraph::new(playback_text); - frame.render_widget(playback_desc, metadata_rect); - } - - let progress = std::cmp::min( - player.playback_progress().expect("non-empty playback"), - track.duration, - ); - render_playback_progress_bar( - frame, - ui, - progress, - track.duration, - progress_bar_rect, - ); - } - Some(rspotify::model::PlayableItem::Episode(ref episode)) => { - let (metadata_rect, progress_bar_rect) = { - // allocate the progress bar rect - let (rect, progress_bar_rect) = { - let chunks = Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]) + let metadata_rect = { + // Render the track's cover image if `image` feature is enabled + #[cfg(feature = "image")] + { + let configs = config::get_config(); + // Split the allocated rectangle into `metadata_rect` and `cover_img_rect` + let (metadata_rect, cover_img_rect) = { + let hor_chunks = Layout::horizontal([ + Constraint::Length(configs.app_config.cover_img_length as u16), + Constraint::Fill(0), // metadata_rect + ]) + .spacing(1) .split(rect); - - (chunks[0], chunks[1]) - }; - - let metadata_rect = { - // Render the track's cover image if `image` feature is enabled - #[cfg(feature = "image")] - { - let configs = config::get_config(); - // Split the allocated rectangle into `metadata_rect` and `cover_img_rect` - let (metadata_rect, cover_img_rect) = { - let hor_chunks = Layout::horizontal([ - Constraint::Length(configs.app_config.cover_img_length as u16), - Constraint::Fill(0), // metadata_rect - ]) - .spacing(1) - .split(rect); - let ver_chunks = Layout::vertical([ - Constraint::Length(configs.app_config.cover_img_width as u16), // cover_img_rect - Constraint::Fill(0), // empty space - ]) - .split(hor_chunks[0]); - - (hor_chunks[1], ver_chunks[0]) - }; - - let url = - crate::utils::get_episode_show_image_url(episode).map(String::from); - if let Some(url) = url { - let needs_clear = if ui.last_cover_image_render_info.url != url - || ui.last_cover_image_render_info.render_area != cover_img_rect - { - ui.last_cover_image_render_info = ImageRenderInfo { - url, - render_area: cover_img_rect, - rendered: false, - }; - true - } else { - false + let ver_chunks = Layout::vertical([ + Constraint::Length(configs.app_config.cover_img_width as u16), // cover_img_rect + Constraint::Fill(0), // empty space + ]) + .split(hor_chunks[0]); + + (hor_chunks[1], ver_chunks[0]) + }; + + let url = match item { + rspotify_model::PlayableItem::Track(track) => { + crate::utils::get_track_album_image_url(track).map(String::from) + } + rspotify_model::PlayableItem::Episode(episode) => { + crate::utils::get_episode_show_image_url(episode).map(String::from) + } + }; + if let Some(url) = url { + let needs_clear = if ui.last_cover_image_render_info.url != url + || ui.last_cover_image_render_info.render_area != cover_img_rect + { + ui.last_cover_image_render_info = ImageRenderInfo { + url, + render_area: cover_img_rect, + rendered: false, }; + true + } else { + false + }; - if needs_clear { - // clear the image's both new and old areas to ensure no remaining artifacts before rendering the image - // See: https://github.com/aome510/spotify-player/issues/389 - clear_area(frame, ui.last_cover_image_render_info.render_area); - clear_area(frame, cover_img_rect); - } else { - if !ui.last_cover_image_render_info.rendered { - if let Err(err) = render_playback_cover_image(state, ui) { - tracing::error!( - "Failed to render playback's cover image: {err:#}" - ); - } + if needs_clear { + // clear the image's both new and old areas to ensure no remaining artifacts before rendering the image + // See: https://github.com/aome510/spotify-player/issues/389 + clear_area(frame, ui.last_cover_image_render_info.render_area); + clear_area(frame, cover_img_rect); + } else { + if !ui.last_cover_image_render_info.rendered { + if let Err(err) = render_playback_cover_image(state, ui) { + tracing::error!( + "Failed to render playback's cover image: {err:#}" + ); } + } - // set the `skip` state of cells in the cover image area - // to prevent buffer from overwriting the image's rendered area - // NOTE: `skip` should not be set when clearing the render area. - // Otherwise, nothing will be clear as the buffer doesn't handle cells with `skip=true`. - for x in cover_img_rect.left()..cover_img_rect.right() { - for y in cover_img_rect.top()..cover_img_rect.bottom() { - frame.buffer_mut().get_mut(x, y).set_skip(true); - } + // set the `skip` state of cells in the cover image area + // to prevent buffer from overwriting the image's rendered area + // NOTE: `skip` should not be set when clearing the render area. + // Otherwise, nothing will be clear as the buffer doesn't handle cells with `skip=true`. + for x in cover_img_rect.left()..cover_img_rect.right() { + for y in cover_img_rect.top()..cover_img_rect.bottom() { + frame.buffer_mut().get_mut(x, y).set_skip(true); } } } - - metadata_rect } - #[cfg(not(feature = "image"))] - { - rect - } - }; + metadata_rect + } - (metadata_rect, progress_bar_rect) + #[cfg(not(feature = "image"))] + { + rect + } }; - if let Some(ref playback) = player.buffered_playback { - let playback_text = construct_playback_text( - ui, - &rspotify_model::PlayableItem::Episode(episode.clone()), - playback, - ); - let playback_desc = Paragraph::new(playback_text); - frame.render_widget(playback_desc, metadata_rect); - } + (metadata_rect, progress_bar_rect) + }; - let progress = std::cmp::min( - player.playback_progress().expect("non-empty playback"), - episode.duration, - ); - render_playback_progress_bar( - frame, - ui, - progress, - episode.duration, - progress_bar_rect, - ); + if let Some(ref playback) = player.buffered_playback { + let playback_text = construct_playback_text(ui, item, playback); + let playback_desc = Paragraph::new(playback_text); + frame.render_widget(playback_desc, metadata_rect); } - None => (), + + let duration = match item { + rspotify_model::PlayableItem::Track(track) => track.duration, + rspotify_model::PlayableItem::Episode(episode) => episode.duration, + }; + + let progress = std::cmp::min( + player.playback_progress().expect("non-empty playback"), + duration, + ); + render_playback_progress_bar(frame, ui, progress, duration, progress_bar_rect); } } else { // Previously rendered image can result in a weird rendering text,