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))
|