diff --git a/Cargo.lock b/Cargo.lock index f73b6ba1..4ff849a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4650,6 +4650,7 @@ dependencies = [ "librespot-metadata", "librespot-oauth", "librespot-playback", + "librespot-protocol", "mpris-server", "objc2", "objc2-foundation", @@ -4658,6 +4659,7 @@ dependencies = [ "openssl", "openssl-sys", "pipewire", + "protobuf", "rand 0.8.5", "ratatui", "realfft", diff --git a/Cargo.toml b/Cargo.toml index 062c85e2..f776efa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,8 @@ librespot-core = { version = "0.8", optional = true } librespot-connect = { version = "0.8", optional = true } librespot-oauth = { version = "0.8", optional = true } librespot-metadata = { version = "0.8", optional = true } +librespot-protocol = { version = "0.8", optional = true, default-features = false } +protobuf = { version = "3.7", optional = true } futures = "0.3.31" [target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies] @@ -91,7 +93,7 @@ block2 = { version = "0.6", optional = true } [features] default = ["telemetry", "streaming", "audio-viz-cpal", "mpris", "macos-media", "discord-rpc"] telemetry = [] -streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata"] +streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata", "librespot-protocol", "protobuf"] # Audio backend features alsa-backend = ["streaming", "librespot-playback/alsa-backend"] pulseaudio-backend = ["streaming", "librespot-playback/pulseaudio-backend"] diff --git a/src/app.rs b/src/app.rs index 73b78c55..cfa55bde 100644 --- a/src/app.rs +++ b/src/app.rs @@ -334,6 +334,45 @@ pub struct NativeTrackInfo { pub duration_ms: u32, } +/// A node in the playlist folder hierarchy from Spotify's rootlist +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(dead_code)] +pub enum PlaylistFolderNodeType { + Folder, + Playlist, +} + +/// A node in the playlist folder hierarchy from Spotify's rootlist +#[derive(Clone, Debug)] +pub struct PlaylistFolderNode { + pub name: Option, + pub node_type: PlaylistFolderNodeType, + pub uri: String, + pub children: Vec, +} + +/// A folder entry for navigation in the playlist panel +#[derive(Clone, Debug)] +pub struct PlaylistFolder { + pub name: String, + /// Folder ID this item is visible in (which folder "page" it appears on) + pub current_id: usize, + /// Folder ID this item navigates to when selected + pub target_id: usize, +} + +/// A flattened item for display in the playlist panel +#[derive(Clone, Debug)] +pub enum PlaylistFolderItem { + Folder(PlaylistFolder), + Playlist { + /// Index into app.all_playlists + index: usize, + /// Folder ID this playlist is visible in + current_id: usize, + }, +} + /// Settings screen category tabs #[derive(Clone, Copy, PartialEq, Debug, Default)] pub enum SettingsCategory { @@ -508,13 +547,16 @@ pub struct App { /// Whether native streaming is active (disables API-based progress calculation) pub is_streaming_active: bool, /// Device id for the native streaming device when known + #[allow(dead_code)] pub native_device_id: Option, /// Native playback state - updated by player events, used when streaming is active /// This is more reliable than current_playback_context.is_playing during native streaming pub native_is_playing: Option, /// Timestamp of the last native device activation + #[allow(dead_code)] pub last_device_activation: Option, /// Whether a native device activation is still in progress + #[allow(dead_code)] pub native_activation_pending: bool, /// Selected index in the Discover view pub discover_selected_index: usize, @@ -545,6 +587,16 @@ pub struct App { pub status_message_expires_at: Option, /// Pending track table selection to apply when new page loads pub pending_track_table_selection: Option, + /// Full flat list of all user playlists (all pages combined) + pub all_playlists: Vec, + /// Folder tree from rootlist (None if not fetched or streaming disabled) + pub playlist_folder_nodes: Option>, + /// Flattened folder+playlist items for display navigation + pub playlist_folder_items: Vec, + /// Current folder ID being viewed (0 = root) + pub current_playlist_folder_id: usize, + /// Incremented every time playlists are refreshed to guard stale background tasks + pub playlist_refresh_generation: u64, /// Reference to the native streaming player for direct control (bypasses event channel) #[cfg(feature = "streaming")] pub streaming_player: Option>, @@ -682,6 +734,11 @@ impl Default for App { status_message: None, status_message_expires_at: None, pending_track_table_selection: None, + all_playlists: Vec::new(), + playlist_folder_nodes: None, + playlist_folder_items: Vec::new(), + current_playlist_folder_id: 0, + playlist_refresh_generation: 0, #[cfg(feature = "streaming")] streaming_player: None, #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -722,6 +779,70 @@ impl App { self.io_tx = None; } + pub fn is_playlist_item_visible_in_current_folder(&self, item: &PlaylistFolderItem) -> bool { + match item { + PlaylistFolderItem::Folder(f) => f.current_id == self.current_playlist_folder_id, + PlaylistFolderItem::Playlist { current_id, .. } => { + *current_id == self.current_playlist_folder_id + } + } + } + + /// Get the number of items visible in the current folder level. + pub fn get_playlist_display_count(&self) -> usize { + self + .playlist_folder_items + .iter() + .filter(|item| self.is_playlist_item_visible_in_current_folder(item)) + .count() + } + + /// Get a visible item by display index in the current folder. + pub fn get_playlist_display_item_at(&self, display_index: usize) -> Option<&PlaylistFolderItem> { + self + .playlist_folder_items + .iter() + .filter(|item| self.is_playlist_item_visible_in_current_folder(item)) + .nth(display_index) + } + + /// Get visible playlist items in the current folder (used by UI rendering). + pub fn get_playlist_display_items(&self) -> Vec<&PlaylistFolderItem> { + self + .playlist_folder_items + .iter() + .filter(|item| self.is_playlist_item_visible_in_current_folder(item)) + .collect() + } + + /// Get the SimplifiedPlaylist for a PlaylistFolderItem::Playlist variant + #[allow(dead_code)] + pub fn get_playlist_for_item(&self, item: &PlaylistFolderItem) -> Option<&SimplifiedPlaylist> { + match item { + PlaylistFolderItem::Playlist { index, .. } => self.all_playlists.get(*index), + PlaylistFolderItem::Folder(_) => None, + } + } + + /// Get the currently selected playlist id in the visible playlist list. + pub fn get_selected_playlist_id(&self) -> Option { + let selected_index = self.selected_playlist_index?; + if let Some(PlaylistFolderItem::Playlist { index, .. }) = + self.get_playlist_display_item_at(selected_index) + { + return self + .all_playlists + .get(*index) + .map(|p| p.id.id().to_string()); + } + + self + .playlists + .as_ref() + .and_then(|playlists| playlists.items.get(selected_index)) + .map(|playlist| playlist.id.id().to_string()) + } + fn apply_seek(&mut self, seek_ms: u32) { if let Some(CurrentPlaybackContext { item: Some(item), .. @@ -1724,16 +1845,19 @@ impl App { } pub fn user_unfollow_playlist(&mut self) { - if let (Some(playlists), Some(selected_index), Some(user)) = - (&self.playlists, self.selected_playlist_index, &self.user) - { - let selected_playlist = &playlists.items[selected_index]; - let selected_id = selected_playlist.id.clone(); - let user_id = user.id.clone(); - self.dispatch(IoEvent::UserUnfollowPlaylist( - user_id.into_static(), - selected_id.into_static(), - )); + if let (Some(selected_index), Some(user)) = (self.selected_playlist_index, &self.user) { + if let Some(PlaylistFolderItem::Playlist { index, .. }) = + self.get_playlist_display_item_at(selected_index) + { + if let Some(playlist) = self.all_playlists.get(*index) { + let selected_id = playlist.id.clone(); + let user_id = user.id.clone(); + self.dispatch(IoEvent::UserUnfollowPlaylist( + user_id.into_static(), + selected_id.into_static(), + )); + } + } } } diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 685ebfa5..803a62d6 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -37,6 +37,7 @@ pub use analyzer::SpectrumData; feature = "audio-viz-cpal" )))] #[derive(Clone, Default)] +#[allow(dead_code)] pub struct SpectrumData { pub bands: [f32; 12], pub peak: f32, @@ -46,12 +47,14 @@ pub struct SpectrumData { all(feature = "audio-viz", target_os = "linux"), feature = "audio-viz-cpal" )))] +#[allow(dead_code)] pub struct AudioCaptureManager; #[cfg(not(any( all(feature = "audio-viz", target_os = "linux"), feature = "audio-viz-cpal" )))] +#[allow(dead_code)] impl AudioCaptureManager { pub fn new() -> Option { None diff --git a/src/handlers/playlist.rs b/src/handlers/playlist.rs index 619b3db2..4b08ba28 100644 --- a/src/handlers/playlist.rs +++ b/src/handlers/playlist.rs @@ -1,5 +1,5 @@ use super::{ - super::app::{App, DialogContext, TrackTableContext}, + super::app::{App, DialogContext, PlaylistFolderItem, TrackTableContext}, common_key_events, }; use crate::app::{ActiveBlock, RouteId}; @@ -10,68 +10,85 @@ pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::right_event(k) => common_key_events::handle_right_event(app), k if common_key_events::down_event(k) => { - if let Some(p) = &app.playlists { - if let Some(selected_playlist_index) = app.selected_playlist_index { - let next_index = - common_key_events::on_down_press_handler(&p.items, Some(selected_playlist_index)); - app.selected_playlist_index = Some(next_index); - } - }; + let count = app.get_playlist_display_count(); + if count > 0 { + let current = app.selected_playlist_index.unwrap_or(0); + app.selected_playlist_index = Some((current + 1) % count); + } } k if common_key_events::up_event(k) => { - if let Some(p) = &app.playlists { - let next_index = - common_key_events::on_up_press_handler(&p.items, app.selected_playlist_index); - app.selected_playlist_index = Some(next_index); - }; + let count = app.get_playlist_display_count(); + if count > 0 { + let current = app.selected_playlist_index.unwrap_or(0); + app.selected_playlist_index = Some(if current == 0 { count - 1 } else { current - 1 }); + } } k if common_key_events::high_event(k) => { - if let Some(_p) = &app.playlists { - let next_index = common_key_events::on_high_press_handler(); - app.selected_playlist_index = Some(next_index); - }; + if app.get_playlist_display_count() > 0 { + app.selected_playlist_index = Some(0); + } } k if common_key_events::middle_event(k) => { - if let Some(p) = &app.playlists { - let next_index = common_key_events::on_middle_press_handler(&p.items); + let count = app.get_playlist_display_count(); + if count > 0 { + let next_index = if count.is_multiple_of(2) { + count.saturating_sub(1) / 2 + } else { + count / 2 + }; app.selected_playlist_index = Some(next_index); - }; + } } k if common_key_events::low_event(k) => { - if let Some(p) = &app.playlists { - let next_index = common_key_events::on_low_press_handler(&p.items); - app.selected_playlist_index = Some(next_index); - }; + let count = app.get_playlist_display_count(); + if count > 0 { + app.selected_playlist_index = Some(count - 1); + } } Key::Enter => { - if let (Some(playlists), Some(selected_playlist_index)) = - (&app.playlists, &app.selected_playlist_index) - { - app.active_playlist_index = Some(selected_playlist_index.to_owned()); - app.track_table.context = Some(TrackTableContext::MyPlaylists); - app.playlist_offset = 0; - if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { - let playlist_id = selected_playlist.id.clone().into_static(); - app.dispatch(IoEvent::GetPlaylistItems( - playlist_id.clone(), - app.playlist_offset, - )); - // Pre-fetch more pages in background for seamless playback - app.dispatch(IoEvent::PreFetchAllPlaylistTracks(playlist_id)); + if let Some(selected_idx) = app.selected_playlist_index { + if let Some(item) = app.get_playlist_display_item_at(selected_idx) { + match item { + PlaylistFolderItem::Folder(folder) => { + // Navigate into/out of folder + app.current_playlist_folder_id = folder.target_id; + app.selected_playlist_index = Some(0); + } + PlaylistFolderItem::Playlist { index, .. } => { + // Open the playlist tracks + if let Some(playlist) = app.all_playlists.get(*index) { + app.active_playlist_index = Some(*index); + app.track_table.context = Some(TrackTableContext::MyPlaylists); + app.playlist_offset = 0; + let playlist_id = playlist.id.clone().into_static(); + app.dispatch(IoEvent::GetPlaylistItems( + playlist_id.clone(), + app.playlist_offset, + )); + // Pre-fetch more pages in background for seamless playback + app.dispatch(IoEvent::PreFetchAllPlaylistTracks(playlist_id)); + } + } + } } - }; + } } Key::Char('D') => { - if let (Some(playlists), Some(selected_index)) = (&app.playlists, app.selected_playlist_index) - { - let selected_playlist = &playlists.items[selected_index].name; - app.dialog = Some(selected_playlist.clone()); - app.confirm = false; + if let Some(selected_idx) = app.selected_playlist_index { + if let Some(PlaylistFolderItem::Playlist { index, .. }) = + app.get_playlist_display_item_at(selected_idx) + { + if let Some(playlist) = app.all_playlists.get(*index) { + let selected_playlist = &playlist.name; + app.dialog = Some(selected_playlist.clone()); + app.confirm = false; - app.push_navigation_stack( - RouteId::Dialog, - ActiveBlock::Dialog(DialogContext::PlaylistWindow), - ); + app.push_navigation_stack( + RouteId::Dialog, + ActiveBlock::Dialog(DialogContext::PlaylistWindow), + ); + } + } } } _ => {} diff --git a/src/handlers/sort_menu.rs b/src/handlers/sort_menu.rs index d1722c08..6a1b331e 100644 --- a/src/handlers/sort_menu.rs +++ b/src/handlers/sort_menu.rs @@ -106,10 +106,8 @@ fn apply_sort(app: &mut App, field: SortField) { SortContext::PlaylistTracks => { // For playlists, dispatch network event to fetch all tracks and sort // Get the current playlist ID - if let (Some(playlists), Some(selected_index)) = - (&app.playlists, app.selected_playlist_index) - { - if let Some(playlist) = playlists.items.get(selected_index) { + if let Some(active_playlist_index) = app.active_playlist_index { + if let Some(playlist) = app.all_playlists.get(active_playlist_index) { let playlist_id = playlist.id.clone().into_static(); app.dispatch(crate::network::IoEvent::FetchAllPlaylistTracksAndSort( playlist_id, diff --git a/src/handlers/track_table.rs b/src/handlers/track_table.rs index c2eb731c..21cf41b2 100644 --- a/src/handlers/track_table.rs +++ b/src/handlers/track_table.rs @@ -21,20 +21,15 @@ pub fn handler(key: Key, app: &mut App) { if current_index == tracks_len - 1 { match &app.track_table.context { Some(TrackTableContext::MyPlaylists) => { - if let (Some(playlists), Some(selected_playlist_index)) = - (&app.playlists, &app.selected_playlist_index) - { - if let Some(selected_playlist) = playlists.items.get(*selected_playlist_index) { - if let Some(playlist_tracks) = &app.playlist_tracks { - // Check if there are more tracks to fetch - if app.playlist_offset + app.large_search_limit < playlist_tracks.total { - app.playlist_offset += app.large_search_limit; - let playlist_id = playlist_id_static_from_ref(&selected_playlist.id); - app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); - // Set pending selection to move to first track when new page loads - app.pending_track_table_selection = Some(PendingTrackSelection::First); - return; - } + if let Some(playlist_id) = active_playlist_id_static(app) { + if let Some(playlist_tracks) = &app.playlist_tracks { + // Check if there are more tracks to fetch + if app.playlist_offset + app.large_search_limit < playlist_tracks.total { + app.playlist_offset += app.large_search_limit; + app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); + // Set pending selection to move to first track when new page loads + app.pending_track_table_selection = Some(PendingTrackSelection::First); + return; } } } @@ -73,17 +68,12 @@ pub fn handler(key: Key, app: &mut App) { match &app.track_table.context { Some(TrackTableContext::MyPlaylists) => { if app.playlist_offset > 0 { - if let (Some(playlists), Some(selected_playlist_index)) = - (&app.playlists, &app.selected_playlist_index) - { - if let Some(selected_playlist) = playlists.items.get(*selected_playlist_index) { - app.playlist_offset = app.playlist_offset.saturating_sub(app.large_search_limit); - let playlist_id = playlist_id_static_from_ref(&selected_playlist.id); - app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); - // Set pending selection to move to last track when previous page loads - app.pending_track_table_selection = Some(PendingTrackSelection::Last); - return; - } + if let Some(playlist_id) = active_playlist_id_static(app) { + app.playlist_offset = app.playlist_offset.saturating_sub(app.large_search_limit); + app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); + // Set pending selection to move to last track when previous page loads + app.pending_track_table_selection = Some(PendingTrackSelection::Last); + return; } } } @@ -129,21 +119,14 @@ pub fn handler(key: Key, app: &mut App) { if let Some(context) = &app.track_table.context { match context { TrackTableContext::MyPlaylists => { - if let (Some(playlists), Some(selected_playlist_index)) = - (&app.playlists, &app.selected_playlist_index) - { - if let Some(selected_playlist) = - playlists.items.get(selected_playlist_index.to_owned()) - { - if let Some(playlist_tracks) = &app.playlist_tracks { - if app.playlist_offset + app.large_search_limit < playlist_tracks.total { - app.playlist_offset += app.large_search_limit; - let playlist_id = playlist_id_static_from_ref(&selected_playlist.id); - app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); - } + if let Some(playlist_id) = active_playlist_id_static(app) { + if let Some(playlist_tracks) = &app.playlist_tracks { + if app.playlist_offset + app.large_search_limit < playlist_tracks.total { + app.playlist_offset += app.large_search_limit; + app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); } } - }; + } } TrackTableContext::RecommendedTracks => {} TrackTableContext::SavedTracks => { @@ -160,19 +143,12 @@ pub fn handler(key: Key, app: &mut App) { if let Some(context) = &app.track_table.context { match context { TrackTableContext::MyPlaylists => { - if let (Some(playlists), Some(selected_playlist_index)) = - (&app.playlists, &app.selected_playlist_index) - { + if let Some(playlist_id) = active_playlist_id_static(app) { if app.playlist_offset >= app.large_search_limit { app.playlist_offset -= app.large_search_limit; - }; - if let Some(selected_playlist) = - playlists.items.get(selected_playlist_index.to_owned()) - { - let playlist_id = playlist_id_static_from_ref(&selected_playlist.id); - app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); } - }; + app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); + } } TrackTableContext::RecommendedTracks => {} TrackTableContext::SavedTracks => { @@ -205,20 +181,8 @@ fn play_random_song(app: &mut App) { if let Some(context) = &app.track_table.context { match context { TrackTableContext::MyPlaylists => { - let (context_id, track_json) = match (&app.selected_playlist_index, &app.playlists) { - (Some(selected_playlist_index), Some(playlists)) => { - if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) - { - ( - Some(playlist_context_id_from_ref(&selected_playlist.id)), - Some(selected_playlist.tracks.total), - ) - } else { - (None, None) - } - } - _ => (None, None), - }; + let context_id = active_playlist_context_id(app); + let track_json = active_playlist_total_tracks(app); if let Some(val) = track_json { app.dispatch(IoEvent::StartPlayback( @@ -322,17 +286,13 @@ fn jump_to_end(app: &mut App) { if let Some(context) = &app.track_table.context { match context { TrackTableContext::MyPlaylists => { - if let (Some(playlists), Some(selected_playlist_index)) = - (&app.playlists, &app.selected_playlist_index) - { - if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { - let total_tracks = selected_playlist.tracks.total; - - if app.large_search_limit < total_tracks { - app.playlist_offset = total_tracks - (total_tracks % app.large_search_limit); - let playlist_id = playlist_id_static_from_ref(&selected_playlist.id); - app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); - } + if let (Some(total_tracks), Some(playlist_id)) = ( + active_playlist_total_tracks(app), + active_playlist_id_static(app), + ) { + if app.large_search_limit < total_tracks { + app.playlist_offset = total_tracks - (total_tracks % app.large_search_limit); + app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); } } } @@ -358,9 +318,9 @@ fn on_enter(app: &mut App) { // Get the track ID to play let track_playable_id = track_playable_id(track.id.clone()); - let context_id = match (&app.active_playlist_index, &app.playlists) { - (Some(active_playlist_index), Some(playlists)) => playlists - .items + let context_id = match &app.active_playlist_index { + Some(active_playlist_index) => app + .all_playlists .get(active_playlist_index.to_owned()) .map(|selected_playlist| playlist_context_id_from_ref(&selected_playlist.id)), _ => None, @@ -545,14 +505,9 @@ fn jump_to_start(app: &mut App) { if let Some(context) = &app.track_table.context { match context { TrackTableContext::MyPlaylists => { - if let (Some(playlists), Some(selected_playlist_index)) = - (&app.playlists, &app.selected_playlist_index) - { - if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { - app.playlist_offset = 0; - let playlist_id = playlist_id_static_from_ref(&selected_playlist.id); - app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); - } + if let Some(playlist_id) = active_playlist_id_static(app) { + app.playlist_offset = 0; + app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); } } TrackTableContext::RecommendedTracks => {} @@ -564,8 +519,25 @@ fn jump_to_start(app: &mut App) { } } -fn playlist_id_static_from_ref(id: &PlaylistId<'_>) -> PlaylistId<'static> { - id.clone().into_static() +fn active_playlist_id_static(app: &App) -> Option> { + app + .active_playlist_index + .and_then(|idx| app.all_playlists.get(idx)) + .map(|playlist| playlist.id.clone().into_static()) +} + +fn active_playlist_context_id(app: &App) -> Option> { + app + .active_playlist_index + .and_then(|idx| app.all_playlists.get(idx)) + .map(|playlist| playlist_context_id_from_ref(&playlist.id)) +} + +fn active_playlist_total_tracks(app: &App) -> Option { + app + .active_playlist_index + .and_then(|idx| app.all_playlists.get(idx)) + .map(|playlist| playlist.tracks.total) } fn playlist_context_id_from_ref(id: &PlaylistId<'_>) -> PlayContextId<'static> { diff --git a/src/network.rs b/src/network.rs index d5f4c829..40fd4dfc 100644 --- a/src/network.rs +++ b/src/network.rs @@ -1,7 +1,7 @@ use crate::app::{ - ActiveBlock, AlbumTableContext, App, Artist, ArtistBlock, EpisodeTableContext, RouteId, - ScrollableResultPages, SelectedAlbum, SelectedFullAlbum, SelectedFullShow, SelectedShow, - TrackTableContext, + ActiveBlock, AlbumTableContext, App, Artist, ArtistBlock, EpisodeTableContext, PlaylistFolder, + PlaylistFolderItem, PlaylistFolderNode, PlaylistFolderNodeType, RouteId, ScrollableResultPages, + SelectedAlbum, SelectedFullAlbum, SelectedFullShow, SelectedShow, TrackTableContext, }; use crate::config::ClientConfig; use crate::ui::util::create_artist_string; @@ -41,6 +41,7 @@ pub enum IoEvent { GetCurrentPlayback, /// After a track transition (e.g., EndOfTrack), ensure we don't end up paused on the next item. /// The payload is the previous track identifier (either base62 id or a `spotify:track:` URI). + #[allow(dead_code)] EnsurePlaybackContinues(String), RefreshAuthentication, GetPlaylists, @@ -138,6 +139,7 @@ struct LrcResponse { } #[derive(Deserialize, Debug)] +#[allow(dead_code)] struct GlobalSongCountResponse { count: u64, } @@ -2493,22 +2495,198 @@ impl Network { } async fn get_current_user_playlists(&mut self) { - let playlists = self + // Step 1: Fetch ONLY the first page (single API call, fast) + let first_page = match self .spotify .current_user_playlists_manual(Some(self.large_search_limit), None) - .await; - - match playlists { - Ok(p) => { - let mut app = self.app.lock().await; - app.playlists = Some(p); - // Select the first playlist - app.selected_playlist_index = Some(0); - } + .await + { + Ok(p) => p, Err(e) => { self.handle_error(anyhow!(e)).await; + return; + } + }; + + let total = first_page.total; + let first_page_count = first_page.items.len() as u32; + let first_page_items = first_page.items.clone(); + + let (refresh_generation, preferred_playlist_id, preferred_folder_id, preferred_selected_index) = { + let mut app = self.app.lock().await; + let preferred_playlist_id = app.get_selected_playlist_id(); + let preferred_folder_id = app.current_playlist_folder_id; + let preferred_selected_index = app.selected_playlist_index; + app.playlist_refresh_generation = app.playlist_refresh_generation.saturating_add(1); + ( + app.playlist_refresh_generation, + preferred_playlist_id, + preferred_folder_id, + preferred_selected_index, + ) + }; + + // Step 2: Immediately populate app state with first page (flat list, no folders yet) + { + let mut app = self.app.lock().await; + app.all_playlists = first_page_items.clone(); + app.playlists = Some(first_page); + app.playlist_folder_nodes = None; + app.playlist_folder_items = build_flat_playlist_items(&app.all_playlists); + reconcile_playlist_selection( + &mut app, + preferred_playlist_id.as_deref(), + preferred_folder_id, + preferred_selected_index, + ); + } + + // Step 3: Spawn background task to fetch remaining pages + rootlist folders + let spotify = self.spotify.clone(); + let app = self.app.clone(); + let limit = self.large_search_limit; + #[cfg(feature = "streaming")] + { + let streaming_player = self.streaming_player.clone(); + tokio::spawn(async move { + Self::fetch_remaining_playlists_and_folders_task( + spotify, + app, + limit, + first_page_count, + total, + first_page_items, + refresh_generation, + preferred_playlist_id, + preferred_folder_id, + preferred_selected_index, + streaming_player, + ) + .await; + }); + } + #[cfg(not(feature = "streaming"))] + { + tokio::spawn(async move { + Self::fetch_remaining_playlists_and_folders_task( + spotify, + app, + limit, + first_page_count, + total, + first_page_items, + refresh_generation, + preferred_playlist_id, + preferred_folder_id, + preferred_selected_index, + ) + .await; + }); + } + } + + /// Background task: fetch remaining playlist pages and rootlist folders, + /// then update app state with the complete folder hierarchy. + async fn fetch_remaining_playlists_and_folders_task( + spotify: AuthCodeSpotify, + app: Arc>, + limit: u32, + first_page_count: u32, + total: u32, + mut all_playlists: Vec, + refresh_generation: u64, + preferred_playlist_id: Option, + preferred_folder_id: usize, + preferred_selected_index: Option, + #[cfg(feature = "streaming")] streaming_player: Option>, + ) { + let max_playlists: u32 = 10_000; + + // Fetch remaining playlist pages + let remaining_playlists_fut = async { + let mut remaining = Vec::new(); + let mut offset = first_page_count; + let mut had_error = false; + while offset < total && offset < max_playlists { + match spotify + .current_user_playlists_manual(Some(limit), Some(offset)) + .await + { + Ok(page) => { + let items_count = page.items.len() as u32; + remaining.extend(page.items); + if items_count < limit { + break; + } + offset += items_count; + } + Err(e) => { + had_error = true; + eprintln!("Failed to fetch playlist page at offset {}: {}", offset, e); + break; + } + } + tokio::task::yield_now().await; } + (remaining, had_error) + }; + + // Fetch rootlist folders concurrently with remaining pages (streaming only) + #[cfg(feature = "streaming")] + let ((remaining_playlists, had_playlist_error), folder_nodes) = tokio::join!( + remaining_playlists_fut, + fetch_rootlist_folders(&streaming_player), + ); + + #[cfg(not(feature = "streaming"))] + let (remaining_playlists, had_playlist_error) = remaining_playlists_fut.await; + #[cfg(not(feature = "streaming"))] + let folder_nodes: Option> = None; + + all_playlists.extend(remaining_playlists); + + // Update app state with complete data + let mut app = app.lock().await; + if app.playlist_refresh_generation != refresh_generation { + return; + } + + app.all_playlists = all_playlists; + let first_items: Vec<_> = app + .all_playlists + .iter() + .take(limit as usize) + .cloned() + .collect(); + let total_items = app.all_playlists.len() as u32; + if let Some(playlists) = app.playlists.as_mut() { + playlists.items = first_items; + playlists.total = total_items; + playlists.offset = 0; + playlists.next = None; + playlists.previous = None; + } + + let folder_items = if let Some(ref nodes) = folder_nodes { + structurize_playlist_folders(nodes, &app.all_playlists) + } else { + build_flat_playlist_items(&app.all_playlists) }; + + app.playlist_folder_nodes = folder_nodes; + app.playlist_folder_items = folder_items; + + reconcile_playlist_selection( + &mut app, + preferred_playlist_id.as_deref(), + preferred_folder_id, + preferred_selected_index, + ); + + if had_playlist_error { + app.status_message = Some("Playlists partially loaded (network error)".to_string()); + app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(4)); + } } async fn get_recently_played(&mut self) { @@ -3076,3 +3254,312 @@ impl Network { ); } } + +/// Fetch folder hierarchy from Spotify's rootlist API (streaming feature only). +/// Standalone function usable from spawned background tasks. +/// Returns parsed folder nodes, or None if unavailable. +#[cfg(feature = "streaming")] +async fn fetch_rootlist_folders( + streaming_player: &Option>, +) -> Option> { + let player = streaming_player.as_ref()?; + let session = player.session(); + + // Request the full rootlist + let bytes = match session.spclient().get_rootlist(0, Some(100_000)).await { + Ok(b) => b, + Err(e) => { + eprintln!("Failed to fetch rootlist: {}", e); + return None; + } + }; + + // Parse the protobuf response + use protobuf::Message; + let selected: librespot_protocol::playlist4_external::SelectedListContent = + match Message::parse_from_bytes(&bytes) { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to parse rootlist protobuf: {}", e); + return None; + } + }; + + let contents = selected.contents.as_ref()?; + let items = &contents.items; + + // Parse URIs into folder tree using start-group/end-group markers + Some(parse_rootlist_items(items)) +} + +fn build_flat_playlist_items( + playlists: &[rspotify::model::playlist::SimplifiedPlaylist], +) -> Vec { + playlists + .iter() + .enumerate() + .map(|(idx, _)| PlaylistFolderItem::Playlist { + index: idx, + current_id: 0, + }) + .collect() +} + +fn reconcile_playlist_selection( + app: &mut App, + preferred_playlist_id: Option<&str>, + preferred_folder_id: usize, + preferred_selected_index: Option, +) { + if app.playlist_folder_items.is_empty() { + app.current_playlist_folder_id = 0; + app.selected_playlist_index = None; + return; + } + + let folder_has_visible = |folder_id: usize, app: &App| { + app.playlist_folder_items.iter().any(|item| match item { + PlaylistFolderItem::Folder(folder) => folder.current_id == folder_id, + PlaylistFolderItem::Playlist { current_id, .. } => *current_id == folder_id, + }) + }; + + app.current_playlist_folder_id = if folder_has_visible(preferred_folder_id, app) { + preferred_folder_id + } else { + 0 + }; + + if let Some(playlist_id) = preferred_playlist_id { + let visible_playlist_index = app + .playlist_folder_items + .iter() + .filter(|item| app.is_playlist_item_visible_in_current_folder(item)) + .enumerate() + .find_map(|(display_idx, item)| match item { + PlaylistFolderItem::Playlist { index, .. } => app + .all_playlists + .get(*index) + .filter(|playlist| playlist.id.id() == playlist_id) + .map(|_| display_idx), + PlaylistFolderItem::Folder(_) => None, + }); + + if let Some(display_idx) = visible_playlist_index { + app.selected_playlist_index = Some(display_idx); + return; + } + + let mut target_folder: Option = None; + for item in &app.playlist_folder_items { + if let PlaylistFolderItem::Playlist { index, current_id } = item { + if let Some(playlist) = app.all_playlists.get(*index) { + if playlist.id.id() == playlist_id { + target_folder = Some(*current_id); + break; + } + } + } + } + + if let Some(folder_id) = target_folder { + app.current_playlist_folder_id = folder_id; + let display_idx = app + .playlist_folder_items + .iter() + .filter(|item| app.is_playlist_item_visible_in_current_folder(item)) + .enumerate() + .find_map(|(idx, item)| match item { + PlaylistFolderItem::Playlist { index, .. } => app + .all_playlists + .get(*index) + .filter(|playlist| playlist.id.id() == playlist_id) + .map(|_| idx), + PlaylistFolderItem::Folder(_) => None, + }); + if let Some(idx) = display_idx { + app.selected_playlist_index = Some(idx); + return; + } + } + } + + let visible_count = app.get_playlist_display_count(); + if visible_count == 0 { + app.current_playlist_folder_id = 0; + let root_count = app.get_playlist_display_count(); + app.selected_playlist_index = if root_count == 0 { + None + } else { + Some(preferred_selected_index.unwrap_or(0).min(root_count - 1)) + }; + return; + } + + app.selected_playlist_index = Some(preferred_selected_index.unwrap_or(0).min(visible_count - 1)); +} + +/// Parse rootlist item URIs into a tree of PlaylistFolderNodes. +/// URIs follow the pattern: +/// - `spotify:playlist:ID` — a playlist +/// - `spotify:start-group:GROUPID:FolderName` — start of a folder +/// - `spotify:end-group:GROUPID` — end of a folder +#[cfg(feature = "streaming")] +fn parse_rootlist_items( + items: &[librespot_protocol::playlist4_external::Item], +) -> Vec { + let mut root: Vec = Vec::new(); + let mut stack: Vec> = Vec::new(); + let mut name_stack: Vec<(String, String)> = Vec::new(); // (group_id, name) + + for item in items { + let uri = item.uri(); + + if let Some(rest) = uri.strip_prefix("spotify:start-group:") { + // Format: GROUPID:FolderName (group ID and name separated by first colon) + let (group_id, name) = match rest.find(':') { + Some(pos) => (rest[..pos].to_string(), rest[pos + 1..].to_string()), + None => (rest.to_string(), String::new()), + }; + name_stack.push((group_id, name.clone())); + stack.push(std::mem::take(&mut root)); + root = Vec::new(); + } else if uri.starts_with("spotify:end-group:") { + // Pop the folder — wrap accumulated children into a folder node + if let Some((group_id, name)) = name_stack.pop() { + let children = std::mem::take(&mut root); + root = stack.pop().unwrap_or_default(); + root.push(PlaylistFolderNode { + name: Some(name), + node_type: PlaylistFolderNodeType::Folder, + uri: format!("spotify:folder:{}", group_id), + children, + }); + } + } else { + // Regular item (playlist, etc.) + root.push(PlaylistFolderNode { + name: None, + node_type: PlaylistFolderNodeType::Playlist, + uri: uri.to_string(), + children: Vec::new(), + }); + } + } + + while let Some((group_id, name)) = name_stack.pop() { + let children = std::mem::take(&mut root); + root = stack.pop().unwrap_or_default(); + root.push(PlaylistFolderNode { + name: Some(name), + node_type: PlaylistFolderNodeType::Folder, + uri: format!("spotify:folder:{}", group_id), + children, + }); + } + + root +} + +/// Convert folder node tree + flat playlist list into a flat vector of PlaylistFolderItems +/// suitable for UI navigation. Each folder creates a "forward" entry (in parent folder) +/// and a "back" entry (inside the folder, pointing back to parent). +fn structurize_playlist_folders( + nodes: &[PlaylistFolderNode], + playlists: &[rspotify::model::playlist::SimplifiedPlaylist], +) -> Vec { + use std::collections::HashMap; + + // Build a map of playlist ID -> index in the playlists vec + let playlist_map: HashMap = playlists + .iter() + .enumerate() + .map(|(idx, p)| (p.id.id().to_string(), idx)) + .collect(); + + let mut items: Vec = Vec::new(); + let mut next_folder_id: usize = 1; // 0 is root + let mut used_playlist_indices: std::collections::HashSet = + std::collections::HashSet::new(); + + fn walk( + nodes: &[PlaylistFolderNode], + current_folder_id: usize, + items: &mut Vec, + next_folder_id: &mut usize, + playlist_map: &HashMap, + used_playlist_indices: &mut std::collections::HashSet, + ) { + for node in nodes { + match node.node_type { + PlaylistFolderNodeType::Folder => { + let folder_id = *next_folder_id; + *next_folder_id += 1; + + let name = node.name.as_deref().unwrap_or("Unnamed Folder").to_string(); + + // Forward entry: visible in parent, navigates into folder + items.push(PlaylistFolderItem::Folder(PlaylistFolder { + name: name.clone(), + current_id: current_folder_id, + target_id: folder_id, + })); + + // Back entry: visible inside folder, navigates back to parent + items.push(PlaylistFolderItem::Folder(PlaylistFolder { + name: format!("\u{2190} {}", name), + current_id: folder_id, + target_id: current_folder_id, + })); + + // Recurse into children + walk( + &node.children, + folder_id, + items, + next_folder_id, + playlist_map, + used_playlist_indices, + ); + } + PlaylistFolderNodeType::Playlist => { + // Extract playlist ID from URI (spotify:playlist:XXXXX) + let playlist_id = node + .uri + .strip_prefix("spotify:playlist:") + .unwrap_or(&node.uri); + + if let Some(&idx) = playlist_map.get(playlist_id) { + items.push(PlaylistFolderItem::Playlist { + index: idx, + current_id: current_folder_id, + }); + used_playlist_indices.insert(idx); + } + // If playlist not found in API results, skip it (could be unfollowed) + } + } + } + } + + walk( + nodes, + 0, + &mut items, + &mut next_folder_id, + &playlist_map, + &mut used_playlist_indices, + ); + + // Add any playlists not found in the folder tree (orphans) to root level + for (idx, _) in playlists.iter().enumerate() { + if !used_playlist_indices.contains(&idx) { + items.push(PlaylistFolderItem::Playlist { + index: idx, + current_id: 0, + }); + } + } + + items +} diff --git a/src/player/streaming.rs b/src/player/streaming.rs index 48991425..8ff57e45 100644 --- a/src/player/streaming.rs +++ b/src/player/streaming.rs @@ -117,6 +117,11 @@ pub struct StreamingPlayer { #[allow(dead_code)] impl StreamingPlayer { + /// Get a reference to the librespot session (for API calls like rootlist) + pub fn session(&self) -> &Session { + &self.session + } + /// Create a new streaming player using librespot-oauth for authentication /// /// This will check for cached credentials first, and if not found, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a0e99a54..4c71205f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -327,9 +327,33 @@ pub fn draw_library_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } pub fn draw_playlist_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { - let playlist_items = match &app.playlists { - Some(p) => p.items.iter().map(|item| item.name.to_owned()).collect(), - None => vec![], + let display_items = app.get_playlist_display_items(); + + let playlist_items: Vec = if app.playlist_folder_items.is_empty() { + // Fallback only when folder-aware items are not initialized yet + match &app.playlists { + Some(p) => p.items.iter().map(|item| item.name.to_owned()).collect(), + None => vec![], + } + } else { + display_items + .iter() + .map(|item| match item { + crate::app::PlaylistFolderItem::Folder(folder) => { + if folder.name.starts_with('\u{2190}') { + // Back entry (already has arrow prefix) + folder.name.clone() + } else { + format!("\u{1F4C1} {}", folder.name) + } + } + crate::app::PlaylistFolderItem::Playlist { index, .. } => app + .all_playlists + .get(*index) + .map(|p| p.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()), + }) + .collect() }; let current_route = app.get_current_route();