From 7baa2a404857aeca7235bd591810066f76a54129 Mon Sep 17 00:00:00 2001 From: Lorelei Noble Date: Sat, 28 Mar 2026 10:33:16 -0400 Subject: [PATCH 1/3] feat: add Create Playlist feature --- src/core/app.rs | 43 +++++ src/infra/network/library.rs | 136 ++++++++++++--- src/infra/network/mod.rs | 10 ++ src/infra/network/playback.rs | 8 + src/infra/network/search.rs | 28 ++++ src/main.rs | 8 + src/tui/handlers/common_key_events.rs | 1 + src/tui/handlers/create_playlist.rs | 229 ++++++++++++++++++++++++++ src/tui/handlers/mod.rs | 15 ++ src/tui/handlers/playlist.rs | 21 ++- src/tui/ui/create_playlist.rs | 218 ++++++++++++++++++++++++ src/tui/ui/library.rs | 5 +- src/tui/ui/mod.rs | 3 + 13 files changed, 697 insertions(+), 28 deletions(-) create mode 100644 src/tui/handlers/create_playlist.rs create mode 100644 src/tui/ui/create_playlist.rs diff --git a/src/core/app.rs b/src/core/app.rs index 8f5d522d..91971e00 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -242,6 +242,7 @@ pub enum ActiveBlock { SortMenu, Queue, Party, + CreatePlaylistForm, } #[derive(Clone, PartialEq, Debug)] @@ -271,6 +272,7 @@ pub enum RouteId { HelpMenu, Queue, Party, + CreatePlaylist, } #[derive(Clone, Copy, PartialEq, Debug)] @@ -509,6 +511,23 @@ pub enum PlaylistFolderItem { }, } +/// Which stage of the "Create Playlist" form we are on +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum CreatePlaylistStage { + #[default] + Name, + AddTracks, +} + +/// Which panel inside the AddTracks stage has focus +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum CreatePlaylistFocus { + #[default] + SearchInput, + SearchResults, + AddedTracks, +} + /// Settings screen category tabs #[derive(Clone, Copy, PartialEq, Debug, Default)] pub enum SettingsCategory { @@ -785,6 +804,19 @@ pub struct App { /// Reference to MPRIS manager for emitting Seeked signals after native seeks #[cfg(all(feature = "mpris", target_os = "linux"))] pub mpris_manager: Option>, + + // Create Playlist form state + pub create_playlist_name: Vec, + pub create_playlist_name_idx: usize, + pub create_playlist_name_cursor: u16, + pub create_playlist_stage: CreatePlaylistStage, + pub create_playlist_tracks: Vec, + pub create_playlist_search_results: Vec, + pub create_playlist_search_input: Vec, + pub create_playlist_search_idx: usize, + pub create_playlist_search_cursor: u16, + pub create_playlist_selected_result: usize, + pub create_playlist_focus: CreatePlaylistFocus, } #[derive(Clone, Copy, PartialEq, Debug)] @@ -951,6 +983,17 @@ impl Default for App { mpris_manager: None, #[cfg(feature = "cover-art")] cover_art: crate::tui::cover_art::CoverArt::new(), + create_playlist_name: Vec::new(), + create_playlist_name_idx: 0, + create_playlist_name_cursor: 0, + create_playlist_stage: CreatePlaylistStage::Name, + create_playlist_tracks: Vec::new(), + create_playlist_search_results: Vec::new(), + create_playlist_search_input: Vec::new(), + create_playlist_search_idx: 0, + create_playlist_search_cursor: 0, + create_playlist_selected_result: 0, + create_playlist_focus: CreatePlaylistFocus::SearchInput, } } } diff --git a/src/infra/network/library.rs b/src/infra/network/library.rs index 72517e49..681c3e24 100644 --- a/src/infra/network/library.rs +++ b/src/infra/network/library.rs @@ -9,7 +9,7 @@ use reqwest::Method; use rspotify::model::{ idtypes::{AlbumId, LibraryId, PlaylistId, ShowId, TrackId, UserId}, page::Page, - playlist::PlaylistItem, + playlist::{PlaylistItem, SimplifiedPlaylist}, track::SavedTrack, PlayableItem, }; @@ -169,6 +169,7 @@ pub trait LibraryNetwork { async fn toggle_save_track(&mut self, track_id: rspotify::model::idtypes::PlayableId<'static>); async fn current_user_saved_tracks_contains(&mut self, ids: Vec>); async fn fetch_all_playlist_tracks_and_sort(&mut self, playlist_id: PlaylistId<'static>); + async fn create_new_playlist(&mut self, name: String, track_ids: Vec>); } // Private helper methods @@ -275,32 +276,40 @@ impl LibraryNetwork for Network { let mut first_page = None; loop { - match self - .spotify - .current_user_playlists_manual(Some(limit), Some(offset)) - .await + // Always use the compat path: parses raw JSON to serde_json::Value first, + // which silently deduplicates keys (last-wins). This handles the known Spotify + // API bug where "items" appears twice in the same JSON object. + let page = match super::requests::spotify_get_typed_compat_for::>( + &self.spotify, + "me/playlists", + &[ + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) + .await { - Ok(page) => { - if offset == 0 { - first_page = Some(page.clone()); - } - - if page.items.is_empty() { - break; - } - - all_playlists.extend(page.items); - - if page.next.is_none() { - break; - } - offset += limit; - } + Ok(page) => page, Err(e) => { self.handle_error(anyhow!(e)).await; return; } + }; + + if offset == 0 { + first_page = Some(page.clone()); } + + if page.items.is_empty() { + break; + } + + all_playlists.extend(page.items); + + if page.next.is_none() { + break; + } + offset += limit; } #[cfg(feature = "streaming")] @@ -766,6 +775,91 @@ impl LibraryNetwork for Network { sorter.sort_tracks(&mut all_tracks); let _ = app.apply_sorted_playlist_tracks_if_current(&playlist_id, all_tracks); } + + async fn create_new_playlist(&mut self, name: String, track_ids: Vec>) { + let user_id = { + let app = self.app.lock().await; + app.user.as_ref().and_then(|u| Some(u.id.clone())) + }; + + let user_id = match user_id { + Some(id) => id, + None => { + self + .show_status_message("Cannot create playlist: not logged in".to_string(), 4) + .await; + return; + } + }; + + // Use raw API call to avoid rspotify deserializing FullPlaylist, which crashes when + // Spotify returns a duplicate "items" key in the response (known API migration bug). + let user_id_str = user_id.id().to_string(); + let create_path = format!("users/{}/playlists", user_id_str); + let create_body = json!({ + "name": name, + "public": false, + "collaborative": false, + "description": "Created with spotatui" + }); + let playlist_value = match spotify_api_request_json_for( + &self.spotify, + Method::POST, + &create_path, + &[], + Some(create_body), + ) + .await + { + Ok(v) => v, + Err(e) => { + self.handle_error(e).await; + return; + } + }; + + let playlist_id_str = match playlist_value.get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => { + self + .show_status_message("Playlist created but could not get its ID".to_string(), 4) + .await; + return; + } + }; + + let playlist_id = match PlaylistId::from_id(playlist_id_str) { + Ok(id) => id.into_static(), + Err(e) => { + self.handle_error(anyhow!(e)).await; + return; + } + }; + + if !track_ids.is_empty() { + let items: Vec = track_ids + .iter() + .map(|id| rspotify::model::idtypes::PlayableId::Track(id.clone())) + .collect(); + if let Err(e) = self + .spotify + .playlist_add_items(playlist_id, items, None) + .await + { + self.handle_error(anyhow!(e)).await; + return; + } + } + + // Refresh playlists + { + let mut app = self.app.lock().await; + app.dispatch(IoEvent::GetPlaylists); + } + + let status = format!("Playlist \"{}\" created!", name); + self.show_status_message(status, 4).await; + } } #[cfg(test)] diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs index 0bec27b7..313cc2b0 100644 --- a/src/infra/network/mod.rs +++ b/src/infra/network/mod.rs @@ -140,6 +140,10 @@ pub enum IoEvent { /// Send a playback command to the party host (guest only, Phase 2) #[allow(dead_code)] PartyPlaybackCommand(sync::PlaybackAction), + /// Search tracks to add to a new playlist + SearchTracksForPlaylist(String), + /// Create a new playlist with the given name and track IDs + CreateNewPlaylist(String, Vec>), } pub struct Network { @@ -413,6 +417,12 @@ impl Network { IoEvent::PartyPlaybackCommand(action) => { self.party_playback_command(action).await; } + IoEvent::SearchTracksForPlaylist(query) => { + self.search_tracks_for_playlist(query).await; + } + IoEvent::CreateNewPlaylist(name, track_ids) => { + self.create_new_playlist(name, track_ids).await; + } }; { diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index d73ed611..5f316c5b 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -365,6 +365,14 @@ impl PlaybackNetwork for Network { return; } + // 404 = no active device/player; treat as idle, not an error + if err.to_string().contains("404") || err.to_string().contains("Not Found") { + app.current_playback_context = None; + app.instant_since_last_current_playback_poll = Instant::now(); + app.is_fetching_current_playback = false; + return; + } + app.handle_error(err); return; } diff --git a/src/infra/network/search.rs b/src/infra/network/search.rs index f56ee96d..953282f7 100644 --- a/src/infra/network/search.rs +++ b/src/infra/network/search.rs @@ -19,6 +19,7 @@ pub struct ArtistSearchResponse { pub trait SearchNetwork { async fn get_search_results(&mut self, search_term: String, country: Option); + async fn search_tracks_for_playlist(&mut self, search_term: String); } impl SearchNetwork for Network { @@ -151,4 +152,31 @@ impl SearchNetwork for Network { app.search_results.playlists = playlist_result; app.search_results.shows = show_result; } + + async fn search_tracks_for_playlist(&mut self, search_term: String) { + let result = self + .spotify + .search( + &search_term, + SearchType::Track, + None, + None, + Some(self.large_search_limit), + Some(0), + ) + .await; + + let tracks = match result { + Ok(SearchResult::Tracks(page)) => page + .items + .into_iter() + .filter_map(|t| if t.id.is_some() { Some(t) } else { None }) + .collect::>(), + _ => return, + }; + + let mut app = self.app.lock().await; + app.create_playlist_search_results = tracks; + app.create_playlist_selected_result = 0; + } } diff --git a/src/main.rs b/src/main.rs index f706ab5c..8b5f9bff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2544,6 +2544,10 @@ async fn start_ui( ActiveBlock::Settings => { ui::settings::draw_settings(f, &app); } + ActiveBlock::CreatePlaylistForm => { + ui::draw_main_layout(f, &app); + ui::draw_create_playlist_form(f, &app); + } _ => { ui::draw_main_layout(f, &app); } @@ -2853,6 +2857,10 @@ async fn start_ui( ActiveBlock::AnnouncementPrompt => ui::draw_announcement_prompt(f, &app), ActiveBlock::ExitPrompt => ui::draw_exit_prompt(f, &app), ActiveBlock::Settings => ui::settings::draw_settings(f, &app), + ActiveBlock::CreatePlaylistForm => { + ui::draw_main_layout(f, &app); + ui::draw_create_playlist_form(f, &app); + } _ => ui::draw_main_layout(f, &app), } })?; diff --git a/src/tui/handlers/common_key_events.rs b/src/tui/handlers/common_key_events.rs index 40c50a8b..186b6809 100644 --- a/src/tui/handlers/common_key_events.rs +++ b/src/tui/handlers/common_key_events.rs @@ -146,6 +146,7 @@ pub fn handle_right_event(app: &mut App) { RouteId::HelpMenu => {} RouteId::Queue => {} RouteId::Party => {} + RouteId::CreatePlaylist => {} }, _ => {} }; diff --git a/src/tui/handlers/create_playlist.rs b/src/tui/handlers/create_playlist.rs new file mode 100644 index 00000000..557aef6f --- /dev/null +++ b/src/tui/handlers/create_playlist.rs @@ -0,0 +1,229 @@ +use crate::core::app::{App, CreatePlaylistFocus, CreatePlaylistStage}; +use crate::infra::network::IoEvent; +use crate::tui::event::Key; +use unicode_width::UnicodeWidthChar; + +pub fn handler(key: Key, app: &mut App) { + match app.create_playlist_stage { + CreatePlaylistStage::Name => handle_name_stage(key, app), + CreatePlaylistStage::AddTracks => handle_add_tracks_stage(key, app), + } +} + +fn handle_name_stage(key: Key, app: &mut App) { + match key { + Key::Enter => { + let name: String = app.create_playlist_name.iter().collect(); + if !name.trim().is_empty() { + app.create_playlist_stage = CreatePlaylistStage::AddTracks; + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + } + Key::Esc => { + close_form(app); + } + Key::Backspace => { + if app.create_playlist_name_idx > 0 { + app.create_playlist_name_idx -= 1; + let removed = app.create_playlist_name.remove(app.create_playlist_name_idx); + let width = removed.width().unwrap_or(1) as u16; + app.create_playlist_name_cursor = app.create_playlist_name_cursor.saturating_sub(width); + } + } + Key::Char(c) => { + app + .create_playlist_name + .insert(app.create_playlist_name_idx, c); + app.create_playlist_name_idx += 1; + app.create_playlist_name_cursor += c.width().unwrap_or(1) as u16; + } + Key::Left => { + if app.create_playlist_name_idx > 0 { + app.create_playlist_name_idx -= 1; + let c = app.create_playlist_name[app.create_playlist_name_idx]; + app.create_playlist_name_cursor = + app.create_playlist_name_cursor.saturating_sub(c.width().unwrap_or(1) as u16); + } + } + Key::Right => { + if app.create_playlist_name_idx < app.create_playlist_name.len() { + let c = app.create_playlist_name[app.create_playlist_name_idx]; + app.create_playlist_name_idx += 1; + app.create_playlist_name_cursor += c.width().unwrap_or(1) as u16; + } + } + _ => {} + } +} + +fn handle_add_tracks_stage(key: Key, app: &mut App) { + match app.create_playlist_focus { + CreatePlaylistFocus::SearchInput => handle_search_input(key, app), + CreatePlaylistFocus::SearchResults => handle_results_nav(key, app), + CreatePlaylistFocus::AddedTracks => handle_added_tracks_nav(key, app), + } +} + +fn handle_search_input(key: Key, app: &mut App) { + match key { + Key::Esc => { + close_form(app); + } + Key::Enter => { + let query: String = app.create_playlist_search_input.iter().collect(); + if !query.trim().is_empty() { + app.dispatch(IoEvent::SearchTracksForPlaylist(query)); + app.create_playlist_focus = CreatePlaylistFocus::SearchResults; + } + } + Key::Tab => { + if !app.create_playlist_tracks.is_empty() { + app.create_playlist_focus = CreatePlaylistFocus::AddedTracks; + } else if !app.create_playlist_search_results.is_empty() { + app.create_playlist_focus = CreatePlaylistFocus::SearchResults; + } + } + Key::Down => { + if !app.create_playlist_search_results.is_empty() { + app.create_playlist_focus = CreatePlaylistFocus::SearchResults; + } + } + Key::Backspace => { + if app.create_playlist_search_idx > 0 { + app.create_playlist_search_idx -= 1; + let removed = app + .create_playlist_search_input + .remove(app.create_playlist_search_idx); + let width = removed.width().unwrap_or(1) as u16; + app.create_playlist_search_cursor = + app.create_playlist_search_cursor.saturating_sub(width); + } + } + Key::Char(c) => { + app + .create_playlist_search_input + .insert(app.create_playlist_search_idx, c); + app.create_playlist_search_idx += 1; + app.create_playlist_search_cursor += c.width().unwrap_or(1) as u16; + } + Key::Left => { + if app.create_playlist_search_idx > 0 { + app.create_playlist_search_idx -= 1; + let c = app.create_playlist_search_input[app.create_playlist_search_idx]; + app.create_playlist_search_cursor = + app.create_playlist_search_cursor.saturating_sub(c.width().unwrap_or(1) as u16); + } + } + Key::Right => { + if app.create_playlist_search_idx < app.create_playlist_search_input.len() { + let c = app.create_playlist_search_input[app.create_playlist_search_idx]; + app.create_playlist_search_idx += 1; + app.create_playlist_search_cursor += c.width().unwrap_or(1) as u16; + } + } + _ => {} + } +} + +fn handle_results_nav(key: Key, app: &mut App) { + let count = app.create_playlist_search_results.len(); + match key { + Key::Esc => { + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + Key::Up => { + if count > 0 && app.create_playlist_selected_result > 0 { + app.create_playlist_selected_result -= 1; + } + } + Key::Down => { + if count > 0 && app.create_playlist_selected_result + 1 < count { + app.create_playlist_selected_result += 1; + } + } + Key::Enter => { + if count > 0 { + let idx = app.create_playlist_selected_result; + if idx < count { + let track = app.create_playlist_search_results[idx].clone(); + app.create_playlist_tracks.push(track); + } + } + } + Key::Tab => { + if !app.create_playlist_tracks.is_empty() { + app.create_playlist_focus = CreatePlaylistFocus::AddedTracks; + } else { + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + } + _ => {} + } +} + +fn handle_added_tracks_nav(key: Key, app: &mut App) { + let count = app.create_playlist_tracks.len(); + match key { + Key::Esc => { + close_form(app); + } + Key::Tab => { + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + Key::Up => { + if count > 0 && app.create_playlist_selected_result > 0 { + app.create_playlist_selected_result -= 1; + } + } + Key::Down => { + if count > 0 && app.create_playlist_selected_result + 1 < count { + app.create_playlist_selected_result += 1; + } + } + Key::Char('d') => { + if count > 0 { + let idx = app.create_playlist_selected_result; + if idx < count { + app.create_playlist_tracks.remove(idx); + if app.create_playlist_selected_result >= app.create_playlist_tracks.len() + && !app.create_playlist_tracks.is_empty() + { + app.create_playlist_selected_result = app.create_playlist_tracks.len() - 1; + } + } + } + } + Key::Enter => { + submit_playlist(app); + } + _ => {} + } +} + +fn submit_playlist(app: &mut App) { + let name: String = app.create_playlist_name.iter().collect(); + let track_ids: Vec> = app + .create_playlist_tracks + .iter() + .filter_map(|t| t.id.clone()) + .collect(); + + app.dispatch(IoEvent::CreateNewPlaylist(name, track_ids)); + close_form(app); +} + +fn close_form(app: &mut App) { + app.pop_navigation_stack(); + // Reset form state + app.create_playlist_name = Vec::new(); + app.create_playlist_name_idx = 0; + app.create_playlist_name_cursor = 0; + app.create_playlist_stage = CreatePlaylistStage::Name; + app.create_playlist_tracks = Vec::new(); + app.create_playlist_search_results = Vec::new(); + app.create_playlist_search_input = Vec::new(); + app.create_playlist_search_idx = 0; + app.create_playlist_search_cursor = 0; + app.create_playlist_selected_result = 0; + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; +} diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 2cec33ab..86d73569 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -5,6 +5,7 @@ mod announcement_prompt; mod artist; mod artists; mod common_key_events; +mod create_playlist; #[cfg(feature = "cover-art")] mod cover_art_view; mod dialog; @@ -75,6 +76,13 @@ pub fn handle_app(key: Key, app: &mut App) { return; } + // When Create Playlist form is open, all keys go directly to the form handler + // (so typed characters aren't stolen by global bindings like 'd', space, etc.) + if app.get_current_route().active_block == ActiveBlock::CreatePlaylistForm { + handle_block_events(key, app); + return; + } + if app.maybe_activate_open_settings_fallback(key) { open_settings(app); if app.pending_keybinding_persist.is_some() { @@ -239,6 +247,7 @@ fn is_input_mode(app: &App) -> bool { | ActiveBlock::Dialog(_) | ActiveBlock::AnnouncementPrompt | ActiveBlock::ExitPrompt + | ActiveBlock::CreatePlaylistForm ) } @@ -333,6 +342,9 @@ fn handle_block_events(key: Key, app: &mut App) { ActiveBlock::Party => { party::handler(key, app); } + ActiveBlock::CreatePlaylistForm => { + create_playlist::handler(key, app); + } } } @@ -380,6 +392,9 @@ fn handle_escape(app: &mut App) { app.sort_context = None; app.set_current_route_state(Some(ActiveBlock::Empty), None); } + ActiveBlock::CreatePlaylistForm => { + create_playlist::handler(Key::Esc, app); + } _ => { app.set_current_route_state(Some(ActiveBlock::Empty), None); } diff --git a/src/tui/handlers/playlist.rs b/src/tui/handlers/playlist.rs index 91619651..989efef7 100644 --- a/src/tui/handlers/playlist.rs +++ b/src/tui/handlers/playlist.rs @@ -4,30 +4,35 @@ use crate::core::app::{App, DialogContext, PlaylistFolderItem, TrackTableContext use crate::infra::network::IoEvent; use crate::tui::event::Key; +/// Total items = playlists/folders + the "Add Playlist" entry at the bottom +fn total_display_count(app: &App) -> usize { + app.get_playlist_display_count() + 1 +} + 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) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); 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) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); 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 app.get_playlist_display_count() > 0 { + if total_display_count(app) > 0 { app.selected_playlist_index = Some(0); } } k if common_key_events::middle_event(k) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); if count > 0 { let next_index = if count.is_multiple_of(2) { count.saturating_sub(1) / 2 @@ -38,14 +43,18 @@ pub fn handler(key: Key, app: &mut App) { } } k if common_key_events::low_event(k) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); if count > 0 { app.selected_playlist_index = Some(count - 1); } } Key::Enter => { if let Some(selected_idx) = app.selected_playlist_index { - if let Some(item) = app.get_playlist_display_item_at(selected_idx) { + let playlist_count = app.get_playlist_display_count(); + if selected_idx == playlist_count { + // "Add Playlist" entry selected + app.push_navigation_stack(RouteId::CreatePlaylist, ActiveBlock::CreatePlaylistForm); + } else if let Some(item) = app.get_playlist_display_item_at(selected_idx) { match item { PlaylistFolderItem::Folder(folder) => { // Navigate into/out of folder diff --git a/src/tui/ui/create_playlist.rs b/src/tui/ui/create_playlist.rs new file mode 100644 index 00000000..718d8fa0 --- /dev/null +++ b/src/tui/ui/create_playlist.rs @@ -0,0 +1,218 @@ +use crate::core::app::{App, CreatePlaylistFocus, CreatePlaylistStage}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::Span, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +fn centered_rect(bounds: Rect, width_pct: u16, height_pct: u16) -> Rect { + let width = (bounds.width * width_pct / 100).max(1); + let height = (bounds.height * height_pct / 100).max(1); + let x = bounds.x + bounds.width.saturating_sub(width) / 2; + let y = bounds.y + bounds.height.saturating_sub(height) / 3; + Rect::new(x, y, width, height) +} + +pub fn draw_create_playlist_form(f: &mut Frame<'_>, app: &App) { + let area = centered_rect(f.area(), 80, 80); + f.render_widget(Clear, area); + + match app.create_playlist_stage { + CreatePlaylistStage::Name => draw_name_stage(f, app, area), + CreatePlaylistStage::AddTracks => draw_add_tracks_stage(f, app, area), + } +} + +fn draw_name_stage(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = &app.user_config.theme; + + let block = Block::default() + .title(Span::styled( + "Create Playlist (Esc to cancel)", + Style::default() + .fg(theme.header) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .style(theme.base_style()) + .border_style(Style::default().fg(theme.active)); + f.render_widget(block, area); + + let inner = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + let label = Paragraph::new("Playlist name:") + .style(theme.base_style()); + f.render_widget(label, inner[0]); + + let name_text: String = app.create_playlist_name.iter().collect(); + let input = Paragraph::new(name_text) + .style(theme.base_style()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.active)), + ); + f.render_widget(input, inner[1]); + + let hint = Paragraph::new("Press Enter to continue, Esc to cancel") + .style(Style::default().fg(theme.inactive)); + f.render_widget(hint, inner[2]); +} + +fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = &app.user_config.theme; + let name: String = app.create_playlist_name.iter().collect(); + let title = format!("Add Tracks to \"{}\" (Enter=create, Tab=switch panel, Esc=cancel)", name); + + let block = Block::default() + .title(Span::styled( + title, + Style::default() + .fg(theme.header) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .style(theme.base_style()) + .border_style(Style::default().fg(theme.active)); + f.render_widget(block, area); + + let inner = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + ]) + .split(area); + + // Search input + let search_text: String = app.create_playlist_search_input.iter().collect(); + let search_border_style = if app.create_playlist_focus == CreatePlaylistFocus::SearchInput { + Style::default().fg(theme.active) + } else { + Style::default().fg(theme.inactive) + }; + let search_input = Paragraph::new(search_text) + .style(theme.base_style()) + .block( + Block::default() + .title(Span::styled( + "Search (Enter to search)", + Style::default().fg(theme.header), + )) + .borders(Borders::ALL) + .border_style(search_border_style), + ); + f.render_widget(search_input, inner[0]); + + // Two-panel area: results + added tracks + let panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner[1]); + + // Left: search results + let results_border_style = if app.create_playlist_focus == CreatePlaylistFocus::SearchResults { + Style::default().fg(theme.active) + } else { + Style::default().fg(theme.inactive) + }; + let result_items: Vec = app + .create_playlist_search_results + .iter() + .map(|t| { + let artist = t + .artists + .first() + .map(|a| a.name.as_str()) + .unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", t.name, artist)) + .style(theme.base_style()) + }) + .collect(); + + let mut results_state = ListState::default(); + if app.create_playlist_focus == CreatePlaylistFocus::SearchResults + && !app.create_playlist_search_results.is_empty() + { + results_state.select(Some(app.create_playlist_selected_result)); + } + + let results_list = List::new(result_items) + .block( + Block::default() + .title(Span::styled( + "Results (Enter to add)", + Style::default().fg(theme.header), + )) + .borders(Borders::ALL) + .border_style(results_border_style), + ) + .highlight_style( + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD), + ) + .style(theme.base_style()); + f.render_stateful_widget(results_list, panels[0], &mut results_state); + + // Right: added tracks + let added_border_style = if app.create_playlist_focus == CreatePlaylistFocus::AddedTracks { + Style::default().fg(theme.active) + } else { + Style::default().fg(theme.inactive) + }; + + let added_items: Vec = app + .create_playlist_tracks + .iter() + .map(|t| { + let artist = t + .artists + .first() + .map(|a| a.name.as_str()) + .unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", t.name, artist)) + .style(theme.base_style()) + }) + .collect(); + + let mut added_state = ListState::default(); + if app.create_playlist_focus == CreatePlaylistFocus::AddedTracks + && !app.create_playlist_tracks.is_empty() + { + added_state.select(Some(app.create_playlist_selected_result)); + } + + let added_tracks_title = format!( + "Added ({}) — d=remove, Enter=create", + app.create_playlist_tracks.len() + ); + let added_list = List::new(added_items) + .block( + Block::default() + .title(Span::styled( + added_tracks_title, + Style::default().fg(theme.header), + )) + .borders(Borders::ALL) + .border_style(added_border_style), + ) + .highlight_style( + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD), + ) + .style(theme.base_style()); + f.render_stateful_widget(added_list, panels[1], &mut added_state); +} diff --git a/src/tui/ui/library.rs b/src/tui/ui/library.rs index 25ec6773..028956b5 100644 --- a/src/tui/ui/library.rs +++ b/src/tui/ui/library.rs @@ -64,12 +64,15 @@ pub fn draw_playlist_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { current_route.hovered_block == ActiveBlock::MyPlaylists, ); + let mut display_list = playlist_items; + display_list.push("+ Add Playlist".to_string()); + draw_selectable_list( f, app, layout_chunk, "Playlists", - &playlist_items, + &display_list, highlight_state, app.selected_playlist_index, ); diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index c60c74a8..98b6f05c 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -1,5 +1,6 @@ pub mod artist; pub mod audio_analysis; +pub mod create_playlist; pub mod discover; pub mod help; pub mod home; @@ -19,6 +20,7 @@ use ratatui::{ }; pub use self::artist::draw_artist_albums; +pub use self::create_playlist::draw_create_playlist_form; pub use self::discover::draw_discover; pub use self::home::draw_home; pub use self::library::draw_user_block; @@ -138,5 +140,6 @@ pub fn draw_routes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { | RouteId::Queue | RouteId::Party => {} // These are drawn outside the main routed content area. RouteId::Dialog => {} // This is handled in draw_dialog. + RouteId::CreatePlaylist => {} // This is drawn as an overlay via draw_create_playlist_form. }; } From 1f877d28c7fc6851878d7598c82e4c8ccf2dbde5 Mon Sep 17 00:00:00 2001 From: Lorelei Noble Date: Sat, 28 Mar 2026 10:50:01 -0400 Subject: [PATCH 2/3] fix: apply rustfmt and clippy fixes --- src/infra/network/library.rs | 7 ++-- src/tui/handlers/create_playlist.rs | 17 ++++++---- src/tui/handlers/mod.rs | 2 +- src/tui/ui/create_playlist.rs | 51 +++++++++++++---------------- src/tui/ui/mod.rs | 2 +- 5 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/infra/network/library.rs b/src/infra/network/library.rs index 681c3e24..34835062 100644 --- a/src/infra/network/library.rs +++ b/src/infra/network/library.rs @@ -282,10 +282,7 @@ impl LibraryNetwork for Network { let page = match super::requests::spotify_get_typed_compat_for::>( &self.spotify, "me/playlists", - &[ - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ], + &[("limit", limit.to_string()), ("offset", offset.to_string())], ) .await { @@ -779,7 +776,7 @@ impl LibraryNetwork for Network { async fn create_new_playlist(&mut self, name: String, track_ids: Vec>) { let user_id = { let app = self.app.lock().await; - app.user.as_ref().and_then(|u| Some(u.id.clone())) + app.user.as_ref().map(|u| u.id.clone()) }; let user_id = match user_id { diff --git a/src/tui/handlers/create_playlist.rs b/src/tui/handlers/create_playlist.rs index 557aef6f..af04accb 100644 --- a/src/tui/handlers/create_playlist.rs +++ b/src/tui/handlers/create_playlist.rs @@ -25,7 +25,9 @@ fn handle_name_stage(key: Key, app: &mut App) { Key::Backspace => { if app.create_playlist_name_idx > 0 { app.create_playlist_name_idx -= 1; - let removed = app.create_playlist_name.remove(app.create_playlist_name_idx); + let removed = app + .create_playlist_name + .remove(app.create_playlist_name_idx); let width = removed.width().unwrap_or(1) as u16; app.create_playlist_name_cursor = app.create_playlist_name_cursor.saturating_sub(width); } @@ -41,8 +43,9 @@ fn handle_name_stage(key: Key, app: &mut App) { if app.create_playlist_name_idx > 0 { app.create_playlist_name_idx -= 1; let c = app.create_playlist_name[app.create_playlist_name_idx]; - app.create_playlist_name_cursor = - app.create_playlist_name_cursor.saturating_sub(c.width().unwrap_or(1) as u16); + app.create_playlist_name_cursor = app + .create_playlist_name_cursor + .saturating_sub(c.width().unwrap_or(1) as u16); } } Key::Right => { @@ -95,8 +98,7 @@ fn handle_search_input(key: Key, app: &mut App) { .create_playlist_search_input .remove(app.create_playlist_search_idx); let width = removed.width().unwrap_or(1) as u16; - app.create_playlist_search_cursor = - app.create_playlist_search_cursor.saturating_sub(width); + app.create_playlist_search_cursor = app.create_playlist_search_cursor.saturating_sub(width); } } Key::Char(c) => { @@ -110,8 +112,9 @@ fn handle_search_input(key: Key, app: &mut App) { if app.create_playlist_search_idx > 0 { app.create_playlist_search_idx -= 1; let c = app.create_playlist_search_input[app.create_playlist_search_idx]; - app.create_playlist_search_cursor = - app.create_playlist_search_cursor.saturating_sub(c.width().unwrap_or(1) as u16); + app.create_playlist_search_cursor = app + .create_playlist_search_cursor + .saturating_sub(c.width().unwrap_or(1) as u16); } } Key::Right => { diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 86d73569..9963f53c 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -5,9 +5,9 @@ mod announcement_prompt; mod artist; mod artists; mod common_key_events; -mod create_playlist; #[cfg(feature = "cover-art")] mod cover_art_view; +mod create_playlist; mod dialog; mod discover; mod empty; diff --git a/src/tui/ui/create_playlist.rs b/src/tui/ui/create_playlist.rs index 718d8fa0..3a5d71c1 100644 --- a/src/tui/ui/create_playlist.rs +++ b/src/tui/ui/create_playlist.rs @@ -50,18 +50,15 @@ fn draw_name_stage(f: &mut Frame<'_>, app: &App, area: Rect) { ]) .split(area); - let label = Paragraph::new("Playlist name:") - .style(theme.base_style()); + let label = Paragraph::new("Playlist name:").style(theme.base_style()); f.render_widget(label, inner[0]); let name_text: String = app.create_playlist_name.iter().collect(); - let input = Paragraph::new(name_text) - .style(theme.base_style()) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.active)), - ); + let input = Paragraph::new(name_text).style(theme.base_style()).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.active)), + ); f.render_widget(input, inner[1]); let hint = Paragraph::new("Press Enter to continue, Esc to cancel") @@ -72,7 +69,10 @@ fn draw_name_stage(f: &mut Frame<'_>, app: &App, area: Rect) { fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { let theme = &app.user_config.theme; let name: String = app.create_playlist_name.iter().collect(); - let title = format!("Add Tracks to \"{}\" (Enter=create, Tab=switch panel, Esc=cancel)", name); + let title = format!( + "Add Tracks to \"{}\" (Enter=create, Tab=switch panel, Esc=cancel)", + name + ); let block = Block::default() .title(Span::styled( @@ -89,10 +89,7 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { let inner = Layout::default() .direction(Direction::Vertical) .margin(1) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - ]) + .constraints([Constraint::Length(3), Constraint::Min(5)]) .split(area); // Search input @@ -102,17 +99,15 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { } else { Style::default().fg(theme.inactive) }; - let search_input = Paragraph::new(search_text) - .style(theme.base_style()) - .block( - Block::default() - .title(Span::styled( - "Search (Enter to search)", - Style::default().fg(theme.header), - )) - .borders(Borders::ALL) - .border_style(search_border_style), - ); + let search_input = Paragraph::new(search_text).style(theme.base_style()).block( + Block::default() + .title(Span::styled( + "Search (Enter to search)", + Style::default().fg(theme.header), + )) + .borders(Borders::ALL) + .border_style(search_border_style), + ); f.render_widget(search_input, inner[0]); // Two-panel area: results + added tracks @@ -136,8 +131,7 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { .first() .map(|a| a.name.as_str()) .unwrap_or("Unknown"); - ListItem::new(format!("{} — {}", t.name, artist)) - .style(theme.base_style()) + ListItem::new(format!("{} — {}", t.name, artist)).style(theme.base_style()) }) .collect(); @@ -182,8 +176,7 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { .first() .map(|a| a.name.as_str()) .unwrap_or("Unknown"); - ListItem::new(format!("{} — {}", t.name, artist)) - .style(theme.base_style()) + ListItem::new(format!("{} — {}", t.name, artist)).style(theme.base_style()) }) .collect(); diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index 98b6f05c..e94a3837 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -139,7 +139,7 @@ pub fn draw_routes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { | RouteId::HelpMenu | RouteId::Queue | RouteId::Party => {} // These are drawn outside the main routed content area. - RouteId::Dialog => {} // This is handled in draw_dialog. + RouteId::Dialog => {} // This is handled in draw_dialog. RouteId::CreatePlaylist => {} // This is drawn as an overlay via draw_create_playlist_form. }; } From 624cc0512fc4bdb5eb1289b4d3948042c659a97f Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:13:55 +0200 Subject: [PATCH 3/3] fix: improve results tab controls --- src/infra/network/search.rs | 6 +++++- src/tui/handlers/create_playlist.rs | 6 +++++- src/tui/ui/create_playlist.rs | 10 ++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/infra/network/search.rs b/src/infra/network/search.rs index 953282f7..6d9385f4 100644 --- a/src/infra/network/search.rs +++ b/src/infra/network/search.rs @@ -172,7 +172,11 @@ impl SearchNetwork for Network { .into_iter() .filter_map(|t| if t.id.is_some() { Some(t) } else { None }) .collect::>(), - _ => return, + Ok(_) => return, + Err(e) => { + self.handle_error(anyhow!(e)).await; + return; + } }; let mut app = self.app.lock().await; diff --git a/src/tui/handlers/create_playlist.rs b/src/tui/handlers/create_playlist.rs index af04accb..6ecc5391 100644 --- a/src/tui/handlers/create_playlist.rs +++ b/src/tui/handlers/create_playlist.rs @@ -81,13 +81,16 @@ fn handle_search_input(key: Key, app: &mut App) { } Key::Tab => { if !app.create_playlist_tracks.is_empty() { + app.create_playlist_selected_result = 0; app.create_playlist_focus = CreatePlaylistFocus::AddedTracks; } else if !app.create_playlist_search_results.is_empty() { + app.create_playlist_selected_result = 0; app.create_playlist_focus = CreatePlaylistFocus::SearchResults; } } Key::Down => { if !app.create_playlist_search_results.is_empty() { + app.create_playlist_selected_result = 0; app.create_playlist_focus = CreatePlaylistFocus::SearchResults; } } @@ -155,6 +158,7 @@ fn handle_results_nav(key: Key, app: &mut App) { } Key::Tab => { if !app.create_playlist_tracks.is_empty() { + app.create_playlist_selected_result = 0; app.create_playlist_focus = CreatePlaylistFocus::AddedTracks; } else { app.create_playlist_focus = CreatePlaylistFocus::SearchInput; @@ -168,7 +172,7 @@ fn handle_added_tracks_nav(key: Key, app: &mut App) { let count = app.create_playlist_tracks.len(); match key { Key::Esc => { - close_form(app); + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; } Key::Tab => { app.create_playlist_focus = CreatePlaylistFocus::SearchInput; diff --git a/src/tui/ui/create_playlist.rs b/src/tui/ui/create_playlist.rs index 3a5d71c1..253f89a8 100644 --- a/src/tui/ui/create_playlist.rs +++ b/src/tui/ui/create_playlist.rs @@ -60,6 +60,10 @@ fn draw_name_stage(f: &mut Frame<'_>, app: &App, area: Rect) { .border_style(Style::default().fg(theme.active)), ); f.render_widget(input, inner[1]); + f.set_cursor_position(( + inner[1].x + 1 + app.create_playlist_name_cursor, + inner[1].y + 1, + )); let hint = Paragraph::new("Press Enter to continue, Esc to cancel") .style(Style::default().fg(theme.inactive)); @@ -109,6 +113,12 @@ fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { .border_style(search_border_style), ); f.render_widget(search_input, inner[0]); + if app.create_playlist_focus == CreatePlaylistFocus::SearchInput { + f.set_cursor_position(( + inner[0].x + 1 + app.create_playlist_search_cursor, + inner[0].y + 1, + )); + } // Two-panel area: results + added tracks let panels = Layout::default()