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
commit e3b4a7ee271606ab38fba4d9ef678cc16816d62b
parent f9d25e6adbbf8a682ad9833869da6eeb9d6175ca
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 15:40:46 +0900

Fix playback: volume, queue semantics, shuffle, and repeat

The player thread ran two loops: an idle one matching only Play and Stop
that dropped every other command through a catch-all arm, and a playing one
that recursed into itself per track. Volume lived only in the playing loop,
so the restored volume sent at startup was discarded and every track that
ended on its own started the next at rodio's default full volume. The
recursion also left a stack frame and a still-connected Sink behind per
track. Both are now one flat loop with the sink, volume and clock as locals.

AddToQueue replaced the queue rather than appending, resetting the current
index to 0 without telling the engine — so picking anything mid-song wiped
the queue while the old track stayed audible and the UI named a different
one. Splitting it into AddToQueue and ReplaceQueue makes the name true; only
the startup load replaces.

Shuffle picked a random index on every advance, so it could repeat a track
immediately, skip others, and never terminate with repeat off. It is now a
real permutation with a position, anchored on the current track when toggled,
and previous walks back along it instead of stepping to current_index - 1.

RepeatMode::One was applied to the manual next action too, leaving no way to
skip forward while it was on, and a decode error routed straight back into
next — under repeat-one an infinite retry that saturated the event channel.
Advancing now distinguishes a user skip from a track ending, and repeated
failures give up.

Removing the playing entry left current_index pointing at the track that
slid into the slot while the removed one kept playing, so that track was
silently skipped; the selection was never clamped either, which killed both
j and d until you pressed k. Clearing the queue and reaching its end set the
UI to stopped without telling the engine, leaving audio running and the clock
climbing. A failure to open the output device was printed into the alternate
screen and swallowed; it now reaches the status bar.

Diffstat:
Msrc/app/mod.rs | 434++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/app/state.rs | 7+++++++
Msrc/audio/player.rs | 239++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/event/mod.rs | 4++++
Msrc/main.rs | 11+++++++----
Msrc/ui/widgets/status_bar.rs | 14+++++++++++++-
6 files changed, 541 insertion(+), 168 deletion(-)

diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -12,6 +12,10 @@ use crate::event::Event;
 use crate::library::Library;
 use state::*;
 
+/// Give up after this many undecodable files in a row rather than walking the
+/// whole queue at decoder speed.
+const MAX_CONSECUTIVE_FAILURES: u32 = 5;
+
 #[derive(Debug, Clone)]
 pub enum AppAction {
     Quit,
@@ -30,12 +34,17 @@ pub enum AppAction {
     FocusNext,
     FocusPrev,
     FocusPane(FocusedPane),
+    /// Append to the queue, leaving what is playing alone.
     AddToQueue(Vec<usize>),
+    /// Discard the queue and start over from the given tracks.
+    ReplaceQueue(Vec<usize>),
     ClearQueue,
     RemoveFromQueue(usize),
     PlayQueueIndex(usize),
     UpdatePosition { position_secs: f64, duration_secs: f64 },
     TrackFinished,
+    /// The engine could not decode the current track.
+    TrackFailed,
     SetQueueSelection(usize),
     AddToPlaylist { playlist_idx: usize, track_idx: usize },
     RemoveFromPlaylist { playlist_idx: usize, track_idx: usize },
@@ -59,6 +68,11 @@ pub struct App {
     pub playlists: Vec<state::Playlist>,
     pub sync_state: SyncState,
     pub initial_scan_complete: bool,
+    /// Set when the output device could not be opened; nothing will ever play.
+    pub audio_error: Option<String>,
+    /// Undecodable files in a row. Bounded so a queue of broken files stops
+    /// instead of spinning through them forever.
+    consecutive_failures: u32,
     audio_engine: Option<AudioEngine>,
     event_tx: Option<Sender<Event>>,
 }
@@ -79,6 +93,8 @@ impl App {
             playlists: vec![state::Playlist::new("Bookmarks")],
             sync_state: SyncState::Idle,
             initial_scan_complete: false,
+            audio_error: None,
+            consecutive_failures: 0,
             audio_engine: None,
             event_tx: None,
         }
@@ -121,7 +137,7 @@ impl App {
                 }
             },
             AppAction::NextTrack => {
-                self.play_next();
+                self.advance(true);
             }
             AppAction::PrevTrack => {
                 self.play_prev();
@@ -157,6 +173,9 @@ impl App {
             }
             AppAction::ToggleShuffle => {
                 self.playback.shuffle = !self.playback.shuffle;
+                // Anchor on the current track so turning shuffle on mid-song
+                // doesn't jump, and so "previous" has somewhere to go back to.
+                self.rebuild_shuffle_order(true);
             }
             AppAction::CycleRepeat => {
                 self.playback.repeat = self.playback.repeat.next();
@@ -174,10 +193,22 @@ impl App {
                 self.focus = pane;
             }
             AppAction::AddToQueue(track_indices) => {
+                let was_empty = self.queue.tracks.is_empty();
+                self.queue.tracks.extend(track_indices);
+                // Only take over the current position when there was nothing to
+                // disturb — appending must not interrupt what is playing.
+                if was_empty && !self.queue.tracks.is_empty() {
+                    self.queue.current_index = Some(0);
+                }
+                self.resync_shuffle_order(true);
+            }
+            AppAction::ReplaceQueue(track_indices) => {
                 self.queue.tracks = track_indices;
-                self.queue.current_index = if self.queue.tracks.is_empty() { None } else { Some(0) };
+                self.queue.current_index =
+                    if self.queue.tracks.is_empty() { None } else { Some(0) };
                 self.queue.selected_index = 0;
                 self.queue.scroll_offset = 0;
+                self.resync_shuffle_order(false);
             }
             AppAction::ClearQueue => {
                 self.queue.tracks.clear();
@@ -189,18 +220,35 @@ impl App {
                 self.stop_playback();
             }
             AppAction::RemoveFromQueue(idx) => {
-                if idx < self.queue.tracks.len() {
-                    self.queue.tracks.remove(idx);
-                    if self.queue.tracks.is_empty() {
-                        self.queue.current_index = None;
-                    } else if let Some(ref mut ci) = self.queue.current_index {
-                        if idx < *ci {
-                            *ci -= 1;
-                        } else if idx == *ci && *ci >= self.queue.tracks.len() {
-                            *ci = self.queue.tracks.len() - 1;
+                if idx >= self.queue.tracks.len() {
+                    return;
+                }
+                let removed_current = self.queue.current_index == Some(idx);
+                self.queue.tracks.remove(idx);
+
+                if self.queue.tracks.is_empty() {
+                    self.queue.current_index = None;
+                    self.stop_playback();
+                } else if let Some(ci) = self.queue.current_index {
+                    if idx < ci {
+                        self.queue.current_index = Some(ci - 1);
+                    } else if removed_current {
+                        // The removed track is still audible. Leaving
+                        // current_index alone would point it at whatever slid
+                        // into the slot, and that track would then be skipped.
+                        let next = ci.min(self.queue.tracks.len() - 1);
+                        if !self.play_queue_position(next) {
+                            self.stop_playback();
                         }
                     }
                 }
+                // Never leave the selection past the end: the highlight
+                // disappears and both `j` and `d` stop responding.
+                self.queue.selected_index = self
+                    .queue
+                    .selected_index
+                    .min(self.queue.tracks.len().saturating_sub(1));
+                self.resync_shuffle_order(true);
             }
             AppAction::PlayQueueIndex(idx) => {
                 // play_queue_position only commits current_index once the track
@@ -215,7 +263,19 @@ impl App {
                 }
             }
             AppAction::TrackFinished => {
-                self.play_next();
+                self.advance(false);
+            }
+            AppAction::TrackFailed => {
+                self.consecutive_failures += 1;
+                if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
+                    self.consecutive_failures = 0;
+                    self.stop_playback();
+                } else {
+                    // Advance as though the user asked: retrying under
+                    // RepeatMode::One would re-open the same broken file forever
+                    // and flood the event channel.
+                    self.advance(true);
+                }
             }
             AppAction::SetQueueSelection(idx) => {
                 if idx < self.queue.tracks.len() {
@@ -302,6 +362,12 @@ impl App {
         });
 
         self.queue.tracks = new_queue_tracks;
+        // The playing file is gone from the library. Without stopping, audio
+        // carries on while current_track() returns None, so the status bar,
+        // album art and info pane all blank out mid-song.
+        if new_current.is_none() && self.queue.current_index.is_some() {
+            self.stop_playback();
+        }
         self.queue.current_index = new_current;
         self.queue.selected_index = self.queue.selected_index.min(
             self.queue.tracks.len().saturating_sub(1)
@@ -342,6 +408,7 @@ impl App {
 
         self.library = new_lib;
         self.sync_state = SyncState::Idle;
+        self.resync_shuffle_order(true);
     }
 
     /// Start the track at queue position `qi`, the single place playback is
@@ -363,6 +430,11 @@ impl App {
             engine.send(PlayerCommand::Play(path));
         }
         self.queue.current_index = Some(qi);
+        // Keep the shuffled pass pointing at what is actually playing, so a
+        // double-clicked row becomes the new starting point for next/previous.
+        if let Some(p) = self.queue.shuffle_order.iter().position(|&q| q == qi) {
+            self.queue.shuffle_pos = p;
+        }
         self.playback.state = PlayState::Playing;
         self.playback.position_secs = 0.0;
         self.playback.duration_secs = dur;
@@ -380,40 +452,96 @@ impl App {
         self.playback.position_secs = 0.0;
     }
 
-    fn play_next(&mut self) {
-        if self.queue.tracks.is_empty() {
+    /// Draw a fresh permutation of the queue positions. With `anchor_current`
+    /// the playing track is moved to the front so toggling shuffle mid-song does
+    /// not jump; on a repeat-all wrap it is not, so the new pass starts clean.
+    fn rebuild_shuffle_order(&mut self, anchor_current: bool) {
+        if !self.playback.shuffle {
+            self.queue.shuffle_order.clear();
+            self.queue.shuffle_pos = 0;
             return;
         }
 
-        match self.playback.repeat {
-            RepeatMode::One => {
-                if let Some(idx) = self.queue.current_index {
-                    self.play_queue_position(idx);
+        use rand::seq::SliceRandom;
+        let mut order: Vec<usize> = (0..self.queue.tracks.len()).collect();
+        order.shuffle(&mut rand::thread_rng());
+
+        if anchor_current {
+            if let Some(cur) = self.queue.current_index {
+                if let Some(p) = order.iter().position(|&q| q == cur) {
+                    order.swap(0, p);
                 }
             }
-            _ => {
-                let next = if self.playback.shuffle {
-                    use rand::Rng;
-                    let mut rng = rand::thread_rng();
-                    Some(rng.gen_range(0..self.queue.tracks.len()))
-                } else if let Some(idx) = self.queue.current_index {
-                    let next_idx = idx + 1;
-                    if next_idx < self.queue.tracks.len() {
-                        Some(next_idx)
-                    } else if self.playback.repeat == RepeatMode::All {
-                        Some(0)
-                    } else {
-                        None
-                    }
-                } else {
-                    Some(0)
-                };
+        }
 
-                match next {
-                    Some(next_idx) if self.play_queue_position(next_idx) => {}
-                    _ => self.stop_playback(),
-                }
+        self.queue.shuffle_order = order;
+        self.queue.shuffle_pos = 0;
+    }
+
+    /// Rebuild only when the queue changed shape underneath the order.
+    fn resync_shuffle_order(&mut self, anchor_current: bool) {
+        if self.playback.shuffle && self.queue.shuffle_order.len() != self.queue.tracks.len() {
+            self.rebuild_shuffle_order(anchor_current);
+        }
+    }
+
+    /// Next position in the shuffled pass, or None once the pass is exhausted
+    /// and repeat is not All. Returning None is what lets a shuffled queue with
+    /// repeat off actually finish.
+    fn shuffle_next(&mut self) -> Option<usize> {
+        self.resync_shuffle_order(true);
+        let next_pos = self.queue.shuffle_pos + 1;
+        if next_pos < self.queue.shuffle_order.len() {
+            self.queue.shuffle_pos = next_pos;
+        } else if self.playback.repeat == RepeatMode::All {
+            self.rebuild_shuffle_order(false);
+        } else {
+            return None;
+        }
+        self.queue.shuffle_order.get(self.queue.shuffle_pos).copied()
+    }
+
+    fn shuffle_prev(&mut self) -> Option<usize> {
+        self.resync_shuffle_order(true);
+        self.queue.shuffle_pos = self.queue.shuffle_pos.saturating_sub(1);
+        self.queue.shuffle_order.get(self.queue.shuffle_pos).copied()
+    }
+
+    /// Move to the next track. `user_initiated` separates pressing `n` from a
+    /// track ending: RepeatMode::One must repeat on the latter and advance on
+    /// the former, otherwise the skip key does nothing while repeat-one is on.
+    fn advance(&mut self, user_initiated: bool) {
+        if self.queue.tracks.is_empty() {
+            return;
+        }
+
+        if !user_initiated && self.playback.repeat == RepeatMode::One {
+            let current = self.queue.current_index;
+            match current {
+                Some(idx) if self.play_queue_position(idx) => {}
+                _ => self.stop_playback(),
             }
+            return;
+        }
+
+        let next = if self.playback.shuffle {
+            self.shuffle_next()
+        } else if let Some(idx) = self.queue.current_index {
+            let next_idx = idx + 1;
+            if next_idx < self.queue.tracks.len() {
+                Some(next_idx)
+            } else if self.playback.repeat == RepeatMode::All {
+                Some(0)
+            } else {
+                None
+            }
+        } else {
+            Some(0)
+        };
+
+        match next {
+            Some(next_idx) if self.play_queue_position(next_idx) => {}
+            _ => self.stop_playback(),
         }
     }
 
@@ -431,23 +559,33 @@ impl App {
             }
         }
 
-        let prev = if let Some(idx) = self.queue.current_index {
-            if idx > 0 {
+        // Under shuffle, walk the shuffled pass backwards. Stepping to
+        // current_index - 1 would land on a queue row that was never played.
+        let prev = if self.playback.shuffle {
+            self.shuffle_prev()
+        } else if let Some(idx) = self.queue.current_index {
+            Some(if idx > 0 {
                 idx - 1
             } else if self.playback.repeat == RepeatMode::All {
                 self.queue.tracks.len() - 1
             } else {
                 0
-            }
+            })
         } else {
-            0
+            Some(0)
         };
 
-        if !self.play_queue_position(prev) {
-            self.stop_playback();
+        match prev {
+            Some(prev_idx) if self.play_queue_position(prev_idx) => {}
+            _ => self.stop_playback(),
         }
     }
 
+    /// The engine confirmed audio is running, so the failure streak is over.
+    pub fn note_playback_started(&mut self) {
+        self.consecutive_failures = 0;
+    }
+
     pub fn current_track(&self) -> Option<&crate::library::track::Track> {
         self.queue
             .current_index
@@ -455,3 +593,209 @@ impl App {
             .and_then(|&ti| self.library.tracks.get(ti))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// A queue over `len` resolvable library tracks. There is no audio engine,
+    /// so `PlayerCommand`s go nowhere, but `play_queue_position` succeeds and
+    /// every queue and shuffle transition is observable.
+    fn app_with_queue(len: usize) -> App {
+        let mut app = App::new(PathBuf::from("."));
+        app.library.tracks = (0..len)
+            .map(|i| crate::library::track::Track {
+                path: PathBuf::from(format!("/music/{i}.flac")),
+                title: format!("track {i}"),
+                artist: String::new(),
+                album: String::new(),
+                album_artist: String::new(),
+                genre: String::new(),
+                year: None,
+                track_number: None,
+                track_total: None,
+                disc_number: None,
+                disc_total: None,
+                comment: None,
+                duration: std::time::Duration::from_secs(1),
+                bitrate: None,
+                sample_rate: None,
+                bit_depth: None,
+                channels: None,
+                lyrics: None,
+            })
+            .collect();
+        app.handle_action(AppAction::ReplaceQueue((0..len).collect()));
+        app
+    }
+
+    #[test]
+    fn add_to_queue_appends_instead_of_replacing() {
+        let mut app = app_with_queue(3);
+        app.queue.current_index = Some(1);
+
+        app.handle_action(AppAction::AddToQueue(vec![7, 8]));
+
+        assert_eq!(app.queue.tracks, vec![0, 1, 2, 7, 8]);
+        // Appending must leave what is playing alone.
+        assert_eq!(app.queue.current_index, Some(1));
+    }
+
+    #[test]
+    fn add_to_an_empty_queue_takes_the_first_slot() {
+        let mut app = app_with_queue(0);
+        assert_eq!(app.queue.current_index, None);
+
+        app.handle_action(AppAction::AddToQueue(vec![4, 5]));
+
+        assert_eq!(app.queue.current_index, Some(0));
+    }
+
+    #[test]
+    fn removing_an_entry_before_the_current_one_shifts_it_down() {
+        let mut app = app_with_queue(4);
+        app.queue.current_index = Some(2);
+
+        app.handle_action(AppAction::RemoveFromQueue(0));
+
+        assert_eq!(app.queue.tracks, vec![1, 2, 3]);
+        assert_eq!(app.queue.current_index, Some(1));
+    }
+
+    #[test]
+    fn removing_the_last_row_pulls_the_selection_back_in_range() {
+        let mut app = app_with_queue(3);
+        app.queue.selected_index = 2;
+
+        app.handle_action(AppAction::RemoveFromQueue(2));
+
+        // Left past the end, the highlight vanishes and j/d both stop working.
+        assert_eq!(app.queue.selected_index, 1);
+        assert!(app.queue.selected_index < app.queue.tracks.len());
+    }
+
+    #[test]
+    fn clearing_the_queue_stops_rather_than_leaving_playback_running() {
+        let mut app = app_with_queue(3);
+        app.playback.state = PlayState::Playing;
+
+        app.handle_action(AppAction::ClearQueue);
+
+        assert_eq!(app.playback.state, PlayState::Stopped);
+        assert_eq!(app.queue.current_index, None);
+    }
+
+    #[test]
+    fn shuffle_order_is_a_permutation_anchored_on_the_current_track() {
+        let mut app = app_with_queue(20);
+        app.queue.current_index = Some(7);
+
+        app.handle_action(AppAction::ToggleShuffle);
+
+        assert!(app.playback.shuffle);
+        assert_eq!(app.queue.shuffle_order.len(), 20);
+        let mut sorted = app.queue.shuffle_order.clone();
+        sorted.sort_unstable();
+        assert_eq!(sorted, (0..20).collect::<Vec<_>>());
+        // Anchored, so turning shuffle on mid-song does not jump.
+        assert_eq!(app.queue.shuffle_order[0], 7);
+    }
+
+    #[test]
+    fn a_shuffled_pass_visits_every_track_once_and_then_ends() {
+        let mut app = app_with_queue(6);
+        app.handle_action(AppAction::ToggleShuffle);
+
+        let mut seen = vec![app.queue.shuffle_order[0]];
+        while let Some(next) = app.shuffle_next() {
+            seen.push(next);
+        }
+
+        seen.sort_unstable();
+        // Every track exactly once — the old random pick repeated some and
+        // skipped others, and never returned None so repeat-off never stopped.
+        assert_eq!(seen, (0..6).collect::<Vec<_>>());
+    }
+
+    #[test]
+    fn shuffle_prev_walks_back_along_the_shuffled_pass() {
+        let mut app = app_with_queue(5);
+        app.handle_action(AppAction::ToggleShuffle);
+        let order = app.queue.shuffle_order.clone();
+
+        assert_eq!(app.shuffle_next(), Some(order[1]));
+        assert_eq!(app.shuffle_next(), Some(order[2]));
+        // Not order[1] - 1, and not current_index - 1.
+        assert_eq!(app.shuffle_prev(), Some(order[1]));
+        assert_eq!(app.shuffle_prev(), Some(order[0]));
+        // Clamps at the start of the pass rather than underflowing.
+        assert_eq!(app.shuffle_prev(), Some(order[0]));
+    }
+
+    #[test]
+    fn repeat_one_repeats_when_a_track_ends_but_not_when_the_user_skips() {
+        let mut app = app_with_queue(3);
+        app.playback.repeat = RepeatMode::One;
+        app.queue.current_index = Some(0);
+
+        // Track ended on its own: repeat it.
+        app.advance(false);
+        assert_eq!(app.queue.current_index, Some(0));
+
+        // User pressed `n`: move on. Repeating here left the skip key dead.
+        app.advance(true);
+        assert_eq!(app.queue.current_index, Some(1));
+    }
+
+    #[test]
+    fn repeat_off_stops_at_the_end_of_the_queue() {
+        let mut app = app_with_queue(2);
+        app.queue.current_index = Some(1);
+
+        app.advance(false);
+
+        assert_eq!(app.playback.state, PlayState::Stopped);
+    }
+
+    #[test]
+    fn shuffle_with_repeat_off_stops_once_the_pass_is_done() {
+        let mut app = app_with_queue(4);
+        app.handle_action(AppAction::ToggleShuffle);
+
+        // One advance per remaining track, then one more.
+        for _ in 0..4 {
+            app.advance(false);
+        }
+
+        // The old random pick always returned Some, so this never terminated.
+        assert_eq!(app.playback.state, PlayState::Stopped);
+    }
+
+    #[test]
+    fn removing_the_playing_entry_moves_on_instead_of_skipping_a_track() {
+        let mut app = app_with_queue(3);
+        app.queue.current_index = Some(0);
+
+        app.handle_action(AppAction::RemoveFromQueue(0));
+
+        assert_eq!(app.queue.tracks, vec![1, 2]);
+        // Position 0 now holds what used to be track 1; it must actually start,
+        // not merely be pointed at while the removed track keeps playing.
+        assert_eq!(app.queue.current_index, Some(0));
+        assert_eq!(app.playback.state, PlayState::Playing);
+    }
+
+    #[test]
+    fn repeated_decode_failures_give_up_instead_of_looping() {
+        let mut app = app_with_queue(3);
+        app.playback.repeat = RepeatMode::One;
+        app.queue.current_index = Some(0);
+
+        // Under repeat-one this used to re-open the same broken file forever.
+        for _ in 0..MAX_CONSECUTIVE_FAILURES {
+            app.handle_action(AppAction::TrackFailed);
+        }
+
+        assert_eq!(app.playback.state, PlayState::Stopped);
+    }
+}
diff --git a/src/app/state.rs b/src/app/state.rs
@@ -149,6 +149,13 @@ pub struct QueueState {
     pub current_index: Option<usize>,
     pub selected_index: usize,
     pub scroll_offset: usize,
+    /// Positions into `tracks`, in shuffled play order — a real permutation, so
+    /// a shuffled pass reaches every track exactly once and can end. Empty while
+    /// shuffle is off.
+    pub shuffle_order: Vec<usize>,
+    /// How far into `shuffle_order` playback has got. Also what makes "previous"
+    /// mean the previous shuffled track rather than the previous queue row.
+    pub shuffle_pos: usize,
 }
 
 #[derive(Debug, Clone)]
diff --git a/src/audio/player.rs b/src/audio/player.rs
@@ -50,11 +50,20 @@ impl AudioEngine {
     }
 }
 
+/// One flat loop over every command, with the sink and the playback clock held
+/// as plain locals.
+///
+/// This used to be two loops: an idle one that matched only Play and Stop and
+/// threw the rest away (`Ok(_) => {}`), and a playing one that recursed into
+/// itself on every track change. That cost a stack frame and a still-connected
+/// `Sink` per track, and — because volume lived only inside the playing loop —
+/// every track that ended on its own started the next one at rodio's default
+/// full volume.
 fn player_thread(cmd_rx: Receiver<PlayerCommand>, event_tx: Sender<Event>) {
     let stream = match OutputStreamBuilder::open_default_stream() {
         Ok(s) => s,
         Err(e) => {
-            eprintln!("Failed to open audio output: {}", e);
+            let _ = event_tx.send(Event::Audio(AudioEvent::DeviceError(e.to_string())));
             return;
         }
     };
@@ -62,37 +71,120 @@ fn player_thread(cmd_rx: Receiver<PlayerCommand>, event_tx: Sender<Event>) {
     let mixer = stream.mixer().clone();
     let position_ticker = tick(Duration::from_millis(250));
 
+    let mut sink: Option<Sink> = None;
+    // Survives across tracks and idle periods; applied to every sink we build.
+    let mut volume: f32 = 1.0;
+    let mut duration: f64 = 0.0;
+    let mut play_start: Option<Instant> = None;
+    let mut accumulated_secs: f64 = 0.0;
+    let mut is_paused = false;
+
     loop {
         select! {
             recv(cmd_rx) -> msg => {
                 match msg {
                     Ok(PlayerCommand::Play(path)) => {
-                        match open_and_play(&mixer, &path) {
-                            Ok((sink, duration)) => {
+                        if let Some(old) = sink.take() {
+                            old.stop();
+                        }
+                        accumulated_secs = 0.0;
+                        is_paused = false;
+                        match open_and_play(&mixer, &path, volume) {
+                            Ok((new_sink, new_duration)) => {
+                                sink = Some(new_sink);
+                                duration = new_duration;
+                                play_start = Some(Instant::now());
                                 let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
-                                run_playback_loop(
-                                    sink, &mixer, &cmd_rx, &event_tx,
-                                    &position_ticker, duration,
-                                );
                             }
                             Err(e) => {
+                                duration = 0.0;
+                                play_start = None;
                                 let _ = event_tx.send(Event::Audio(AudioEvent::TrackError(e)));
                             }
                         }
                     }
+                    Ok(PlayerCommand::Pause) => {
+                        if let Some(ref s) = sink {
+                            if !is_paused {
+                                s.pause();
+                                if let Some(start) = play_start.take() {
+                                    accumulated_secs += start.elapsed().as_secs_f64();
+                                }
+                                is_paused = true;
+                                let _ = event_tx.send(Event::Audio(AudioEvent::Paused));
+                            }
+                        }
+                    }
+                    Ok(PlayerCommand::Resume) => {
+                        if let Some(ref s) = sink {
+                            if is_paused {
+                                s.play();
+                                play_start = Some(Instant::now());
+                                is_paused = false;
+                                let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
+                            }
+                        }
+                    }
                     Ok(PlayerCommand::Stop) => {
+                        if let Some(old) = sink.take() {
+                            old.stop();
+                        }
+                        play_start = None;
+                        accumulated_secs = 0.0;
+                        is_paused = false;
                         let _ = event_tx.send(Event::Audio(AudioEvent::Stopped));
                     }
-                    Ok(_) => {}
+                    Ok(PlayerCommand::SetVolume(vol)) => {
+                        // Remembered even with nothing playing, so the volume
+                        // restored at startup applies to the first track.
+                        volume = vol.clamp(0.0, 1.0);
+                        if let Some(ref s) = sink {
+                            s.set_volume(volume);
+                        }
+                    }
+                    Ok(PlayerCommand::Seek(secs)) => {
+                        if let Some(ref s) = sink {
+                            if s.try_seek(Duration::from_secs_f64(secs)).is_ok() {
+                                accumulated_secs = secs;
+                                if !is_paused {
+                                    play_start = Some(Instant::now());
+                                }
+                            }
+                        }
+                    }
                     Err(_) => break,
                 }
             }
-            recv(position_ticker) -> _ => {}
+            recv(position_ticker) -> _ => {
+                let finished = sink.as_ref().is_some_and(|s| s.empty()) && !is_paused;
+                if finished {
+                    sink = None;
+                    play_start = None;
+                    accumulated_secs = 0.0;
+                    let _ = event_tx.send(Event::Audio(AudioEvent::TrackFinished));
+                } else if sink.is_some() {
+                    let pos = if is_paused {
+                        accumulated_secs
+                    } else if let Some(start) = play_start {
+                        accumulated_secs + start.elapsed().as_secs_f64()
+                    } else {
+                        accumulated_secs
+                    };
+                    // Only clamp when the decoder actually reported a length —
+                    // VBR MP3s without a Xing header give 0.0, and clamping to
+                    // that pins the elapsed time at 0:00 for the whole track.
+                    let position_secs = if duration > 0.0 { pos.min(duration) } else { pos };
+                    let _ = event_tx.send(Event::Audio(AudioEvent::PositionUpdate {
+                        position_secs,
+                        duration_secs: duration,
+                    }));
+                }
+            }
         }
     }
 }
 
-fn open_and_play(mixer: &Mixer, path: &PathBuf) -> Result<(Sink, f64), String> {
+fn open_and_play(mixer: &Mixer, path: &PathBuf, volume: f32) -> Result<(Sink, f64), String> {
     let ext = path
         .extension()
         .and_then(|e| e.to_str())
@@ -106,10 +198,7 @@ fn open_and_play(mixer: &Mixer, path: &PathBuf) -> Result<(Sink, f64), String> {
             let duration = Source::total_duration(&source)
                 .map(|d| d.as_secs_f64())
                 .unwrap_or(0.0);
-            let sink = Sink::connect_new(mixer);
-            sink.append(source);
-            sink.play();
-            return Ok((sink, duration));
+            return Ok((start_sink(mixer, source, volume), duration));
         }
     }
 
@@ -127,19 +216,30 @@ fn open_and_play(mixer: &Mixer, path: &PathBuf) -> Result<(Sink, f64), String> {
             let duration = Source::total_duration(&source)
                 .map(|d| d.as_secs_f64())
                 .unwrap_or(0.0);
-            let sink = Sink::connect_new(mixer);
-            sink.append(source);
-            sink.play();
-            return Ok((sink, duration));
+            return Ok((start_sink(mixer, source, volume), duration));
         }
     }
 
     // Fall back to symphonia direct decoding for m4a/mp4/etc
-    decode_with_symphonia(mixer, path)
+    decode_with_symphonia(mixer, path, volume)
+}
+
+/// Build a playing sink at the player's current volume. A fresh `Sink` starts at
+/// rodio's default 1.0, so skipping the `set_volume` here is what made every
+/// naturally-ended track hand off to the next one at full blast.
+fn start_sink<S>(mixer: &Mixer, source: S, volume: f32) -> Sink
+where
+    S: Source + Send + 'static,
+{
+    let sink = Sink::connect_new(mixer);
+    sink.set_volume(volume);
+    sink.append(source);
+    sink.play();
+    sink
 }
 
 /// Decode using symphonia directly, buffer the entire track, and play via rodio Sink.
-fn decode_with_symphonia(mixer: &Mixer, path: &Path) -> Result<(Sink, f64), String> {
+fn decode_with_symphonia(mixer: &Mixer, path: &Path, volume: f32) -> Result<(Sink, f64), String> {
     let file = File::open(path).map_err(|e| format!("Open: {}", e))?;
     let mss = MediaSourceStream::new(Box::new(file), Default::default());
 
@@ -222,102 +322,5 @@ fn decode_with_symphonia(mixer: &Mixer, path: &Path) -> Result<(Sink, f64), Stri
         .map(|d| d.as_secs_f64())
         .unwrap_or(duration_secs);
 
-    let sink = Sink::connect_new(mixer);
-    sink.append(buffer);
-    sink.play();
-    Ok((sink, actual_duration))
-}
-
-fn run_playback_loop(
-    sink: Sink,
-    mixer: &Mixer,
-    cmd_rx: &Receiver<PlayerCommand>,
-    event_tx: &Sender<Event>,
-    position_ticker: &Receiver<Instant>,
-    mut duration: f64,
-) {
-    let mut play_start: Option<Instant> = Some(Instant::now());
-    let mut accumulated_secs: f64 = 0.0;
-    let mut is_paused = false;
-
-    loop {
-        select! {
-            recv(cmd_rx) -> msg => {
-                match msg {
-                    Ok(PlayerCommand::Play(path)) => {
-                        sink.stop();
-                        match open_and_play(mixer, &path) {
-                            Ok((new_sink, new_dur)) => {
-                                new_sink.set_volume(sink.volume());
-                                duration = new_dur;
-                                let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
-                                run_playback_loop(
-                                    new_sink, mixer, cmd_rx, event_tx,
-                                    position_ticker, duration,
-                                );
-                            }
-                            Err(e) => {
-                                let _ = event_tx.send(Event::Audio(AudioEvent::TrackError(e)));
-                            }
-                        }
-                        return;
-                    }
-                    Ok(PlayerCommand::Pause) => {
-                        if !is_paused {
-                            sink.pause();
-                            if let Some(start) = play_start.take() {
-                                accumulated_secs += start.elapsed().as_secs_f64();
-                            }
-                            is_paused = true;
-                            let _ = event_tx.send(Event::Audio(AudioEvent::Paused));
-                        }
-                    }
-                    Ok(PlayerCommand::Resume) => {
-                        if is_paused {
-                            sink.play();
-                            play_start = Some(Instant::now());
-                            is_paused = false;
-                            let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
-                        }
-                    }
-                    Ok(PlayerCommand::Stop) => {
-                        sink.stop();
-                        let _ = event_tx.send(Event::Audio(AudioEvent::Stopped));
-                        return;
-                    }
-                    Ok(PlayerCommand::SetVolume(vol)) => {
-                        sink.set_volume(vol);
-                    }
-                    Ok(PlayerCommand::Seek(secs)) => {
-                        if sink.try_seek(Duration::from_secs_f64(secs)).is_ok() {
-                            accumulated_secs = secs;
-                            if !is_paused {
-                                play_start = Some(Instant::now());
-                            }
-                        }
-                    }
-                    Err(_) => return,
-                }
-            }
-            recv(position_ticker) -> _ => {
-                if sink.empty() && !is_paused {
-                    let _ = event_tx.send(Event::Audio(AudioEvent::TrackFinished));
-                    return;
-                }
-
-                let pos = if is_paused {
-                    accumulated_secs
-                } else if let Some(start) = play_start {
-                    accumulated_secs + start.elapsed().as_secs_f64()
-                } else {
-                    accumulated_secs
-                };
-
-                let _ = event_tx.send(Event::Audio(AudioEvent::PositionUpdate {
-                    position_secs: pos.min(duration),
-                    duration_secs: duration,
-                }));
-            }
-        }
-    }
+    Ok((start_sink(mixer, buffer, volume), actual_duration))
 }
diff --git a/src/event/mod.rs b/src/event/mod.rs
@@ -17,6 +17,10 @@ pub enum AudioEvent {
     PositionUpdate { position_secs: f64, duration_secs: f64 },
     TrackFinished,
     TrackError(String),
+    /// The output device could not be opened. The player thread exits after
+    /// sending this, so every later command is dropped — the UI has to say so
+    /// rather than show a progress bar advancing over silence.
+    DeviceError(String),
     Playing,
     Paused,
     Stopped,
diff --git a/src/main.rs b/src/main.rs
@@ -131,7 +131,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                                 app.library = lib;
                                 // Load all tracks into queue by default
                                 let all_indices: Vec<usize> = (0..app.library.tracks.len()).collect();
-                                app.handle_action(app::AppAction::AddToQueue(all_indices));
+                                app.handle_action(app::AppAction::ReplaceQueue(all_indices));
                                 ui.refresh_dir_browser(&app);
 
                                 // Restore persisted state
@@ -263,12 +263,15 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                                 duration_secs,
                             }],
                             AudioEvent::TrackFinished => vec![app::AppAction::TrackFinished],
-                            AudioEvent::TrackError(_) => {
-                                // Skip to next track on decode error
-                                vec![app::AppAction::NextTrack]
+                            AudioEvent::TrackError(_) => vec![app::AppAction::TrackFailed],
+                            AudioEvent::DeviceError(msg) => {
+                                app.audio_error = Some(msg);
+                                app.playback.state = app::state::PlayState::Stopped;
+                                vec![]
                             }
                             AudioEvent::Playing => {
                                 app.playback.state = app::state::PlayState::Playing;
+                                app.note_playback_started();
                                 vec![]
                             }
                             AudioEvent::Paused => {
diff --git a/src/ui/widgets/status_bar.rs b/src/ui/widgets/status_bar.rs
@@ -9,7 +9,19 @@ use crate::app::state::{PlayState, SyncState};
 use crate::ui::theme::Theme;
 
 pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme, resize_mode: bool) {
-    let block = if resize_mode {
+    let block = if let Some(err) = &app.audio_error {
+        // Nothing will ever play; without this the UI shows a play state and a
+        // moving progress bar over silence.
+        Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(Color::Rgb(255, 90, 90)))
+            .title(format!(" [NO AUDIO] {} ", err))
+            .title_style(
+                Style::default()
+                    .fg(Color::Rgb(255, 90, 90))
+                    .add_modifier(Modifier::BOLD),
+            )
+    } else if resize_mode {
         Block::default()
             .borders(Borders::ALL)
             .border_style(Style::default().fg(Color::Yellow))