Reworked queue. Fixed global view. minor improvements

This commit is contained in:
Ultradesu
2026-06-10 16:52:09 +01:00
parent e72bd1592e
commit 00a558570c
7 changed files with 351 additions and 93 deletions
+13
View File
@@ -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).
+2
View File
@@ -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(),
+42 -6
View File
@@ -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 {
+17 -10
View File
@@ -75,6 +75,9 @@ pub struct GlobalTab {
pub selected: usize,
pub view: ViewMode,
pub stack: Vec<GlobalView>,
/// 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<i64>,
}
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<OpenedPlaylist>,
}
/// 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<usize>,
pub volume: u8,
pub shuffle: bool,
/// Track ids in pre-shuffle order; restores the queue when shuffle is
/// turned off.
pub original_order: Option<Vec<i64>>,
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<i64>,
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
+231 -26
View File
@@ -103,6 +103,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
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<Effect> {
}
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<TrackItem> {
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<TrackItem>, 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<Effect> {
let player = &mut state.player;
if player.queue.is_empty() {
@@ -303,10 +325,6 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
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<Effect> {
/// 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<usize> {
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<usize> {
}
}
/// 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<Effect> {
let player = &mut state.player;
if player.queue.is_empty() {
@@ -355,10 +373,6 @@ pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
}
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<Effect> {
}
}
/// 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<Effect> {
}
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<Effect> {
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<Effect> {
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<i64> = 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<i64> = 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();
+5 -4
View File
@@ -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"
+41 -47
View File
@@ -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
}