ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git

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 }