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/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/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/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..dd656844 100644 --- a/spotify_player/src/client/handlers.rs +++ b/spotify_player/src/client/handlers.rs @@ -46,25 +46,41 @@ 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().expect("all non-local tracks have ids")), + 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() + .expect("all non-local tracks have ids") + .uri() + != id.uri() + { client_pub.send(ClientRequest::GetCurrentUserQueue)?; } } @@ -77,16 +93,16 @@ 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()))?; + client_pub.send(ClientRequest::AddPlayableToQueue(id))?; handler_state.add_track_to_queue_req_timer = std::time::Instant::now(); } } @@ -124,6 +140,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 => { @@ -148,7 +165,9 @@ fn handle_page_change_event( artists, scroll_offset, } => { - if let Some(current_track) = state.player.read().current_playing_track() { + 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 f585f4c0..dd006746 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) { @@ -403,6 +413,7 @@ impl Client { "`GetContext` request for `tracks` context is not supported!" ); } + ContextId::Show(show_id) => self.show_context(show_id).await?, }; state @@ -443,12 +454,11 @@ impl Client { ); } } - ClientRequest::AddTrackToQueue(track_id) => { - self.add_item_to_queue(PlayableId::Track(track_id), None) - .await? + ClientRequest::AddPlayableToQueue(playable_id) => { + self.add_item_to_queue(playable_id, None).await? } - ClientRequest::AddTrackToPlaylist(playlist_id, track_id) => { - self.add_track_to_playlist(state, playlist_id, track_id) + ClientRequest::AddPlayableToPlaylist(playlist_id, playable_id) => { + self.add_item_to_playlist(state, playlist_id, playable_id) .await?; } ClientRequest::AddAlbumToQueue(album_id) => { @@ -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(); @@ -807,6 +827,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( @@ -880,14 +903,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) = 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::Playlist), + self.search_specific_type(query, rspotify_model::SearchType::Show), + self.search_specific_type(query, rspotify_model::SearchType::Episode) )?; - let (tracks, artists, albums, playlists) = ( + let (tracks, artists, albums, playlists, shows, episodes) = ( match track_result { rspotify_model::SearchResult::Tracks(p) => p .items @@ -916,6 +948,18 @@ 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() + } + _ => anyhow::bail!("expect a episode search result"), + }, ); Ok(SearchResults { @@ -923,6 +967,8 @@ impl Client { artists, albums, playlists, + shows, + episodes, }) } @@ -939,26 +985,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()); @@ -1103,6 +1145,14 @@ impl Client { } } } + 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(()) } @@ -1145,6 +1195,16 @@ impl Client { }); self.playlist_unfollow(id).await?; } + 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(()) } @@ -1270,6 +1330,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 @@ -1369,18 +1451,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 +1510,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() 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); @@ -1464,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(()) @@ -1507,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(); @@ -1529,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/client/request.rs b/spotify_player/src/client/request.rs index 763ba0bc..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, @@ -37,9 +38,9 @@ pub enum ClientRequest { seed_name: String, }, Search(String), - AddTrackToQueue(TrackId<'static>), + AddPlayableToQueue(PlayableId<'static>), AddAlbumToQueue(AlbumId<'static>), - AddTrackToPlaylist(PlaylistId<'static>, TrackId<'static>), + AddPlayableToPlaylist(PlaylistId<'static>, PlayableId<'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 908ee17c..90596cd1 100644 --- a/spotify_player/src/command.rs +++ b/spotify_player/src/command.rs @@ -1,5 +1,6 @@ use crate::state::{ - Album, Artist, DataReadGuard, Playlist, PlaylistFolder, PlaylistFolderItem, Track, + Album, Artist, DataReadGuard, Episode, Playlist, PlaylistFolder, PlaylistFolderItem, Show, + Track, }; use serde::Deserialize; @@ -86,6 +87,7 @@ pub enum Action { GoToArtist, GoToAlbum, GoToRadio, + GoToShow, AddToLibrary, AddToPlaylist, AddToQueue, @@ -95,6 +97,7 @@ pub enum Action { DeleteFromPlaylist, ShowActionsOnAlbum, ShowActionsOnArtist, + ShowActionsOnShow, ToggleLiked, CopyLink, Follow, @@ -107,9 +110,11 @@ pub enum ActionContext { Album(Album), Artist(Artist), Playlist(Playlist), + Episode(Episode), #[allow(dead_code)] // TODO: support actions for playlist folders PlaylistFolder(PlaylistFolder), + Show(Show), } #[derive(Debug, PartialEq, Clone, Deserialize, Default, Copy)] @@ -148,6 +153,18 @@ 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) + } +} + impl From for ActionContext { fn from(value: PlaylistFolderItem) -> Self { match value { @@ -164,8 +181,10 @@ 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![], + Self::Show(show) => construct_show_actions(show, data), } } } @@ -242,6 +261,27 @@ 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]; + if data.user_data.saved_shows.iter().any(|s| s.id == show.id) { + actions.push(Action::DeleteFromLibrary); + } else { + actions.push(Action::AddToLibrary); + } + actions +} + +/// constructs a list of actions on an episode +pub fn construct_episode_actions(episode: &Episode, _data: &DataReadGuard) -> Vec { + let mut actions = vec![Action::CopyLink, Action::AddToPlaylist, Action::AddToQueue]; + if episode.show.is_some() { + actions.push(Action::ShowActionsOnShow); + actions.push(Action::GoToShow); + } + actions +} impl Command { pub fn desc(&self) -> &'static str { diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index a80b53c4..ac8a72c1 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); @@ -159,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) } @@ -346,6 +347,78 @@ 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) + } + 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 { + 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::AddPlayableToQueue(episode.id.into()))?; + 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::AddEpisode { + folder_id: 0, + episode_id: episode.id, + }, + ListState::default(), + )); + Ok(true) + } + 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) + } + _ => Ok(false), + }, // TODO: support actions for playlist folders ActionContext::PlaylistFolder(_) => Ok(false), } @@ -401,15 +474,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 +580,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 +695,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 +732,9 @@ fn handle_global_command( } #[cfg(feature = "lyric-finder")] Command::LyricPage => { - if let Some(track) = state.player.read().current_playing_track() { + 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(), @@ -688,13 +789,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().expect("all non-local tracks have ids")) + } + Some(rspotify_model::PlayableItem::Episode(episode)) => { + PlayableId::Episode(episode.id.clone()) + } None => return Ok(false), }; @@ -707,7 +808,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..c82a5633 100644 --- a/spotify_player/src/event/page.rs +++ b/spotify_player/src/event/page.rs @@ -254,6 +254,39 @@ 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(), + 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..0958764c 100644 --- a/spotify_player/src/event/popup.rs +++ b/spotify_player/src/event/popup.rs @@ -150,9 +150,46 @@ 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 + } + }; + Ok(()) + }, + |ui: &mut UIStateGuard| { + ui.popup = None; + }, + ) + } + 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::AddPlayableToPlaylist( + p.id.clone(), + episode_id.into(), ))?; None } @@ -501,5 +538,11 @@ 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 49d89925..6cdb3acb 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,13 @@ pub fn handle_action_for_focused_context_page( ui, client_pub, ), + Some(Context::Show { episodes, .. }) => handle_action_for_selected_item( + action, + ui.search_filtered_items(episodes), + &data, + ui, + client_pub, + ), None => Ok(false), } } @@ -201,6 +211,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), } @@ -309,7 +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()).collect(), None) + Playback::URIs(tracks.iter().map(|t| t.id.clone().into()).collect(), None) }; client_pub.send(ClientRequest::Player(PlayerRequest::StartPlayback( @@ -326,8 +339,85 @@ 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), + } + 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), @@ -358,7 +448,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, )))?; } @@ -370,7 +460,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), } @@ -503,3 +595,76 @@ 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, + 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/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(()) } 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/data.rs b/spotify_player/src/state/data.rs index 9fb56aa4..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, } @@ -87,6 +89,9 @@ impl AppData { top_tracks: tracks, .. } => tracks, Context::Tracks { tracks, .. } => tracks, + Context::Show { .. } => { + todo!("implement context_tracks_mut"); + } }) } @@ -98,6 +103,9 @@ impl AppData { top_tracks: tracks, .. } => tracks, Context::Tracks { tracks, .. } => tracks, + Context::Show { .. } => { + todo!("implement context_tracks"); + } }) } } @@ -118,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/state/model.rs b/spotify_player/src/state/model.rs index 61754edb..133bf265 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, ShowId, TrackId, UserId, +}; use crate::utils::map_join; use html_escape::decode_html_entities; @@ -29,6 +31,10 @@ pub enum Context { tracks: Vec, desc: String, }, + Show { + show: Show, + episodes: Vec, + }, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -44,18 +50,19 @@ pub enum ContextId { Album(AlbumId<'static>), Artist(ArtistId<'static>), Tracks(TracksId), + Show(ShowId<'static>), } -#[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 /// - 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), + URIs(Vec>, Option), } #[derive(Default, Clone, Debug, Deserialize, Serialize)] @@ -65,6 +72,8 @@ pub struct SearchResults { pub artists: Vec, pub albums: Vec, pub playlists: Vec, + pub shows: Vec, + pub episodes: Vec, } #[derive(Debug)] @@ -84,6 +93,7 @@ pub enum Item { Album(Album), Artist(Artist), Playlist(Playlist), + Show(Show), } #[derive(Debug, Clone)] @@ -92,6 +102,7 @@ pub enum ItemId { Album(AlbumId<'static>), Artist(ArtistId<'static>), Playlist(PlaylistId<'static>), + Show(ShowId<'static>), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -160,6 +171,24 @@ pub struct Playlist { pub current_folder_id: usize, } +#[derive(Deserialize, Serialize, Debug, Clone)] +/// 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, + pub description: String, + pub duration: std::time::Duration, + pub show: Option, + pub release_date: String, +} + #[derive(Deserialize, Serialize, Debug, Clone)] /// A playlist folder, not related to Spotify API yet pub struct PlaylistFolder { @@ -225,6 +254,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()), } } } @@ -236,6 +269,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(), } } } @@ -289,7 +323,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 { @@ -310,7 +344,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 { @@ -481,6 +515,62 @@ impl std::fmt::Display for Playlist { } } +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 { + id: episode.id, + name: episode.name, + description: episode.description, + duration: episode.duration.to_std().expect("valid chrono duration"), + show: None, + release_date: episode.release_date, + } + } +} + +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"), + show: Some(episode.show.into()), + release_date: episode.release_date, + } + } +} + +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) diff --git a/spotify_player/src/state/player.rs b/spotify_player/src/state/player.rs index 34356a0a..16ff6056 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().and_then(|p| p.item.as_ref()) } pub fn playback_progress(&self) -> Option { @@ -89,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, } } diff --git a/spotify_player/src/state/ui/page.rs b/spotify_player/src/state/ui/page.rs index 0e4e783d..a5faf8f7 100644 --- a/spotify_player/src/state/ui/page.rs +++ b/spotify_player/src/state/ui/page.rs @@ -60,6 +60,8 @@ 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, } @@ -86,6 +88,9 @@ pub enum ContextPageUIState { Tracks { track_table: TableState, }, + Show { + episode_table: TableState, + }, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -109,6 +114,8 @@ pub enum SearchFocusState { Albums, Artists, Playlists, + Shows, + Episodes, } #[derive(Clone, Debug)] @@ -182,6 +189,8 @@ impl PageState { album_list, artist_list, playlist_list, + show_list, + episode_list, focus, }, .. @@ -191,6 +200,8 @@ 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 { ContextPageUIState::Tracks { track_table } => { @@ -212,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)), @@ -247,6 +261,8 @@ 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, } } @@ -261,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"), }, } } @@ -293,6 +310,12 @@ impl ContextPageUIState { track_table: TableState::default(), } } + + pub fn new_show() -> Self { + Self::Show { + episode_table: TableState::default(), + } + } } impl<'a> MutableWindowState<'a> { @@ -410,5 +433,7 @@ impl_focusable!( [Tracks, Albums], [Albums, Artists], [Artists, Playlists], - [Playlists, Input] + [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 af774ddd..6284bd44 100644 --- a/spotify_player/src/state/ui/popup.rs +++ b/spotify_player/src/state/ui/popup.rs @@ -32,6 +32,8 @@ pub enum ActionListItem { Artist(Artist, Vec), Album(Album, Vec), Playlist(Playlist, Vec), + Show(Show, Vec), + Episode(Episode, Vec), } /// An action on an item in a playlist popup list @@ -44,6 +46,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 @@ -106,6 +112,8 @@ 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(), } } @@ -115,6 +123,8 @@ 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, } } @@ -132,6 +142,12 @@ 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/streaming.rs b/spotify_player/src/streaming.rs index 5d159ac5..566b7857 100644 --- a/spotify_player/src/streaming.rs +++ b/spotify_player/src/streaming.rs @@ -11,7 +11,7 @@ use librespot_playback::{ mixer::{self, Mixer}, player, }; -use rspotify::model::TrackId; +use rspotify::model::{EpisodeId, Id, PlayableId, TrackId}; use serde::Serialize; #[cfg(not(any( @@ -39,21 +39,21 @@ For more information, visit https://github.com/aome510/spotify-player?tab=readme #[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 +62,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 +117,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 +126,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 +136,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, }) diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index d88ce461..c36ddb31 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,35 @@ pub fn render_search_page( utils::construct_list_widget(&ui.theme, playlist_items, is_active) }; + 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 + .map(|s| { + s.episodes + .iter() + .map(|e| (format!("{}", e), 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 +221,20 @@ 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, + episode_rect, + n_episodes, + &mut page_state.episode_list, + ); } pub fn render_context_page( @@ -297,6 +349,16 @@ pub fn render_context_page( &data, ); } + Context::Show { episodes, .. } => { + render_episode_table( + frame, + rect, + is_active, + state, + ui.search_filtered_items(episodes), + ui, + ); + } } } None => { @@ -634,7 +696,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 { @@ -918,14 +980,100 @@ 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, +) { + 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![ + Cell::from(id), + Cell::from(t.name.clone()), + Cell::from(t.release_date.clone()), + 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(4), + Constraint::Fill(4), + Constraint::Fill(3), + Constraint::Fill(1), + ], + ) + .header( + Row::new(vec![ + Cell::from("#"), + Cell::from("Title"), + Cell::from("Date"), + 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); } } 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/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(); 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}`,