From 6f7966b62ee9a391b3b8c17af44eaa35fbf44210 Mon Sep 17 00:00:00 2001 From: Ritze03 Date: Fri, 10 Jul 2026 04:55:03 +0200 Subject: [PATCH] feat: playlist folder navigation in the add-to-playlist picker --- CHANGELOG.md | 1 + src/core/app.rs | 63 +++++++++++-- src/tui/handlers/dialog.rs | 179 +++++++++++++++++++++++++++++-------- src/tui/ui/popups.rs | 43 +++++---- 4 files changed, 227 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c35e84..5018669e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Added +- **Playlist folders in the add-to-playlist picker**: The `w` "Add Track To Playlist" dialog now shows your Spotify playlist folders and navigates them like the sidebar: `Enter` on a 📁 row opens the folder, the `←` row goes back, and `Enter` on a playlist adds the track. Only editable (owned or collaborative) playlists are offered, and the "Playlist Folders First" setting orders the dialog too. If folder data is unavailable (librespot rootlist fetch fails, or a build without native streaming), the dialog falls back to the previous flat list. - **Global customization**: Nearly every part of the UI is now configurable via `config.yml` and the Settings screen. Pick the screen that opens at launch (`startup_route`: home, recently played, podcasts, discover, artists, or saved albums — fetched on startup so it arrives populated) and a default sort order per screen (`default_sort_playlist_tracks` / `_saved_albums` / `_saved_artists` / `_recently_played`, as `field` or `field:desc`). Rearrange the layout with `sidebar_position: left|right|hidden` (hidden auto-reveals while the sidebar has focus) and `playbar_position: bottom|top`, and tune the responsive breakpoints (`small_terminal_width`/`_height`). New icon fields (progress-gauge fill, sort arrows, episode-played marker, active-source dot, list cursor) join the existing ones in a new **Icons** Settings tab, with one-column width validation where table alignment depends on it. The playbar buttons can be relabeled (`behavior.playbar_control_labels`, hitboxes resize automatically), the playbar status line and window title are format templates (`format:` section with `{state}`, `{device}`, `{volume}`, … placeholders), and every table's columns can be reordered, removed, renamed, and resized (`tables:` section). Timing constants are exposed too: status-message duration (`status_message_ttl_percent`), playback poll interval, table scroll padding, and like-animation length. Invalid values never brick the app — structural config errors log a warning and fall back to the built-in default. Documented in `docs/configuration.md` with a full commented example at `examples/config.example.yml`. - **Native cross-source play queue**: Press `z` on any track to add it to a native queue that plays across sources (Spotify, Local Files, Subsonic, and YouTube in one FIFO; internet radio is excluded since a live stream is not a finite track). Queued tracks play before the underlying playlist/album context, then that context resumes where it left off. Open the queue with `Shift+Q`: remove the selected item with `x` (rebindable via `remove_from_queue`, default `x`), reorder it with `J`/`K`, or press `Enter` to skip ahead and play it now. The Queue screen also previews what resumes from the underlying context once the queue drains. The queue survives restarts (persisted in `last_session.yml`). Spotify tracks queue through native (librespot) streaming; when you are controlling an external Spotify Connect device instead, `z` keeps the previous Web-API "add to Spotify's queue" behavior ([#206](https://github.com/LargeModGames/spotatui/issues/206)). - **Terminal (ANSI) theme preset**: A new theme preset built entirely from the terminal's own ANSI palette colors instead of hardcoded RGB, so terminal-theming tools like pywal recolor spotatui live, without a restart ([#336](https://github.com/LargeModGames/spotatui/issues/336)). diff --git a/src/core/app.rs b/src/core/app.rs index a1b3388c..264c25ee 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -740,6 +740,14 @@ pub enum PlaylistFolderItem { }, } +/// A row in the add-to-playlist picker dialog: a navigable folder or an +/// editable destination playlist. +#[derive(Debug)] +pub enum PlaylistPickerRow<'a> { + Folder(&'a PlaylistFolder), + Playlist(&'a PlaylistInfo), +} + /// Which stage of the "Create Playlist" form we are on #[derive(Clone, Copy, PartialEq, Debug, Default)] pub enum CreatePlaylistStage { @@ -1154,6 +1162,8 @@ pub struct App { pub playlist_track_positions: Option>, /// Selected playlist index in the add-to-playlist picker dialog pub playlist_picker_selected_index: usize, + /// Folder ID the add-to-playlist picker dialog is viewing (0 = root) + pub playlist_picker_folder_id: usize, /// Pending track to add in add-to-playlist dialog flow pub pending_playlist_track_add: Option, /// Pending track removal info in remove-from-playlist confirmation flow @@ -1438,6 +1448,7 @@ impl Default for App { pending_track_table_selection: None, playlist_track_positions: None, playlist_picker_selected_index: 0, + playlist_picker_folder_id: 0, pending_playlist_track_add: None, pending_playlist_track_removal: None, all_playlists: Vec::new(), @@ -1818,6 +1829,7 @@ impl App { self.pending_playlist_track_add = None; self.pending_playlist_track_removal = None; self.playlist_picker_selected_index = 0; + self.playlist_picker_folder_id = 0; } pub fn clear_friend_add_dialog_state(&mut self) { @@ -2028,15 +2040,52 @@ impl App { .collect() } - /// The destination playlists offered by the add-track picker dialog for the - /// active source: local YouTube playlists under YouTube, the user's editable - /// Spotify playlists otherwise. - pub fn playlist_picker_items(&self) -> Vec<&PlaylistInfo> { + /// The rows offered by the add-track picker dialog for the active source: + /// local YouTube playlists under YouTube (flat), otherwise the user's + /// editable Spotify playlists plus folder rows scoped to + /// `playlist_picker_folder_id`, mirroring the sidebar's folder navigation. + pub fn playlist_picker_items(&self) -> Vec> { if self.active_source == Source::YouTube { - self.youtube_playlists.iter().collect() - } else { - self.editable_playlists() + return self + .youtube_playlists + .iter() + .map(PlaylistPickerRow::Playlist) + .collect(); + } + + // Fallback: folder items never built (rootlist fetch failed, streaming + // disabled, …) — flat editable list, same as the pre-folder behavior. + if self.playlist_folder_items.is_empty() { + return self + .editable_playlists() + .into_iter() + .map(PlaylistPickerRow::Playlist) + .collect(); + } + + let mut rows: Vec = self + .playlist_folder_items + .iter() + .filter_map(|item| match item { + PlaylistFolderItem::Folder(f) if f.current_id == self.playlist_picker_folder_id => { + Some(PlaylistPickerRow::Folder(f)) + } + PlaylistFolderItem::Playlist { index, current_id } + if *current_id == self.playlist_picker_folder_id => + { + self + .all_playlists + .get(*index) + .filter(|playlist| self.playlist_is_editable(playlist)) + .map(PlaylistPickerRow::Playlist) + } + _ => None, + }) + .collect(); + if self.user_config.behavior.group_folders_first { + rows.sort_by_key(|row| !matches!(row, PlaylistPickerRow::Folder(_))); } + rows } pub fn begin_add_track_to_playlist_flow(&mut self, track_id: Option, track_name: String) { diff --git a/src/tui/handlers/dialog.rs b/src/tui/handlers/dialog.rs index a07077f2..befccf79 100644 --- a/src/tui/handlers/dialog.rs +++ b/src/tui/handlers/dialog.rs @@ -1,5 +1,5 @@ use super::common_key_events; -use crate::core::app::{ActiveBlock, App, DialogContext}; +use crate::core::app::{ActiveBlock, App, DialogContext, PlaylistPickerRow}; use crate::infra::network::IoEvent; use crate::tui::event::Key; @@ -53,60 +53,67 @@ fn handle_confirmation_dialog(key: Key, app: &mut App, dialog_context: DialogCon } fn handle_add_to_playlist_picker(key: Key, app: &mut App) { - // Destinations follow the active source: local YouTube playlists under - // YouTube, editable Spotify playlists otherwise. - let editable_playlists = app.playlist_picker_items(); - let playlist_count = editable_playlists.len(); + // Rows follow the active source: local YouTube playlists under YouTube, + // editable Spotify playlists plus navigable folder rows otherwise. + let picker_rows = app.playlist_picker_items(); + let row_count = picker_rows.len(); match key { - k if common_key_events::down_event(k, &app.user_config.keys) && playlist_count > 0 => { + k if common_key_events::down_event(k, &app.user_config.keys) && row_count > 0 => { let next = common_key_events::on_down_press_handler( - &editable_playlists, + &picker_rows, Some(app.playlist_picker_selected_index), ); app.playlist_picker_selected_index = next; } - k if common_key_events::up_event(k, &app.user_config.keys) && playlist_count > 0 => { + k if common_key_events::up_event(k, &app.user_config.keys) && row_count > 0 => { let next = common_key_events::on_up_press_handler( - &editable_playlists, + &picker_rows, Some(app.playlist_picker_selected_index), ); app.playlist_picker_selected_index = next; } - k if common_key_events::high_event(k) && playlist_count > 0 => { + k if common_key_events::high_event(k) && row_count > 0 => { app.playlist_picker_selected_index = common_key_events::on_high_press_handler(); } - k if common_key_events::middle_event(k) && playlist_count > 0 => { - app.playlist_picker_selected_index = - common_key_events::on_middle_press_handler(&editable_playlists); + k if common_key_events::middle_event(k) && row_count > 0 => { + app.playlist_picker_selected_index = common_key_events::on_middle_press_handler(&picker_rows); } - k if common_key_events::low_event(k) && playlist_count > 0 => { - app.playlist_picker_selected_index = - common_key_events::on_low_press_handler(&editable_playlists); + k if common_key_events::low_event(k) && row_count > 0 => { + app.playlist_picker_selected_index = common_key_events::on_low_press_handler(&picker_rows); } Key::Enter => { - if let Some(pending_add) = app.pending_playlist_track_add.clone() { - let selected = app - .playlist_picker_selected_index - .min(playlist_count.saturating_sub(1)); - let playlist_id = editable_playlists - .get(selected) - .and_then(|playlist| playlist.id.clone()); - let is_youtube = app.active_source == crate::core::source::Source::YouTube; - if let Some(playlist_id) = playlist_id { - if is_youtube { - app.dispatch(IoEvent::AddTrackToYouTubePlaylist( - playlist_id, - pending_add.track_id, - )); - } else { - app.dispatch(IoEvent::AddTrackToPlaylist( - playlist_id, - pending_add.track_id, - )); + let selected = app + .playlist_picker_selected_index + .min(row_count.saturating_sub(1)); + match picker_rows.get(selected) { + // Descend into (or back out of) the folder; the dialog stays open, + // same semantics as Enter in the sidebar (handlers/playlist.rs). + Some(PlaylistPickerRow::Folder(folder)) => { + let target_id = folder.target_id; + app.playlist_picker_folder_id = target_id; + app.playlist_picker_selected_index = 0; + } + Some(PlaylistPickerRow::Playlist(playlist)) => { + let playlist_id = playlist.id.clone(); + if let (Some(playlist_id), Some(pending_add)) = + (playlist_id, app.pending_playlist_track_add.clone()) + { + if app.active_source == crate::core::source::Source::YouTube { + app.dispatch(IoEvent::AddTrackToYouTubePlaylist( + playlist_id, + pending_add.track_id, + )); + } else { + app.dispatch(IoEvent::AddTrackToPlaylist( + playlist_id, + pending_add.track_id, + )); + } } + close_dialog(app); } + None => close_dialog(app), } - close_dialog(app); } Key::Char('q') => { close_dialog(app); @@ -162,7 +169,7 @@ fn close_dialog(app: &mut App) { mod tests { use super::*; use crate::core::{ - app::{PendingPlaylistTrackAdd, RouteId}, + app::{PendingPlaylistTrackAdd, PlaylistFolder, PlaylistFolderItem, RouteId}, pagination::Paged, test_helpers::{playlist_info, user_info}, user_config::UserConfig, @@ -224,4 +231,102 @@ mod tests { _ => panic!("expected add-track event"), } } + + #[test] + fn add_to_playlist_picker_navigates_folders_and_filters_uneditable() { + let (tx, rx) = channel(); + let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + app.user = Some(user_info("spotatui-owner")); + app.all_playlists = vec![ + playlist_info("37i9dQZF1DXcBWIGoYBM5M", "Owned", "spotatui-owner", false), + playlist_info("37i9dQZF1DWZqd5JICZI0u", "Followed", "friend-owner", false), + ]; + app.playlist_folder_items = vec![ + PlaylistFolderItem::Folder(PlaylistFolder { + name: "Mixes".to_string(), + current_id: 0, + target_id: 1, + }), + PlaylistFolderItem::Folder(PlaylistFolder { + name: "\u{2190} Mixes".to_string(), + current_id: 1, + target_id: 0, + }), + PlaylistFolderItem::Playlist { + index: 0, + current_id: 1, + }, + PlaylistFolderItem::Playlist { + index: 1, + current_id: 0, + }, + ]; + app.pending_playlist_track_add = Some(PendingPlaylistTrackAdd { + track_id: "0000000000000000000001".to_string(), + track_name: "Track".to_string(), + }); + app.push_navigation_stack( + RouteId::Dialog, + ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker), + ); + + // Root shows only the folder row: "Followed" is not editable. + assert_eq!(app.playlist_picker_items().len(), 1); + + // Enter on the folder descends, keeps the dialog open, dispatches nothing. + handler(Key::Enter, &mut app); + assert_eq!(app.playlist_picker_folder_id, 1); + assert_eq!(app.playlist_picker_selected_index, 0); + assert!(matches!( + app.get_current_route().active_block, + ActiveBlock::Dialog(DialogContext::AddTrackToPlaylistPicker) + )); + assert!(rx.try_recv().is_err()); + + // Inside the folder: back row + the owned playlist. Enter adds the track. + assert_eq!(app.playlist_picker_items().len(), 2); + app.playlist_picker_selected_index = 1; + handler(Key::Enter, &mut app); + match rx.recv().unwrap() { + IoEvent::AddTrackToPlaylist(playlist_id, track_id) => { + assert_eq!(playlist_id, "37i9dQZF1DXcBWIGoYBM5M"); + assert_eq!(track_id, "0000000000000000000001"); + } + _ => panic!("expected add-track event"), + } + } + + #[test] + fn picker_rows_respect_group_folders_first() { + let (tx, _rx) = channel(); + let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + app.user = Some(user_info("spotatui-owner")); + app.all_playlists = vec![playlist_info( + "37i9dQZF1DXcBWIGoYBM5M", + "Owned", + "spotatui-owner", + false, + )]; + app.playlist_folder_items = vec![ + PlaylistFolderItem::Playlist { + index: 0, + current_id: 0, + }, + PlaylistFolderItem::Folder(PlaylistFolder { + name: "Mixes".to_string(), + current_id: 0, + target_id: 1, + }), + ]; + + assert!(matches!( + app.playlist_picker_items()[0], + PlaylistPickerRow::Playlist(_) + )); + app.user_config.behavior.group_folders_first = true; + assert!(matches!( + app.playlist_picker_items()[0], + PlaylistPickerRow::Folder(_) + )); + } } diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index 533a59f1..eb8f3eaa 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -1,4 +1,4 @@ -use crate::core::app::{ActiveBlock, AnnouncementLevel, App, DialogContext}; +use crate::core::app::{ActiveBlock, AnnouncementLevel, App, DialogContext, PlaylistPickerRow}; use crate::core::plugin_api::PlayableInfo; use crate::core::plugin_api::PopupLine; use crate::infra::network::sync::PartyStatus; @@ -536,12 +536,12 @@ fn draw_add_track_to_playlist_picker_dialog(f: &mut Frame<'_>, app: &App) { f.render_widget(header, vchunks[0]); let mut list_state = ListState::default(); - // Destinations follow the active source: local YouTube playlists under the - // YouTube source, editable Spotify playlists otherwise (must stay in sync - // with the picker's key handler). - let editable_playlists = app.playlist_picker_items(); + // Rows follow the active source: local YouTube playlists under the YouTube + // source, editable Spotify playlists plus folder rows otherwise (must stay + // in sync with the picker's key handler). + let picker_rows = app.playlist_picker_items(); - if editable_playlists.is_empty() { + if picker_rows.is_empty() { let empty_text = Paragraph::new("No editable playlists available") .style(Style::default().fg(app.user_config.theme.inactive)) .alignment(Alignment::Center); @@ -556,21 +556,34 @@ fn draw_add_track_to_playlist_picker_dialog(f: &mut Frame<'_>, app: &App) { .as_ref() .is_some_and(|user| Some(user.id.as_str()) == playlist.owner_id.as_deref()) }; - let items: Vec = editable_playlists + let items: Vec = picker_rows .iter() - .map(|playlist| { - let label = if is_own_playlist(playlist) { - playlist.name.clone() - } else { - // `owner` is the display name, falling back to the owner id. - format!("{} - {} (collab)", playlist.name, playlist.owner) + .map(|row| { + let label = match row { + // Same folder rendering as the sidebar (ui/library.rs): back rows + // ("← name") as-is, other folders with a 📁 prefix. + PlaylistPickerRow::Folder(folder) => { + if folder.name.starts_with('\u{2190}') { + folder.name.clone() + } else { + format!("\u{1F4C1} {}", folder.name) + } + } + PlaylistPickerRow::Playlist(playlist) => { + if is_own_playlist(playlist) { + playlist.name.clone() + } else { + // `owner` is the display name, falling back to the owner id. + format!("{} - {} (collab)", playlist.name, playlist.owner) + } + } }; ListItem::new(Span::raw(label)) }) .collect(); let selected = app .playlist_picker_selected_index - .min(editable_playlists.len() - 1); + .min(picker_rows.len() - 1); list_state.select(Some(selected)); let list = List::new(items) @@ -582,7 +595,7 @@ fn draw_add_track_to_playlist_picker_dialog(f: &mut Frame<'_>, app: &App) { } let footer = Paragraph::new(format!( - "Enter add | q cancel | {}/{} or arrows move | H/M/L jump", + "Enter add/open | q cancel | {}/{} or arrows move | H/M/L jump", app.user_config.keys.move_down, app.user_config.keys.move_up, )) .style(Style::default().fg(app.user_config.theme.inactive))