Added Visual select for multiple tracks, added Queue management. Added info for tracks
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumi_tui"
|
name = "furumi_tui"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ pub struct TrackItem {
|
|||||||
/// Absent in the artist-appearance variant of track payloads.
|
/// Absent in the artist-appearance variant of track payloads.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub track_number: Option<i32>,
|
pub track_number: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
pub duration_seconds: f64,
|
pub duration_seconds: f64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub artists: Vec<ArtistRef>,
|
pub artists: Vec<ArtistRef>,
|
||||||
@@ -95,12 +97,18 @@ pub struct TrackItem {
|
|||||||
pub stream_url: String,
|
pub stream_url: String,
|
||||||
#[allow(dead_code, reason = "now-playing artwork in milestone 3")]
|
#[allow(dead_code, reason = "now-playing artwork in milestone 3")]
|
||||||
pub cover_url: Option<String>,
|
pub cover_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub uploader_name: String,
|
||||||
pub audio_format: Option<String>,
|
pub audio_format: Option<String>,
|
||||||
pub audio_bitrate: Option<i32>,
|
pub audio_bitrate: Option<i32>,
|
||||||
pub audio_sample_rate: Option<i32>,
|
pub audio_sample_rate: Option<i32>,
|
||||||
|
pub audio_bit_depth: Option<i32>,
|
||||||
pub file_size_bytes: Option<i64>,
|
pub file_size_bytes: Option<i64>,
|
||||||
|
pub lastfm_listeners: Option<i64>,
|
||||||
#[allow(dead_code, reason = "popularity column later")]
|
#[allow(dead_code, reason = "popularity column later")]
|
||||||
pub lastfm_playcount: Option<i64>,
|
pub lastfm_playcount: Option<i64>,
|
||||||
|
pub lastfm_rating: Option<f64>,
|
||||||
|
pub lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrackItem {
|
impl TrackItem {
|
||||||
|
|||||||
+10
-1
@@ -28,8 +28,11 @@ pub enum Action {
|
|||||||
ToggleShuffle,
|
ToggleShuffle,
|
||||||
CycleRepeat,
|
CycleRepeat,
|
||||||
ToggleLike,
|
ToggleLike,
|
||||||
|
ToggleTrackSelection,
|
||||||
|
OpenTrackInfo,
|
||||||
QueueAddNext,
|
QueueAddNext,
|
||||||
QueueAddLast,
|
QueueAddLast,
|
||||||
|
RemoveFromQueue,
|
||||||
ClearQueue,
|
ClearQueue,
|
||||||
GoToRelease,
|
GoToRelease,
|
||||||
AddToPlaylist,
|
AddToPlaylist,
|
||||||
@@ -86,10 +89,13 @@ impl Action {
|
|||||||
| Action::CycleRepeat => Category::Playback,
|
| Action::CycleRepeat => Category::Playback,
|
||||||
Action::QueueAddNext
|
Action::QueueAddNext
|
||||||
| Action::QueueAddLast
|
| Action::QueueAddLast
|
||||||
|
| Action::RemoveFromQueue
|
||||||
| Action::ClearQueue
|
| Action::ClearQueue
|
||||||
| Action::AddToPlaylist
|
| Action::AddToPlaylist
|
||||||
| Action::NewPlaylist
|
| Action::NewPlaylist
|
||||||
| Action::ToggleLike => Category::Queue,
|
| Action::ToggleLike
|
||||||
|
| Action::ToggleTrackSelection
|
||||||
|
| Action::OpenTrackInfo => Category::Queue,
|
||||||
Action::MoveUp
|
Action::MoveUp
|
||||||
| Action::MoveDown
|
| Action::MoveDown
|
||||||
| Action::MoveLeft
|
| Action::MoveLeft
|
||||||
@@ -157,8 +163,11 @@ impl Action {
|
|||||||
Action::ToggleShuffle => "Toggle shuffle".into(),
|
Action::ToggleShuffle => "Toggle shuffle".into(),
|
||||||
Action::CycleRepeat => "Cycle repeat mode".into(),
|
Action::CycleRepeat => "Cycle repeat mode".into(),
|
||||||
Action::ToggleLike => "Like / unlike".into(),
|
Action::ToggleLike => "Like / unlike".into(),
|
||||||
|
Action::ToggleTrackSelection => "Track line selection".into(),
|
||||||
|
Action::OpenTrackInfo => "Track info".into(),
|
||||||
Action::QueueAddNext => "Queue: add next".into(),
|
Action::QueueAddNext => "Queue: add next".into(),
|
||||||
Action::QueueAddLast => "Queue: add to end".into(),
|
Action::QueueAddLast => "Queue: add to end".into(),
|
||||||
|
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
||||||
Action::ClearQueue => "Queue: clear".into(),
|
Action::ClearQueue => "Queue: clear".into(),
|
||||||
Action::GoToRelease => "Open the track's release".into(),
|
Action::GoToRelease => "Open the track's release".into(),
|
||||||
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
Action::AddToPlaylist => "Add track to a playlist…".into(),
|
||||||
|
|||||||
+69
-14
@@ -431,7 +431,7 @@ fn spawn_art_fetch(
|
|||||||
|
|
||||||
/// Execute a side effect requested by update().
|
/// Execute a side effect requested by update().
|
||||||
fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
match effect {
|
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 {
|
let Some(api) = runtime.api.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match api.toggle_like(track_id).await {
|
for track_id in track_ids {
|
||||||
Ok(liked) => {
|
match api.toggle_like(track_id).await {
|
||||||
let _ = tx.send(AppEvent::LikeToggled { track_id, liked });
|
Ok(liked) => {
|
||||||
}
|
let _ = tx.send(AppEvent::LikeToggled { track_id, liked });
|
||||||
Err(err) => {
|
}
|
||||||
tracing::warn!(%err, track_id, "like toggle failed");
|
Err(err) => {
|
||||||
let _ = tx.send(AppEvent::StatusMessage(format!("like failed: {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 {
|
let Some(target) = state.devices.remote_target_id().map(str::to_string) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -528,7 +553,7 @@ fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
Effect::SeekBy(delta) => {
|
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;
|
state.player.position_secs = target_time;
|
||||||
send_device_command(
|
send_device_command(
|
||||||
runtime,
|
runtime,
|
||||||
@@ -543,7 +568,7 @@ fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect
|
|||||||
runtime,
|
runtime,
|
||||||
target,
|
target,
|
||||||
"set_volume",
|
"set_volume",
|
||||||
serde_json::json!({ "volume": f64::from(volume) / 100.0 }),
|
serde_json::json!({ "volume": f64::from(*volume) / 100.0 }),
|
||||||
);
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -559,7 +584,20 @@ fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect
|
|||||||
);
|
);
|
||||||
true
|
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() {
|
if index >= state.player.queue.len() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let current_id = state.player.current.as_ref().map(|track| track.id);
|
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.queue.remove(index);
|
||||||
|
state.player.prefetched_pos = None;
|
||||||
|
state.track_selection.clear();
|
||||||
if state.player.queue.is_empty() {
|
if state.player.queue.is_empty() {
|
||||||
state.player = state::PlayerBar::default();
|
state.player = state::PlayerBar::default();
|
||||||
runtime.player.stop();
|
runtime.player.stop();
|
||||||
|
push_state_now(state, runtime);
|
||||||
|
push_media_update(state, runtime, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.player.queue_pos = current_id
|
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));
|
.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.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);
|
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) {
|
fn move_queue_index(state: &mut AppState, from: usize, to: usize) {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
|
|||||||
busy,
|
busy,
|
||||||
} => handle_name_entry(state, runtime, for_track, input, busy, key),
|
} => handle_name_entry(state, runtime, for_track, input, busy, key),
|
||||||
Popup::Devices { cursor } => handle_devices(state, runtime, cursor, 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 {
|
Popup::LogDetail(entry) => match key.code {
|
||||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||||
_ => state.popup = Some(Popup::LogDetail(entry)),
|
_ => 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<TrackItem>,
|
||||||
|
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(
|
fn handle_picker(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
|
|||||||
@@ -186,6 +186,71 @@ pub struct QueueTab {
|
|||||||
pub cursor: usize,
|
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<TrackSelectionScope>,
|
||||||
|
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<Vec<usize>> {
|
||||||
|
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.
|
/// Severity steps for the Logs tab filter, cycled with the view-toggle key.
|
||||||
pub const LOG_LEVELS: [tracing::Level; 5] = [
|
pub const LOG_LEVELS: [tracing::Level; 5] = [
|
||||||
tracing::Level::ERROR,
|
tracing::Level::ERROR,
|
||||||
@@ -263,6 +328,12 @@ pub enum Popup {
|
|||||||
},
|
},
|
||||||
/// Connected devices list; Enter transfers active playback to the row.
|
/// Connected devices list; Enter transfers active playback to the row.
|
||||||
Devices { cursor: usize },
|
Devices { cursor: usize },
|
||||||
|
/// Track metadata viewer; left/right switch between selected tracks.
|
||||||
|
TrackInfo {
|
||||||
|
tracks: Vec<TrackItem>,
|
||||||
|
cursor: usize,
|
||||||
|
scroll: usize,
|
||||||
|
},
|
||||||
/// Full, wrapped view of one log entry (Enter on the Logs tab).
|
/// Full, wrapped view of one log entry (Enter on the Logs tab).
|
||||||
LogDetail(crate::config::logging::LogEntry),
|
LogDetail(crate::config::logging::LogEntry),
|
||||||
}
|
}
|
||||||
@@ -519,6 +590,7 @@ pub struct AppState {
|
|||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
pub devices: DevicesState,
|
pub devices: DevicesState,
|
||||||
pub queue_tab: QueueTab,
|
pub queue_tab: QueueTab,
|
||||||
|
pub track_selection: TrackSelection,
|
||||||
/// Shift-J jump in flight: focus this (release, track) once the release
|
/// Shift-J jump in flight: focus this (release, track) once the release
|
||||||
/// view finishes loading.
|
/// view finishes loading.
|
||||||
pub pending_release_focus: Option<(i64, i64)>,
|
pub pending_release_focus: Option<(i64, i64)>,
|
||||||
|
|||||||
+573
-20
@@ -5,7 +5,7 @@ use crate::api::models::TrackItem;
|
|||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
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);
|
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
|
/// Side effects requested by `update()`; executed by the app loop, which
|
||||||
/// owns the Runtime (audio controller, API client). Keeps update() pure.
|
/// owns the Runtime (audio controller, API client). Keeps update() pure.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Effect {
|
pub enum Effect {
|
||||||
/// (Re)start playback of `queue[queue_pos]`.
|
/// (Re)start playback of `queue[queue_pos]`.
|
||||||
PlayCurrent,
|
PlayCurrent,
|
||||||
@@ -28,8 +28,13 @@ pub enum Effect {
|
|||||||
id: i64,
|
id: i64,
|
||||||
next: bool,
|
next: bool,
|
||||||
},
|
},
|
||||||
ToggleLike {
|
ToggleLikes {
|
||||||
track_id: i64,
|
track_ids: Vec<i64>,
|
||||||
|
},
|
||||||
|
RemoveQueueIndices {
|
||||||
|
indices: Vec<usize>,
|
||||||
|
restart_paused: Option<bool>,
|
||||||
|
stop: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,23 +208,62 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::Select => select_current(state),
|
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 => {
|
Action::Back => {
|
||||||
go_back(state);
|
go_back(state);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::ToggleLike => {
|
Action::ToggleLike => {
|
||||||
let target =
|
let tracks = selected_tracks(state);
|
||||||
selected_track(state)
|
let track_ids: Vec<i64> = if tracks.is_empty() {
|
||||||
.map(|t| t.id)
|
state
|
||||||
.or(state.player.current.as_ref().map(|t| t.id));
|
.player
|
||||||
match target {
|
.current
|
||||||
Some(track_id) => Some(Effect::ToggleLike { track_id }),
|
.as_ref()
|
||||||
None => {
|
.map(|track| vec![track.id])
|
||||||
state.status_message = Some("no track selected".into());
|
.unwrap_or_default()
|
||||||
None
|
} 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<i64> = 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::QueueAddNext => queue_add(state, true),
|
||||||
Action::QueueAddLast => queue_add(state, false),
|
Action::QueueAddLast => queue_add(state, false),
|
||||||
Action::GoToRelease => {
|
Action::GoToRelease => {
|
||||||
@@ -258,6 +302,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
state.player.prefetched_pos = None;
|
state.player.prefetched_pos = None;
|
||||||
state.player.original_order = None;
|
state.player.original_order = None;
|
||||||
state.queue_tab.cursor = 0;
|
state.queue_tab.cursor = 0;
|
||||||
|
state.track_selection.clear();
|
||||||
if had_tracks {
|
if had_tracks {
|
||||||
state.status_message = Some("queue cleared".into());
|
state.status_message = Some("queue cleared".into());
|
||||||
Some(Effect::StopPlayback)
|
Some(Effect::StopPlayback)
|
||||||
@@ -271,6 +316,309 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<TrackItem> {
|
||||||
|
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<usize> {
|
||||||
|
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<Effect> {
|
||||||
|
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<bool>,
|
||||||
|
stop: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemovalOutcome {
|
||||||
|
let len = state.player.queue.len();
|
||||||
|
let mut unique: Vec<usize> = 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.
|
/// The track under the cursor in whatever view is showing tracks.
|
||||||
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||||
match state.active_tab {
|
match state.active_tab {
|
||||||
@@ -343,13 +691,19 @@ fn selected_release_id(state: &AppState) -> Option<i64> {
|
|||||||
/// a / shift-a: queue the selection — a single track directly, a release via
|
/// a / shift-a: queue the selection — a single track directly, a release via
|
||||||
/// an async fetch effect.
|
/// an async fetch effect.
|
||||||
fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
||||||
if let Some(track) = selected_track(state) {
|
let tracks = selected_tracks(state);
|
||||||
let title = track.title.clone();
|
if !tracks.is_empty() {
|
||||||
enqueue_tracks(state, vec![track], next);
|
let count = tracks.len();
|
||||||
state.status_message = Some(if next {
|
let title = tracks[0].title.clone();
|
||||||
|
enqueue_tracks(state, tracks, next);
|
||||||
|
state.status_message = Some(if count == 1 && next {
|
||||||
format!("queued next: {title}")
|
format!("queued next: {title}")
|
||||||
} else {
|
} else if count == 1 {
|
||||||
format!("queued: {title}")
|
format!("queued: {title}")
|
||||||
|
} else if next {
|
||||||
|
format!("queued next: {count} tracks")
|
||||||
|
} else {
|
||||||
|
format!("queued: {count} tracks")
|
||||||
});
|
});
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -620,6 +974,18 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
}
|
}
|
||||||
return;
|
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 {
|
if state.active_tab == Tab::Playlists {
|
||||||
let len = playlists_view_len(state);
|
let len = playlists_view_len(state);
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
@@ -629,10 +995,12 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
match &mut state.playlists.opened {
|
match &mut state.playlists.opened {
|
||||||
Some(opened) => {
|
Some(opened) => {
|
||||||
opened.cursor = (opened.cursor as isize + dy).clamp(0, last) as usize;
|
opened.cursor = (opened.cursor as isize + dy).clamp(0, last) as usize;
|
||||||
|
refresh_track_selection_cursor(state);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
state.playlists.selected =
|
state.playlists.selected =
|
||||||
(state.playlists.selected as isize + dy).clamp(0, last) as usize;
|
(state.playlists.selected as isize + dy).clamp(0, last) as usize;
|
||||||
|
state.track_selection.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -644,6 +1012,7 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
}
|
}
|
||||||
state.queue_tab.cursor =
|
state.queue_tab.cursor =
|
||||||
(state.queue_tab.cursor as isize + dy).clamp(0, len as isize - 1) as usize;
|
(state.queue_tab.cursor as isize + dy).clamp(0, len as isize - 1) as usize;
|
||||||
|
refresh_track_selection_cursor(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if state.active_tab != Tab::Global {
|
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;
|
let last = global.artists.len() as isize - 1;
|
||||||
global.selected = (global.selected as isize + step).clamp(0, last) as usize;
|
global.selected = (global.selected as isize + step).clamp(0, last) as usize;
|
||||||
|
state.track_selection.clear();
|
||||||
}
|
}
|
||||||
Some(GlobalView::Artist { id, cursor }) => {
|
Some(GlobalView::Artist { id, cursor }) => {
|
||||||
let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else {
|
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);
|
set_view_cursor(state, next);
|
||||||
|
state.track_selection.clear();
|
||||||
}
|
}
|
||||||
Some(GlobalView::Release { id, cursor }) => {
|
Some(GlobalView::Release { id, cursor }) => {
|
||||||
let Some(Loadable::Ready(detail)) = state.release_views.get(&id) else {
|
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);
|
let next = (cursor as isize + dy).clamp(0, total - 1);
|
||||||
set_view_cursor(state, next as usize);
|
set_view_cursor(state, next as usize);
|
||||||
|
refresh_track_selection_cursor(state);
|
||||||
}
|
}
|
||||||
Some(GlobalView::Search { cursor }) => {
|
Some(GlobalView::Search { cursor }) => {
|
||||||
let total = state.search.results.as_ref().map_or(0, |r| r.len()) as isize;
|
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);
|
let next = (cursor as isize + dy).clamp(0, total - 1);
|
||||||
set_view_cursor(state, next as usize);
|
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) {
|
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 {
|
if state.active_tab == Tab::Queue {
|
||||||
let len = state.player.queue.len();
|
let len = state.player.queue.len();
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
state.queue_tab.cursor = if first { 0 } else { len - 1 };
|
state.queue_tab.cursor = if first { 0 } else { len - 1 };
|
||||||
|
refresh_track_selection_cursor(state);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -817,10 +1201,13 @@ fn jump_selection(state: &mut AppState, first: bool) {
|
|||||||
Some(opened) => opened.cursor = target,
|
Some(opened) => opened.cursor = target,
|
||||||
None => state.playlists.selected = target,
|
None => state.playlists.selected = target,
|
||||||
}
|
}
|
||||||
|
refresh_track_selection_cursor(state);
|
||||||
} else if state.global.stack.is_empty() {
|
} else if state.global.stack.is_empty() {
|
||||||
state.global.selected = target;
|
state.global.selected = target;
|
||||||
|
state.track_selection.clear();
|
||||||
} else {
|
} else {
|
||||||
set_view_cursor(state, target);
|
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
|
/// Esc/Backspace: pop the navigation stack; leaving a search view resets the
|
||||||
/// search so the next `:/` starts clean.
|
/// search so the next `:/` starts clean.
|
||||||
fn go_back(state: &mut AppState) {
|
fn go_back(state: &mut AppState) {
|
||||||
|
state.track_selection.clear();
|
||||||
match state.active_tab {
|
match state.active_tab {
|
||||||
Tab::Playlists => {
|
Tab::Playlists => {
|
||||||
state.playlists.opened = None;
|
state.playlists.opened = None;
|
||||||
@@ -1042,11 +1430,13 @@ fn go_back(state: &mut AppState) {
|
|||||||
fn switch_tab(state: &mut AppState, tab: Tab) {
|
fn switch_tab(state: &mut AppState, tab: Tab) {
|
||||||
state.active_tab = tab;
|
state.active_tab = tab;
|
||||||
state.help_visible = false;
|
state.help_visible = false;
|
||||||
|
state.track_selection.clear();
|
||||||
// Manually leaving a view cancels any pending Shift-J return path.
|
// Manually leaving a view cancels any pending Shift-J return path.
|
||||||
state.jump_origin = None;
|
state.jump_origin = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_tab(state: &mut AppState, tab: Tab) {
|
fn reset_tab(state: &mut AppState, tab: Tab) {
|
||||||
|
state.track_selection.clear();
|
||||||
match tab {
|
match tab {
|
||||||
Tab::Global => {
|
Tab::Global => {
|
||||||
if state
|
if state
|
||||||
@@ -1075,7 +1465,7 @@ fn not_yet(state: &mut AppState, what: &str) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::models::ArtistCard;
|
use crate::api::models::{ArtistCard, ArtistDetail, TrackItem};
|
||||||
|
|
||||||
fn with_artists(n: usize) -> AppState {
|
fn with_artists(n: usize) -> AppState {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
@@ -1091,6 +1481,33 @@ mod tests {
|
|||||||
state
|
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]
|
#[test]
|
||||||
fn quit_needs_double_press() {
|
fn quit_needs_double_press() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
@@ -1297,6 +1714,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
title: format!("t{id}"),
|
title: format!("t{id}"),
|
||||||
track_number: None,
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
duration_seconds: 1.0,
|
duration_seconds: 1.0,
|
||||||
artists: vec![],
|
artists: vec![],
|
||||||
featured_artists: vec![],
|
featured_artists: vec![],
|
||||||
@@ -1305,11 +1723,16 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_url: None,
|
||||||
stream_url: format!("/api/player/stream/{id}"),
|
stream_url: format!("/api/player/stream/{id}"),
|
||||||
|
uploader_name: String::new(),
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
|
lastfm_listeners: None,
|
||||||
lastfm_playcount: None,
|
lastfm_playcount: None,
|
||||||
|
lastfm_rating: None,
|
||||||
|
lastfm_updated_at: None,
|
||||||
};
|
};
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
state.player.queue = vec![track(1), track(2)];
|
state.player.queue = vec![track(1), track(2)];
|
||||||
@@ -1358,6 +1781,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
title: format!("t{id}"),
|
title: format!("t{id}"),
|
||||||
track_number: None,
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
duration_seconds: 1.0,
|
duration_seconds: 1.0,
|
||||||
artists: vec![],
|
artists: vec![],
|
||||||
featured_artists: vec![],
|
featured_artists: vec![],
|
||||||
@@ -1366,11 +1790,16 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_url: None,
|
||||||
stream_url: format!("/s/{id}"),
|
stream_url: format!("/s/{id}"),
|
||||||
|
uploader_name: String::new(),
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
|
lastfm_listeners: None,
|
||||||
lastfm_playcount: None,
|
lastfm_playcount: None,
|
||||||
|
lastfm_rating: None,
|
||||||
|
lastfm_updated_at: None,
|
||||||
};
|
};
|
||||||
let mut state = AppState {
|
let mut state = AppState {
|
||||||
active_tab: Tab::Queue,
|
active_tab: Tab::Queue,
|
||||||
@@ -1399,6 +1828,118 @@ mod tests {
|
|||||||
assert!(!state.player.playing);
|
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<i64> = 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<i64> = 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<i64> = 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<i64> = 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]
|
#[test]
|
||||||
fn shuffle_reorders_tail_and_restores() {
|
fn shuffle_reorders_tail_and_restores() {
|
||||||
use crate::api::models::TrackItem;
|
use crate::api::models::TrackItem;
|
||||||
@@ -1406,6 +1947,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
title: format!("t{id}"),
|
title: format!("t{id}"),
|
||||||
track_number: None,
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
duration_seconds: 1.0,
|
duration_seconds: 1.0,
|
||||||
artists: vec![],
|
artists: vec![],
|
||||||
featured_artists: vec![],
|
featured_artists: vec![],
|
||||||
@@ -1414,11 +1956,16 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_url: None,
|
||||||
stream_url: format!("/s/{id}"),
|
stream_url: format!("/s/{id}"),
|
||||||
|
uploader_name: String::new(),
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
|
lastfm_listeners: None,
|
||||||
lastfm_playcount: None,
|
lastfm_playcount: None,
|
||||||
|
lastfm_rating: None,
|
||||||
|
lastfm_updated_at: None,
|
||||||
};
|
};
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
state.player.queue = (1..=8).map(track).collect();
|
state.player.queue = (1..=8).map(track).collect();
|
||||||
@@ -1449,6 +1996,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
title: format!("t{id}"),
|
title: format!("t{id}"),
|
||||||
track_number: None,
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
duration_seconds: 1.0,
|
duration_seconds: 1.0,
|
||||||
artists: vec![],
|
artists: vec![],
|
||||||
featured_artists: vec![],
|
featured_artists: vec![],
|
||||||
@@ -1457,11 +2005,16 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_url: None,
|
cover_url: None,
|
||||||
stream_url: format!("/s/{id}"),
|
stream_url: format!("/s/{id}"),
|
||||||
|
uploader_name: String::new(),
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
file_size_bytes: None,
|
file_size_bytes: None,
|
||||||
|
lastfm_listeners: None,
|
||||||
lastfm_playcount: None,
|
lastfm_playcount: None,
|
||||||
|
lastfm_rating: None,
|
||||||
|
lastfm_updated_at: None,
|
||||||
};
|
};
|
||||||
let mut state = AppState {
|
let mut state = AppState {
|
||||||
active_tab: Tab::Queue,
|
active_tab: Tab::Queue,
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ key_sequence = "shift-c"
|
|||||||
command = "ClearQueue"
|
command = "ClearQueue"
|
||||||
context = "queue"
|
context = "queue"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "d"
|
||||||
|
command = "RemoveFromQueue"
|
||||||
|
context = "queue"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "delete"
|
||||||
|
command = "RemoveFromQueue"
|
||||||
|
context = "queue"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "shift-j"
|
key_sequence = "shift-j"
|
||||||
command = "GoToRelease"
|
command = "GoToRelease"
|
||||||
@@ -185,6 +195,14 @@ command = "CycleRepeat"
|
|||||||
key_sequence = "x"
|
key_sequence = "x"
|
||||||
command = "ToggleLike"
|
command = "ToggleLike"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "shift-v"
|
||||||
|
command = "ToggleTrackSelection"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "i"
|
||||||
|
command = "OpenTrackInfo"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "shift-l"
|
key_sequence = "shift-l"
|
||||||
command = "Logout"
|
command = "Logout"
|
||||||
|
|||||||
+29
-4
@@ -490,11 +490,25 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
cursor_item,
|
cursor_item,
|
||||||
&mut |frame, rect, item| match item {
|
&mut |frame, rect, item| match item {
|
||||||
PlanItem::Track { cursor_index } => {
|
PlanItem::Track { cursor_index } => {
|
||||||
let (track, number) = if *cursor_index < tracks {
|
let (track, number, visual_selected) = if *cursor_index < tracks {
|
||||||
(&detail.top_tracks[*cursor_index], cursor_index + 1)
|
(
|
||||||
|
&detail.top_tracks[*cursor_index],
|
||||||
|
cursor_index + 1,
|
||||||
|
state.track_selection.contains(
|
||||||
|
&crate::app::state::TrackSelectionScope::ArtistTop(id),
|
||||||
|
*cursor_index,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let offset = cursor_index - tracks - releases_len;
|
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(
|
super::track_row(
|
||||||
frame,
|
frame,
|
||||||
@@ -503,6 +517,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
track,
|
track,
|
||||||
number.to_string(),
|
number.to_string(),
|
||||||
cursor == *cursor_index,
|
cursor == *cursor_index,
|
||||||
|
visual_selected,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
PlanItem::TileRow(row) => {
|
PlanItem::TileRow(row) => {
|
||||||
@@ -678,7 +693,17 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
|||||||
.track_number
|
.track_number
|
||||||
.map(|n| n.to_string())
|
.map(|n| n.to_string())
|
||||||
.unwrap_or_else(|| (offset + 1).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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-4
@@ -12,7 +12,7 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
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;
|
use crate::config::keymap::Keymap;
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, state: &AppState, 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,
|
track: &crate::api::models::TrackItem,
|
||||||
index_label: String,
|
index_label: String,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
|
visual_selected: bool,
|
||||||
) {
|
) {
|
||||||
let heart = if state.likes.contains(&track.id) {
|
let heart = if state.likes.contains(&track.id) {
|
||||||
Span::styled("♥ ", theme::accent())
|
Span::styled("♥ ", theme::accent())
|
||||||
@@ -82,6 +83,9 @@ pub(crate) fn track_row(
|
|||||||
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
|
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
|
if visual_selected {
|
||||||
|
frame.buffer_mut().set_style(area, theme::selection());
|
||||||
|
}
|
||||||
if selected {
|
if selected {
|
||||||
frame.buffer_mut().set_style(area, theme::tab_active());
|
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 player = &state.player;
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(format!(
|
.title(format!(
|
||||||
" Queue — {} tracks · enter: play · shift-c: clear ",
|
" Queue — {} tracks · enter: play · d: remove · shift-v: select · shift-c: clear ",
|
||||||
player.queue.len()
|
player.queue.len()
|
||||||
))
|
))
|
||||||
.title_style(theme::header())
|
.title_style(theme::header())
|
||||||
@@ -168,10 +172,21 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
} else {
|
} else {
|
||||||
(index + 1).to_string()
|
(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
|
// Tracks before the playing one are history: greyed out unless the
|
||||||
// cursor is on them.
|
// 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);
|
frame.buffer_mut().set_style(row, played_style);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Paragraph};
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
|
|
||||||
use super::{theme, track_row};
|
use super::{theme, track_row};
|
||||||
use crate::app::state::{AppState, Loadable};
|
use crate::app::state::{AppState, Loadable, TrackSelectionScope};
|
||||||
use crate::app::update::playlist_tracks;
|
use crate::app::update::playlist_tracks;
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
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,
|
track,
|
||||||
(index + 1).to_string(),
|
(index + 1).to_string(),
|
||||||
index == cursor,
|
index == cursor,
|
||||||
|
state
|
||||||
|
.track_selection
|
||||||
|
.contains(&TrackSelectionScope::Playlist(id), index),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-1
@@ -1,9 +1,10 @@
|
|||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Clear, Paragraph};
|
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||||
|
|
||||||
use super::theme;
|
use super::theme;
|
||||||
|
use crate::api::models::{ArtistRef, TrackItem};
|
||||||
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
|
use crate::app::state::{AppState, Loadable, Popup, addable_playlists};
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
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::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy),
|
||||||
Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor),
|
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),
|
Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry),
|
||||||
None => {}
|
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<Line<'static>> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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>) -> String {
|
||||||
|
value
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "—".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_display<T: std::fmt::Display>(value: Option<T>) -> String {
|
||||||
|
opt_map(value, |value| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_map<T>(value: Option<T>, format: impl FnOnce(T) -> String) -> String {
|
||||||
|
value.map(format).unwrap_or_else(|| "—".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_size(value: Option<i64>) -> 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) {
|
fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: usize) {
|
||||||
let options = addable_playlists(state);
|
let options = addable_playlists(state);
|
||||||
let loading = !matches!(&state.playlists.list, Some(Loadable::Ready(_)));
|
let loading = !matches!(&state.playlists.list, Some(Loadable::Ready(_)));
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ pub fn tab_active() -> Style {
|
|||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn selection() -> Style {
|
||||||
|
Style::new().fg(Color::White).bg(Color::Rgb(24, 68, 72))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn header() -> Style {
|
pub fn header() -> Style {
|
||||||
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
|
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user