diff --git a/Cargo.lock b/Cargo.lock index 3e3d3346..da5af2a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5079,9 +5079,9 @@ dependencies = [ [[package]] name = "rspotify" -version = "0.15.3" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6b781f7f2b4afac99cd6cb9506a427a128601cd3206a59156fad095c7280e1" +checksum = "1e731e399d4d7d7874e81d2ea1438fd5cdcb94090f58fb699fecaa2a042b2e7c" dependencies = [ "async-stream", "async-trait", @@ -5104,9 +5104,9 @@ dependencies = [ [[package]] name = "rspotify-http" -version = "0.15.3" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc03fb2b9aa31f484f1d47ab62a70b836e26477d1ab1a97559c5ca43af186d" +checksum = "82de318f57084b084779e4111f1de982380b67355985a0cf636630b5af61bb2e" dependencies = [ "async-trait", "log", @@ -5118,15 +5118,15 @@ dependencies = [ [[package]] name = "rspotify-macros" -version = "0.15.3" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10364677556c46b4e9e922360979db311cd0d7e598eaf7861497b56068fdc9e4" +checksum = "7f1fcd8df7d47c14f3cb46634f4a61506b303b305077e8d26ba19d8b7d353b0d" [[package]] name = "rspotify-model" -version = "0.15.3" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17695906aa54f120d1e43bcf5e8b19c64b1a9194d446af39e8f486107e7bbc1a" +checksum = "c1f55c05fbb7a22d50438457228c5b64c1b9a9476395abc2142c883ccd92ee1a" dependencies = [ "chrono", "enum_dispatch", diff --git a/pr-description.md b/pr-description.md new file mode 100644 index 00000000..a311394f --- /dev/null +++ b/pr-description.md @@ -0,0 +1,12 @@ +## Summary +- Replace `viuer` with `ratatui-image` for rendering cover images +- Remove manual `set_skip` / `clear_area` hacks — `ratatui-image` renders as a native ratatui widget +- Remove `viuer` initialization in `main.rs`, use `Picker::from_query_stdio()` for protocol auto-detection +- Remove `cover_img_scale` usage (no longer needed — scaling is handled by `ratatui-image`) + +## Notes +`Picker::from_query_stdio()` must run after `enable_raw_mode()` but before `EnterAlternateScreen` for correct protocol detection. + +In nested terminals (e.g. neovim floating terminal), stdio queries don't reach the actual terminal emulator, so the protocol falls back to Halfblocks. This is a known limitation of `ratatui-image`'s detection approach. Regular terminals (foot, kitty, iTerm2, etc.) work correctly. + +README still references `viuer` — leaving that for maintainer to decide how to update. diff --git a/spotify_player/Cargo.toml b/spotify_player/Cargo.toml index 3a945344..dba2c3cd 100644 --- a/spotify_player/Cargo.toml +++ b/spotify_player/Cargo.toml @@ -24,7 +24,7 @@ log = "0.4.29" chrono = "0.4.44" chrono-humanize = "0.2.3" reqwest = { version = "0.13.2", features = ["json", "query"] } -rspotify = {version = "0.15.3", features = ["cli"] } +rspotify = {version = "0.16.0", features = ["cli"] } serde = { version = "1.0.228", features = ["derive"] } tokio = { version = "1.50.0", features = [ "rt", diff --git a/spotify_player/src/cli/client.rs b/spotify_player/src/cli/client.rs index d3481c8f..77e27610 100644 --- a/spotify_player/src/cli/client.rs +++ b/spotify_player/src/cli/client.rs @@ -20,7 +20,10 @@ use crate::{ PlaylistId, SharedState, TrackId, }, }; -use rspotify::prelude::{BaseClient, OAuthClient}; +use rspotify::{ + model::LibraryId, + prelude::{BaseClient, OAuthClient}, +}; use super::{ Command, Deserialize, EditAction, GetRequest, IdOrName, ItemId, ItemType, Key, PlaylistCommand, @@ -179,9 +182,9 @@ async fn handle_socket_request( if let Some(id) = track.and_then(|t| t.id.clone()) { if unlike { - client.current_user_saved_tracks_delete([id]).await?; + client.library_remove([LibraryId::Track(id)]).await?; } else { - client.current_user_saved_tracks_add([id]).await?; + client.library_add([LibraryId::Track(id)]).await?; } } @@ -513,7 +516,7 @@ async fn handle_playlist_request(client: &AppClient, command: PlaylistCommand) - } PlaylistCommand::Delete { id } => { let following = client - .playlist_check_follow(id.clone(), &[uid]) + .library_contains([LibraryId::Playlist(id.clone())]) .await .context(format!("Could not find playlist '{}'", id.id()))? .pop() @@ -521,7 +524,9 @@ async fn handle_playlist_request(client: &AppClient, command: PlaylistCommand) - // Won't delete if not following if following { - client.playlist_unfollow(id.clone()).await?; + client + .library_remove([LibraryId::Playlist(id.clone())]) + .await?; Ok(format!("Playlist '{id}' was deleted/unfollowed")) } else { Ok(format!( @@ -595,7 +600,7 @@ async fn handle_playlist_request(client: &AppClient, command: PlaylistCommand) - } let pl_follow = client - .playlist_check_follow(to_id.as_ref(), &[uid.as_ref()]) + .library_contains([LibraryId::Playlist(to_id.as_ref())]) .await? .pop() .unwrap(); diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs index dca20f4b..06aa0a6b 100644 --- a/spotify_player/src/client/mod.rs +++ b/spotify_player/src/client/mod.rs @@ -25,6 +25,7 @@ use librespot_core::SpotifyUri; use parking_lot::Mutex; use reqwest::StatusCode; +use rspotify::model::LibraryId; use rspotify::{http::Query, prelude::*}; mod handlers; @@ -689,6 +690,7 @@ impl AppClient { /// Get Spotify's available browse categories pub async fn browse_categories(&self) -> Result> { + #[allow(deprecated)] let first_page = self .categories_manual(Some("EN"), None, Some(50), None) .await?; @@ -996,6 +998,7 @@ impl AppClient { .filter_map(|t| TrackId::from_id(t.original_gid).ok()); // Retrieve tracks based on IDs + #[allow(deprecated)] let tracks = self .tracks(track_ids, Some(rspotify::model::Market::FromToken)) .await?; @@ -1200,10 +1203,10 @@ impl AppClient { match item { Item::Track(track) => { let contains = self - .current_user_saved_tracks_contains([track.id.as_ref()]) + .library_contains([LibraryId::Track(track.id.as_ref())]) .await?; if !contains[0] { - self.current_user_saved_tracks_add([track.id.as_ref()]) + self.library_add([LibraryId::Track(track.id.as_ref())]) .await?; // update the in-memory `user_data` state @@ -1216,19 +1219,22 @@ impl AppClient { } Item::Album(album) => { let contains = self - .current_user_saved_albums_contains([album.id.as_ref()]) + .library_contains([LibraryId::Album(album.id.as_ref())]) .await?; if !contains[0] { - self.current_user_saved_albums_add([album.id.as_ref()]) + self.library_add([LibraryId::Album(album.id.as_ref())]) .await?; // update the in-memory `user_data` state.data.write().user_data.saved_albums.insert(0, album); } } Item::Artist(artist) => { - let follows = self.user_artist_check_follow([artist.id.as_ref()]).await?; + let follows = self + .library_contains([LibraryId::Artist(artist.id.as_ref())]) + .await?; if !follows[0] { - self.user_follow_artists([artist.id.as_ref()]).await?; + self.library_add([LibraryId::Artist(artist.id.as_ref())]) + .await?; // update the in-memory `user_data` state .data @@ -1247,12 +1253,13 @@ impl AppClient { .as_ref() .map(|u| u.id.clone()); - if let Some(user_id) = user_id { + if let Some(_user_id) = user_id { let follows = self - .playlist_check_follow(playlist.id.as_ref(), &[user_id]) + .library_contains([LibraryId::Playlist(playlist.id.as_ref())]) .await?; if !follows[0] { - self.playlist_follow(playlist.id.as_ref(), None).await?; + self.library_add([LibraryId::Playlist(playlist.id.as_ref())]) + .await?; // update the in-memory `user_data` state .data @@ -1264,9 +1271,12 @@ impl AppClient { } } Item::Show(show) => { - let follows = self.check_users_saved_shows([show.id.as_ref()]).await?; + let follows = self + .library_contains([LibraryId::Show(show.id.as_ref())]) + .await?; if !follows[0] { - self.save_shows([show.id.as_ref()]).await?; + self.library_add([LibraryId::Show(show.id.as_ref())]) + .await?; // update the in-memory `user_data` state.data.write().user_data.saved_shows.insert(0, show); } @@ -1280,7 +1290,7 @@ impl AppClient { match id { ItemId::Track(id) => { let uri = id.uri(); - self.current_user_saved_tracks_delete([id]).await?; + self.library_remove([LibraryId::Track(id)]).await?; state.data.write().user_data.saved_tracks.remove(&uri); } ItemId::Album(id) => { @@ -1290,7 +1300,7 @@ impl AppClient { .user_data .saved_albums .retain(|a| a.id != id); - self.current_user_saved_albums_delete([id]).await?; + self.library_remove([LibraryId::Album(id)]).await?; } ItemId::Artist(id) => { state @@ -1299,7 +1309,7 @@ impl AppClient { .user_data .followed_artists .retain(|a| a.id != id); - self.user_unfollow_artists([id]).await?; + self.library_remove([LibraryId::Artist(id)]).await?; } ItemId::Playlist(id) => { state @@ -1311,7 +1321,7 @@ impl AppClient { PlaylistFolderItem::Playlist(p) => p.id != id, PlaylistFolderItem::Folder(_) => true, }); - self.playlist_unfollow(id).await?; + self.library_remove([LibraryId::Playlist(id)]).await?; } ItemId::Show(id) => { state @@ -1320,8 +1330,7 @@ impl AppClient { .user_data .saved_shows .retain(|s| s.id != id); - self.remove_users_saved_shows([id], Some(rspotify::model::Market::FromToken)) - .await?; + self.library_remove([LibraryId::Show(id)]).await?; } } Ok(()) @@ -1356,7 +1365,7 @@ impl AppClient { "{SPOTIFY_API_ENDPOINT}/playlists/{}/tracks", playlist_id.id(), ), - playlist.tracks.total as usize, + playlist.items.total as usize, ) .await? .into_iter() @@ -1418,6 +1427,8 @@ impl AppClient { .context("get artist")? .into(); + // this is the main feb 2026 spotify api update + #[allow(deprecated)] let top_tracks = self .artist_top_tracks(artist_id.as_ref(), Some(rspotify::model::Market::FromToken)) .await @@ -1726,6 +1737,7 @@ impl AppClient { &track.album.id.as_ref().unwrap().id()[..6] ) } + #[allow(deprecated)] rspotify::model::PlayableItem::Episode(ref episode) => { format!( "{}-{}-cover-{}.jpg", diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs index f3861e2b..ab0b06ed 100644 --- a/spotify_player/src/event/mod.rs +++ b/spotify_player/src/event/mod.rs @@ -8,12 +8,12 @@ use crate::{ key::{Key, KeySequence}, state::{ ActionListItem, Album, AlbumId, Artist, ArtistFocusState, ArtistId, ArtistPopupAction, - BrowsePageUIState, Context, ContextId, ContextPageType, ContextPageUIState, DataReadGuard, - Focusable, Id, Item, ItemId, LibraryFocusState, LibraryPageUIState, PageState, PageType, - PlayableId, Playback, PlaylistCreateCurrentField, PlaylistFolderItem, PlaylistId, - PlaylistPopupAction, PopupState, SearchFocusState, SearchPageUIState, SharedState, ShowId, - Track, TrackId, TrackOrder, TracksId, UIStateGuard, USER_LIKED_TRACKS_ID, - USER_RECENTLY_PLAYED_TRACKS_ID, USER_TOP_TRACKS_ID, + BrowsePageUIState, ConfirmableAction, Context, ContextId, ContextPageType, + ContextPageUIState, DataReadGuard, Focusable, Id, Item, ItemId, LibraryFocusState, + LibraryPageUIState, PageState, PageType, PlayableId, Playback, PlaylistCreateCurrentField, + PlaylistFolderItem, PlaylistId, PlaylistPopupAction, PopupState, SearchFocusState, + SearchPageUIState, SharedState, ShowId, Track, TrackId, TrackOrder, TracksId, UIStateGuard, + USER_LIKED_TRACKS_ID, USER_RECENTLY_PLAYED_TRACKS_ID, USER_TOP_TRACKS_ID, }, ui::{single_line_input::LineInput, Orientation}, utils::parse_uri, @@ -289,12 +289,14 @@ pub fn handle_action_in_context( .. } = ui.current_page() { - client_pub.send(ClientRequest::DeleteTrackFromPlaylist( - playlist_id.clone_static(), - track.id, - ))?; + ui.popup = Some(PopupState::ConfirmAction { + message: format!("Delete {}?", track.name), + action: ConfirmableAction::DeleteTrackFromPlaylist { + playlist_id: playlist_id.clone_static(), + track_id: track.id, + }, + }); } - ui.popup = None; Ok(true) } _ => Ok(false), @@ -318,8 +320,10 @@ pub fn handle_action_in_context( Ok(true) } Action::DeleteFromLibrary => { - client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Album(album.id)))?; - ui.popup = None; + ui.popup = Some(PopupState::ConfirmAction { + message: format!("Delete {}?", album.name), + action: ConfirmableAction::DeleteFromLibrary(ItemId::Album(album.id)), + }); Ok(true) } Action::CopyLink => { @@ -376,10 +380,10 @@ pub fn handle_action_in_context( Ok(true) } Action::DeleteFromLibrary => { - client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Playlist( - playlist.id, - )))?; - ui.popup = None; + ui.popup = Some(PopupState::ConfirmAction { + message: format!("Delete {}?", playlist.name), + action: ConfirmableAction::DeleteFromLibrary(ItemId::Playlist(playlist.id)), + }); Ok(true) } _ => Ok(false), @@ -397,8 +401,10 @@ pub fn handle_action_in_context( Ok(true) } Action::DeleteFromLibrary => { - client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Show(show.id)))?; - ui.popup = None; + ui.popup = Some(PopupState::ConfirmAction { + message: format!("Delete {}?", show.name), + action: ConfirmableAction::DeleteFromLibrary(ItemId::Show(show.id)), + }); Ok(true) } _ => Ok(false), diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs index 7b7c8521..4c7f971c 100644 --- a/spotify_player/src/event/popup.rs +++ b/spotify_player/src/event/popup.rs @@ -1,5 +1,7 @@ use super::*; -use crate::{command::construct_artist_actions, utils::filtered_items_from_query}; +use crate::{ + command::construct_artist_actions, state::ConfirmableAction, utils::filtered_items_from_query, +}; use anyhow::Context; pub fn handle_key_sequence_for_popup( @@ -30,6 +32,14 @@ pub fn handle_key_sequence_for_popup( return Ok(true); } } + PopupState::ConfirmAction { action, .. } => { + return handle_key_sequence_for_confirm_popup( + key_sequence, + client_pub, + ui, + action.clone(), + ); + } _ => {} } @@ -41,6 +51,9 @@ pub fn handle_key_sequence_for_popup( }; match ui.popup.as_ref().context("empty popup")? { + PopupState::ConfirmAction { .. } => { + anyhow::bail!("confirm action should be handler before") + } PopupState::Search { .. } => anyhow::bail!("search popup should be handled before"), PopupState::PlaylistCreate { .. } => { anyhow::bail!("create playlist popup should be handled before") @@ -600,3 +613,32 @@ fn handle_key_sequence_for_playlist_search_popup( false } + +fn handle_key_sequence_for_confirm_popup( + key_sequence: &KeySequence, + client_pub: &flume::Sender, + ui: &mut UIStateGuard, + action: ConfirmableAction, +) -> Result { + if matches!( + key_sequence.keys.as_slice(), + [Key::None(crossterm::event::KeyCode::Char('y'))] + ) { + match action { + ConfirmableAction::DeleteTrackFromPlaylist { + playlist_id, + track_id, + } => { + client_pub.send(ClientRequest::DeleteTrackFromPlaylist( + playlist_id, + track_id, + ))?; + } + ConfirmableAction::DeleteFromLibrary(item_id) => { + client_pub.send(ClientRequest::DeleteFromLibrary(item_id))?; + } + } + } + ui.popup = None; + Ok(true) +} diff --git a/spotify_player/src/media_control.rs b/spotify_player/src/media_control.rs index 0fb158db..b69af892 100644 --- a/spotify_player/src/media_control.rs +++ b/spotify_player/src/media_control.rs @@ -53,6 +53,7 @@ fn update_control_metadata( controls.set_metadata(MediaMetadata { title: Some(&episode.name), album: Some(&episode.show.name), + #[allow(deprecated)] artist: Some(&episode.show.publisher), duration: episode.duration.to_std().ok(), cover_url: utils::get_episode_show_image_url(episode), diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs index 94777792..4029e948 100644 --- a/spotify_player/src/state/model.rs +++ b/spotify_player/src/state/model.rs @@ -356,6 +356,7 @@ impl Track { /// tries to convert from a `rspotify::model::SimplifiedTrack` into `Track` pub fn try_from_simplified_track(track: rspotify::model::SimplifiedTrack) -> Option { if track.is_playable.unwrap_or(true) { + #[allow(deprecated)] let id = match track.linked_from { Some(d) => d.id?, None => track.id?, @@ -380,6 +381,7 @@ impl Track { added_at: Option>, ) -> Option { if track.is_playable.unwrap_or(true) { + #[allow(deprecated)] let id = match track.linked_from { Some(d) => d.id?, None => track.id?, @@ -405,7 +407,9 @@ impl Track { /// tries to convert from a `rspotify::model::PlaylistItem` into `Track` pub fn try_from_playlist_item(item: rspotify::model::PlaylistItem) -> Option { - let rspotify::model::PlayableItem::Track(track) = item.track? else { + #[allow(deprecated)] + let rspotify::model::PlayableItem::Track(track) = item.track? + else { return None; }; diff --git a/spotify_player/src/state/ui/popup.rs b/spotify_player/src/state/ui/popup.rs index a69415ab..806e8421 100644 --- a/spotify_player/src/state/ui/popup.rs +++ b/spotify_player/src/state/ui/popup.rs @@ -1,9 +1,13 @@ use crate::{ command, - state::model::{Album, Artist, Episode, EpisodeId, Playlist, Show, Track, TrackId}, + state::{ + model::{Album, Artist, Episode, EpisodeId, Playlist, Show, Track, TrackId}, + ItemId, + }, ui::single_line_input::LineInput, }; use ratatui::widgets::ListState; +use rspotify::model::PlaylistId; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlaylistCreateCurrentField { @@ -28,6 +32,19 @@ pub enum PopupState { desc: LineInput, current_field: PlaylistCreateCurrentField, }, + ConfirmAction { + message: String, + action: ConfirmableAction, + }, +} + +#[derive(Debug, Clone)] +pub enum ConfirmableAction { + DeleteTrackFromPlaylist { + playlist_id: PlaylistId<'static>, + track_id: TrackId<'static>, + }, + DeleteFromLibrary(ItemId), } #[derive(Debug, Clone)] @@ -77,7 +94,7 @@ impl PopupState { | Self::ArtistList(.., list_state) | Self::ThemeList(.., list_state) | Self::ActionList(.., list_state) => Some(list_state), - Self::Search { .. } | Self::PlaylistCreate { .. } => None, + Self::Search { .. } | Self::PlaylistCreate { .. } | Self::ConfirmAction { .. } => None, } } @@ -91,7 +108,7 @@ impl PopupState { | Self::ArtistList(.., list_state) | Self::ThemeList(.., list_state) | Self::ActionList(.., list_state) => Some(list_state), - Self::Search { .. } | Self::PlaylistCreate { .. } => None, + Self::Search { .. } | Self::PlaylistCreate { .. } | Self::ConfirmAction { .. } => None, } } diff --git a/spotify_player/src/ui/page.rs b/spotify_player/src/ui/page.rs index 10fb472e..494436f7 100644 --- a/spotify_player/src/ui/page.rs +++ b/spotify_player/src/ui/page.rs @@ -737,6 +737,7 @@ pub fn render_queue_page( .map(|a| a.name.as_str()) .collect::>() .join(", "), + #[allow(deprecated)] PlayableItem::Episode(FullEpisode { ref show, .. }) => show.publisher.clone(), PlayableItem::Unknown(_) => String::new(), } diff --git a/spotify_player/src/ui/playback.rs b/spotify_player/src/ui/playback.rs index 43081567..6086fe12 100644 --- a/spotify_player/src/ui/playback.rs +++ b/spotify_player/src/ui/playback.rs @@ -347,6 +347,7 @@ fn construct_playback_text( to_bidi_string(&crate::utils::map_join(&track.artists, |a| &a.name, ", ")), ui.theme.playback_artists(), ), + #[allow(deprecated)] rspotify::model::PlayableItem::Episode(episode) => { (episode.show.publisher.clone(), ui.theme.playback_artists()) } diff --git a/spotify_player/src/ui/popup.rs b/spotify_player/src/ui/popup.rs index a133ef65..46241079 100644 --- a/spotify_player/src/ui/popup.rs +++ b/spotify_player/src/ui/popup.rs @@ -196,6 +196,22 @@ pub fn render_popup( let rect = render_list_popup(frame, rect, "Artists", items, 5, ui); (rect, false) } + PopupState::ConfirmAction { message, .. } => { + let chunks = + Layout::vertical([Constraint::Fill(0), Constraint::Length(3)]).split(rect); + + let confirm_rect = construct_and_render_block( + "Confirm", + &ui.theme, + Borders::ALL, + frame, + chunks[1], + ); + + frame.render_widget(Paragraph::new(format!("{message} (y/n)")), confirm_rect); + + (chunks[0], true) + } }, } }