From ba5a73816e303e98dfcf9aa47fcdc067c10ea2a8 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Mon, 15 Jun 2026 12:28:34 +0100 Subject: [PATCH] Added Visual select for multiple tracks, added Queue management. Added info for tracks --- Cargo.toml | 2 +- src/api/models.rs | 8 + src/app/action.rs | 11 +- src/app/mod.rs | 83 ++++- src/app/popup.rs | 57 ++++ src/app/state.rs | 72 ++++ src/app/update.rs | 593 +++++++++++++++++++++++++++++++-- src/config/default_keymap.toml | 18 + src/ui/global.rs | 33 +- src/ui/mod.rs | 23 +- src/ui/playlists.rs | 5 +- src/ui/popup.rs | 154 ++++++++- src/ui/theme.rs | 4 + 13 files changed, 1017 insertions(+), 46 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 988f7b7..ac1895a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi_tui" -version = "0.1.2" +version = "0.1.3" edition = "2024" [[bin]] diff --git a/src/api/models.rs b/src/api/models.rs index 077843d..3dbffaf 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -78,6 +78,8 @@ pub struct TrackItem { /// Absent in the artist-appearance variant of track payloads. #[serde(default)] pub track_number: Option, + #[serde(default)] + pub disc_number: Option, pub duration_seconds: f64, #[serde(default)] pub artists: Vec, @@ -95,12 +97,18 @@ pub struct TrackItem { pub stream_url: String, #[allow(dead_code, reason = "now-playing artwork in milestone 3")] pub cover_url: Option, + #[serde(default)] + pub uploader_name: String, pub audio_format: Option, pub audio_bitrate: Option, pub audio_sample_rate: Option, + pub audio_bit_depth: Option, pub file_size_bytes: Option, + pub lastfm_listeners: Option, #[allow(dead_code, reason = "popularity column later")] pub lastfm_playcount: Option, + pub lastfm_rating: Option, + pub lastfm_updated_at: Option, } impl TrackItem { diff --git a/src/app/action.rs b/src/app/action.rs index 30c439e..2065087 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -28,8 +28,11 @@ pub enum Action { ToggleShuffle, CycleRepeat, ToggleLike, + ToggleTrackSelection, + OpenTrackInfo, QueueAddNext, QueueAddLast, + RemoveFromQueue, ClearQueue, GoToRelease, AddToPlaylist, @@ -86,10 +89,13 @@ impl Action { | Action::CycleRepeat => Category::Playback, Action::QueueAddNext | Action::QueueAddLast + | Action::RemoveFromQueue | Action::ClearQueue | Action::AddToPlaylist | Action::NewPlaylist - | Action::ToggleLike => Category::Queue, + | Action::ToggleLike + | Action::ToggleTrackSelection + | Action::OpenTrackInfo => Category::Queue, Action::MoveUp | Action::MoveDown | Action::MoveLeft @@ -157,8 +163,11 @@ impl Action { Action::ToggleShuffle => "Toggle shuffle".into(), Action::CycleRepeat => "Cycle repeat mode".into(), Action::ToggleLike => "Like / unlike".into(), + Action::ToggleTrackSelection => "Track line selection".into(), + Action::OpenTrackInfo => "Track info".into(), Action::QueueAddNext => "Queue: add next".into(), Action::QueueAddLast => "Queue: add to end".into(), + Action::RemoveFromQueue => "Queue: remove selected".into(), Action::ClearQueue => "Queue: clear".into(), Action::GoToRelease => "Open the track's release".into(), Action::AddToPlaylist => "Add track to a playlist…".into(), diff --git a/src/app/mod.rs b/src/app/mod.rs index ed70fcb..3f0b9df 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -431,7 +431,7 @@ fn spawn_art_fetch( /// Execute a side effect requested by update(). fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { - if perform_remote_effect(state, runtime, effect) { + if perform_remote_effect(state, runtime, &effect) { return; } match effect { @@ -480,27 +480,52 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } }); } - Effect::ToggleLike { track_id } => { + Effect::ToggleLikes { track_ids } => { + if track_ids.is_empty() { + return; + } let Some(api) = runtime.api.clone() else { return; }; let tx = runtime.event_tx.clone(); tokio::spawn(async move { - match api.toggle_like(track_id).await { - Ok(liked) => { - let _ = tx.send(AppEvent::LikeToggled { track_id, liked }); - } - Err(err) => { - tracing::warn!(%err, track_id, "like toggle failed"); - let _ = tx.send(AppEvent::StatusMessage(format!("like failed: {err}"))); + for track_id in track_ids { + match api.toggle_like(track_id).await { + Ok(liked) => { + let _ = tx.send(AppEvent::LikeToggled { track_id, liked }); + } + Err(err) => { + tracing::warn!(%err, track_id, "like toggle failed"); + let _ = tx.send(AppEvent::StatusMessage(format!("like failed: {err}"))); + break; + } } } }); } + Effect::RemoveQueueIndices { + restart_paused, + stop, + .. + } => { + if stop { + runtime.player.stop(); + push_state_now(state, runtime); + push_media_update(state, runtime, true); + } else if let Some(paused) = restart_paused { + start_current_audio(state, runtime, 0.0, paused); + push_state_now(state, runtime); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } else { + push_state_now(state, runtime); + push_media_update(state, runtime, true); + } + } } } -fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect) -> bool { +fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: &Effect) -> bool { let Some(target) = state.devices.remote_target_id().map(str::to_string) else { return false; }; @@ -528,7 +553,7 @@ fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect true } Effect::SeekBy(delta) => { - let target_time = (state.player.position_secs + delta as f64).max(0.0); + let target_time = (state.player.position_secs + *delta as f64).max(0.0); state.player.position_secs = target_time; send_device_command( runtime, @@ -543,7 +568,7 @@ fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect runtime, target, "set_volume", - serde_json::json!({ "volume": f64::from(volume) / 100.0 }), + serde_json::json!({ "volume": f64::from(*volume) / 100.0 }), ); true } @@ -559,7 +584,20 @@ fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect ); true } - Effect::EnqueueRelease { .. } | Effect::ToggleLike { .. } => false, + Effect::RemoveQueueIndices { indices, .. } => { + let mut indices = indices.clone(); + indices.sort_unstable_by(|a, b| b.cmp(a)); + for index in indices { + send_device_command( + runtime, + target.clone(), + "queue_remove", + serde_json::json!({ "index": index }), + ); + } + true + } + Effect::EnqueueRelease { .. } | Effect::ToggleLikes { .. } => false, } } @@ -1181,15 +1219,26 @@ fn apply_options_payload(state: &mut AppState, payload: &serde_json::Value) { } } -fn remove_queue_index(state: &mut AppState, runtime: &Runtime, index: usize) { +fn remove_queue_index(state: &mut AppState, runtime: &mut Runtime, index: usize) { if index >= state.player.queue.len() { return; } let current_id = state.player.current.as_ref().map(|track| track.id); + let removed_current = state + .player + .queue + .get(index) + .is_some_and(|track| Some(track.id) == current_id); + let was_loaded = state.player.playing; + let was_paused = state.player.paused; state.player.queue.remove(index); + state.player.prefetched_pos = None; + state.track_selection.clear(); if state.player.queue.is_empty() { state.player = state::PlayerBar::default(); runtime.player.stop(); + push_state_now(state, runtime); + push_media_update(state, runtime, true); return; } state.player.queue_pos = current_id @@ -1197,6 +1246,12 @@ fn remove_queue_index(state: &mut AppState, runtime: &Runtime, index: usize) { .unwrap_or_else(|| state.player.queue_pos.min(state.player.queue.len() - 1)); state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); + if removed_current && was_loaded { + start_current_audio(state, runtime, 0.0, was_paused); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } + push_state_now(state, runtime); } fn move_queue_index(state: &mut AppState, from: usize, to: usize) { diff --git a/src/app/popup.rs b/src/app/popup.rs index 0b6cbfc..941e8bf 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -23,6 +23,11 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { busy, } => handle_name_entry(state, runtime, for_track, input, busy, key), Popup::Devices { cursor } => handle_devices(state, runtime, cursor, key), + Popup::TrackInfo { + tracks, + cursor, + scroll, + } => handle_track_info(state, tracks, cursor, scroll, key), Popup::LogDetail(entry) => match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} _ => state.popup = Some(Popup::LogDetail(entry)), @@ -75,6 +80,58 @@ fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: K } } +fn handle_track_info( + state: &mut AppState, + tracks: Vec, + cursor: usize, + scroll: usize, + key: KeyEvent, +) { + let len = tracks.len(); + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} + KeyCode::Up | KeyCode::Char('k') => { + state.popup = Some(Popup::TrackInfo { + tracks, + cursor, + scroll: scroll.saturating_sub(1), + }); + } + KeyCode::Down | KeyCode::Char('j') => { + state.popup = Some(Popup::TrackInfo { + tracks, + cursor, + scroll: scroll + 1, + }); + } + KeyCode::Left | KeyCode::Char('h') => { + state.popup = Some(Popup::TrackInfo { + tracks, + cursor: cursor.saturating_sub(1), + scroll: 0, + }); + } + KeyCode::Right | KeyCode::Char('l') => { + state.popup = Some(Popup::TrackInfo { + tracks, + cursor: if len == 0 { + 0 + } else { + (cursor + 1).min(len - 1) + }, + scroll: 0, + }); + } + _ => { + state.popup = Some(Popup::TrackInfo { + tracks, + cursor: cursor.min(len.saturating_sub(1)), + scroll, + }); + } + } +} + fn handle_picker( state: &mut AppState, runtime: &Runtime, diff --git a/src/app/state.rs b/src/app/state.rs index 309246a..e0acc40 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -186,6 +186,71 @@ pub struct QueueTab { pub cursor: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrackSelectionScope { + ArtistTop(i64), + ArtistFeatured(i64), + Release(i64), + Playlist(i64), + Queue, +} + +/// Vim-like Shift-V selection for line-oriented track lists. The selected +/// range is always contiguous: anchor is where visual mode started, cursor is +/// extended by normal navigation. +#[derive(Debug, Clone, Default)] +pub struct TrackSelection { + pub scope: Option, + pub anchor: usize, + pub cursor: usize, +} + +impl TrackSelection { + pub fn is_active(&self) -> bool { + self.scope.is_some() + } + + pub fn is_active_for(&self, scope: &TrackSelectionScope) -> bool { + self.scope.as_ref() == Some(scope) + } + + pub fn start(&mut self, scope: TrackSelectionScope, cursor: usize) { + self.scope = Some(scope); + self.anchor = cursor; + self.cursor = cursor; + } + + pub fn clear(&mut self) { + self.scope = None; + self.anchor = 0; + self.cursor = 0; + } + + pub fn set_cursor(&mut self, scope: TrackSelectionScope, cursor: usize) { + if self.scope.as_ref() == Some(&scope) { + self.cursor = cursor; + } + } + + pub fn contains(&self, scope: &TrackSelectionScope, index: usize) -> bool { + if self.scope.as_ref() != Some(scope) { + return false; + } + let start = self.anchor.min(self.cursor); + let end = self.anchor.max(self.cursor); + (start..=end).contains(&index) + } + + pub fn indices(&self, scope: &TrackSelectionScope, len: usize) -> Option> { + if len == 0 || self.scope.as_ref() != Some(scope) { + return None; + } + let start = self.anchor.min(self.cursor).min(len - 1); + let end = self.anchor.max(self.cursor).min(len - 1); + Some((start..=end).collect()) + } +} + /// Severity steps for the Logs tab filter, cycled with the view-toggle key. pub const LOG_LEVELS: [tracing::Level; 5] = [ tracing::Level::ERROR, @@ -263,6 +328,12 @@ pub enum Popup { }, /// Connected devices list; Enter transfers active playback to the row. Devices { cursor: usize }, + /// Track metadata viewer; left/right switch between selected tracks. + TrackInfo { + tracks: Vec, + cursor: usize, + scroll: usize, + }, /// Full, wrapped view of one log entry (Enter on the Logs tab). LogDetail(crate::config::logging::LogEntry), } @@ -519,6 +590,7 @@ pub struct AppState { pub logs: LogsTab, pub devices: DevicesState, pub queue_tab: QueueTab, + pub track_selection: TrackSelection, /// Shift-J jump in flight: focus this (release, track) once the release /// view finishes loading. pub pending_release_focus: Option<(i64, i64)>, diff --git a/src/app/update.rs b/src/app/update.rs index 0ee93e9..e0c224d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -5,7 +5,7 @@ use crate::api::models::TrackItem; use super::state::{ AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab, - ViewMode, release_display_order, release_rows, + TrackSelectionScope, ViewMode, release_display_order, release_rows, }; pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500); @@ -13,7 +13,7 @@ pub const QUIT_CONFIRM_HINT: &str = "press quit again to exit"; /// Side effects requested by `update()`; executed by the app loop, which /// owns the Runtime (audio controller, API client). Keeps update() pure. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Effect { /// (Re)start playback of `queue[queue_pos]`. PlayCurrent, @@ -28,8 +28,13 @@ pub enum Effect { id: i64, next: bool, }, - ToggleLike { - track_id: i64, + ToggleLikes { + track_ids: Vec, + }, + RemoveQueueIndices { + indices: Vec, + restart_paused: Option, + stop: bool, }, } @@ -203,23 +208,62 @@ pub fn update(state: &mut AppState, action: Action) -> Option { None } Action::Select => select_current(state), + Action::Back if state.track_selection.is_active() => { + state.track_selection.clear(); + state.status_message = Some("selection cleared".into()); + None + } Action::Back => { go_back(state); None } Action::ToggleLike => { - let target = - selected_track(state) - .map(|t| t.id) - .or(state.player.current.as_ref().map(|t| t.id)); - match target { - Some(track_id) => Some(Effect::ToggleLike { track_id }), - None => { - state.status_message = Some("no track selected".into()); - None - } + let tracks = selected_tracks(state); + let track_ids: Vec = if tracks.is_empty() { + state + .player + .current + .as_ref() + .map(|track| vec![track.id]) + .unwrap_or_default() + } else { + tracks.into_iter().map(|track| track.id).collect() + }; + if track_ids.is_empty() { + state.status_message = Some("no track selected".into()); + None + } else { + let should_like = track_ids.iter().any(|id| !state.likes.contains(id)); + let toggles: Vec = track_ids + .into_iter() + .filter(|id| state.likes.contains(id) != should_like) + .collect(); + state.status_message = Some(if should_like { + format!("liking {} track(s)", toggles.len()) + } else { + format!("removing like from {} track(s)", toggles.len()) + }); + Some(Effect::ToggleLikes { track_ids: toggles }) } } + Action::ToggleTrackSelection => { + toggle_track_selection(state); + None + } + Action::OpenTrackInfo => { + let tracks = selected_tracks(state); + if tracks.is_empty() { + state.status_message = Some("no track selected".into()); + } else { + state.popup = Some(super::state::Popup::TrackInfo { + tracks, + cursor: 0, + scroll: 0, + }); + } + None + } + Action::RemoveFromQueue => remove_selected_from_queue(state), Action::QueueAddNext => queue_add(state, true), Action::QueueAddLast => queue_add(state, false), Action::GoToRelease => { @@ -258,6 +302,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { state.player.prefetched_pos = None; state.player.original_order = None; state.queue_tab.cursor = 0; + state.track_selection.clear(); if had_tracks { state.status_message = Some("queue cleared".into()); Some(Effect::StopPlayback) @@ -271,6 +316,309 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } } +fn toggle_track_selection(state: &mut AppState) { + let Some((scope, cursor, len)) = current_track_list_context(state) else { + state.status_message = Some("track selection is unavailable here".into()); + return; + }; + if len == 0 { + state.status_message = Some("no tracks here".into()); + return; + } + if state.track_selection.is_active_for(&scope) { + state.track_selection.clear(); + state.status_message = Some("selection cleared".into()); + } else { + state.track_selection.start(scope, cursor); + visual_selection_status(state); + } +} + +fn visual_selection_status(state: &mut AppState) { + let Some((scope, _, len)) = current_track_list_context(state) else { + return; + }; + if let Some(indices) = state.track_selection.indices(&scope, len) { + state.status_message = Some(format!("-- VISUAL LINE -- {} track(s)", indices.len())); + } +} + +fn refresh_track_selection_cursor(state: &mut AppState) { + let Some((scope, cursor, _)) = current_track_list_context(state) else { + state.track_selection.clear(); + return; + }; + state.track_selection.set_cursor(scope, cursor); + if state.track_selection.is_active() { + visual_selection_status(state); + } +} + +fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, value: usize) { + match scope { + TrackSelectionScope::ArtistTop(id) => { + if matches!( + state.global.stack.last(), + Some(GlobalView::Artist { id: current, .. }) if current == id + ) { + set_view_cursor(state, value); + } + } + TrackSelectionScope::ArtistFeatured(id) => { + let Some(GlobalView::Artist { id: current, .. }) = state.global.stack.last() else { + return; + }; + if current != id { + return; + } + let Some(Loadable::Ready(detail)) = state.artist_views.get(id) else { + return; + }; + let flat = detail.top_tracks.len() + detail.releases.len() + value; + set_view_cursor(state, flat); + } + TrackSelectionScope::Release(id) => { + if matches!( + state.global.stack.last(), + Some(GlobalView::Release { id: current, .. }) if current == id + ) { + set_view_cursor(state, value); + } + } + TrackSelectionScope::Playlist(id) => { + if let Some(opened) = &mut state.playlists.opened { + if opened.id == *id { + opened.cursor = value; + } + } + } + TrackSelectionScope::Queue => { + state.queue_tab.cursor = value; + } + } +} + +fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> { + match state.active_tab { + Tab::Global => match state.global.stack.last()? { + GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { + Loadable::Ready(detail) => { + let tracks = detail.top_tracks.len(); + let releases = detail.releases.len(); + if *cursor < tracks { + Some((TrackSelectionScope::ArtistTop(*id), *cursor, tracks)) + } else { + let featured = cursor.checked_sub(tracks + releases)?; + (featured < detail.featured_tracks.len()).then_some(( + TrackSelectionScope::ArtistFeatured(*id), + featured, + detail.featured_tracks.len(), + )) + } + } + _ => None, + }, + GlobalView::Release { id, cursor } => match state.release_views.get(id)? { + Loadable::Ready(detail) => Some(( + TrackSelectionScope::Release(*id), + *cursor, + detail.tracks.len(), + )), + _ => None, + }, + _ => None, + }, + Tab::Playlists => { + let opened = state.playlists.opened.as_ref()?; + let len = playlist_tracks(state, opened.id)?.len(); + Some((TrackSelectionScope::Playlist(opened.id), opened.cursor, len)) + } + Tab::Queue => Some(( + TrackSelectionScope::Queue, + state.queue_tab.cursor, + state.player.queue.len(), + )), + Tab::Logs => None, + } +} + +fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &[TrackItem])> { + match state.active_tab { + Tab::Global => match state.global.stack.last()? { + GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { + Loadable::Ready(detail) => { + let tracks = detail.top_tracks.len(); + let releases = detail.releases.len(); + if *cursor < tracks { + Some(( + TrackSelectionScope::ArtistTop(*id), + *cursor, + &detail.top_tracks, + )) + } else { + let featured = cursor.checked_sub(tracks + releases)?; + (featured < detail.featured_tracks.len()).then_some(( + TrackSelectionScope::ArtistFeatured(*id), + featured, + &detail.featured_tracks, + )) + } + } + _ => None, + }, + GlobalView::Release { id, cursor } => match state.release_views.get(id)? { + Loadable::Ready(detail) => { + Some((TrackSelectionScope::Release(*id), *cursor, &detail.tracks)) + } + _ => None, + }, + _ => None, + }, + Tab::Playlists => { + let opened = state.playlists.opened.as_ref()?; + Some(( + TrackSelectionScope::Playlist(opened.id), + opened.cursor, + playlist_tracks(state, opened.id)?, + )) + } + Tab::Queue => Some(( + TrackSelectionScope::Queue, + state.queue_tab.cursor, + &state.player.queue, + )), + Tab::Logs => None, + } +} + +pub fn selected_tracks(state: &AppState) -> Vec { + let Some((scope, cursor, tracks)) = current_track_list(state) else { + return selected_track(state).into_iter().collect(); + }; + let indices = state + .track_selection + .indices(&scope, tracks.len()) + .unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]); + indices + .into_iter() + .filter_map(|index| tracks.get(index).cloned()) + .collect() +} + +fn selected_queue_indices(state: &AppState) -> Vec { + if state.active_tab != Tab::Queue || state.player.queue.is_empty() { + return Vec::new(); + } + state + .track_selection + .indices(&TrackSelectionScope::Queue, state.player.queue.len()) + .unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)]) +} + +fn remove_selected_from_queue(state: &mut AppState) -> Option { + let indices = selected_queue_indices(state); + if indices.is_empty() { + state.status_message = Some("queue is empty".into()); + return None; + } + let outcome = remove_queue_indices(state, &indices); + state.status_message = Some(format!("removed {} track(s) from queue", indices.len())); + Some(Effect::RemoveQueueIndices { + indices, + restart_paused: outcome.restart_paused, + stop: outcome.stop, + }) +} + +struct QueueRemovalOutcome { + restart_paused: Option, + stop: bool, +} + +fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemovalOutcome { + let len = state.player.queue.len(); + let mut unique: Vec = indices + .iter() + .copied() + .filter(|index| *index < len) + .collect(); + unique.sort_unstable(); + unique.dedup(); + if unique.is_empty() { + return QueueRemovalOutcome { + restart_paused: None, + stop: false, + }; + } + + let old_queue_pos = state.player.queue_pos; + let current_id = state.player.current.as_ref().map(|track| track.id); + let current_removed = current_id.is_some_and(|id| { + unique.iter().any(|index| { + state + .player + .queue + .get(*index) + .is_some_and(|track| track.id == id) + }) + }); + let removed_before_current = unique + .iter() + .filter(|index| **index < old_queue_pos) + .count(); + let was_loaded = state.player.playing; + let was_paused = state.player.paused; + + for index in unique.iter().rev() { + state.player.queue.remove(*index); + } + state.player.prefetched_pos = None; + state.track_selection.clear(); + + if state.player.queue.is_empty() { + state.player = super::state::PlayerBar::default(); + state.queue_tab.cursor = 0; + return QueueRemovalOutcome { + restart_paused: None, + stop: true, + }; + } + + if current_removed { + let desired = old_queue_pos.saturating_sub(removed_before_current); + state.player.queue_pos = desired.min(state.player.queue.len() - 1); + state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); + state.player.position_secs = 0.0; + state.player.track_started_at = None; + state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); + return QueueRemovalOutcome { + restart_paused: was_loaded.then_some(was_paused), + stop: false, + }; + } + + if let Some(id) = current_id { + if let Some(position) = state.player.queue.iter().position(|track| track.id == id) { + state.player.queue_pos = position; + } + } else { + state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1); + } + state.player.current = current_id.and_then(|id| { + state + .player + .queue + .iter() + .find(|track| track.id == id) + .cloned() + }); + state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); + QueueRemovalOutcome { + restart_paused: None, + stop: false, + } +} + /// The track under the cursor in whatever view is showing tracks. pub fn selected_track(state: &AppState) -> Option { match state.active_tab { @@ -343,13 +691,19 @@ fn selected_release_id(state: &AppState) -> Option { /// a / shift-a: queue the selection — a single track directly, a release via /// an async fetch effect. fn queue_add(state: &mut AppState, next: bool) -> Option { - if let Some(track) = selected_track(state) { - let title = track.title.clone(); - enqueue_tracks(state, vec![track], next); - state.status_message = Some(if next { + let tracks = selected_tracks(state); + if !tracks.is_empty() { + let count = tracks.len(); + let title = tracks[0].title.clone(); + enqueue_tracks(state, tracks, next); + state.status_message = Some(if count == 1 && next { format!("queued next: {title}") - } else { + } else if count == 1 { format!("queued: {title}") + } else if next { + format!("queued next: {count} tracks") + } else { + format!("queued: {count} tracks") }); return None; } @@ -620,6 +974,18 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { } return; } + if state.track_selection.is_active() + && dx == 0 + && let Some((scope, cursor, len)) = current_track_list_context(state) + { + if len == 0 { + return; + } + let next = (cursor as isize + dy).clamp(0, len as isize - 1) as usize; + set_track_scope_cursor(state, &scope, next); + refresh_track_selection_cursor(state); + return; + } if state.active_tab == Tab::Playlists { let len = playlists_view_len(state); if len == 0 { @@ -629,10 +995,12 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { match &mut state.playlists.opened { Some(opened) => { opened.cursor = (opened.cursor as isize + dy).clamp(0, last) as usize; + refresh_track_selection_cursor(state); } None => { state.playlists.selected = (state.playlists.selected as isize + dy).clamp(0, last) as usize; + state.track_selection.clear(); } } return; @@ -644,6 +1012,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { } state.queue_tab.cursor = (state.queue_tab.cursor as isize + dy).clamp(0, len as isize - 1) as usize; + refresh_track_selection_cursor(state); return; } if state.active_tab != Tab::Global { @@ -661,6 +1030,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { }; let last = global.artists.len() as isize - 1; global.selected = (global.selected as isize + step).clamp(0, last) as usize; + state.track_selection.clear(); } Some(GlobalView::Artist { id, cursor }) => { let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else { @@ -714,6 +1084,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { } }; set_view_cursor(state, next); + state.track_selection.clear(); } Some(GlobalView::Release { id, cursor }) => { let Some(Loadable::Ready(detail)) = state.release_views.get(&id) else { @@ -725,6 +1096,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { } let next = (cursor as isize + dy).clamp(0, total - 1); set_view_cursor(state, next as usize); + refresh_track_selection_cursor(state); } Some(GlobalView::Search { cursor }) => { let total = state.search.results.as_ref().map_or(0, |r| r.len()) as isize; @@ -733,6 +1105,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { } let next = (cursor as isize + dy).clamp(0, total - 1); set_view_cursor(state, next as usize); + state.track_selection.clear(); } } } @@ -782,10 +1155,21 @@ fn current_view_len(state: &AppState) -> usize { } fn jump_selection(state: &mut AppState, first: bool) { + if state.track_selection.is_active() + && let Some((scope, _, len)) = current_track_list_context(state) + { + if len > 0 { + let target = if first { 0 } else { len - 1 }; + set_track_scope_cursor(state, &scope, target); + refresh_track_selection_cursor(state); + } + return; + } 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 }; + refresh_track_selection_cursor(state); } return; } @@ -817,10 +1201,13 @@ fn jump_selection(state: &mut AppState, first: bool) { Some(opened) => opened.cursor = target, None => state.playlists.selected = target, } + refresh_track_selection_cursor(state); } else if state.global.stack.is_empty() { state.global.selected = target; + state.track_selection.clear(); } else { set_view_cursor(state, target); + refresh_track_selection_cursor(state); } } @@ -1014,6 +1401,7 @@ fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) { /// Esc/Backspace: pop the navigation stack; leaving a search view resets the /// search so the next `:/` starts clean. fn go_back(state: &mut AppState) { + state.track_selection.clear(); match state.active_tab { Tab::Playlists => { state.playlists.opened = None; @@ -1042,11 +1430,13 @@ fn go_back(state: &mut AppState) { fn switch_tab(state: &mut AppState, tab: Tab) { state.active_tab = tab; state.help_visible = false; + state.track_selection.clear(); // Manually leaving a view cancels any pending Shift-J return path. state.jump_origin = None; } fn reset_tab(state: &mut AppState, tab: Tab) { + state.track_selection.clear(); match tab { Tab::Global => { if state @@ -1075,7 +1465,7 @@ fn not_yet(state: &mut AppState, what: &str) { #[cfg(test)] mod tests { use super::*; - use crate::api::models::ArtistCard; + use crate::api::models::{ArtistCard, ArtistDetail, TrackItem}; fn with_artists(n: usize) -> AppState { let mut state = AppState::default(); @@ -1091,6 +1481,33 @@ mod tests { state } + fn test_track(id: i64) -> TrackItem { + TrackItem { + id, + title: format!("t{id}"), + track_number: None, + disc_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}"), + uploader_name: String::new(), + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + lastfm_listeners: None, + lastfm_playcount: None, + lastfm_rating: None, + lastfm_updated_at: None, + } + } + #[test] fn quit_needs_double_press() { let mut state = AppState::default(); @@ -1297,6 +1714,7 @@ mod tests { id, title: format!("t{id}"), track_number: None, + disc_number: None, duration_seconds: 1.0, artists: vec![], featured_artists: vec![], @@ -1305,11 +1723,16 @@ mod tests { release_year: None, cover_url: None, stream_url: format!("/api/player/stream/{id}"), + uploader_name: String::new(), audio_format: None, audio_bitrate: None, audio_sample_rate: None, + audio_bit_depth: None, file_size_bytes: None, + lastfm_listeners: None, lastfm_playcount: None, + lastfm_rating: None, + lastfm_updated_at: None, }; let mut state = AppState::default(); state.player.queue = vec![track(1), track(2)]; @@ -1358,6 +1781,7 @@ mod tests { id, title: format!("t{id}"), track_number: None, + disc_number: None, duration_seconds: 1.0, artists: vec![], featured_artists: vec![], @@ -1366,11 +1790,16 @@ mod tests { release_year: None, cover_url: None, stream_url: format!("/s/{id}"), + uploader_name: String::new(), audio_format: None, audio_bitrate: None, audio_sample_rate: None, + audio_bit_depth: None, file_size_bytes: None, + lastfm_listeners: None, lastfm_playcount: None, + lastfm_rating: None, + lastfm_updated_at: None, }; let mut state = AppState { active_tab: Tab::Queue, @@ -1399,6 +1828,118 @@ mod tests { assert!(!state.player.playing); } + #[test] + fn visual_selection_removes_queue_range() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = (1..=4).map(test_track).collect(); + state.queue_tab.cursor = 1; + + assert_eq!(update(&mut state, Action::ToggleTrackSelection), None); + update(&mut state, Action::MoveDown); + + let selected: Vec = selected_tracks(&state) + .into_iter() + .map(|track| track.id) + .collect(); + assert_eq!(selected, vec![2, 3]); + assert_eq!( + update(&mut state, Action::RemoveFromQueue), + Some(Effect::RemoveQueueIndices { + indices: vec![1, 2], + restart_paused: None, + stop: false, + }) + ); + let remaining: Vec = state.player.queue.iter().map(|track| track.id).collect(); + assert_eq!(remaining, vec![1, 4]); + assert!(!state.track_selection.is_active()); + } + + #[test] + fn artist_top_track_selection_queues_all_selected_tracks() { + let mut state = AppState::default(); + state + .global + .stack + .push(GlobalView::Artist { id: 9, cursor: 0 }); + state.artist_views.insert( + 9, + Loadable::Ready(ArtistDetail { + id: 9, + name: "artist".into(), + image_url: None, + total_track_count: 3, + total_play_count: 0, + top_tracks: (1..=3).map(test_track).collect(), + releases: vec![], + featured_tracks: vec![], + }), + ); + + update(&mut state, Action::ToggleTrackSelection); + update(&mut state, Action::MoveDown); + assert_eq!(update(&mut state, Action::QueueAddLast), None,); + let queued: Vec = state.player.queue.iter().map(|track| track.id).collect(); + assert_eq!(queued, vec![1, 2]); + } + + #[test] + fn removing_current_queue_track_requests_paused_restart() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = (1..=3).map(test_track).collect(); + state.player.queue_pos = 1; + state.queue_tab.cursor = 1; + state.player.current = Some(test_track(2)); + state.player.playing = true; + state.player.paused = true; + + assert_eq!( + update(&mut state, Action::RemoveFromQueue), + Some(Effect::RemoveQueueIndices { + indices: vec![1], + restart_paused: Some(true), + stop: false, + }) + ); + let remaining: Vec = state.player.queue.iter().map(|track| track.id).collect(); + assert_eq!(remaining, vec![1, 3]); + assert_eq!(state.player.queue_pos, 1); + assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3)); + } + + #[test] + fn bulk_like_targets_only_tracks_that_need_toggle() { + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = (1..=3).map(test_track).collect(); + state.likes.insert(1); + + update(&mut state, Action::ToggleTrackSelection); + update(&mut state, Action::SelectLast); + assert_eq!( + update(&mut state, Action::ToggleLike), + Some(Effect::ToggleLikes { + track_ids: vec![2, 3], + }) + ); + + state.likes = [1, 2, 3].into_iter().collect(); + assert_eq!( + update(&mut state, Action::ToggleLike), + Some(Effect::ToggleLikes { + track_ids: vec![1, 2, 3], + }) + ); + } + #[test] fn shuffle_reorders_tail_and_restores() { use crate::api::models::TrackItem; @@ -1406,6 +1947,7 @@ mod tests { id, title: format!("t{id}"), track_number: None, + disc_number: None, duration_seconds: 1.0, artists: vec![], featured_artists: vec![], @@ -1414,11 +1956,16 @@ mod tests { release_year: None, cover_url: None, stream_url: format!("/s/{id}"), + uploader_name: String::new(), audio_format: None, audio_bitrate: None, audio_sample_rate: None, + audio_bit_depth: None, file_size_bytes: None, + lastfm_listeners: None, lastfm_playcount: None, + lastfm_rating: None, + lastfm_updated_at: None, }; let mut state = AppState::default(); state.player.queue = (1..=8).map(track).collect(); @@ -1449,6 +1996,7 @@ mod tests { id, title: format!("t{id}"), track_number: None, + disc_number: None, duration_seconds: 1.0, artists: vec![], featured_artists: vec![], @@ -1457,11 +2005,16 @@ mod tests { release_year: None, cover_url: None, stream_url: format!("/s/{id}"), + uploader_name: String::new(), audio_format: None, audio_bitrate: None, audio_sample_rate: None, + audio_bit_depth: None, file_size_bytes: None, + lastfm_listeners: None, lastfm_playcount: None, + lastfm_rating: None, + lastfm_updated_at: None, }; let mut state = AppState { active_tab: Tab::Queue, diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index 0dc71a2..ac8de00 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -60,6 +60,16 @@ key_sequence = "shift-c" command = "ClearQueue" context = "queue" +[[keymaps]] +key_sequence = "d" +command = "RemoveFromQueue" +context = "queue" + +[[keymaps]] +key_sequence = "delete" +command = "RemoveFromQueue" +context = "queue" + [[keymaps]] key_sequence = "shift-j" command = "GoToRelease" @@ -185,6 +195,14 @@ command = "CycleRepeat" key_sequence = "x" command = "ToggleLike" +[[keymaps]] +key_sequence = "shift-v" +command = "ToggleTrackSelection" + +[[keymaps]] +key_sequence = "i" +command = "OpenTrackInfo" + [[keymaps]] key_sequence = "shift-l" command = "Logout" diff --git a/src/ui/global.rs b/src/ui/global.rs index 8648691..3dcc106 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -490,11 +490,25 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: cursor_item, &mut |frame, rect, item| match item { PlanItem::Track { cursor_index } => { - let (track, number) = if *cursor_index < tracks { - (&detail.top_tracks[*cursor_index], cursor_index + 1) + let (track, number, visual_selected) = if *cursor_index < tracks { + ( + &detail.top_tracks[*cursor_index], + cursor_index + 1, + state.track_selection.contains( + &crate::app::state::TrackSelectionScope::ArtistTop(id), + *cursor_index, + ), + ) } else { let offset = cursor_index - tracks - releases_len; - (&detail.featured_tracks[offset], offset + 1) + ( + &detail.featured_tracks[offset], + offset + 1, + state.track_selection.contains( + &crate::app::state::TrackSelectionScope::ArtistFeatured(id), + offset, + ), + ) }; super::track_row( frame, @@ -503,6 +517,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: track, number.to_string(), cursor == *cursor_index, + visual_selected, ); } PlanItem::TileRow(row) => { @@ -678,7 +693,17 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor .track_number .map(|n| n.to_string()) .unwrap_or_else(|| (offset + 1).to_string()); - super::track_row(frame, rect, state, track, number, cursor == offset); + super::track_row( + frame, + rect, + state, + track, + number, + cursor == offset, + state + .track_selection + .contains(&crate::app::state::TrackSelectionScope::Release(id), offset), + ); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 57fd449..e4d9a12 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -12,7 +12,7 @@ use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, Tabs}; -use crate::app::state::{AppState, Screen, Tab}; +use crate::app::state::{AppState, Screen, Tab, TrackSelectionScope}; use crate::config::keymap::Keymap; pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { @@ -63,6 +63,7 @@ pub(crate) fn track_row( track: &crate::api::models::TrackItem, index_label: String, selected: bool, + visual_selected: bool, ) { let heart = if state.likes.contains(&track.id) { Span::styled("♥ ", theme::accent()) @@ -82,6 +83,9 @@ pub(crate) fn track_row( Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right), area, ); + if visual_selected { + frame.buffer_mut().set_style(area, theme::selection()); + } if selected { frame.buffer_mut().set_style(area, theme::tab_active()); } @@ -125,7 +129,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { let player = &state.player; let block = Block::bordered() .title(format!( - " Queue — {} tracks · enter: play · shift-c: clear ", + " Queue — {} tracks · enter: play · d: remove · shift-v: select · shift-c: clear ", player.queue.len() )) .title_style(theme::header()) @@ -168,10 +172,21 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { } else { (index + 1).to_string() }; - track_row(frame, row, state, track, label, index == cursor); + let visual_selected = state + .track_selection + .contains(&TrackSelectionScope::Queue, index); + track_row( + frame, + row, + state, + track, + label, + index == cursor, + visual_selected, + ); // Tracks before the playing one are history: greyed out unless the // cursor is on them. - if index < player.queue_pos && index != cursor { + if index < player.queue_pos && index != cursor && !visual_selected { frame.buffer_mut().set_style(row, played_style); } } diff --git a/src/ui/playlists.rs b/src/ui/playlists.rs index ef216c3..25da663 100644 --- a/src/ui/playlists.rs +++ b/src/ui/playlists.rs @@ -5,7 +5,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use super::{theme, track_row}; -use crate::app::state::{AppState, Loadable}; +use crate::app::state::{AppState, Loadable, TrackSelectionScope}; use crate::app::update::playlist_tracks; pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { @@ -158,6 +158,9 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: track, (index + 1).to_string(), index == cursor, + state + .track_selection + .contains(&TrackSelectionScope::Playlist(id), index), ); } } diff --git a/src/ui/popup.rs b/src/ui/popup.rs index b5476bb..b34614f 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -1,9 +1,10 @@ use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Clear, Paragraph}; +use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; use super::theme; +use crate::api::models::{ArtistRef, TrackItem}; use crate::app::state::{AppState, Loadable, Popup, addable_playlists}; pub fn draw(frame: &mut Frame, state: &AppState) { @@ -13,6 +14,11 @@ pub fn draw(frame: &mut Frame, state: &AppState) { } Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy), Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor), + Some(Popup::TrackInfo { + tracks, + cursor, + scroll, + }) => draw_track_info(frame, tracks, *cursor, *scroll), Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry), None => {} } @@ -137,6 +143,152 @@ fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) ); } +fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scroll: usize) { + let Some(track) = tracks.get(cursor.min(tracks.len().saturating_sub(1))) else { + return; + }; + let width = 92.min(frame.area().width.saturating_sub(4)).max(48); + let height = 24.min(frame.area().height.saturating_sub(2)).max(10); + let area = centered(frame.area(), width, height); + let title = if tracks.len() > 1 { + format!(" Track info — {}/{} ", cursor + 1, tracks.len()) + } else { + " Track info ".to_string() + }; + + let block = Block::bordered() + .title(title) + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); + let lines = track_info_lines(track); + let max_scroll = lines.len().saturating_sub(usize::from(body.height)); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll.min(max_scroll) as u16, 0)), + body, + ); + + let hint = if tracks.len() > 1 { + "j/k scroll · h/left previous · l/right next · esc close" + } else { + "j/k scroll · esc close" + }; + frame.render_widget( + Paragraph::new(Line::styled(hint, theme::dim())).alignment(Alignment::Center), + footer, + ); +} + +fn track_info_lines(track: &TrackItem) -> Vec> { + vec![ + field("ID", track.id.to_string()), + field("Title", track.title.clone()), + field("Artists", artist_refs(&track.artists)), + field("Featured artists", artist_refs(&track.featured_artists)), + field("Release", release_label(track)), + field("Release ID", track.release_id.to_string()), + field("Disc", opt_display(track.disc_number)), + field("Track number", opt_display(track.track_number)), + field( + "Duration", + format!( + "{} ({:.2}s)", + track.duration_label(), + track.duration_seconds + ), + ), + field("Uploader", empty_dash(&track.uploader_name)), + field("Audio format", opt_string(track.audio_format.clone())), + field( + "Bitrate", + opt_map(track.audio_bitrate, |v| format!("{v} kbps")), + ), + field( + "Sample rate", + opt_map(track.audio_sample_rate, |v| { + format!("{:.1} kHz", f64::from(v) / 1000.0) + }), + ), + field( + "Bit depth", + opt_map(track.audio_bit_depth, |v| format!("{v} bit")), + ), + field("File size", file_size(track.file_size_bytes)), + field("Last.fm listeners", opt_display(track.lastfm_listeners)), + field("Last.fm plays", opt_display(track.lastfm_playcount)), + field( + "Last.fm rating", + opt_map(track.lastfm_rating, |v| format!("{v:.3}")), + ), + field( + "Last.fm updated", + opt_string(track.lastfm_updated_at.clone()), + ), + field("Stream URL", empty_dash(&track.stream_url)), + field("Cover URL", opt_string(track.cover_url.clone())), + ] +} + +fn field(label: &'static str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:<18}"), theme::dim()), + Span::raw(value), + ]) +} + +fn artist_refs(items: &[ArtistRef]) -> String { + if items.is_empty() { + return "—".to_string(); + } + items + .iter() + .map(|artist| format!("{} ({})", artist.name, artist.id)) + .collect::>() + .join(", ") +} + +fn release_label(track: &TrackItem) -> String { + let mut label = empty_dash(&track.release_title); + if let Some(year) = track.release_year { + label.push_str(&format!(" ({year})")); + } + label +} + +fn empty_dash(value: &str) -> String { + if value.trim().is_empty() { + "—".to_string() + } else { + value.to_string() + } +} + +fn opt_string(value: Option) -> String { + value + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "—".to_string()) +} + +fn opt_display(value: Option) -> String { + opt_map(value, |value| value.to_string()) +} + +fn opt_map(value: Option, format: impl FnOnce(T) -> String) -> String { + value.map(format).unwrap_or_else(|| "—".to_string()) +} + +fn file_size(value: Option) -> String { + value + .map(|bytes| format!("{:.1} MB ({} bytes)", bytes as f64 / 1_048_576.0, bytes)) + .unwrap_or_else(|| "—".to_string()) +} + fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: usize) { let options = addable_playlists(state); let loading = !matches!(&state.playlists.list, Some(Loadable::Ready(_))); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 35cd6d9..587619b 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -18,6 +18,10 @@ pub fn tab_active() -> Style { .add_modifier(Modifier::BOLD) } +pub fn selection() -> Style { + Style::new().fg(Color::White).bg(Color::Rgb(24, 68, 72)) +} + pub fn header() -> Style { Style::new().fg(ACCENT).add_modifier(Modifier::BOLD) }