Added nix files. revorked queue append, added library migration.
This commit is contained in:
@@ -38,6 +38,8 @@ pub enum Action {
|
||||
OpenCurrentTrackInfo,
|
||||
QueueAddNext,
|
||||
QueueAddLast,
|
||||
MoveQueueUp,
|
||||
MoveQueueDown,
|
||||
/// Download the selected federated track(s) into the local library.
|
||||
DownloadSelected,
|
||||
RemoveFromQueue,
|
||||
@@ -107,6 +109,8 @@ impl Action {
|
||||
| Action::OpenListenHistory => Category::Playback,
|
||||
Action::QueueAddNext
|
||||
| Action::QueueAddLast
|
||||
| Action::MoveQueueUp
|
||||
| Action::MoveQueueDown
|
||||
| Action::DownloadSelected
|
||||
| Action::RemoveFromQueue
|
||||
| Action::ClearQueue
|
||||
@@ -193,6 +197,8 @@ impl Action {
|
||||
Action::OpenCurrentTrackInfo => "Current track info".into(),
|
||||
Action::QueueAddNext => "Queue: add next".into(),
|
||||
Action::QueueAddLast => "Queue: add to end".into(),
|
||||
Action::MoveQueueUp => "Queue: move track/selection up".into(),
|
||||
Action::MoveQueueDown => "Queue: move track/selection down".into(),
|
||||
Action::DownloadSelected => "Federation: download to library".into(),
|
||||
Action::RemoveFromQueue => "Queue: remove selected".into(),
|
||||
Action::ClearQueue => "Queue: clear".into(),
|
||||
|
||||
@@ -17,6 +17,16 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => cancel(state),
|
||||
KeyCode::Enter => commit(state, runtime),
|
||||
KeyCode::Up => {
|
||||
if state.cmdline.history_previous() {
|
||||
after_change(state, runtime);
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if state.cmdline.history_next() {
|
||||
after_change(state, runtime);
|
||||
}
|
||||
}
|
||||
// Backspace on an empty line closes it, like vim.
|
||||
KeyCode::Backspace if state.cmdline.input.is_empty() => cancel(state),
|
||||
_ => {
|
||||
@@ -162,7 +172,9 @@ pub(super) fn refresh_local_search(state: &mut AppState, runtime: &Runtime) {
|
||||
/// Enter: close the line. Live commands already took effect (their view
|
||||
/// stays open); one-shot commands execute here.
|
||||
fn commit(state: &mut AppState, runtime: &mut Runtime) {
|
||||
let remembered = state.cmdline.input.as_str().to_string();
|
||||
let parsed = command::parse(&state.cmdline.input);
|
||||
state.cmdline.remember(&remembered);
|
||||
close(state);
|
||||
match parsed {
|
||||
Parsed::Empty => {}
|
||||
@@ -272,6 +284,7 @@ fn close(state: &mut AppState) {
|
||||
state.cmdline.active = false;
|
||||
state.cmdline.input.clear();
|
||||
state.cmdline.live = false;
|
||||
state.cmdline.begin_history_navigation();
|
||||
}
|
||||
|
||||
/// Pop the live search view if this command-line session opened it.
|
||||
|
||||
@@ -56,6 +56,8 @@ pub enum AppEvent {
|
||||
LocalContentIdsLoaded(Result<Vec<String>, String>),
|
||||
/// Counts and storage footprint of the local library/database.
|
||||
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
|
||||
MusicDirectoryValidated(Result<std::path::PathBuf, String>),
|
||||
MusicDirectoryChanged(Result<crate::library::MusicRelocationStats, String>),
|
||||
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
|
||||
/// One content id became available locally while the UI is open.
|
||||
LocalContentAvailable {
|
||||
|
||||
+155
-7
@@ -8,6 +8,7 @@ pub mod state;
|
||||
pub mod update;
|
||||
|
||||
use std::io;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
@@ -61,6 +62,8 @@ pub struct Runtime {
|
||||
pub art_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// The terminal screen was externally disturbed and needs a full repaint.
|
||||
pub force_redraw: bool,
|
||||
/// The alternate screen is temporarily suspended for copy-friendly text.
|
||||
pub plain_text_mode: bool,
|
||||
/// Monotonic sequence for live search; stale responses are dropped.
|
||||
pub search_seq: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub player: player::Controller,
|
||||
@@ -106,6 +109,15 @@ fn refresh_local_library_stats(runtime: &Runtime) {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn validate_music_directory(state: &mut AppState, runtime: &Runtime, path: PathBuf) {
|
||||
state.status_message = Some("checking music directory write access…".into());
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = Library::validate_music_directory(&path).map_err(err_string);
|
||||
let _ = tx.send(AppEvent::MusicDirectoryValidated(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String) {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
@@ -244,6 +256,7 @@ pub async fn run(
|
||||
};
|
||||
state.player.volume = settings.volume;
|
||||
state.global.filters = settings.library;
|
||||
state.music_dir = settings.music_dir.clone();
|
||||
if let Err(err) = state.visualizer.load_library() {
|
||||
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
||||
}
|
||||
@@ -255,6 +268,7 @@ pub async fn run(
|
||||
Arc::clone(&library),
|
||||
Arc::clone(&devices),
|
||||
Arc::clone(&jam),
|
||||
settings.music_dir.clone(),
|
||||
);
|
||||
state.federation.settings = federation.settings();
|
||||
state.federation.devices = Some(devices.status());
|
||||
@@ -288,6 +302,7 @@ pub async fn run(
|
||||
fed_streaming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
|
||||
force_redraw: false,
|
||||
plain_text_mode: false,
|
||||
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
player: player::spawn(move |event| {
|
||||
let _ = player_events.send(AppEvent::Player(event));
|
||||
@@ -318,11 +333,30 @@ pub async fn run(
|
||||
visual_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
if runtime.force_redraw {
|
||||
terminal.clear()?;
|
||||
runtime.force_redraw = false;
|
||||
let plain_text = match state.popup.as_ref() {
|
||||
Some(state::Popup::PlainText { text }) => Some(text.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
match (runtime.plain_text_mode, plain_text) {
|
||||
(false, Some(text)) => {
|
||||
enter_plain_text_mode(text)?;
|
||||
runtime.plain_text_mode = true;
|
||||
}
|
||||
(true, None) => {
|
||||
leave_plain_text_mode()?;
|
||||
runtime.plain_text_mode = false;
|
||||
runtime.force_redraw = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !runtime.plain_text_mode {
|
||||
if runtime.force_redraw {
|
||||
terminal.clear()?;
|
||||
runtime.force_redraw = false;
|
||||
}
|
||||
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
|
||||
}
|
||||
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
|
||||
|
||||
tokio::select! {
|
||||
maybe_event = input.next() => match maybe_event {
|
||||
@@ -347,6 +381,10 @@ pub async fn run(
|
||||
}
|
||||
|
||||
if state.should_quit {
|
||||
if runtime.plain_text_mode {
|
||||
leave_plain_text_mode()?;
|
||||
runtime.plain_text_mode = false;
|
||||
}
|
||||
state.shutting_down = true;
|
||||
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
|
||||
runtime.federation.shutdown().await;
|
||||
@@ -356,6 +394,32 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_plain_text_mode(text: &str) -> Result<()> {
|
||||
let text: String = text
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.collect();
|
||||
crossterm::execute!(
|
||||
io::stdout(),
|
||||
crossterm::terminal::LeaveAlternateScreen,
|
||||
crossterm::event::DisableBracketedPaste,
|
||||
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
|
||||
crossterm::cursor::MoveTo(0, 0),
|
||||
crossterm::style::Print(text)
|
||||
)?;
|
||||
io::stdout().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leave_plain_text_mode() -> Result<()> {
|
||||
crossterm::execute!(
|
||||
io::stdout(),
|
||||
crossterm::terminal::EnterAlternateScreen,
|
||||
crossterm::event::EnableBracketedPaste
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
|
||||
if state.device_playback.is_control() {
|
||||
extrapolate_control_position(state);
|
||||
@@ -453,6 +517,7 @@ fn apply_playback_state_to_ui(
|
||||
.filter(|track| update::track_allowed_by_source_mode(state, track))
|
||||
.collect();
|
||||
state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1));
|
||||
state.player.play_next_end = None;
|
||||
state.player.playing = wire.playing && !state.player.queue.is_empty();
|
||||
state.player.paused = wire.paused;
|
||||
state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused {
|
||||
@@ -1331,6 +1396,14 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
}
|
||||
Effect::SetOptions => {}
|
||||
Effect::PlaybackQueueChanged => {}
|
||||
Effect::QueueOrderChanged { restart_current } => {
|
||||
if restart_current && state.player.playing && state.player.current.is_some() {
|
||||
let paused = state.player.paused;
|
||||
start_current_audio(state, runtime, state.player.position_secs, paused);
|
||||
push_media_metadata(state, runtime);
|
||||
push_media_update(state, runtime, true);
|
||||
}
|
||||
}
|
||||
Effect::SourceModeChanged => {
|
||||
runtime.library_network_refresh_at = None;
|
||||
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
|
||||
@@ -1349,6 +1422,30 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
perform_effect(state, runtime, effect);
|
||||
}
|
||||
}
|
||||
Effect::ChangeMusicDirectory {
|
||||
path,
|
||||
move_existing,
|
||||
} => {
|
||||
if state.music_dir_changing {
|
||||
state.status_message = Some("music directory change is already running".into());
|
||||
return;
|
||||
}
|
||||
state.music_dir_changing = true;
|
||||
state.status_message = Some(if move_existing {
|
||||
"moving saved music to the new directory…".into()
|
||||
} else {
|
||||
"changing music save directory…".into()
|
||||
});
|
||||
let federation = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = federation
|
||||
.change_media_dir(path, move_existing)
|
||||
.await
|
||||
.map_err(err_string);
|
||||
let _ = tx.send(AppEvent::MusicDirectoryChanged(result));
|
||||
});
|
||||
}
|
||||
Effect::EnqueueRelease { id, next } => {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
@@ -1733,6 +1830,7 @@ fn is_controlled_playback_effect(effect: &Effect) -> bool {
|
||||
| Effect::SetOptions
|
||||
| Effect::RemoveQueueIndices { .. }
|
||||
| Effect::PlaybackQueueChanged
|
||||
| Effect::QueueOrderChanged { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1766,7 +1864,9 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
|
||||
Effect::SetOptions
|
||||
| Effect::RemoveQueueIndices { .. }
|
||||
| Effect::PlaybackQueueChanged
|
||||
| Effect::LoadListenHistory => {}
|
||||
| Effect::QueueOrderChanged { .. }
|
||||
| Effect::LoadListenHistory
|
||||
| Effect::ChangeMusicDirectory { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
if local_only_volume {
|
||||
@@ -2678,6 +2778,7 @@ fn save_app_settings(state: &AppState) {
|
||||
let settings = crate::config::settings::AppSettings {
|
||||
volume: state.player.volume,
|
||||
library: state.global.filters,
|
||||
music_dir: state.music_dir.clone(),
|
||||
};
|
||||
if let Err(err) = crate::config::settings::save(&settings) {
|
||||
tracing::warn!(%err, "saving app settings failed");
|
||||
@@ -2939,6 +3040,44 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
Err(err) => state::Loadable::Failed(err),
|
||||
});
|
||||
}
|
||||
AppEvent::MusicDirectoryValidated(result) => match result {
|
||||
Ok(path) => {
|
||||
let current = std::fs::canonicalize(&state.music_dir)
|
||||
.unwrap_or_else(|_| state.music_dir.clone());
|
||||
if path == current {
|
||||
state.status_message = Some("this is already the music save directory".into());
|
||||
} else {
|
||||
state.popup = Some(state::Popup::ConfirmMusicDirectory { path });
|
||||
state.status_message = None;
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
state.status_message = Some(format!(
|
||||
"music directory is not writable; nothing changed: {message}"
|
||||
));
|
||||
}
|
||||
},
|
||||
AppEvent::MusicDirectoryChanged(result) => {
|
||||
state.music_dir_changing = false;
|
||||
match result {
|
||||
Ok(stats) => {
|
||||
state.music_dir = runtime.federation.media_dir();
|
||||
save_app_settings(state);
|
||||
state.status_message = Some(format!(
|
||||
"music directory changed · moved {} track(s), {} image(s)",
|
||||
stats.tracks, stats.images
|
||||
));
|
||||
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
|
||||
message: state.status_message.clone(),
|
||||
});
|
||||
}
|
||||
Err(message) => {
|
||||
state.status_message = Some(format!(
|
||||
"music directory change failed; old library kept: {message}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::FederationStatus(status) => {
|
||||
state.federation.status = Some(status);
|
||||
}
|
||||
@@ -3018,6 +3157,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
title: "Device invite".to_string(),
|
||||
text: invite,
|
||||
help: "Use this invite on another client within 10 minutes to pair it with this device group.".to_string(),
|
||||
cursor: 0,
|
||||
});
|
||||
state.status_message = Some("device invite generated".to_string());
|
||||
}
|
||||
@@ -3117,6 +3257,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
title: "Jam invite".to_string(),
|
||||
text: invite,
|
||||
help: "Copied capability lets federation peers control this host player until restart or regeneration.".to_string(),
|
||||
cursor: 0,
|
||||
});
|
||||
state.status_message = Some("Jam started".into());
|
||||
}
|
||||
@@ -3273,6 +3414,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
// Skip the failed track instead of stalling the queue.
|
||||
if state.player.queue_pos + 1 < state.player.queue.len() {
|
||||
state.player.queue_pos += 1;
|
||||
update::normalize_play_next_block(&mut state.player);
|
||||
start_current_audio(state, runtime, 0.0, state.player.paused);
|
||||
push_media_metadata(state, runtime);
|
||||
push_media_update(state, runtime, true);
|
||||
@@ -3374,6 +3516,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
text: ticket,
|
||||
help: "Copy this ticket and paste it into Connect to a peer on another client."
|
||||
.to_string(),
|
||||
cursor: 0,
|
||||
});
|
||||
}
|
||||
Err(message) => state.status_message = Some(message),
|
||||
@@ -3516,6 +3659,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
.take()
|
||||
.unwrap_or(state.player.queue_pos + 1);
|
||||
state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1));
|
||||
update::normalize_play_next_block(&mut state.player);
|
||||
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
||||
state.player.position_secs = 0.0;
|
||||
state.player.track_started_at = Some(now_epoch_seconds());
|
||||
@@ -3648,9 +3792,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
AppEvent::EnqueueTracks { tracks, next } => {
|
||||
let previous_len = state.player.queue.len();
|
||||
update::enqueue_tracks(state, tracks, next);
|
||||
let restart_current = update::enqueue_tracks(state, tracks, next);
|
||||
let count = state.player.queue.len().saturating_sub(previous_len);
|
||||
record_control_playback_state(state, runtime, false);
|
||||
perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
Effect::QueueOrderChanged { restart_current },
|
||||
);
|
||||
state.status_message = Some(if count == 0 {
|
||||
"no tracks available in the current source mode".to_string()
|
||||
} else if next {
|
||||
|
||||
+79
-7
@@ -136,17 +136,52 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {}
|
||||
_ => state.popup = Some(Popup::FedText { title, text }),
|
||||
},
|
||||
Popup::FedCopyText { title, text, help } => match key.code {
|
||||
Popup::FedCopyText {
|
||||
title,
|
||||
text,
|
||||
help,
|
||||
mut cursor,
|
||||
} => match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||
KeyCode::Left | KeyCode::Right | KeyCode::Tab | KeyCode::BackTab => {
|
||||
cursor = usize::from(cursor == 0);
|
||||
state.popup = Some(Popup::FedCopyText {
|
||||
title,
|
||||
text,
|
||||
help,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
KeyCode::Enter if cursor == 1 => state.popup = Some(Popup::PlainText { text }),
|
||||
KeyCode::Char('p') => state.popup = Some(Popup::PlainText { text }),
|
||||
KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) {
|
||||
Ok(()) => state.status_message = Some("copied to clipboard".to_string()),
|
||||
Err(err) => {
|
||||
state.status_message = Some(format!("copy failed: {err}"));
|
||||
state.popup = Some(Popup::FedCopyText { title, text, help });
|
||||
state.popup = Some(Popup::FedCopyText {
|
||||
title,
|
||||
text,
|
||||
help,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
},
|
||||
_ => state.popup = Some(Popup::FedCopyText { title, text, help }),
|
||||
_ => {
|
||||
state.popup = Some(Popup::FedCopyText {
|
||||
title,
|
||||
text,
|
||||
help,
|
||||
cursor,
|
||||
})
|
||||
}
|
||||
},
|
||||
Popup::PlainText { text } => match key.code {
|
||||
KeyCode::Esc => {}
|
||||
_ => state.popup = Some(Popup::PlainText { text }),
|
||||
},
|
||||
Popup::ConfirmMusicDirectory { path } => {
|
||||
handle_music_directory_confirmation(state, runtime, path, key)
|
||||
}
|
||||
Popup::FederationStatusDetails {
|
||||
focus,
|
||||
status_cursor,
|
||||
@@ -502,6 +537,13 @@ fn handle_fed_input(
|
||||
!state.federation.settings.network_id.is_empty();
|
||||
super::fed_apply_settings(state, runtime);
|
||||
}
|
||||
FedInputField::MusicDirectory => {
|
||||
if value.is_empty() {
|
||||
state.status_message = Some("music directory is empty".into());
|
||||
} else {
|
||||
super::validate_music_directory(state, runtime, value.into());
|
||||
}
|
||||
}
|
||||
FedInputField::ConnectTicket => {
|
||||
if value.is_empty() {
|
||||
state.status_message = Some("ticket is empty".into());
|
||||
@@ -551,6 +593,31 @@ fn handle_fed_input(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_music_directory_confirmation(
|
||||
state: &mut AppState,
|
||||
runtime: &mut Runtime,
|
||||
path: std::path::PathBuf,
|
||||
key: KeyEvent,
|
||||
) {
|
||||
let move_existing = match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => return,
|
||||
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => true,
|
||||
KeyCode::Char('n') | KeyCode::Char('N') => false,
|
||||
_ => {
|
||||
state.popup = Some(Popup::ConfirmMusicDirectory { path });
|
||||
return;
|
||||
}
|
||||
};
|
||||
super::perform_effect(
|
||||
state,
|
||||
runtime,
|
||||
crate::app::update::Effect::ChangeMusicDirectory {
|
||||
path,
|
||||
move_existing,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_device_pairing(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
@@ -934,10 +1001,15 @@ fn handle_track_info(
|
||||
KeyCode::Char('c') => {
|
||||
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
|
||||
match crate::share::track_share_link(track) {
|
||||
Some(link) => match copy_to_clipboard(&link) {
|
||||
Ok(()) => state.status_message = Some("frid link copied".into()),
|
||||
Err(err) => state.status_message = Some(format!("copy failed: {err}")),
|
||||
},
|
||||
Some(link) => {
|
||||
state.popup = Some(Popup::FedCopyText {
|
||||
title: "Track share link".into(),
|
||||
text: link,
|
||||
help: "Copy the link with a clipboard helper, or show it as one clean terminal line for mouse selection.".into(),
|
||||
cursor: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
None => state.status_message = Some("no content id for this track yet".into()),
|
||||
}
|
||||
}
|
||||
|
||||
+116
-1
@@ -389,6 +389,7 @@ mod tests {
|
||||
featured_tracks: Vec::new(),
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
|
||||
state.artist_fed_views.insert(
|
||||
detail.id,
|
||||
Loadable::Ready(crate::federation::FedArtistCard {
|
||||
@@ -716,7 +717,16 @@ pub enum Popup {
|
||||
title: String,
|
||||
text: String,
|
||||
help: String,
|
||||
cursor: usize,
|
||||
},
|
||||
/// A copy-friendly terminal screen containing exactly one logical line.
|
||||
/// The app loop temporarily leaves the alternate screen while this is
|
||||
/// active so terminal selection does not acquire TUI borders or hard
|
||||
/// line breaks.
|
||||
PlainText { text: String },
|
||||
/// The destination is already write-tested; Enter/y migrates managed
|
||||
/// files, while n changes only the destination for future downloads.
|
||||
ConfirmMusicDirectory { path: std::path::PathBuf },
|
||||
/// Incoming trusted-device pairing request.
|
||||
DevicePairing {
|
||||
request_id: String,
|
||||
@@ -801,6 +811,7 @@ impl StatusDetailFocus {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FedInputField {
|
||||
MusicDirectory,
|
||||
NetworkId,
|
||||
ConnectTicket,
|
||||
DeviceName,
|
||||
@@ -811,6 +822,7 @@ pub enum FedInputField {
|
||||
impl FedInputField {
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
FedInputField::MusicDirectory => "Music save directory",
|
||||
FedInputField::NetworkId => "Network ID",
|
||||
FedInputField::ConnectTicket => "Connect to peer (paste ticket)",
|
||||
FedInputField::DeviceName => "Device name",
|
||||
@@ -821,6 +833,9 @@ impl FedInputField {
|
||||
|
||||
pub fn help(self) -> &'static str {
|
||||
match self {
|
||||
FedInputField::MusicDirectory => {
|
||||
"Federated tracks saved to your library use this directory. The directory is checked for write access before anything changes."
|
||||
}
|
||||
FedInputField::NetworkId => {
|
||||
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
|
||||
}
|
||||
@@ -867,6 +882,7 @@ impl FedRow {
|
||||
/// the config directory.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsRow {
|
||||
MusicDirectory,
|
||||
Federation(FedRow),
|
||||
StatusDetails,
|
||||
DeviceName,
|
||||
@@ -1004,7 +1020,7 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
|
||||
}
|
||||
|
||||
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
|
||||
let mut rows = Vec::new();
|
||||
let mut rows = vec![SettingsRow::MusicDirectory];
|
||||
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
|
||||
rows.push(SettingsRow::DeviceName);
|
||||
rows.push(SettingsRow::DeviceInvite);
|
||||
@@ -1064,6 +1080,98 @@ pub struct Cmdline {
|
||||
/// A live command (search) applied effects during this session; Esc
|
||||
/// undoes them, Enter keeps them.
|
||||
pub live: bool,
|
||||
/// Commands committed during this process, oldest first.
|
||||
pub history: Vec<String>,
|
||||
/// Entry currently recalled with Up/Down. `None` is the editable draft
|
||||
/// after the newest history entry.
|
||||
history_index: Option<usize>,
|
||||
history_draft: String,
|
||||
}
|
||||
|
||||
impl Cmdline {
|
||||
const HISTORY_LIMIT: usize = 100;
|
||||
|
||||
pub fn begin_history_navigation(&mut self) {
|
||||
self.history_index = None;
|
||||
self.history_draft.clear();
|
||||
}
|
||||
|
||||
pub fn remember(&mut self, value: &str) {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.history.last().is_none_or(|last| last != value) {
|
||||
self.history.push(value.to_string());
|
||||
if self.history.len() > Self::HISTORY_LIMIT {
|
||||
self.history.remove(0);
|
||||
}
|
||||
}
|
||||
self.begin_history_navigation();
|
||||
}
|
||||
|
||||
pub fn history_previous(&mut self) -> bool {
|
||||
if self.history.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let index = match self.history_index {
|
||||
Some(index) => index.saturating_sub(1),
|
||||
None => {
|
||||
self.history_draft = self.input.as_str().to_string();
|
||||
self.history.len() - 1
|
||||
}
|
||||
};
|
||||
self.history_index = Some(index);
|
||||
self.input = LineEdit::new(self.history[index].clone());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn history_next(&mut self) -> bool {
|
||||
let Some(index) = self.history_index else {
|
||||
return false;
|
||||
};
|
||||
if index + 1 < self.history.len() {
|
||||
self.history_index = Some(index + 1);
|
||||
self.input = LineEdit::new(self.history[index + 1].clone());
|
||||
} else {
|
||||
self.history_index = None;
|
||||
self.input = LineEdit::new(std::mem::take(&mut self.history_draft));
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cmdline_history_tests {
|
||||
use super::Cmdline;
|
||||
use crate::app::input::LineEdit;
|
||||
|
||||
#[test]
|
||||
fn history_walks_oldest_and_restores_the_draft() {
|
||||
let mut cmdline = Cmdline::default();
|
||||
cmdline.remember("volume 20");
|
||||
cmdline.remember("/ambient");
|
||||
cmdline.input = LineEdit::new("unfinished");
|
||||
|
||||
assert!(cmdline.history_previous());
|
||||
assert_eq!(cmdline.input.as_str(), "/ambient");
|
||||
assert!(cmdline.history_previous());
|
||||
assert_eq!(cmdline.input.as_str(), "volume 20");
|
||||
assert!(cmdline.history_previous());
|
||||
assert_eq!(cmdline.input.as_str(), "volume 20");
|
||||
assert!(cmdline.history_next());
|
||||
assert_eq!(cmdline.input.as_str(), "/ambient");
|
||||
assert!(cmdline.history_next());
|
||||
assert_eq!(cmdline.input.as_str(), "unfinished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_deduplicates_consecutive_commands() {
|
||||
let mut cmdline = Cmdline::default();
|
||||
cmdline.remember("q");
|
||||
cmdline.remember(" q ");
|
||||
assert_eq!(cmdline.history, vec!["q"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live search state driven by the `:/query` command.
|
||||
@@ -1169,6 +1277,9 @@ impl RepeatMode {
|
||||
pub struct PlayerBar {
|
||||
pub queue: Vec<TrackItem>,
|
||||
pub queue_pos: usize,
|
||||
/// Exclusive end of the contiguous "play next" block built by
|
||||
/// sequential `a` actions. New `a` additions are inserted here.
|
||||
pub play_next_end: Option<usize>,
|
||||
pub current: Option<TrackItem>,
|
||||
/// A track is loaded (playing or paused); false = stopped.
|
||||
pub playing: bool,
|
||||
@@ -1194,6 +1305,7 @@ impl Default for PlayerBar {
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
queue_pos: 0,
|
||||
play_next_end: None,
|
||||
current: None,
|
||||
playing: false,
|
||||
paused: false,
|
||||
@@ -1283,6 +1395,9 @@ pub struct AppState {
|
||||
pub status_message: Option<String>,
|
||||
pub spinner_frame: usize,
|
||||
pub settings_cursor: usize,
|
||||
/// Root for music permanently downloaded from federation peers.
|
||||
pub music_dir: std::path::PathBuf,
|
||||
pub music_dir_changing: bool,
|
||||
pub player: PlayerBar,
|
||||
pub device_playback: DevicePlaybackState,
|
||||
pub jam: crate::jam::JamStatus,
|
||||
|
||||
+131
-11
@@ -48,8 +48,19 @@ pub enum Effect {
|
||||
},
|
||||
/// Queue/options changed without a direct audio engine action.
|
||||
PlaybackQueueChanged,
|
||||
/// Queue order changed. A decoded gapless-next source must be discarded
|
||||
/// by restarting the current source at its current position.
|
||||
QueueOrderChanged {
|
||||
restart_current: bool,
|
||||
},
|
||||
/// Persist and apply a Local / My / Global source-mode change.
|
||||
SourceModeChanged,
|
||||
/// Switch the permanent federation-download root, optionally relocating
|
||||
/// files managed under the previous root first.
|
||||
ChangeMusicDirectory {
|
||||
path: std::path::PathBuf,
|
||||
move_existing: bool,
|
||||
},
|
||||
/// Persist the federation settings and start/stop the node.
|
||||
FedApplySettings,
|
||||
/// Force an immediate library publish into the DHT.
|
||||
@@ -180,6 +191,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
}
|
||||
Action::ToggleShuffle => {
|
||||
state.player.shuffle = !state.player.shuffle;
|
||||
state.player.play_next_end = None;
|
||||
// Shuffle physically reorders the unplayed tail, so the Queue
|
||||
// tab always shows the real upcoming order; turning it off
|
||||
// restores the original ordering.
|
||||
@@ -275,6 +287,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
Action::OpenCommandLine => {
|
||||
state.cmdline.active = true;
|
||||
state.cmdline.input.clear();
|
||||
state.cmdline.begin_history_navigation();
|
||||
None
|
||||
}
|
||||
Action::OpenSearch => {
|
||||
@@ -283,6 +296,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
state.cmdline.active = true;
|
||||
state.cmdline.input = crate::app::input::LineEdit::new("/");
|
||||
state.cmdline.live = true;
|
||||
state.cmdline.begin_history_navigation();
|
||||
state.search = SearchState::default();
|
||||
state.active_tab = Tab::Global;
|
||||
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
|
||||
@@ -371,6 +385,8 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
||||
Action::QueueAddNext => queue_add(state, true),
|
||||
Action::QueueAddLast => queue_add(state, false),
|
||||
Action::MoveQueueUp => move_selected_queue(state, true),
|
||||
Action::MoveQueueDown => move_selected_queue(state, false),
|
||||
Action::GoToRelease => {
|
||||
let track = selected_track(state).or_else(|| state.player.current.clone());
|
||||
match track {
|
||||
@@ -418,6 +434,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
let had_tracks = !state.player.queue.is_empty();
|
||||
state.player.queue.clear();
|
||||
state.player.queue_pos = 0;
|
||||
state.player.play_next_end = None;
|
||||
state.player.current = None;
|
||||
state.player.playing = false;
|
||||
state.player.paused = false;
|
||||
@@ -1030,6 +1047,68 @@ fn selected_queue_indices(state: &AppState) -> Vec<usize> {
|
||||
.unwrap_or_else(|| vec![state.queue_tab.cursor.min(state.player.queue.len() - 1)])
|
||||
}
|
||||
|
||||
fn move_selected_queue(state: &mut AppState, up: bool) -> Option<Effect> {
|
||||
let indices = selected_queue_indices(state);
|
||||
let Some(&start) = indices.first() else {
|
||||
state.status_message = Some("queue is empty".into());
|
||||
return None;
|
||||
};
|
||||
let end = *indices.last().unwrap_or(&start);
|
||||
let len = state.player.queue.len();
|
||||
if (up && start == 0) || (!up && end + 1 >= len) {
|
||||
state.status_message = Some(if up {
|
||||
"selection is already at the top of the queue".into()
|
||||
} else {
|
||||
"selection is already at the bottom of the queue".into()
|
||||
});
|
||||
return None;
|
||||
}
|
||||
|
||||
let old_current = state.player.queue_pos;
|
||||
let new_current = if up {
|
||||
let displaced = state.player.queue.remove(start - 1);
|
||||
state.player.queue.insert(end, displaced);
|
||||
if old_current == start - 1 {
|
||||
end
|
||||
} else if (start..=end).contains(&old_current) {
|
||||
old_current - 1
|
||||
} else {
|
||||
old_current
|
||||
}
|
||||
} else {
|
||||
let displaced = state.player.queue.remove(end + 1);
|
||||
state.player.queue.insert(start, displaced);
|
||||
if old_current == end + 1 {
|
||||
start
|
||||
} else if (start..=end).contains(&old_current) {
|
||||
old_current + 1
|
||||
} else {
|
||||
old_current
|
||||
}
|
||||
};
|
||||
let delta: isize = if up { -1 } else { 1 };
|
||||
state.player.queue_pos = new_current;
|
||||
state.queue_tab.cursor = (state.queue_tab.cursor as isize + delta) as usize;
|
||||
if state
|
||||
.track_selection
|
||||
.is_active_for(&TrackSelectionScope::Queue)
|
||||
{
|
||||
state.track_selection.anchor = (state.track_selection.anchor as isize + delta) as usize;
|
||||
state.track_selection.cursor = (state.track_selection.cursor as isize + delta) as usize;
|
||||
}
|
||||
// An explicit manual order supersedes both the temporary play-next block
|
||||
// and a saved pre-shuffle order.
|
||||
state.player.play_next_end = None;
|
||||
state.player.original_order = None;
|
||||
let restart_current = state.player.prefetched_pos.take().is_some();
|
||||
state.status_message = Some(format!(
|
||||
"moved {} track(s) {}",
|
||||
indices.len(),
|
||||
if up { "up" } else { "down" }
|
||||
));
|
||||
Some(Effect::QueueOrderChanged { restart_current })
|
||||
}
|
||||
|
||||
fn remove_selected_from_queue(state: &mut AppState) -> Option<Effect> {
|
||||
let indices = selected_queue_indices(state);
|
||||
if indices.is_empty() {
|
||||
@@ -1081,6 +1160,10 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
||||
.iter()
|
||||
.filter(|index| **index < old_queue_pos)
|
||||
.count();
|
||||
if let Some(end) = state.player.play_next_end {
|
||||
let removed_before_end = unique.iter().filter(|index| **index < end).count();
|
||||
state.player.play_next_end = Some(end.saturating_sub(removed_before_end));
|
||||
}
|
||||
let was_loaded = state.player.playing;
|
||||
let was_paused = state.player.paused;
|
||||
|
||||
@@ -1107,6 +1190,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
||||
state.player.track_started_at = None;
|
||||
state.player.listen_id = None;
|
||||
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||
normalize_play_next_block(&mut state.player);
|
||||
return QueueRemovalOutcome {
|
||||
restart_paused: was_loaded.then_some(was_paused),
|
||||
stop: false,
|
||||
@@ -1134,6 +1218,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
||||
.cloned()
|
||||
});
|
||||
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||
normalize_play_next_block(&mut state.player);
|
||||
QueueRemovalOutcome {
|
||||
restart_paused: None,
|
||||
stop: false,
|
||||
@@ -1267,7 +1352,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
||||
if !tracks.is_empty() {
|
||||
let count = tracks.len();
|
||||
let title = tracks[0].title.clone();
|
||||
enqueue_tracks(state, tracks, next);
|
||||
let restart_current = enqueue_tracks(state, tracks, next);
|
||||
state.track_selection.clear();
|
||||
state.status_message = Some(if count == 1 && next {
|
||||
format!("queued next: {title}")
|
||||
@@ -1278,7 +1363,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
||||
} else {
|
||||
format!("queued: {count} tracks")
|
||||
});
|
||||
return Some(Effect::PlaybackQueueChanged);
|
||||
return Some(Effect::QueueOrderChanged { restart_current });
|
||||
}
|
||||
if let Some(id) = selected_release_id(state) {
|
||||
return Some(Effect::EnqueueRelease { id, next });
|
||||
@@ -1372,19 +1457,22 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models
|
||||
refs
|
||||
}
|
||||
|
||||
/// Insert tracks after the playing one (`next`) or at the end. Keeps the
|
||||
/// gapless prefetch index pointing at the same track if items shift.
|
||||
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) {
|
||||
/// Insert tracks into the stable "play next" block (`next`) or at the end.
|
||||
/// Returns whether a decoded gapless-next source became stale.
|
||||
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) -> bool {
|
||||
let tracks: Vec<_> = tracks
|
||||
.into_iter()
|
||||
.filter(|track| track_allowed_by_source_mode(state, track))
|
||||
.collect();
|
||||
let player = &mut state.player;
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let insert_at = if next && !player.queue.is_empty() {
|
||||
(player.queue_pos + 1).min(player.queue.len())
|
||||
player
|
||||
.play_next_end
|
||||
.filter(|end| *end >= player.queue_pos.saturating_add(1) && *end <= player.queue.len())
|
||||
.unwrap_or_else(|| (player.queue_pos + 1).min(player.queue.len()))
|
||||
} else if next {
|
||||
0
|
||||
} else {
|
||||
@@ -1394,14 +1482,28 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
|
||||
for (offset, track) in tracks.into_iter().enumerate() {
|
||||
player.queue.insert(insert_at + offset, track);
|
||||
}
|
||||
if let Some(prefetched) = &mut player.prefetched_pos
|
||||
&& insert_at <= *prefetched
|
||||
{
|
||||
*prefetched += count;
|
||||
let restart_current = player
|
||||
.prefetched_pos
|
||||
.is_some_and(|prefetched| insert_at <= prefetched);
|
||||
if restart_current {
|
||||
player.prefetched_pos = None;
|
||||
}
|
||||
if insert_at <= player.queue_pos && player.current.is_some() {
|
||||
player.queue_pos += count;
|
||||
}
|
||||
if next {
|
||||
player.play_next_end = Some(insert_at + count);
|
||||
}
|
||||
restart_current
|
||||
}
|
||||
|
||||
pub(super) fn normalize_play_next_block(player: &mut super::state::PlayerBar) {
|
||||
if player
|
||||
.play_next_end
|
||||
.is_some_and(|end| end <= player.queue_pos || end > player.queue.len())
|
||||
{
|
||||
player.play_next_end = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual queue navigation (n / p); the tail is pre-shuffled when shuffle
|
||||
@@ -1426,6 +1528,7 @@ fn queue_step(state: &mut AppState, direction: isize) -> Option<Effect> {
|
||||
} else {
|
||||
player.queue_pos = next as usize;
|
||||
}
|
||||
normalize_play_next_block(player);
|
||||
Some(Effect::PlayCurrent)
|
||||
}
|
||||
|
||||
@@ -1464,9 +1567,11 @@ pub fn advance_after_finish(state: &mut AppState) -> Option<Effect> {
|
||||
repeat => {
|
||||
if player.queue_pos + 1 < player.queue.len() {
|
||||
player.queue_pos += 1;
|
||||
normalize_play_next_block(player);
|
||||
Some(Effect::PlayCurrent)
|
||||
} else if repeat == super::state::RepeatMode::All {
|
||||
player.queue_pos = 0;
|
||||
player.play_next_end = None;
|
||||
Some(Effect::PlayCurrent)
|
||||
} else {
|
||||
player.playing = false;
|
||||
@@ -2076,6 +2181,7 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
return None;
|
||||
}
|
||||
state.player.queue_pos = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||
normalize_play_next_block(&mut state.player);
|
||||
return Some(Effect::PlayCurrent);
|
||||
}
|
||||
if state.active_tab != Tab::Global {
|
||||
@@ -2616,6 +2722,19 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
|
||||
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
use super::state::{FedInputField, FedRow, Popup, SettingsRow};
|
||||
match settings_rows(state).get(state.settings_cursor).copied()? {
|
||||
SettingsRow::MusicDirectory => {
|
||||
if state.music_dir_changing {
|
||||
state.status_message = Some("music directory change is already running".into());
|
||||
return None;
|
||||
}
|
||||
state.popup = Some(Popup::FedInput {
|
||||
field: FedInputField::MusicDirectory,
|
||||
input: crate::app::input::LineEdit::new(
|
||||
state.music_dir.to_string_lossy().into_owned(),
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
SettingsRow::Federation(FedRow::Toggle) => {
|
||||
let settings = &mut state.federation.settings;
|
||||
if !settings.enabled && settings.network_id.trim().is_empty() {
|
||||
@@ -2789,6 +2908,7 @@ fn require_connected_devices_enabled(state: &mut AppState) -> bool {
|
||||
pub(super) fn on_new_queue(state: &mut AppState) {
|
||||
let player = &mut state.player;
|
||||
player.original_order = None;
|
||||
player.play_next_end = None;
|
||||
if player.shuffle && !player.queue.is_empty() {
|
||||
player.original_order = Some(player.queue.iter().map(track_key).collect());
|
||||
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
|
||||
|
||||
+68
-1
@@ -457,6 +457,7 @@ fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
|
||||
active_tab: Tab::Playlists,
|
||||
..AppState::default()
|
||||
};
|
||||
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
|
||||
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
|
||||
state.playlist_views.insert(
|
||||
7,
|
||||
@@ -512,6 +513,7 @@ fn network_modes_show_pending_federation_playlist_tracks() {
|
||||
#[test]
|
||||
fn local_mode_rejects_async_federation_queue_additions() {
|
||||
let mut state = AppState::default();
|
||||
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
|
||||
|
||||
enqueue_tracks(
|
||||
&mut state,
|
||||
@@ -732,13 +734,78 @@ fn artist_top_track_selection_queues_all_selected_tracks() {
|
||||
update(&mut state, Action::MoveDown);
|
||||
assert_eq!(
|
||||
update(&mut state, Action::QueueAddLast),
|
||||
Some(Effect::PlaybackQueueChanged),
|
||||
Some(Effect::QueueOrderChanged {
|
||||
restart_current: false,
|
||||
}),
|
||||
);
|
||||
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||
assert_eq!(queued, vec![1, 2]);
|
||||
assert!(!state.track_selection.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_queue_next_additions_keep_their_order_as_one_block() {
|
||||
let mut state = AppState::default();
|
||||
state.player.queue = (10..=13).map(test_track).collect();
|
||||
state.player.queue_pos = 0;
|
||||
state.player.current = Some(test_track(10));
|
||||
|
||||
assert!(!enqueue_tracks(&mut state, vec![test_track(1)], true));
|
||||
assert!(!enqueue_tracks(&mut state, vec![test_track(2)], true));
|
||||
assert!(!enqueue_tracks(&mut state, vec![test_track(3)], true));
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![10, 1, 2, 3, 11, 12, 13]
|
||||
);
|
||||
assert_eq!(state.player.play_next_end, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_selection_moves_as_a_group_and_preserves_current_track() {
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.queue = (1..=5).map(test_track).collect();
|
||||
state.player.queue_pos = 0;
|
||||
state.player.current = Some(test_track(1));
|
||||
state.queue_tab.cursor = 2;
|
||||
state.track_selection.start(TrackSelectionScope::Queue, 2);
|
||||
state
|
||||
.track_selection
|
||||
.set_cursor(TrackSelectionScope::Queue, 3);
|
||||
|
||||
assert_eq!(
|
||||
update(&mut state, Action::MoveQueueUp),
|
||||
Some(Effect::QueueOrderChanged {
|
||||
restart_current: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3, 4, 2, 5]
|
||||
);
|
||||
assert_eq!(state.player.queue_pos, 0);
|
||||
assert_eq!(state.queue_tab.cursor, 1);
|
||||
assert_eq!(
|
||||
state
|
||||
.track_selection
|
||||
.indices(&TrackSelectionScope::Queue, 5),
|
||||
Some(vec![1, 2])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_current_queue_track_requests_paused_restart() {
|
||||
let mut state = AppState {
|
||||
|
||||
Reference in New Issue
Block a user