Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
63 changes: 56 additions & 7 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1154,6 +1162,8 @@ pub struct App {
pub playlist_track_positions: Option<Vec<usize>>,
/// 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<PendingPlaylistTrackAdd>,
/// Pending track removal info in remove-from-playlist confirmation flow
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<PlaylistPickerRow<'_>> {
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<PlaylistPickerRow> = 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<String>, track_name: String) {
Expand Down
179 changes: 142 additions & 37 deletions src/tui/handlers/dialog.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(_)
));
}
}
Loading
Loading