From 00a558570c08c573007bfdcfae6591712bad1b6d Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 10 Jun 2026 16:52:09 +0100 Subject: [PATCH] Reworked queue. Fixed global view. minor improvements --- src/api/client.rs | 13 ++ src/app/action.rs | 2 + src/app/mod.rs | 48 +++++- src/app/state.rs | 27 ++-- src/app/update.rs | 257 +++++++++++++++++++++++++++++---- src/config/default_keymap.toml | 9 +- src/ui/mod.rs | 88 ++++++----- 7 files changed, 351 insertions(+), 93 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index c461a8c..e557570 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -300,6 +300,19 @@ impl ApiClient { .await } + /// Tell last.fm (via the server) what is playing right now. Called at + /// track start; the completed-play scrobble goes through /history. + pub async fn lastfm_now_playing(&self, track_id: i64) -> Result<(), ApiError> { + #[derive(Serialize)] + struct Body { + track_id: i64, + } + let _: serde_json::Value = self + .post_json("/api/player/lastfm/now-playing", &Body { track_id }) + .await?; + Ok(()) + } + /// Report a finished/aborted listen to the play history. /// Body shape is the backend's HistoryEntry; `completed` marks a full /// play (vs a manual skip). diff --git a/src/app/action.rs b/src/app/action.rs index 6d15c62..1aa5c1a 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -30,6 +30,7 @@ pub enum Action { ToggleLike, QueueAddNext, QueueAddLast, + ClearQueue, ToggleHelp, ToggleViewMode, OpenCommandLine, @@ -65,6 +66,7 @@ impl Action { Action::ToggleLike => "Like / unlike".into(), Action::QueueAddNext => "Queue: add next".into(), Action::QueueAddLast => "Queue: add to end".into(), + Action::ClearQueue => "Queue: clear".into(), Action::ToggleHelp => "Show / hide keybindings".into(), Action::ToggleViewMode => "Toggle tiles / table view".into(), Action::OpenCommandLine => "Open command line (:/name searches)".into(), diff --git a/src/app/mod.rs b/src/app/mod.rs index d85839c..c46ca18 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -117,9 +117,17 @@ pub async fn run( } } -const ARTISTS_PAGE_SIZE: i64 = 48; const ARTISTS_PREFETCH_MARGIN: usize = 24; +/// How many artist tiles one screen holds right now (grid geometry from the +/// live terminal size), so the initial load always fills the viewport. +fn artist_grid_capacity() -> usize { + let (width, height) = crossterm::terminal::size().unwrap_or((80, 24)); + let columns = usize::from((width.saturating_sub(2) / state::TILE_WIDTH).max(1)); + let rows = usize::from((height.saturating_sub(5) / state::TILE_HEIGHT).max(1)); + columns * rows +} + /// Runs after every event: kicks off whatever background work the current /// state needs — the first artists page, the next page when the selection /// nears the end, and artwork for loaded artists. @@ -133,16 +141,26 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { { let global = &mut state.global; - let initial = global.artists.is_empty(); - let near_end = - !initial && global.selected + ARTISTS_PREFETCH_MARGIN >= global.artists.len(); - if global.has_more && !global.loading && global.error.is_none() && (initial || near_end) { + // Keep at least a full screen plus a margin loaded, and stay ahead + // of the cursor: a big terminal fills itself on startup without any + // scrolling, page after page. + let needed = artist_grid_capacity() + .max(global.selected + ARTISTS_PREFETCH_MARGIN) + + ARTISTS_PREFETCH_MARGIN; + if global.has_more + && !global.loading + && global.error.is_none() + && global.artists.len() < needed + { global.loading = true; let page = global.next_page; + let limit = *global + .page_limit + .get_or_insert_with(|| (needed as i64).clamp(48, 200)); let api = Arc::clone(&api); let tx = runtime.event_tx.clone(); tokio::spawn(async move { - let event = match api.artists(page, ARTISTS_PAGE_SIZE).await { + let event = match api.artists(page, limit).await { Ok(page) => AppEvent::ArtistsLoaded(Ok(page)), Err(ApiError::SessionExpired) => AppEvent::SessionExpired, Err(err) => AppEvent::ArtistsLoaded(Err(err.to_string())), @@ -423,6 +441,7 @@ fn play_current(state: &mut AppState, runtime: &Runtime) { state.player.track_started_at = Some(auth::now_epoch_seconds()); state.player.prefetched_pos = None; state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line())); + report_now_playing(runtime, track.id); let controller = runtime.player.clone(); let volume = player::amplitude(state.player.volume); @@ -518,6 +537,19 @@ fn push_state_now(state: &AppState, runtime: &mut Runtime) { }); } +/// Announce the just-started track as "now playing" on last.fm. Quiet on +/// failure — last.fm may simply not be connected for this account. +fn report_now_playing(runtime: &Runtime, track_id: i64) { + let Some(api) = runtime.api.clone() else { + return; + }; + tokio::spawn(async move { + if let Err(err) = api.lastfm_now_playing(track_id).await { + tracing::debug!(%err, track_id, "lastfm now-playing failed"); + } + }); +} + /// Fire-and-forget history report; listens shorter than 5s are noise. fn report_history( runtime: &Runtime, @@ -654,6 +686,7 @@ fn reset_library_state(state: &mut AppState) { state.release_views.clear(); state.playlists = state::PlaylistsTab::default(); state.playlist_views.clear(); + state.queue_tab = state::QueueTab::default(); state.likes.clear(); state.likes_loaded = false; state.search = state::SearchState::default(); @@ -776,6 +809,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.player.position_secs = 0.0; state.player.track_started_at = Some(auth::now_epoch_seconds()); + if let Some(track) = &state.player.current { + report_now_playing(runtime, track.id); + } push_media_metadata(state, runtime); push_media_update(state, runtime, true); } else { diff --git a/src/app/state.rs b/src/app/state.rs index dfd23ef..213e628 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -75,6 +75,9 @@ pub struct GlobalTab { pub selected: usize, pub view: ViewMode, pub stack: Vec, + /// Page size, fixed at the first request — the server's offset is + /// `(page-1) * limit`, so it must not change between pages. + pub page_limit: Option, } impl Default for GlobalTab { @@ -89,6 +92,7 @@ impl Default for GlobalTab { selected: 0, view: ViewMode::default(), stack: Vec::new(), + page_limit: None, } } } @@ -167,6 +171,13 @@ pub struct PlaylistsTab { pub opened: Option, } +/// The Queue tab's own cursor — independent from the playing position, so +/// the user can browse and pick tracks while something else plays. +#[derive(Debug, Default)] +pub struct QueueTab { + pub cursor: usize, +} + /// Severity steps for the Logs tab filter, cycled with the view-toggle key. pub const LOG_LEVELS: [tracing::Level; 5] = [ tracing::Level::ERROR, @@ -300,25 +311,17 @@ pub enum Tab { Global, Playlists, Queue, - Devices, Logs, } impl Tab { - pub const ALL: [Tab; 5] = [ - Tab::Global, - Tab::Playlists, - Tab::Queue, - Tab::Devices, - Tab::Logs, - ]; + pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Logs]; pub fn title(self) -> &'static str { match self { Tab::Global => "Global", Tab::Playlists => "Playlists", Tab::Queue => "Queue", - Tab::Devices => "Devices", Tab::Logs => "Logs", } } @@ -344,7 +347,6 @@ impl Tab { Tab::Global => KeyContext::Library, Tab::Playlists => KeyContext::Playlists, Tab::Queue => KeyContext::Queue, - Tab::Devices => KeyContext::Devices, Tab::Logs => KeyContext::Logs, } } @@ -393,6 +395,9 @@ pub struct PlayerBar { pub prefetched_pos: Option, pub volume: u8, pub shuffle: bool, + /// Track ids in pre-shuffle order; restores the queue when shuffle is + /// turned off. + pub original_order: Option>, pub repeat: RepeatMode, } @@ -407,6 +412,7 @@ impl Default for PlayerBar { position_secs: 0.0, track_started_at: None, prefetched_pos: None, + original_order: None, volume: 80, shuffle: false, repeat: RepeatMode::Off, @@ -439,6 +445,7 @@ pub struct AppState { pub likes: std::collections::HashSet, pub likes_loaded: bool, pub logs: LogsTab, + pub queue_tab: QueueTab, pub cmdline: Cmdline, pub search: SearchState, /// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by diff --git a/src/app/update.rs b/src/app/update.rs index fb72f27..f11386b 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -103,6 +103,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } Action::ToggleShuffle => { state.player.shuffle = !state.player.shuffle; + // Shuffle physically reorders the unplayed tail, so the Queue + // tab always shows the real upcoming order; turning it off + // restores the original ordering. + if state.player.shuffle { + shuffle_upcoming(&mut state.player); + } else { + restore_queue_order(&mut state.player); + } None } Action::CycleRepeat => { @@ -179,6 +187,23 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } Action::QueueAddNext => queue_add(state, true), Action::QueueAddLast => queue_add(state, false), + Action::ClearQueue => { + let had_tracks = !state.player.queue.is_empty(); + state.player.queue.clear(); + state.player.queue_pos = 0; + state.player.current = None; + state.player.playing = false; + state.player.paused = false; + state.player.prefetched_pos = None; + state.player.original_order = None; + state.queue_tab.cursor = 0; + if had_tracks { + state.status_message = Some("queue cleared".into()); + Some(Effect::StopPlayback) + } else { + None + } + } // Needs the Runtime, so it is intercepted in app::handle_main_key // before reaching update(). Action::Logout => None, @@ -207,12 +232,8 @@ pub fn selected_track(state: &AppState) -> Option { let opened = state.playlists.opened.as_ref()?; playlist_tracks(state, opened.id)?.get(opened.cursor).cloned() } - Tab::Queue => state - .player - .queue - .get(state.player.queue_pos) - .cloned(), - Tab::Devices | Tab::Logs => None, + Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(), + Tab::Logs => None, } } @@ -295,7 +316,8 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec, next: bool) } } -/// Manual queue navigation (n / p). +/// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle +/// is on, so stepping is always sequential. fn queue_step(state: &mut AppState, direction: isize) -> Option { let player = &mut state.player; if player.queue.is_empty() { @@ -303,10 +325,6 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option { return None; } let len = player.queue.len(); - if player.shuffle && direction > 0 { - player.queue_pos = pseudo_random(len); - return Some(Effect::PlayCurrent); - } let next = player.queue_pos as isize + direction; if next < 0 { player.queue_pos = 0; @@ -325,13 +343,13 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option { /// What plays after the current track, without mutating anything — used to /// pick the gapless prefetch target. Mirrors `advance_after_finish`. +/// Shuffle needs no special case: the queue tail is already shuffled. pub fn peek_next_pos(player: &super::state::PlayerBar) -> Option { if player.queue.is_empty() { return None; } match player.repeat { super::state::RepeatMode::One => Some(player.queue_pos), - _ if player.shuffle => Some(pseudo_random(player.queue.len())), repeat => { if player.queue_pos + 1 < player.queue.len() { Some(player.queue_pos + 1) @@ -344,8 +362,8 @@ pub fn peek_next_pos(player: &super::state::PlayerBar) -> Option { } } -/// The current track finished: pick what plays next according to -/// repeat/shuffle, or stop at the end of the queue. +/// The current track finished: play the next queue position (the tail is +/// pre-shuffled when shuffle is on), or stop at the end. pub fn advance_after_finish(state: &mut AppState) -> Option { let player = &mut state.player; if player.queue.is_empty() { @@ -355,10 +373,6 @@ pub fn advance_after_finish(state: &mut AppState) -> Option { } match player.repeat { super::state::RepeatMode::One => Some(Effect::PlayCurrent), - _ if player.shuffle => { - player.queue_pos = pseudo_random(player.queue.len()); - Some(Effect::PlayCurrent) - } repeat => { if player.queue_pos + 1 < player.queue.len() { player.queue_pos += 1; @@ -375,13 +389,55 @@ pub fn advance_after_finish(state: &mut AppState) -> Option { } } -/// Shuffle pick without a rand dependency: clock-derived index. -fn pseudo_random(len: usize) -> usize { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0); - nanos as usize % len.max(1) +/// First index of the not-yet-played queue tail: everything after the +/// current track, or from the current position when nothing is loaded. +fn upcoming_start(player: &super::state::PlayerBar) -> usize { + if player.current.is_some() { + (player.queue_pos + 1).min(player.queue.len()) + } else { + player.queue_pos.min(player.queue.len()) + } +} + +/// Remember the original order and Fisher-Yates the unplayed tail. +pub fn shuffle_upcoming(player: &mut super::state::PlayerBar) { + if player.queue.is_empty() { + return; + } + if player.original_order.is_none() { + player.original_order = Some(player.queue.iter().map(|t| t.id).collect()); + } + shuffle_range(player, upcoming_start(player)); +} + +/// Put the unplayed tail back into pre-shuffle order. Tracks queued while +/// shuffled (absent from the snapshot) keep their relative order at the end. +pub fn restore_queue_order(player: &mut super::state::PlayerBar) { + let Some(order) = player.original_order.take() else { + return; + }; + let start = upcoming_start(player); + if start >= player.queue.len() { + return; + } + let tail = player.queue.split_off(start); + let mut used = vec![false; order.len()]; + let mut keyed: Vec<(usize, usize, crate::api::models::TrackItem)> = tail + .into_iter() + .enumerate() + .map(|(position, track)| { + let key = order + .iter() + .enumerate() + .position(|(slot, id)| !used[slot] && *id == track.id) + .inspect(|&slot| used[slot] = true) + .unwrap_or(usize::MAX); + (key, position, track) + }) + .collect(); + keyed.sort_by_key(|(key, position, _)| (*key, *position)); + player.queue.extend(keyed.into_iter().map(|(_, _, track)| track)); + player.prefetched_pos = None; } /// Columns of the Global tile grid. Derived from the terminal width the same @@ -461,6 +517,15 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { } return; } + if state.active_tab == Tab::Queue { + let len = state.player.queue.len(); + if len == 0 { + return; + } + state.queue_tab.cursor = + (state.queue_tab.cursor as isize + dy).clamp(0, len as isize - 1) as usize; + return; + } if state.active_tab != Tab::Global { return not_yet(state, "Navigation in this view"); } @@ -575,6 +640,9 @@ fn current_view_len(state: &AppState) -> usize { if state.active_tab == Tab::Playlists { return playlists_view_len(state); } + if state.active_tab == Tab::Queue { + return state.player.queue.len(); + } match state.global.stack.last() { None => state.global.artists.len(), Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) { @@ -590,6 +658,13 @@ fn current_view_len(state: &AppState) -> usize { } fn jump_selection(state: &mut AppState, first: bool) { + if state.active_tab == Tab::Queue { + let len = state.player.queue.len(); + if len > 0 { + state.queue_tab.cursor = if first { 0 } else { len - 1 }; + } + return; + } if state.active_tab == Tab::Logs { if first { state.logs.follow = false; @@ -631,6 +706,7 @@ fn select_playlist(state: &mut AppState) -> Option { } state.player.queue = tracks; state.player.queue_pos = opened.cursor.min(state.player.queue.len() - 1); + on_new_queue(state); Some(Effect::PlayCurrent) } None => { @@ -650,6 +726,16 @@ fn select_current(state: &mut AppState) -> Option { if state.active_tab == Tab::Playlists { return select_playlist(state); } + // Queue: jump playback to the track under the cursor. Earlier tracks + // stay in the queue as "played"; picking one of them just moves the + // playing position back. + if state.active_tab == Tab::Queue { + if state.player.queue.is_empty() { + return None; + } + state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1); + return Some(Effect::PlayCurrent); + } if state.active_tab != Tab::Global { not_yet(state, "Navigation in this view"); return None; @@ -729,12 +815,43 @@ fn select_current(state: &mut AppState) -> Option { Outcome::Play { tracks, start } => { state.player.queue = tracks; state.player.queue_pos = start; + on_new_queue(state); Some(Effect::PlayCurrent) } Outcome::Nothing => None, } } +/// A freshly created play context: drop the stale pre-shuffle snapshot and, +/// if shuffle is on, shuffle everything after the chosen track right away. +fn on_new_queue(state: &mut AppState) { + let player = &mut state.player; + player.original_order = None; + if player.shuffle && !player.queue.is_empty() { + player.original_order = Some(player.queue.iter().map(|t| t.id).collect()); + shuffle_range(player, (player.queue_pos + 1).min(player.queue.len())); + } +} + +fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) { + let tail = &mut player.queue[start..]; + let mut seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(1) + | 1; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for i in (1..tail.len()).rev() { + tail.swap(i, next() as usize % (i + 1)); + } + player.prefetched_pos = None; +} + /// Esc/Backspace: pop the navigation stack; leaving a search view resets the /// search so the next `:/` starts clean. fn go_back(state: &mut AppState) { @@ -776,7 +893,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) { state.logs.follow = true; state.logs.scroll_from_end = 0; } - Tab::Queue | Tab::Devices => {} + Tab::Queue => {} } } @@ -1056,6 +1173,94 @@ mod tests { assert!(state.playlists.opened.is_none()); } + #[test] + fn queue_tab_select_and_clear() { + use crate::api::models::TrackItem; + let track = |id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id: 1, + release_title: "r".into(), + release_year: None, + cover_url: None, + stream_url: format!("/s/{id}"), + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + file_size_bytes: None, + lastfm_playcount: None, + }; + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = vec![track(1), track(2), track(3)]; + state.player.queue_pos = 2; + + // Cursor moves independently; enter rewinds playback to that track + // without dropping anything from the queue. + update(&mut state, Action::MoveUp); + update(&mut state, Action::MoveUp); + assert_eq!(state.queue_tab.cursor, 0); + assert_eq!(update(&mut state, Action::Select), Some(Effect::PlayCurrent)); + assert_eq!(state.player.queue_pos, 0); + assert_eq!(state.player.queue.len(), 3); + + assert_eq!( + update(&mut state, Action::ClearQueue), + Some(Effect::StopPlayback) + ); + assert!(state.player.queue.is_empty()); + assert!(!state.player.playing); + } + + #[test] + fn shuffle_reorders_tail_and_restores() { + use crate::api::models::TrackItem; + let track = |id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id: 1, + release_title: "r".into(), + release_year: None, + cover_url: None, + stream_url: format!("/s/{id}"), + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + file_size_bytes: None, + lastfm_playcount: None, + }; + let mut state = AppState::default(); + state.player.queue = (1..=8).map(track).collect(); + state.player.queue_pos = 2; + state.player.current = Some(track(3)); + + update(&mut state, Action::ToggleShuffle); + assert!(state.player.shuffle); + // Played part and the current track stay in place. + let ids: Vec = state.player.queue.iter().map(|t| t.id).collect(); + assert_eq!(&ids[..3], &[1, 2, 3]); + // The tail is a permutation of the original tail. + let mut tail = ids[3..].to_vec(); + tail.sort_unstable(); + assert_eq!(tail, vec![4, 5, 6, 7, 8]); + + update(&mut state, Action::ToggleShuffle); + assert!(!state.player.shuffle); + let restored: Vec = state.player.queue.iter().map(|t| t.id).collect(); + assert_eq!(restored, vec![1, 2, 3, 4, 5, 6, 7, 8]); + assert!(state.player.original_order.is_none()); + } + #[test] fn view_toggle() { let mut state = AppState::default(); diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index 9ec770e..6a6cf5a 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -47,10 +47,6 @@ command = { GoToTab = 2 } key_sequence = "4" command = { GoToTab = 3 } -[[keymaps]] -key_sequence = "5" -command = { GoToTab = 4 } - [[keymaps]] key_sequence = "a" command = "QueueAddNext" @@ -59,6 +55,11 @@ command = "QueueAddNext" key_sequence = "shift-a" command = "QueueAddLast" +[[keymaps]] +key_sequence = "shift-c" +command = "ClearQueue" +context = "queue" + [[keymaps]] key_sequence = "j" command = "MoveDown" diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b879a9e..1ba9787 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,6 +7,7 @@ pub mod theme; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs}; @@ -30,7 +31,6 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { Tab::Global => global::draw(frame, main_area, state), Tab::Playlists => playlists::draw(frame, main_area, state), Tab::Queue => draw_queue(frame, main_area, state), - Tab::Devices => draw_main(frame, main_area, state), Tab::Logs => logs::draw(frame, main_area, state), } draw_status(frame, status_area, state); @@ -52,30 +52,6 @@ fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) { frame.render_widget(tabs, area); } -fn draw_main(frame: &mut Frame, area: Rect, state: &AppState) { - let block = Block::bordered() - .title(format!(" {} ", state.active_tab.title())) - .title_style(theme::header()) - .border_style(theme::dim()); - let inner = block.inner(area); - frame.render_widget(block, area); - - let (summary, milestone) = match state.active_tab { - Tab::Devices => ("Connected devices and playback transfer", "milestone 5"), - _ => ("", ""), - }; - let lines = vec![ - Line::default(), - Line::styled(summary, theme::accent()), - Line::styled(format!("coming in {milestone}"), theme::dim()), - Line::default(), - Line::styled("Tab / Shift-Tab or 1-5 to switch tabs", theme::dim()), - Line::styled("? keybindings q quit", theme::dim()), - ]; - let paragraph = Paragraph::new(lines).alignment(Alignment::Center); - frame.render_widget(paragraph, centered_vertically(inner, 6)); -} - /// One track row used by every track list: ♥ marker for liked tracks, the /// title and artists on the left, tech info and duration on the right. pub(crate) fn track_row( @@ -114,11 +90,15 @@ pub(crate) fn track_row( } } -/// Read-only queue listing; the playing track is highlighted. +/// Interactive queue: its own cursor, enter plays the selected track and +/// already-played tracks stay listed, greyed out. fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { let player = &state.player; let block = Block::bordered() - .title(format!(" Queue — {} tracks ", player.queue.len())) + .title(format!( + " Queue — {} tracks · enter: play · shift-c: clear ", + player.queue.len() + )) .title_style(theme::header()) .border_style(theme::dim()); let inner = block.inner(area); @@ -137,11 +117,14 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { return; } + let cursor = state.queue_tab.cursor.min(player.queue.len() - 1); let visible = usize::from(inner.height.max(1)); - let first = player - .queue_pos + let first = cursor .saturating_sub(visible / 2) .min(player.queue.len().saturating_sub(visible)); + let played_style = Style::new() + .fg(Color::DarkGray) + .bg(Color::Rgb(28, 28, 32)); for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) { let row = Rect { x: inner.x, @@ -149,14 +132,17 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { width: inner.width, height: 1, }; - track_row( - frame, - row, - state, - track, - (index + 1).to_string(), - index == player.queue_pos, - ); + let label = if index == player.queue_pos && player.playing { + "▶".to_string() + } else { + (index + 1).to_string() + }; + track_row(frame, row, state, track, label, index == cursor); + // Tracks before the playing one are history: greyed out unless the + // cursor is on them. + if index < player.queue_pos && index != cursor { + frame.buffer_mut().set_style(row, played_style); + } } } @@ -203,17 +189,31 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line< Span::styled("█".repeat(volume_cells), theme::accent()), Span::styled("░".repeat(10 - volume_cells), theme::dim()), Span::raw(format!(" {:3}%", player.volume)), - Span::styled(" shuffle ", theme::dim()), - Span::raw(if player.shuffle { "on" } else { "off" }.to_string()), - Span::styled(" repeat ", theme::dim()), - Span::raw(player.repeat.label().to_string()), + Span::raw(" "), ]); + // Enabled modes light up as filled chips; disabled stay dim text. + if player.shuffle { + spans.push(Span::styled(" shuffle ", theme::tab_active())); + } else { + spans.push(Span::styled("shuffle off", theme::dim())); + } + spans.push(Span::raw(" ")); + if player.repeat == crate::app::state::RepeatMode::Off { + spans.push(Span::styled("repeat off", theme::dim())); + } else { + spans.push(Span::styled( + format!(" repeat {} ", player.repeat.label()), + theme::tab_active(), + )); + } } else { spans.push(Span::styled( format!(" {}%", player.volume), theme::dim(), )); } + // Keep a gap between the flags and the username block to the right. + spans.push(Span::raw(" ")); Line::from(spans) } @@ -344,9 +344,3 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { rect } -fn centered_vertically(area: Rect, content_height: u16) -> Rect { - let [rect] = Layout::vertical([Constraint::Length(content_height)]) - .flex(ratatui::layout::Flex::Center) - .areas(area); - rect -}