Reworked queue. Fixed global view. minor improvements
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user