Added Visual select for multiple tracks, added Queue management. Added info for tracks
This commit is contained in:
+573
-20
@@ -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<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
|
||||
}
|
||||
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<i64> = 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<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::QueueAddLast => queue_add(state, false),
|
||||
Action::GoToRelease => {
|
||||
@@ -258,6 +302,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
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<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.
|
||||
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
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
|
||||
/// an async fetch effect.
|
||||
fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
||||
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<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]
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user