src/app/mod.rs (30039B)
1 pub mod handler;
2 pub mod persist;
3 pub mod state;
4
5 use std::collections::HashMap;
6 use std::path::PathBuf;
7
8 use crossbeam_channel::Sender;
9
10 use crate::audio::{AudioEngine, PlayerCommand};
11 use crate::event::Event;
12 use crate::library::Library;
13 use state::*;
14
15 /// Give up after this many undecodable files in a row rather than walking the
16 /// whole queue at decoder speed.
17 const MAX_CONSECUTIVE_FAILURES: u32 = 5;
18
19 #[derive(Debug, Clone)]
20 pub enum AppAction {
21 Quit,
22 PauseResume,
23 NextTrack,
24 PrevTrack,
25 SetVolume(f32),
26 VolumeUp,
27 VolumeDown,
28 Seek(f64),
29 SeekForward,
30 SeekBackward,
31 ToggleShuffle,
32 CycleRepeat,
33 SwitchTab(Tab),
34 FocusNext,
35 FocusPrev,
36 FocusPane(FocusedPane),
37 /// Append to the queue, leaving what is playing alone.
38 AddToQueue(Vec<usize>),
39 /// Discard the queue and start over from the given tracks.
40 ReplaceQueue(Vec<usize>),
41 ClearQueue,
42 RemoveFromQueue(usize),
43 PlayQueueIndex(usize),
44 UpdatePosition { position_secs: f64, duration_secs: f64 },
45 TrackFinished,
46 /// The engine could not decode the current track.
47 TrackFailed,
48 SetQueueSelection(usize),
49 AddToPlaylist { playlist_idx: usize, track_idx: usize },
50 RemoveFromPlaylist { playlist_idx: usize, track_idx: usize },
51 CreatePlaylist(String),
52 DeletePlaylist(usize),
53 RenamePlaylist { idx: usize, name: String },
54 LibrarySync,
55 }
56
57 pub struct App {
58 pub should_quit: bool,
59 pub tab: Tab,
60 pub focus: FocusedPane,
61 pub playback: PlaybackState,
62 pub queue: QueueState,
63 pub library: Library,
64 pub music_dir: PathBuf,
65 pub playlists: Vec<state::Playlist>,
66 pub sync_state: SyncState,
67 pub initial_scan_complete: bool,
68 /// Set when the output device could not be opened; nothing will ever play.
69 pub audio_error: Option<String>,
70 /// Undecodable files in a row. Bounded so a queue of broken files stops
71 /// instead of spinning through them forever.
72 consecutive_failures: u32,
73 /// Every track index that appears in some playlist.
74 ///
75 /// The queue pane draws a star per row and asked
76 /// `playlists.iter().any(|pl| pl.tracks.contains(..))` for each visible
77 /// row — a linear scan of every playlist, per row, per frame.
78 playlist_members: std::collections::HashSet<usize>,
79 audio_engine: Option<AudioEngine>,
80 event_tx: Option<Sender<Event>>,
81 }
82
83 impl App {
84 pub fn new(music_dir: PathBuf) -> Self {
85 Self {
86 should_quit: false,
87 tab: Tab::Queue,
88 focus: FocusedPane::Library,
89 playback: PlaybackState::default(),
90 queue: QueueState::default(),
91 library: Library::new(),
92 music_dir,
93 playlists: vec![state::Playlist::new("Bookmarks")],
94 sync_state: SyncState::Idle,
95 initial_scan_complete: false,
96 audio_error: None,
97 consecutive_failures: 0,
98 playlist_members: std::collections::HashSet::new(),
99 audio_engine: None,
100 event_tx: None,
101 }
102 }
103
104 pub fn set_event_tx(&mut self, tx: Sender<Event>) {
105 self.event_tx = Some(tx);
106 }
107
108 pub fn set_audio_engine(&mut self, engine: AudioEngine) {
109 self.audio_engine = Some(engine);
110 }
111
112 pub fn handle_action(&mut self, action: AppAction) {
113 match action {
114 AppAction::Quit => {
115 self.should_quit = true;
116 if let Some(ref engine) = self.audio_engine {
117 engine.send(PlayerCommand::Stop);
118 }
119 }
120 AppAction::PauseResume => match self.playback.state {
121 PlayState::Playing => {
122 if let Some(ref engine) = self.audio_engine {
123 engine.send(PlayerCommand::Pause);
124 }
125 self.playback.state = PlayState::Paused;
126 }
127 PlayState::Paused => {
128 if let Some(ref engine) = self.audio_engine {
129 engine.send(PlayerCommand::Resume);
130 }
131 self.playback.state = PlayState::Playing;
132 }
133 PlayState::Stopped => {
134 // Try to play current queue item
135 if let Some(idx) = self.queue.current_index {
136 self.play_queue_position(idx);
137 }
138 }
139 },
140 AppAction::NextTrack => {
141 self.advance(true);
142 }
143 AppAction::PrevTrack => {
144 self.play_prev();
145 }
146 AppAction::SetVolume(vol) => {
147 self.playback.volume = vol.clamp(0.0, 1.0);
148 if let Some(ref engine) = self.audio_engine {
149 engine.send(PlayerCommand::SetVolume(self.playback.volume));
150 }
151 }
152 AppAction::VolumeUp => {
153 let vol = (self.playback.volume + 0.05).min(1.0);
154 self.handle_action(AppAction::SetVolume(vol));
155 }
156 AppAction::VolumeDown => {
157 let vol = (self.playback.volume - 0.05).max(0.0);
158 self.handle_action(AppAction::SetVolume(vol));
159 }
160 AppAction::Seek(secs) => {
161 let clamped = secs.clamp(0.0, self.playback.duration_secs);
162 if let Some(ref engine) = self.audio_engine {
163 engine.send(PlayerCommand::Seek(clamped));
164 }
165 self.playback.position_secs = clamped;
166 }
167 AppAction::SeekForward => {
168 let pos = self.playback.position_secs + 5.0;
169 self.handle_action(AppAction::Seek(pos));
170 }
171 AppAction::SeekBackward => {
172 let pos = self.playback.position_secs - 5.0;
173 self.handle_action(AppAction::Seek(pos));
174 }
175 AppAction::ToggleShuffle => {
176 self.playback.shuffle = !self.playback.shuffle;
177 // Anchor on the current track so turning shuffle on mid-song
178 // doesn't jump, and so "previous" has somewhere to go back to.
179 self.rebuild_shuffle_order(true);
180 }
181 AppAction::CycleRepeat => {
182 self.playback.repeat = self.playback.repeat.next();
183 }
184 AppAction::SwitchTab(tab) => {
185 self.tab = tab;
186 }
187 AppAction::FocusNext => {
188 self.focus = self.focus.next();
189 }
190 AppAction::FocusPrev => {
191 self.focus = self.focus.prev();
192 }
193 AppAction::FocusPane(pane) => {
194 self.focus = pane;
195 }
196 AppAction::AddToQueue(track_indices) => {
197 let was_empty = self.queue.tracks.is_empty();
198 self.queue.tracks.extend(track_indices);
199 // Only take over the current position when there was nothing to
200 // disturb — appending must not interrupt what is playing.
201 if was_empty && !self.queue.tracks.is_empty() {
202 self.queue.current_index = Some(0);
203 }
204 self.resync_shuffle_order(true);
205 }
206 AppAction::ReplaceQueue(track_indices) => {
207 self.queue.tracks = track_indices;
208 self.queue.current_index =
209 if self.queue.tracks.is_empty() { None } else { Some(0) };
210 self.queue.selected_index = 0;
211 self.resync_shuffle_order(false);
212 }
213 AppAction::ClearQueue => {
214 self.queue.tracks.clear();
215 self.queue.current_index = None;
216 self.queue.selected_index = 0;
217 // Without this the engine keeps playing a track the queue no
218 // longer contains, and its position updates keep the clock running.
219 self.stop_playback();
220 }
221 AppAction::RemoveFromQueue(idx) => {
222 if idx >= self.queue.tracks.len() {
223 return;
224 }
225 let removed_current = self.queue.current_index == Some(idx);
226 self.queue.tracks.remove(idx);
227
228 if self.queue.tracks.is_empty() {
229 self.queue.current_index = None;
230 self.stop_playback();
231 } else if let Some(ci) = self.queue.current_index {
232 if idx < ci {
233 self.queue.current_index = Some(ci - 1);
234 } else if removed_current {
235 // The removed track is still audible. Leaving
236 // current_index alone would point it at whatever slid
237 // into the slot, and that track would then be skipped.
238 let next = ci.min(self.queue.tracks.len() - 1);
239 if !self.play_queue_position(next) {
240 self.stop_playback();
241 }
242 }
243 }
244 // Never leave the selection past the end: the highlight
245 // disappears and both `j` and `d` stop responding.
246 self.queue.selected_index = self
247 .queue
248 .selected_index
249 .min(self.queue.tracks.len().saturating_sub(1));
250 self.resync_shuffle_order(true);
251 }
252 AppAction::PlayQueueIndex(idx) => {
253 // play_queue_position only commits current_index once the track
254 // is known to resolve, so a stale row cannot move the ▶ marker
255 // while a different track keeps playing.
256 self.play_queue_position(idx);
257 }
258 AppAction::UpdatePosition { position_secs, duration_secs } => {
259 self.playback.position_secs = position_secs;
260 if duration_secs > 0.0 {
261 self.playback.duration_secs = duration_secs;
262 }
263 }
264 AppAction::TrackFinished => {
265 self.advance(false);
266 }
267 AppAction::TrackFailed => {
268 self.consecutive_failures += 1;
269 if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
270 self.consecutive_failures = 0;
271 self.stop_playback();
272 } else {
273 // Advance as though the user asked: retrying under
274 // RepeatMode::One would re-open the same broken file forever
275 // and flood the event channel.
276 self.advance(true);
277 }
278 }
279 AppAction::SetQueueSelection(idx) => {
280 if idx < self.queue.tracks.len() {
281 self.queue.selected_index = idx;
282 }
283 }
284 AppAction::AddToPlaylist { playlist_idx, track_idx } => {
285 if let Some(pl) = self.playlists.get_mut(playlist_idx) {
286 if !pl.tracks.contains(&track_idx) {
287 pl.tracks.push(track_idx);
288 }
289 }
290 self.rebuild_playlist_members();
291 }
292 AppAction::RemoveFromPlaylist { playlist_idx, track_idx } => {
293 if let Some(pl) = self.playlists.get_mut(playlist_idx) {
294 pl.tracks.retain(|&t| t != track_idx);
295 }
296 self.rebuild_playlist_members();
297 }
298 AppAction::CreatePlaylist(name) => {
299 self.playlists.push(state::Playlist::new(name));
300 }
301 AppAction::DeletePlaylist(idx) => {
302 if idx < self.playlists.len() {
303 self.playlists.remove(idx);
304 self.rebuild_playlist_members();
305 }
306 }
307 AppAction::RenamePlaylist { idx, name } => {
308 if let Some(pl) = self.playlists.get_mut(idx) {
309 pl.name = name;
310 }
311 }
312 AppAction::LibrarySync => {
313 if self.sync_state == SyncState::Scanning || !self.initial_scan_complete {
314 return;
315 }
316 self.sync_state = SyncState::Scanning;
317 if let Some(ref tx) = self.event_tx {
318 let dir = self.music_dir.clone();
319 let tx = tx.clone();
320 std::thread::spawn(move || {
321 let lib = Library::scan(&dir);
322 let _ = tx.send(Event::LibraryReady(Box::new(lib)));
323 });
324 }
325 }
326 }
327 }
328
329 pub fn replace_library(&mut self, new_lib: Library) {
330 // An empty scan against a non-empty library means the music directory
331 // went away (unmounted, renamed, permissions), not that the user deleted
332 // every file. Applying it would prune the queue and every playlist, and
333 // the pruned result is what gets written to disk on exit.
334 if new_lib.tracks.is_empty() && !self.library.tracks.is_empty() {
335 self.sync_state = SyncState::Idle;
336 return;
337 }
338
339 // Build path→new_index map
340 let path_map: HashMap<PathBuf, usize> = new_lib.tracks.iter().enumerate()
341 .map(|(i, t)| (t.path.clone(), i))
342 .collect();
343
344 // Capture current playing track path
345 let playing_path = self.queue.current_index
346 .and_then(|qi| self.queue.tracks.get(qi))
347 .and_then(|&ti| self.library.tracks.get(ti))
348 .map(|t| t.path.clone());
349
350 // Remap queue tracks
351 let new_queue_tracks: Vec<usize> = self.queue.tracks.iter()
352 .filter_map(|&old_idx| {
353 self.library.tracks.get(old_idx)
354 .and_then(|t| path_map.get(&t.path))
355 .copied()
356 })
357 .collect();
358
359 // Remap current_index: find playing track in new queue
360 let new_current = playing_path.and_then(|pp| {
361 path_map.get(&pp).and_then(|&new_ti| {
362 new_queue_tracks.iter().position(|&idx| idx == new_ti)
363 })
364 });
365
366 self.queue.tracks = new_queue_tracks;
367 // The playing file is gone from the library. Without stopping, audio
368 // carries on while current_track() returns None, so the status bar,
369 // album art and info pane all blank out mid-song.
370 if new_current.is_none() && self.queue.current_index.is_some() {
371 self.stop_playback();
372 }
373 self.queue.current_index = new_current;
374 self.queue.selected_index = self.queue.selected_index.min(
375 self.queue.tracks.len().saturating_sub(1)
376 );
377
378 // Remap playlists. Entries the new library lacks are parked in `missing`
379 // rather than dropped, and anything parked earlier is restored if it came
380 // back.
381 for pl in &mut self.playlists {
382 let mut tracks = Vec::with_capacity(pl.tracks.len());
383 let mut missing = Vec::new();
384 for &old_idx in &pl.tracks {
385 let Some(path) = self.library.tracks.get(old_idx).map(|t| &t.path) else {
386 continue;
387 };
388 match path_map.get(path) {
389 Some(&new_idx) => tracks.push(new_idx),
390 None => missing.push(path.clone()),
391 }
392 }
393 for path in pl.missing.drain(..) {
394 match path_map.get(&path) {
395 Some(&new_idx) => tracks.push(new_idx),
396 None => missing.push(path),
397 }
398 }
399 pl.tracks = tracks;
400 pl.missing = missing;
401 }
402
403 self.library = new_lib;
404 self.sync_state = SyncState::Idle;
405 self.resync_shuffle_order(true);
406 self.rebuild_playlist_members();
407 }
408
409 /// Start the track at queue position `qi`, the single place playback is
410 /// actually kicked off. Returns false when the position — or the library
411 /// index behind it — no longer exists, which is why every caller goes
412 /// through here instead of indexing `library.tracks` directly.
413 fn play_queue_position(&mut self, qi: usize) -> bool {
414 let Some((path, dur)) = self
415 .queue
416 .tracks
417 .get(qi)
418 .and_then(|&ti| self.library.tracks.get(ti))
419 .map(|t| (t.path.clone(), t.duration.as_secs_f64()))
420 else {
421 return false;
422 };
423
424 if let Some(ref engine) = self.audio_engine {
425 engine.send(PlayerCommand::Play(path));
426 }
427 self.queue.current_index = Some(qi);
428 // Keep the shuffled pass pointing at what is actually playing, so a
429 // double-clicked row becomes the new starting point for next/previous.
430 if let Some(p) = self.queue.shuffle_order.iter().position(|&q| q == qi) {
431 self.queue.shuffle_pos = p;
432 }
433 self.playback.state = PlayState::Playing;
434 self.playback.position_secs = 0.0;
435 self.playback.duration_secs = dur;
436 true
437 }
438
439 /// Tell the audio engine to stop and mark playback stopped. Setting only the
440 /// UI state leaves the engine playing and its position updates overwriting
441 /// the clock.
442 fn stop_playback(&mut self) {
443 if let Some(ref engine) = self.audio_engine {
444 engine.send(PlayerCommand::Stop);
445 }
446 self.playback.state = PlayState::Stopped;
447 self.playback.position_secs = 0.0;
448 }
449
450 /// Draw a fresh permutation of the queue positions. With `anchor_current`
451 /// the playing track is moved to the front so toggling shuffle mid-song does
452 /// not jump; on a repeat-all wrap it is not, so the new pass starts clean.
453 fn rebuild_shuffle_order(&mut self, anchor_current: bool) {
454 if !self.playback.shuffle {
455 self.queue.shuffle_order.clear();
456 self.queue.shuffle_pos = 0;
457 return;
458 }
459
460 use rand::seq::SliceRandom;
461 let mut order: Vec<usize> = (0..self.queue.tracks.len()).collect();
462 order.shuffle(&mut rand::thread_rng());
463
464 if anchor_current {
465 if let Some(cur) = self.queue.current_index {
466 if let Some(p) = order.iter().position(|&q| q == cur) {
467 order.swap(0, p);
468 }
469 }
470 }
471
472 self.queue.shuffle_order = order;
473 self.queue.shuffle_pos = 0;
474 }
475
476 /// Rebuild only when the queue changed shape underneath the order.
477 fn resync_shuffle_order(&mut self, anchor_current: bool) {
478 if self.playback.shuffle && self.queue.shuffle_order.len() != self.queue.tracks.len() {
479 self.rebuild_shuffle_order(anchor_current);
480 }
481 }
482
483 /// Next position in the shuffled pass, or None once the pass is exhausted
484 /// and repeat is not All. Returning None is what lets a shuffled queue with
485 /// repeat off actually finish.
486 fn shuffle_next(&mut self) -> Option<usize> {
487 self.resync_shuffle_order(true);
488 let next_pos = self.queue.shuffle_pos + 1;
489 if next_pos < self.queue.shuffle_order.len() {
490 self.queue.shuffle_pos = next_pos;
491 } else if self.playback.repeat == RepeatMode::All {
492 self.rebuild_shuffle_order(false);
493 } else {
494 return None;
495 }
496 self.queue.shuffle_order.get(self.queue.shuffle_pos).copied()
497 }
498
499 fn shuffle_prev(&mut self) -> Option<usize> {
500 self.resync_shuffle_order(true);
501 self.queue.shuffle_pos = self.queue.shuffle_pos.saturating_sub(1);
502 self.queue.shuffle_order.get(self.queue.shuffle_pos).copied()
503 }
504
505 /// Move to the next track. `user_initiated` separates pressing `n` from a
506 /// track ending: RepeatMode::One must repeat on the latter and advance on
507 /// the former, otherwise the skip key does nothing while repeat-one is on.
508 fn advance(&mut self, user_initiated: bool) {
509 if self.queue.tracks.is_empty() {
510 return;
511 }
512
513 if !user_initiated && self.playback.repeat == RepeatMode::One {
514 let current = self.queue.current_index;
515 match current {
516 Some(idx) if self.play_queue_position(idx) => {}
517 _ => self.stop_playback(),
518 }
519 return;
520 }
521
522 let next = if self.playback.shuffle {
523 self.shuffle_next()
524 } else if let Some(idx) = self.queue.current_index {
525 let next_idx = idx + 1;
526 if next_idx < self.queue.tracks.len() {
527 Some(next_idx)
528 } else if self.playback.repeat == RepeatMode::All {
529 Some(0)
530 } else {
531 None
532 }
533 } else {
534 Some(0)
535 };
536
537 match next {
538 Some(next_idx) if self.play_queue_position(next_idx) => {}
539 _ => self.stop_playback(),
540 }
541 }
542
543 fn play_prev(&mut self) {
544 if self.queue.tracks.is_empty() {
545 return;
546 }
547
548 // If more than 3 seconds in, restart current track
549 if self.playback.position_secs > 3.0 {
550 if let Some(idx) = self.queue.current_index {
551 if self.play_queue_position(idx) {
552 return;
553 }
554 }
555 }
556
557 // Under shuffle, walk the shuffled pass backwards. Stepping to
558 // current_index - 1 would land on a queue row that was never played.
559 let prev = if self.playback.shuffle {
560 self.shuffle_prev()
561 } else if let Some(idx) = self.queue.current_index {
562 Some(if idx > 0 {
563 idx - 1
564 } else if self.playback.repeat == RepeatMode::All {
565 self.queue.tracks.len() - 1
566 } else {
567 0
568 })
569 } else {
570 Some(0)
571 };
572
573 match prev {
574 Some(prev_idx) if self.play_queue_position(prev_idx) => {}
575 _ => self.stop_playback(),
576 }
577 }
578
579 /// Is this track starred in any playlist? O(1).
580 pub fn is_in_playlist(&self, track_idx: usize) -> bool {
581 self.playlist_members.contains(&track_idx)
582 }
583
584 pub fn rebuild_playlist_members(&mut self) {
585 self.playlist_members = self.playlists.iter().flat_map(|pl| pl.tracks.iter().copied()).collect();
586 }
587
588 /// The engine confirmed audio is running, so the failure streak is over.
589 pub fn note_playback_started(&mut self) {
590 self.consecutive_failures = 0;
591 }
592
593 pub fn current_track(&self) -> Option<&crate::library::track::Track> {
594 self.queue
595 .current_index
596 .and_then(|qi| self.queue.tracks.get(qi))
597 .and_then(|&ti| self.library.tracks.get(ti))
598 }
599 }
600
601 #[cfg(test)]
602 mod tests {
603 use super::*;
604
605 /// A queue over `len` resolvable library tracks. There is no audio engine,
606 /// so `PlayerCommand`s go nowhere, but `play_queue_position` succeeds and
607 /// every queue and shuffle transition is observable.
608 fn app_with_queue(len: usize) -> App {
609 let mut app = App::new(PathBuf::from("."));
610 app.library.tracks = (0..len)
611 .map(|i| crate::library::track::Track {
612 path: PathBuf::from(format!("/music/{i}.flac")),
613 title: format!("track {i}"),
614 artist: String::new(),
615 album: String::new(),
616 album_artist: String::new(),
617 genre: String::new(),
618 year: None,
619 track_number: None,
620 track_total: None,
621 disc_number: None,
622 disc_total: None,
623 comment: None,
624 duration: std::time::Duration::from_secs(1),
625 bitrate: None,
626 sample_rate: None,
627 bit_depth: None,
628 channels: None,
629 })
630 .collect();
631 app.handle_action(AppAction::ReplaceQueue((0..len).collect()));
632 app
633 }
634
635 #[test]
636 fn add_to_queue_appends_instead_of_replacing() {
637 let mut app = app_with_queue(3);
638 app.queue.current_index = Some(1);
639
640 app.handle_action(AppAction::AddToQueue(vec![7, 8]));
641
642 assert_eq!(app.queue.tracks, vec![0, 1, 2, 7, 8]);
643 // Appending must leave what is playing alone.
644 assert_eq!(app.queue.current_index, Some(1));
645 }
646
647 #[test]
648 fn add_to_an_empty_queue_takes_the_first_slot() {
649 let mut app = app_with_queue(0);
650 assert_eq!(app.queue.current_index, None);
651
652 app.handle_action(AppAction::AddToQueue(vec![4, 5]));
653
654 assert_eq!(app.queue.current_index, Some(0));
655 }
656
657 #[test]
658 fn removing_an_entry_before_the_current_one_shifts_it_down() {
659 let mut app = app_with_queue(4);
660 app.queue.current_index = Some(2);
661
662 app.handle_action(AppAction::RemoveFromQueue(0));
663
664 assert_eq!(app.queue.tracks, vec![1, 2, 3]);
665 assert_eq!(app.queue.current_index, Some(1));
666 }
667
668 #[test]
669 fn removing_the_last_row_pulls_the_selection_back_in_range() {
670 let mut app = app_with_queue(3);
671 app.queue.selected_index = 2;
672
673 app.handle_action(AppAction::RemoveFromQueue(2));
674
675 // Left past the end, the highlight vanishes and j/d both stop working.
676 assert_eq!(app.queue.selected_index, 1);
677 assert!(app.queue.selected_index < app.queue.tracks.len());
678 }
679
680 #[test]
681 fn clearing_the_queue_stops_rather_than_leaving_playback_running() {
682 let mut app = app_with_queue(3);
683 app.playback.state = PlayState::Playing;
684
685 app.handle_action(AppAction::ClearQueue);
686
687 assert_eq!(app.playback.state, PlayState::Stopped);
688 assert_eq!(app.queue.current_index, None);
689 }
690
691 #[test]
692 fn shuffle_order_is_a_permutation_anchored_on_the_current_track() {
693 let mut app = app_with_queue(20);
694 app.queue.current_index = Some(7);
695
696 app.handle_action(AppAction::ToggleShuffle);
697
698 assert!(app.playback.shuffle);
699 assert_eq!(app.queue.shuffle_order.len(), 20);
700 let mut sorted = app.queue.shuffle_order.clone();
701 sorted.sort_unstable();
702 assert_eq!(sorted, (0..20).collect::<Vec<_>>());
703 // Anchored, so turning shuffle on mid-song does not jump.
704 assert_eq!(app.queue.shuffle_order[0], 7);
705 }
706
707 #[test]
708 fn a_shuffled_pass_visits_every_track_once_and_then_ends() {
709 let mut app = app_with_queue(6);
710 app.handle_action(AppAction::ToggleShuffle);
711
712 let mut seen = vec![app.queue.shuffle_order[0]];
713 while let Some(next) = app.shuffle_next() {
714 seen.push(next);
715 }
716
717 seen.sort_unstable();
718 // Every track exactly once — the old random pick repeated some and
719 // skipped others, and never returned None so repeat-off never stopped.
720 assert_eq!(seen, (0..6).collect::<Vec<_>>());
721 }
722
723 #[test]
724 fn shuffle_prev_walks_back_along_the_shuffled_pass() {
725 let mut app = app_with_queue(5);
726 app.handle_action(AppAction::ToggleShuffle);
727 let order = app.queue.shuffle_order.clone();
728
729 assert_eq!(app.shuffle_next(), Some(order[1]));
730 assert_eq!(app.shuffle_next(), Some(order[2]));
731 // Not order[1] - 1, and not current_index - 1.
732 assert_eq!(app.shuffle_prev(), Some(order[1]));
733 assert_eq!(app.shuffle_prev(), Some(order[0]));
734 // Clamps at the start of the pass rather than underflowing.
735 assert_eq!(app.shuffle_prev(), Some(order[0]));
736 }
737
738 #[test]
739 fn repeat_one_repeats_when_a_track_ends_but_not_when_the_user_skips() {
740 let mut app = app_with_queue(3);
741 app.playback.repeat = RepeatMode::One;
742 app.queue.current_index = Some(0);
743
744 // Track ended on its own: repeat it.
745 app.advance(false);
746 assert_eq!(app.queue.current_index, Some(0));
747
748 // User pressed `n`: move on. Repeating here left the skip key dead.
749 app.advance(true);
750 assert_eq!(app.queue.current_index, Some(1));
751 }
752
753 #[test]
754 fn repeat_off_stops_at_the_end_of_the_queue() {
755 let mut app = app_with_queue(2);
756 app.queue.current_index = Some(1);
757
758 app.advance(false);
759
760 assert_eq!(app.playback.state, PlayState::Stopped);
761 }
762
763 #[test]
764 fn shuffle_with_repeat_off_stops_once_the_pass_is_done() {
765 let mut app = app_with_queue(4);
766 app.handle_action(AppAction::ToggleShuffle);
767
768 // One advance per remaining track, then one more.
769 for _ in 0..4 {
770 app.advance(false);
771 }
772
773 // The old random pick always returned Some, so this never terminated.
774 assert_eq!(app.playback.state, PlayState::Stopped);
775 }
776
777 #[test]
778 fn removing_the_playing_entry_moves_on_instead_of_skipping_a_track() {
779 let mut app = app_with_queue(3);
780 app.queue.current_index = Some(0);
781
782 app.handle_action(AppAction::RemoveFromQueue(0));
783
784 assert_eq!(app.queue.tracks, vec![1, 2]);
785 // Position 0 now holds what used to be track 1; it must actually start,
786 // not merely be pointed at while the removed track keeps playing.
787 assert_eq!(app.queue.current_index, Some(0));
788 assert_eq!(app.playback.state, PlayState::Playing);
789 }
790
791 #[test]
792 fn repeated_decode_failures_give_up_instead_of_looping() {
793 let mut app = app_with_queue(3);
794 app.playback.repeat = RepeatMode::One;
795 app.queue.current_index = Some(0);
796
797 // Under repeat-one this used to re-open the same broken file forever.
798 for _ in 0..MAX_CONSECUTIVE_FAILURES {
799 app.handle_action(AppAction::TrackFailed);
800 }
801
802 assert_eq!(app.playback.state, PlayState::Stopped);
803 }
804 }