1pub mod commands;
2pub mod history;
3pub mod state;
4pub mod undo;
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::sync::atomic::AtomicU64;
9use std::thread;
10
11use thiserror::Error;
12
13use crate::audio::{
14 analyzer::VizAnalyzer,
15 backend::{self, AudioBackend, AudioEngineHandle, BackendError, SampleRateWatch},
16 buffer, streaming,
17 viz::{VizBuffer, VizSnapshot},
18};
19use buffer::PlaybackTimeline;
20use commands::{CommandChannel, PlayerCommand};
21use history::{InFlight, PlayEvent, PlayRecorder};
22use state::{
23 ItemState, LoadState, PlaybackSource, PlaybackState, QueueItemId, SharedPlayerState, TrackInfo,
24};
25use undo::{UndoEntry, UndoStack};
26
27pub(crate) const RING_BUFFER_SIZE: usize = 192_000 * 2;
29
30const SEEK_END_GUARD_MS: u64 = 500;
33
34#[derive(Debug, Error)]
35pub enum PlayerError {
36 #[error("backend error: {0}")]
37 Backend(#[from] BackendError),
38 #[error("decode error: {0}")]
39 Decode(#[from] buffer::DecodeError),
40}
41
42#[derive(Clone)]
45struct StreamSource {
46 path: PathBuf,
47 bytes_written: Arc<AtomicU64>,
48 total: u64,
49 mode: streaming::ProbeMode,
50}
51
52fn hint_for(path: &Path) -> symphonia::core::formats::probe::Hint {
54 let mut hint = symphonia::core::formats::probe::Hint::new();
55 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
56 hint.with_extension(ext);
57 }
58 hint
59}
60
61fn lengthless_mode_for(path: &Path) -> streaming::ProbeMode {
69 let ogg = path
70 .extension()
71 .and_then(|e| e.to_str())
72 .is_some_and(|ext| {
73 matches!(
74 ext.to_ascii_lowercase().as_str(),
75 "ogg" | "oga" | "opus" | "spx"
76 )
77 });
78 if ogg {
79 streaming::ProbeMode::LengthlessWholeEnd
80 } else {
81 streaming::ProbeMode::Lengthless
82 }
83}
84
85pub struct Player {
87 shared_state: Arc<SharedPlayerState>,
88 commands: CommandChannel,
89 active_playback: Option<ActivePlayback>,
90 timeline: Arc<PlaybackTimeline>,
91 viz_buffer: Arc<VizBuffer>,
92 viz_snapshot: Arc<VizSnapshot>,
93 _viz_analyzer: VizAnalyzer,
95 undo_stack: UndoStack,
96 batch_buffer: Option<Vec<UndoEntry>>,
99 output_device_name: Option<String>,
101 backend: Box<dyn AudioBackend>,
103 last_skip: std::time::Instant,
105 stream_mode: streaming::ProbeMode,
108 history: Option<PlayRecorder>,
111 in_flight: Option<InFlight>,
113 #[cfg(test)]
116 playback_starts: usize,
117}
118
119struct ActivePlayback {
121 engine: Box<dyn AudioEngineHandle>,
122 decode_handle: buffer::DecodeHandle,
123 _rate_watch: Option<Box<dyn SampleRateWatch>>,
126}
127
128impl Default for Player {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl Player {
135 pub fn new() -> Self {
136 let viz_buffer = VizBuffer::new();
137 let viz_snapshot = VizSnapshot::new();
138 let timeline = PlaybackTimeline::new();
139 let cfg = crate::config::Config::load_or_default();
140 let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
141 Arc::clone(&viz_buffer),
142 &cfg.visualizer,
143 Arc::clone(&viz_snapshot),
144 timeline.samples_played_counter(),
145 );
146
147 Self {
148 shared_state: SharedPlayerState::new(),
149 commands: CommandChannel::new(),
150 active_playback: None,
151 timeline,
152 viz_buffer,
153 viz_snapshot,
154 _viz_analyzer: viz_analyzer,
155 undo_stack: UndoStack::new(),
156 batch_buffer: None,
157 output_device_name: cfg.playback.output_device.clone(),
158 backend: crate::audio::platform_backend(),
159 last_skip: std::time::Instant::now(),
160 stream_mode: streaming::ProbeMode::Full,
161 history: None,
162 in_flight: None,
163 #[cfg(test)]
164 playback_starts: 0,
165 }
166 }
167
168 pub fn shared_state(&self) -> Arc<SharedPlayerState> {
170 self.shared_state.clone()
171 }
172
173 pub fn timeline(&self) -> Arc<PlaybackTimeline> {
175 self.timeline.clone()
176 }
177
178 pub fn viz_buffer(&self) -> Arc<VizBuffer> {
180 self.viz_buffer.clone()
181 }
182
183 pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
186 self.viz_snapshot.clone()
187 }
188
189 pub fn undo_stack(&self) -> &UndoStack {
191 &self.undo_stack
192 }
193
194 #[allow(clippy::type_complexity)]
202 fn create_engine_for(
203 &self,
204 info: &buffer::StreamInfo,
205 consumer: rtrb::Consumer<f32>,
206 ) -> Result<(Box<dyn AudioEngineHandle>, Option<Box<dyn SampleRateWatch>>), PlayerError> {
207 let device = self.resolve_device()?;
208 let device_rate = self.backend.get_device_sample_rate(&device)?;
209 let source_rate = info.sample_rate as f64;
210
211 self.shared_state.clear_output_sample_rate();
215
216 let settled = if (device_rate - source_rate).abs() > 0.1 {
217 log::info!(
218 "switching device sample rate: {}Hz → {}Hz",
219 device_rate,
220 source_rate
221 );
222 match self.backend.set_device_sample_rate(&device, source_rate) {
223 Ok(rate) => rate,
224 Err(e) => {
225 log::warn!("failed to set device sample rate: {}", e);
226 device_rate
227 }
228 }
229 } else {
230 device_rate
231 };
232
233 if (settled - source_rate).abs() > 0.1 {
234 log::warn!(
235 "device stayed at {}Hz (wanted {}Hz) — output is resampled, not bit-perfect",
236 settled,
237 source_rate
238 );
239 }
240
241 self.shared_state
244 .set_output_sample_rate(settled.round() as u32);
245
246 let watch_state = self.shared_state.clone();
250 let watch_name = device.name.clone();
251 let rate_watch = self.backend.watch_device_sample_rate(
252 &device,
253 Box::new(move |rate| {
254 log::info!("device sample rate changed externally: {rate}Hz on '{watch_name}'");
255 watch_state.set_output_sample_rate(rate.round() as u32);
256 }),
257 );
258
259 let engine = self.backend.create_engine(
260 &device,
261 source_rate,
262 info.channels as u32,
263 consumer,
264 self.timeline.samples_played_counter(),
265 )?;
266
267 Ok((engine, rate_watch))
268 }
269
270 fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
273 if let Some(ref name) = self.output_device_name {
274 match self.backend.list_devices() {
275 Ok(devices) => {
276 if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
277 return Ok(dev);
278 }
279 log::warn!(
280 "configured output device '{}' not found, falling back to default",
281 name,
282 );
283 }
284 Err(e) => {
285 log::warn!("failed to list devices while resolving '{}': {}", name, e);
286 }
287 }
288 }
289 Ok(self.backend.default_device()?)
290 }
291
292 pub fn set_output_device(&mut self, name: String) {
295 log::info!("switching output device to: {}", name);
296 self.output_device_name = Some(name.clone());
297
298 if let Err(e) = crate::config::Config::persist(|cfg| {
299 cfg.playback.output_device = Some(name);
300 }) {
301 log::error!("failed to save output device config: {}", e);
302 }
303
304 self.restart_on_current_track();
305 }
306
307 pub fn clear_output_device(&mut self) {
309 log::info!("reverting to system default output device");
310 self.output_device_name = None;
311
312 if let Err(e) = crate::config::Config::persist(|cfg| {
313 cfg.playback.output_device = None;
314 }) {
315 log::error!("failed to save output device config: {}", e);
316 }
317
318 self.restart_on_current_track();
319 }
320
321 fn restart_on_current_track(&mut self) {
324 if let Some(info) = self.shared_state.track_info() {
325 let position_ms = self.shared_state.position_ms();
326 if let Err(e) = self.restart_current(&info, position_ms) {
327 log::error!("failed to restart playback on device switch: {}", e);
328 }
329 }
330 }
331
332 pub fn output_device_name(&self) -> Option<&str> {
334 self.output_device_name.as_deref()
335 }
336
337 pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
339 self.commands.tx.clone()
340 }
341
342 pub fn play(&mut self, id: QueueItemId) {
345 self.shared_state.set_cursor(Some(id));
346
347 match self.shared_state.item_playback_source(id) {
348 Some(PlaybackSource::Ready(path)) => {
349 if let Err(e) = self.start_playback(id, &path, 0) {
350 log::error!("play failed: {}", e);
351 }
352 }
353 Some(PlaybackSource::Streaming {
354 path,
355 bytes_written,
356 total,
357 }) => {
358 self.stop_engine();
362 self.shared_state.set_playback_state(PlaybackState::Stopped);
363 self.probe_stream_for_playback(id, &path, bytes_written, total);
364 }
365 None => {
366 self.stop_engine();
368 self.shared_state.set_playback_state(PlaybackState::Stopped);
369 log::info!("play: item {:?} not ready, waiting for TrackReady", id);
370 }
371 }
372 }
373
374 fn start_playback(
379 &mut self,
380 id: QueueItemId,
381 path: &Path,
382 seek_ms: u64,
383 ) -> Result<(), PlayerError> {
384 #[cfg(test)]
385 {
386 self.playback_starts += 1;
387 }
388 let result = self.open_playback(id, path, seek_ms);
389 if result.is_err() {
390 self.stop_playback_and_clear_state();
391 }
392 result
393 }
394
395 fn open_playback(
396 &mut self,
397 id: QueueItemId,
398 path: &Path,
399 seek_ms: u64,
400 ) -> Result<(), PlayerError> {
401 self.stop_engine();
402
403 let info = buffer::probe_file(path)?;
404
405 self.shared_state.set_track_info(Some(TrackInfo {
409 id,
410 path: path.to_path_buf(),
411 codec: info.codec.clone(),
412 sample_rate: info.sample_rate,
413 bit_depth: info.bit_depth,
414 bitrate_kbps: info.bitrate_kbps,
415 channels: info.channels,
416 duration_ms: info.duration_ms,
417 }));
418 self.shared_state.set_position_ms(seek_ms);
419 self.on_track_changed(id);
420 log::info!(
421 "playing: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
422 path.display(),
423 id,
424 info.codec,
425 info.sample_rate,
426 info.channels,
427 info.duration_ms,
428 if seek_ms > 0 {
429 format!(" @{}ms", seek_ms)
430 } else {
431 String::new()
432 }
433 );
434
435 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
436
437 self.timeline.reset();
439
440 let advance_state = self.shared_state.clone();
444 let decode_cursor = parking_lot::Mutex::new(Some(id));
445 let next_track = move || {
446 let current = decode_cursor.lock().take()?;
447 let next = advance_state.peek_next_ready_after(current);
448 if let Some((next_id, _)) = &next {
449 let mut guard = decode_cursor.lock();
450 *guard = Some(*next_id);
451 }
452 next
453 };
454
455 let cfg = crate::config::Config::load_or_default();
457 let rg_mode = cfg.playback.replaygain;
458 let pre_amp_db = cfg.playback.pre_amp_db;
459
460 let finish_tx = self.commands.tx.clone();
461 let (_stream_info, decode_handle) = buffer::start_decode_file(
462 id,
463 path,
464 producer,
465 seek_ms,
466 next_track,
467 self.timeline.clone(),
468 Some(self.viz_buffer.clone()),
469 rg_mode,
470 pre_amp_db,
471 move || {
472 finish_tx.send(PlayerCommand::DecodeFinished).ok();
473 },
474 )?;
475
476 let (engine, rate_watch) = self.create_engine_for(&info, consumer)?;
477 engine.start()?;
478
479 self.shared_state.set_playback_state(PlaybackState::Playing);
480
481 self.active_playback = Some(ActivePlayback {
482 engine,
483 decode_handle,
484 _rate_watch: rate_watch,
485 });
486
487 Ok(())
488 }
489
490 fn probe_stream_for_playback(
505 &self,
506 id: QueueItemId,
507 path: &Path,
508 bytes_written: Arc<AtomicU64>,
509 total: u64,
510 ) {
511 let path = path.to_path_buf();
512 let tx = self.commands.tx.clone();
513 let hint = hint_for(&path);
514
515 let status = {
520 let downloading = self.stream_status_fn(id);
521 let state = self.shared_state.clone();
522 Arc::new(move || {
523 if state.is_cursor(id) {
524 downloading()
525 } else {
526 streaming::StreamStatus::Failed
527 }
528 }) as Arc<dyn Fn() -> streaming::StreamStatus + Send + Sync>
529 };
530
531 let spawned = thread::Builder::new()
532 .name("koan-stream-probe".into())
533 .spawn(move || {
534 let attempt = |mode, wait: bool| {
541 let open = if wait {
542 streaming::PartialFileSource::open(
543 &path,
544 bytes_written.clone(),
545 total,
546 status.clone(),
547 mode,
548 )
549 } else {
550 streaming::PartialFileSource::open_for_probe(
551 &path,
552 bytes_written.clone(),
553 total,
554 status.clone(),
555 mode,
556 )
557 };
558 open.map_err(buffer::DecodeError::Io).and_then(|source| {
559 let mss = symphonia::core::io::MediaSourceStream::new(
560 Box::new(source),
561 Default::default(),
562 );
563 buffer::probe_source(mss, &hint)
564 })
565 };
566
567 let info = match attempt(streaming::ProbeMode::Full, false) {
571 Ok(info) => Some((info, streaming::ProbeMode::Full)),
572 Err(e) => {
573 log::info!(
579 "stream probe: {} needs more than has arrived ({}), opening without a length",
580 path.display(),
581 e
582 );
583 let lengthless = lengthless_mode_for(&path);
584 attempt(lengthless, true)
585 .ok()
586 .map(|info| (info, lengthless))
587 }
588 };
589
590 match info {
591 Some((info, mode)) => {
592 tx.send(PlayerCommand::StreamProbed {
593 id,
594 info: Box::new(info),
595 mode,
596 })
597 .ok();
598 }
599 None => log::info!(
602 "stream probe: {} cannot start early, waiting for the download",
603 path.display()
604 ),
605 }
606 });
607
608 if let Err(e) = spawned {
609 log::warn!("stream probe: could not spawn for {:?}: {}", id, e);
610 }
611 }
612
613 fn stream_probed(
616 &mut self,
617 id: QueueItemId,
618 info: buffer::StreamInfo,
619 mode: streaming::ProbeMode,
620 ) {
621 if !self.shared_state.is_cursor(id) {
622 return; }
624 if self.shared_state.playback_state() != PlaybackState::Stopped {
625 return; }
627
628 match self.shared_state.item_playback_source(id) {
629 Some(PlaybackSource::Ready(path)) => {
631 if let Err(e) = self.start_playback(id, &path, 0) {
632 log::error!("stream probe: playback failed: {}", e);
633 }
634 }
635 Some(PlaybackSource::Streaming {
636 path,
637 bytes_written,
638 total,
639 }) => {
640 let source = StreamSource {
641 path,
642 bytes_written,
643 total,
644 mode,
645 };
646 if let Err(e) = self.start_streaming_playback(id, source, 0, info) {
647 log::error!("stream probe: streaming playback failed: {}", e);
648 }
649 }
650 None => {}
651 }
652 }
653
654 fn stream_status_fn(
658 &self,
659 id: QueueItemId,
660 ) -> Arc<dyn Fn() -> streaming::StreamStatus + Send + Sync> {
661 let state = self.shared_state.clone();
662 Arc::new(move || match state.item_load_state(id) {
663 Some(LoadState::Ready) => streaming::StreamStatus::Complete,
664 Some(LoadState::Failed(_)) => streaming::StreamStatus::Failed,
665 _ => streaming::StreamStatus::Downloading,
666 })
667 }
668
669 fn start_streaming_playback(
679 &mut self,
680 id: QueueItemId,
681 source: StreamSource,
682 seek_ms: u64,
683 info: buffer::StreamInfo,
684 ) -> Result<(), PlayerError> {
685 let result = self.open_streaming_playback(id, source, seek_ms, info);
686 if result.is_err() {
687 self.stop_playback_and_clear_state();
688 }
689 result
690 }
691
692 fn open_streaming_playback(
693 &mut self,
694 id: QueueItemId,
695 source: StreamSource,
696 seek_ms: u64,
697 info: buffer::StreamInfo,
698 ) -> Result<(), PlayerError> {
699 self.stop_engine();
700 self.stream_mode = source.mode;
702 let path = source.path.as_path();
703
704 let status = self.stream_status_fn(id);
705 let open_source = {
706 let StreamSource {
707 path,
708 bytes_written,
709 total,
710 mode,
711 } = source.clone();
712 let status = status.clone();
713 move || {
714 streaming::PartialFileSource::open(
715 &path,
716 bytes_written.clone(),
717 total,
718 status.clone(),
719 mode,
720 )
721 }
722 };
723
724 self.shared_state.set_track_info(Some(TrackInfo {
725 id,
726 path: path.to_path_buf(),
727 codec: info.codec.clone(),
728 sample_rate: info.sample_rate,
729 bit_depth: info.bit_depth,
730 bitrate_kbps: info.bitrate_kbps,
731 channels: info.channels,
732 duration_ms: info.duration_ms,
733 }));
734 self.shared_state.set_position_ms(seek_ms);
735 self.on_track_changed(id);
736 log::info!(
737 "streaming: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
738 path.display(),
739 id,
740 info.codec,
741 info.sample_rate,
742 info.channels,
743 info.duration_ms,
744 if seek_ms > 0 {
745 format!(" @{}ms", seek_ms)
746 } else {
747 String::new()
748 },
749 );
750
751 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
752
753 self.timeline.reset();
754
755 let advance_state = self.shared_state.clone();
757 let decode_cursor = parking_lot::Mutex::new(Some(id));
758 let next_track = move || {
759 let current = decode_cursor.lock().take()?;
760 let next = advance_state.peek_next_ready_after(current);
761 if let Some((next_id, _)) = &next {
762 let mut guard = decode_cursor.lock();
763 *guard = Some(*next_id);
764 }
765 next
766 };
767
768 let first = buffer::SourceEntry {
769 id,
770 path: path.to_path_buf(),
771 hint: hint_for(path),
772 make_mss: Box::new(move || {
773 Ok(symphonia::core::io::MediaSourceStream::new(
774 Box::new(open_source()?),
775 Default::default(),
776 ))
777 }),
778 };
779
780 let cfg = crate::config::Config::load_or_default();
782 let rg_mode = cfg.playback.replaygain;
783 let pre_amp_db = cfg.playback.pre_amp_db;
784
785 let finish_tx = self.commands.tx.clone();
786 let (_stream_info, decode_handle) = buffer::start_decode(
787 first,
788 producer,
789 seek_ms,
790 move || {
791 let (next_id, next_path) = next_track()?;
792 Some(buffer::SourceEntry::from_file(next_id, next_path))
793 },
794 self.timeline.clone(),
795 Some(self.viz_buffer.clone()),
796 rg_mode,
797 pre_amp_db,
798 move || {
799 finish_tx.send(PlayerCommand::DecodeFinished).ok();
800 },
801 )?;
802
803 let (engine, rate_watch) = self.create_engine_for(&info, consumer)?;
804 engine.start()?;
805
806 self.shared_state.set_playback_state(PlaybackState::Playing);
807
808 self.active_playback = Some(ActivePlayback {
809 engine,
810 decode_handle,
811 _rate_watch: rate_watch,
812 });
813
814 Ok(())
815 }
816
817 pub fn seek(&mut self, position_ms: u64) {
824 let Some(info) = self.shared_state.track_info() else {
825 return;
826 };
827 let seekable = self.shared_state.seekable_ms();
829 if seekable == 0 {
830 log::debug!("seek declined: {:?} is not seekable yet", info.id);
834 return;
835 }
836 let ceiling = seekable.min(
837 self.shared_state
838 .duration_ms()
839 .saturating_sub(SEEK_END_GUARD_MS),
840 );
841 let clamped = position_ms.min(ceiling);
842
843 if let Err(e) = self.restart_current(&info, clamped) {
844 log::error!("seek failed: {}", e);
845 }
846 }
847
848 fn restart_current(&mut self, info: &TrackInfo, position_ms: u64) -> Result<(), PlayerError> {
856 let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
857
858 match self.shared_state.item_playback_source(info.id) {
859 Some(PlaybackSource::Streaming {
860 path,
861 bytes_written,
862 total,
863 }) => {
864 let known = buffer::StreamInfo {
868 codec: info.codec.clone(),
869 sample_rate: info.sample_rate,
870 channels: info.channels,
871 bit_depth: info.bit_depth,
872 bitrate_kbps: info.bitrate_kbps,
873 duration_ms: info.duration_ms,
874 };
875 let source = StreamSource {
876 path,
877 bytes_written,
878 total,
879 mode: self.stream_mode,
880 };
881 self.start_streaming_playback(info.id, source, position_ms, known)?;
882 }
883 Some(PlaybackSource::Ready(path)) => {
884 self.start_playback(info.id, &path, position_ms)?;
885 }
886 None => return Ok(()),
887 }
888
889 if was_paused {
890 self.pause();
891 }
892 Ok(())
893 }
894
895 pub fn next_track(&mut self) {
897 match self.shared_state.advance_cursor_loadable() {
898 Some(id) => self.play(id),
899 None => {
900 log::info!("no more tracks in playlist");
901 self.stop_playback_and_clear_state();
902 }
903 }
904 }
905
906 pub fn prev_track(&mut self) {
908 match self.shared_state.retreat_cursor() {
909 Some((id, path)) => {
910 if matches!(path.try_exists(), Ok(true)) {
911 if let Err(e) = self.start_playback(id, &path, 0) {
912 log::error!("prev track failed: {}", e);
913 }
914 } else {
915 log::warn!("prev track path doesn't exist: {}", path.display());
916 }
917 }
918 None => {
919 if let Some(info) = self.shared_state.track_info()
921 && let Err(e) = self.restart_current(&info, 0)
922 {
923 log::error!("restart failed: {}", e);
924 }
925 }
926 }
927 }
928
929 pub fn pause(&mut self) {
931 if let Some(ref playback) = self.active_playback {
932 if let Err(e) = playback.engine.stop() {
933 log::error!("pause failed: {}", e);
934 return;
935 }
936 self.shared_state.set_playback_state(PlaybackState::Paused);
937 }
938 }
939
940 pub fn resume(&mut self) {
942 if let Some(ref playback) = self.active_playback {
943 if let Err(e) = playback.engine.start() {
944 log::error!("resume failed: {}", e);
945 return;
946 }
947 self.shared_state.set_playback_state(PlaybackState::Playing);
948 }
949 }
950
951 pub fn stop(&mut self) {
953 self.shared_state.clear_playlist();
954 self.stop_playback_and_clear_state();
955 }
956
957 fn stop_engine(&mut self) {
964 let Some(playback) = self.active_playback.take() else {
965 return;
966 };
967 let ActivePlayback {
968 engine,
969 mut decode_handle,
970 _rate_watch,
971 } = playback;
972
973 let _ = engine.stop();
984 decode_handle.stop();
985 drop(engine);
986 }
987
988 fn stop_playback_and_clear_state(&mut self) {
990 self.finish_play();
991 self.stop_engine();
992 self.timeline.reset();
993 self.shared_state.set_playback_state(PlaybackState::Stopped);
994 self.shared_state.set_position_ms(0);
995 self.shared_state.set_track_info(None);
996 }
997
998 pub fn remove_from_playlist(&mut self, id: QueueItemId) {
1005 let was_cursor = self.shared_state.is_cursor(id);
1006 let resume_after = was_cursor
1007 .then(|| self.shared_state.item_before(id))
1008 .flatten();
1009 self.shared_state.remove_item(id);
1010 if was_cursor {
1011 self.shared_state.set_cursor(resume_after);
1012 self.next_track();
1013 }
1014 }
1015
1016 pub fn track_ready(&mut self, id: QueueItemId) {
1019 self.shared_state.update_item_state(id, ItemState::Ready);
1021
1022 if !self.shared_state.is_cursor(id) {
1023 return;
1024 }
1025
1026 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
1027 let current_track_id = self.shared_state.track_info().map(|t| t.id);
1028
1029 if is_playing && current_track_id == Some(id) {
1030 log::info!(
1033 "track_ready: download complete while streaming {:?}, refreshing metadata",
1034 id
1035 );
1036 self.refresh_track_metadata(id);
1037 return;
1038 }
1039
1040 if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
1042 log::info!("track_ready: starting playback for {:?}", id);
1043 if let Err(e) = self.start_playback(id, &path, 0) {
1044 log::error!("track_ready playback failed: {}", e);
1045 }
1046 }
1047 }
1048
1049 pub fn track_stream_ready(&mut self, id: QueueItemId) {
1052 if !self.shared_state.is_cursor(id) {
1053 return;
1054 }
1055
1056 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
1057 if is_playing {
1058 return; }
1060
1061 match self.shared_state.item_playback_source(id) {
1062 Some(PlaybackSource::Streaming {
1063 path,
1064 bytes_written,
1065 total,
1066 }) => {
1067 log::info!("track_stream_ready: probing partial file for {:?}", id);
1068 self.probe_stream_for_playback(id, &path, bytes_written, total);
1069 }
1070 Some(PlaybackSource::Ready(path)) => {
1071 log::info!(
1073 "track_stream_ready: track already ready, starting normal playback for {:?}",
1074 id
1075 );
1076 if let Err(e) = self.start_playback(id, &path, 0) {
1077 log::error!("track_stream_ready playback failed: {}", e);
1078 }
1079 }
1080 None => {} }
1082 }
1083
1084 fn refresh_track_metadata(&mut self, id: QueueItemId) {
1088 use crate::index::metadata;
1089
1090 let path = match self.shared_state.item_path_if_ready(id) {
1091 Some(p) => p,
1092 None => return,
1093 };
1094
1095 match metadata::read_metadata(&path) {
1096 Ok(meta) => {
1097 self.shared_state.update_item_metadata(
1098 id,
1099 meta.title,
1100 meta.artist,
1101 meta.album_artist.unwrap_or_default(),
1102 meta.album,
1103 meta.duration_ms.map(|d| d as u64),
1104 );
1105
1106 if let Some(current) = self.shared_state.track_info()
1115 && current.id == id
1116 {
1117 let probed = buffer::probe_file(&path).ok();
1118 let duration_ms = probed
1119 .as_ref()
1120 .map(|s| s.duration_ms)
1121 .filter(|d| *d > current.duration_ms)
1122 .unwrap_or(current.duration_ms);
1123 if duration_ms != current.duration_ms {
1124 log::info!(
1125 "track_ready: duration corrected {}ms → {}ms",
1126 current.duration_ms,
1127 duration_ms
1128 );
1129 }
1130 self.shared_state.set_track_info(Some(TrackInfo {
1131 duration_ms,
1132 path: path.clone(),
1133 ..current
1134 }));
1135 }
1136
1137 self.shared_state.signal_metadata_refresh();
1139 log::info!("track_ready: metadata refreshed for {:?}", id);
1140 }
1141 Err(e) => {
1142 log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
1143 }
1144 }
1145 }
1146
1147 fn on_track_changed(&mut self, id: QueueItemId) {
1157 if self.in_flight.as_ref().is_some_and(|f| f.item == id) {
1158 return;
1159 }
1160 self.finish_play();
1161 let track_id = self.shared_state.item_db_id(id);
1162 self.in_flight = Some(InFlight::new(id, track_id));
1163 if let (Some(track_id), Some(recorder)) = (track_id, self.history.as_ref()) {
1164 recorder.record(PlayEvent::Started { track_id });
1165 }
1166 }
1167
1168 fn finish_play(&mut self) -> Option<PlayEvent> {
1171 let flight = self.in_flight.take()?;
1172 let event = PlayEvent::Finished {
1173 track_id: flight.track_id()?,
1174 listened_ms: flight.listened_ms(),
1175 };
1176 if let Some(recorder) = self.history.as_ref() {
1177 recorder.record(event);
1178 }
1179 Some(event)
1180 }
1181
1182 pub fn update_playback_state(&mut self) {
1183 if self.active_playback.is_none() {
1184 return;
1185 }
1186
1187 if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
1188 self.shared_state.set_position_ms(position_ms);
1189
1190 self.on_track_changed(id);
1193 if let Some(f) = self.in_flight.as_mut() {
1194 f.advance(position_ms);
1195 }
1196
1197 let current_id = self.shared_state.track_info().map(|t| t.id);
1200 if current_id != Some(id) {
1201 log::info!("timeline: now playing {:?}", id);
1202 self.shared_state.set_track_info(Some(TrackInfo {
1203 id,
1204 path,
1205 codec: info.codec,
1206 sample_rate: info.sample_rate,
1207 bit_depth: info.bit_depth,
1208 bitrate_kbps: info.bitrate_kbps,
1209 channels: info.channels,
1210 duration_ms: info.duration_ms,
1211 }));
1212 self.shared_state.set_cursor(Some(id));
1213 }
1214 }
1215 }
1216
1217 pub fn track_failed(&mut self, id: QueueItemId) {
1224 if !self.shared_state.is_cursor(id) {
1225 return;
1226 }
1227 if self.shared_state.playback_state() != PlaybackState::Stopped {
1231 return;
1232 }
1233 log::info!("track {:?} cannot load, moving on", id);
1234 self.next_track();
1235 }
1236
1237 fn on_decode_finished(&mut self) {
1244 log::info!("decode finished, checking for next track");
1245 match self.shared_state.advance_cursor_loadable() {
1246 Some(id) => self.play(id),
1247 None => {
1248 log::info!("no more tracks — stopping");
1249 self.stop_playback_and_clear_state();
1250 }
1251 }
1252 }
1253
1254 fn snapshot_for_undo(
1258 &self,
1259 ids: &[QueueItemId],
1260 ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
1261 self.shared_state
1262 .items_before(ids)
1263 .into_iter()
1264 .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
1265 .collect()
1266 }
1267
1268 fn push_undo(&mut self, entry: UndoEntry) {
1270 if let Some(ref mut batch) = self.batch_buffer {
1271 batch.push(entry);
1272 } else {
1273 self.undo_stack.push(entry);
1274 }
1275 }
1276
1277 pub fn process_command(&mut self, cmd: PlayerCommand) {
1279 match cmd {
1280 PlayerCommand::Play(id) => self.play(id),
1281 PlayerCommand::Pause => self.pause(),
1282 PlayerCommand::Resume => self.resume(),
1283 PlayerCommand::Stop => self.stop(),
1284 PlayerCommand::Seek(pos) => self.seek(pos),
1285 PlayerCommand::NextTrack => {
1286 let now = std::time::Instant::now();
1288 if now.duration_since(self.last_skip).as_millis() >= 150 {
1289 self.last_skip = now;
1290 self.next_track();
1291 }
1292 }
1293 PlayerCommand::PrevTrack => {
1294 let now = std::time::Instant::now();
1295 if now.duration_since(self.last_skip).as_millis() >= 150 {
1296 self.last_skip = now;
1297 self.prev_track();
1298 }
1299 }
1300 PlayerCommand::AddToPlaylist(items) => {
1301 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1302 self.shared_state.add_items(items);
1303 self.push_undo(UndoEntry::Added { ids });
1304 }
1305 PlayerCommand::UpdatePaths(updates) => {
1306 self.shared_state.update_paths(&updates);
1307 if let Some(info) = self.shared_state.track_info()
1308 && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
1309 {
1310 self.shared_state.set_track_info(Some(TrackInfo {
1311 path: new_path.clone(),
1312 ..info
1313 }));
1314 }
1315 }
1316 PlayerCommand::InsertInPlaylist { items, after } => {
1317 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1318 self.shared_state.insert_items_after(items, after);
1319 self.push_undo(UndoEntry::Inserted { ids });
1320 }
1321 PlayerCommand::ClearPlaylist => {
1322 self.stop_playback_and_clear_state();
1326 let (items, cursor) = self.shared_state.snapshot_playlist();
1327 self.shared_state.clear_playlist();
1328 self.push_undo(UndoEntry::Replaced { items, cursor });
1329 }
1330 PlayerCommand::ReplacePlaylist { items, start } => {
1331 self.stop_playback_and_clear_state();
1335 let (old_items, cursor) = self.shared_state.snapshot_playlist();
1336 self.shared_state.clear_playlist();
1337 self.push_undo(UndoEntry::Replaced {
1338 items: old_items,
1339 cursor,
1340 });
1341
1342 if items.is_empty() {
1343 return;
1344 }
1345 let start_id = items.get(start).unwrap_or(&items[0]).id;
1346 self.shared_state.add_items(items);
1347 self.play(start_id);
1348 }
1349 PlayerCommand::RemoveFromPlaylist(id) => {
1350 let item = self.shared_state.get_item(id);
1351 let after = self.shared_state.item_before(id);
1352 self.remove_from_playlist(id);
1353 if let Some(item) = item {
1354 self.push_undo(UndoEntry::Removed {
1355 items: vec![(Box::new(item), after)],
1356 });
1357 }
1358 }
1359 PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1360 let items_with_pos = self.snapshot_for_undo(&ids);
1364 let resume_after = match self.shared_state.cursor() {
1365 Some(cursor) if ids.contains(&cursor) => {
1366 Some(self.shared_state.surviving_item_before(cursor, &ids))
1367 }
1368 _ => None,
1369 };
1370
1371 self.shared_state.remove_items(&ids);
1372
1373 if let Some(resume_after) = resume_after {
1374 self.shared_state.set_cursor(resume_after);
1375 self.next_track();
1376 }
1377
1378 if !items_with_pos.is_empty() {
1379 self.push_undo(UndoEntry::Removed {
1380 items: items_with_pos,
1381 });
1382 }
1383 }
1384 PlayerCommand::MoveInPlaylist { id, target, after } => {
1385 let was_after = self.shared_state.item_before(id);
1386 self.shared_state.move_item(id, target, after);
1387 self.push_undo(UndoEntry::Moved { id, was_after });
1388 }
1389 PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1390 let entries = self.shared_state.items_before(&ids);
1391 self.shared_state.move_items(&ids, target, after);
1392 self.push_undo(UndoEntry::MovedBatch { entries });
1393 }
1394 PlayerCommand::ReorderPlaylist(order) => {
1395 let entries = self.shared_state.items_before(&order);
1399 self.shared_state.reorder_to(&order);
1400 self.push_undo(UndoEntry::MovedBatch { entries });
1401 }
1402 PlayerCommand::TrackReady(id) => self.track_ready(id),
1403 PlayerCommand::DecodeFinished => self.on_decode_finished(),
1404 PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1405 PlayerCommand::StreamProbed { id, info, mode } => self.stream_probed(id, *info, mode),
1406 PlayerCommand::TrackFailed(id) => self.track_failed(id),
1407 PlayerCommand::Undo => self.execute_undo(),
1408 PlayerCommand::Redo => self.execute_redo(),
1409 PlayerCommand::BeginUndoBatch => {
1410 self.batch_buffer = Some(Vec::new());
1411 }
1412 PlayerCommand::EndUndoBatch => {
1413 if let Some(entries) = self.batch_buffer.take() {
1414 if entries.len() == 1 {
1415 self.undo_stack.push(entries.into_iter().next().unwrap());
1417 } else if !entries.is_empty() {
1418 self.undo_stack.push(UndoEntry::Batch(entries));
1419 }
1420 }
1421 }
1422 PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1423 PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1424 }
1425 }
1426
1427 fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1429 match entry {
1430 UndoEntry::Added { ids } => {
1431 let items_with_pos = self.snapshot_for_undo(&ids);
1433 self.shared_state.remove_items(&ids);
1434 Some(UndoEntry::Removed {
1435 items: items_with_pos,
1436 })
1437 }
1438 UndoEntry::Removed { items } => {
1439 let mut ids = Vec::with_capacity(items.len());
1441 for (item, after) in items {
1442 ids.push(item.id);
1443 self.shared_state.insert_item_at(*item, after);
1444 }
1445 Some(UndoEntry::Added { ids })
1446 }
1447 UndoEntry::Inserted { ids } => {
1448 let items_with_pos = self.snapshot_for_undo(&ids);
1450 self.shared_state.remove_items(&ids);
1451 Some(UndoEntry::Removed {
1452 items: items_with_pos,
1453 })
1454 }
1455 UndoEntry::Moved { id, was_after } => {
1456 let current_after = self.shared_state.item_before(id);
1457 self.shared_state.move_item_to(id, was_after);
1458 Some(UndoEntry::Moved {
1459 id,
1460 was_after: current_after,
1461 })
1462 }
1463 UndoEntry::MovedBatch { entries } => {
1464 let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1465 let current_positions = self.shared_state.items_before(&ids);
1466 self.shared_state.move_items_to(&entries);
1467 Some(UndoEntry::MovedBatch {
1468 entries: current_positions,
1469 })
1470 }
1471 UndoEntry::Replaced { items, cursor } => {
1472 let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1473 self.shared_state.restore_playlist(items, cursor);
1474 Some(UndoEntry::Replaced {
1475 items: current_items,
1476 cursor: current_cursor,
1477 })
1478 }
1479 UndoEntry::Batch(entries) => {
1480 let mut inverses = Vec::with_capacity(entries.len());
1482 for entry in entries.into_iter().rev() {
1483 if let Some(inverse) = self.apply_entry(entry) {
1484 inverses.push(inverse);
1485 }
1486 }
1487 inverses.reverse();
1488 Some(UndoEntry::Batch(inverses))
1489 }
1490 }
1491 }
1492
1493 fn reconcile_playback(&mut self) {
1507 let Some(playing) = self.shared_state.track_info().map(|t| t.id) else {
1508 return;
1509 };
1510 if self.shared_state.get_item(playing).is_some() {
1511 return;
1512 }
1513 let resume = (self.shared_state.playback_state() == PlaybackState::Playing)
1518 .then(|| self.shared_state.cursor())
1519 .flatten();
1520 self.stop_playback_and_clear_state();
1521 if let Some(id) = resume {
1522 self.play(id);
1523 }
1524 }
1525
1526 fn execute_undo(&mut self) {
1528 let Some(entry) = self.undo_stack.pop_undo() else {
1529 return;
1530 };
1531 if let Some(inverse) = self.apply_entry(entry) {
1532 self.undo_stack.push_redo(inverse);
1533 }
1534 self.reconcile_playback();
1535 }
1536
1537 fn execute_redo(&mut self) {
1539 let Some(entry) = self.undo_stack.pop_redo() else {
1540 return;
1541 };
1542 if let Some(inverse) = self.apply_entry(entry) {
1543 self.undo_stack.push_undo_keep_redo(inverse);
1544 }
1545 self.reconcile_playback();
1546 }
1547
1548 pub fn run(&mut self) {
1550 use std::time::Duration;
1551
1552 let rx = self.commands.rx.clone();
1553 loop {
1554 match rx.recv_timeout(Duration::from_millis(50)) {
1556 Ok(cmd) => self.process_command(cmd),
1557 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1558 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1559 }
1560 self.update_playback_state();
1561 }
1562 self.stop();
1563 }
1564
1565 pub fn spawn() -> (
1568 Arc<SharedPlayerState>,
1569 Arc<PlaybackTimeline>,
1570 Arc<VizSnapshot>,
1571 crossbeam_channel::Sender<PlayerCommand>,
1572 ) {
1573 let mut player = Self::new();
1574 player.history = PlayRecorder::spawn();
1575 let state = player.shared_state();
1576 let timeline = player.timeline();
1577 let viz_snapshot = player.viz_snapshot();
1578 let tx = player.command_sender();
1579
1580 thread::Builder::new()
1581 .name("koan-player".into())
1582 .spawn(move || player.run())
1583 .expect("failed to spawn player thread");
1584
1585 (state, timeline, viz_snapshot, tx)
1586 }
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591 use super::*;
1592 use state::PlaylistItem;
1593 use std::path::PathBuf;
1594
1595 fn make_item(title: &str) -> PlaylistItem {
1596 PlaylistItem {
1597 playlist_entry_id: None,
1598 id: QueueItemId::new(),
1599 db_id: None,
1600 path: PathBuf::from(format!("/music/{title}.flac")),
1601 title: title.to_string(),
1602 artist: String::new(),
1603 album_artist: String::new(),
1604 album: String::new(),
1605 year: None,
1606 codec: None,
1607 track_number: None,
1608 disc: None,
1609 duration_ms: None,
1610 state: ItemState::Ready,
1611 }
1612 }
1613
1614 fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1615 let (items, _) = player.shared_state.snapshot_playlist();
1616 items.iter().map(|i| i.id).collect()
1617 }
1618
1619 fn playlist_titles(player: &Player) -> Vec<String> {
1620 let (items, _) = player.shared_state.snapshot_playlist();
1621 items.iter().map(|i| i.title.clone()).collect()
1622 }
1623
1624 fn pending_item(title: &str) -> PlaylistItem {
1625 PlaylistItem {
1626 playlist_entry_id: None,
1627 state: ItemState::Pending,
1628 ..make_item(title)
1629 }
1630 }
1631
1632 fn pretend_playing(player: &mut Player, id: QueueItemId) {
1636 let item = player
1637 .shared_state
1638 .get_item(id)
1639 .expect("item is in the queue");
1640 player.shared_state.set_track_info(Some(TrackInfo {
1641 id,
1642 path: item.path,
1643 codec: String::new(),
1644 sample_rate: 44_100,
1645 bit_depth: None,
1646 bitrate_kbps: None,
1647 channels: 2,
1648 duration_ms: 1_000,
1649 }));
1650 player
1651 .shared_state
1652 .set_playback_state(PlaybackState::Playing);
1653 }
1654
1655 fn playing_id(player: &Player) -> Option<QueueItemId> {
1656 player.shared_state.track_info().map(|t| t.id)
1657 }
1658
1659 fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
1661 let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
1662 let ids = items.iter().map(|i| i.id).collect();
1663 player.process_command(PlayerCommand::AddToPlaylist(items));
1664 ids
1665 }
1666
1667 fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
1671 let mut at = from_ms;
1672 if let Some(f) = player.in_flight.as_mut() {
1673 f.advance(at); }
1675 while at < to_ms {
1676 at = (at + 50).min(to_ms);
1677 if let Some(f) = player.in_flight.as_mut() {
1678 f.advance(at);
1679 }
1680 }
1681 }
1682
1683 fn start(player: &mut Player, track_id: i64) -> QueueItemId {
1684 let id = QueueItemId::new();
1685 player.on_track_changed(id);
1686 player
1688 .in_flight
1689 .as_mut()
1690 .unwrap()
1691 .track_id_for_test(track_id);
1692 id
1693 }
1694
1695 #[test]
1696 fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
1697 let mut player = Player::new();
1698 start(&mut player, 11);
1699 listen(&mut player, 0, 200_000);
1700
1701 let b = QueueItemId::new();
1702 player.on_track_changed(b);
1703 let f = player
1704 .in_flight
1705 .as_ref()
1706 .expect("the next track is counting");
1707 assert_eq!(f.item, b);
1708 assert_eq!(f.listened_ms(), 0, "and starts from nothing");
1709 }
1710
1711 #[test]
1712 fn a_track_skipped_seconds_in_is_still_history() {
1713 let mut player = Player::new();
1714 start(&mut player, 7);
1715 listen(&mut player, 0, 2_000);
1716
1717 let event = player
1718 .finish_play()
1719 .expect("putting something on is a thing you did, however briefly");
1720 assert!(matches!(
1721 event,
1722 history::PlayEvent::Finished {
1723 track_id: 7,
1724 listened_ms: 2_000
1725 }
1726 ));
1727 }
1728
1729 #[test]
1730 fn a_track_is_closed_out_once() {
1731 let mut player = Player::new();
1732 start(&mut player, 7);
1733 listen(&mut player, 0, 200_000);
1734
1735 assert!(player.finish_play().is_some());
1736 assert!(player.finish_play().is_none());
1737 }
1738
1739 #[test]
1740 fn seeking_around_a_track_does_not_enter_it_twice() {
1741 let mut player = Player::new();
1742 let id = start(&mut player, 7);
1743 listen(&mut player, 0, 120_000);
1744
1745 player.on_track_changed(id);
1747 assert_eq!(
1748 player.in_flight.as_ref().unwrap().listened_ms(),
1749 120_000,
1750 "the seek kept the count rather than restarting it"
1751 );
1752 listen(&mut player, 30_000, 40_000);
1753
1754 let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
1755 panic!("still one play");
1756 };
1757 assert_eq!(listened_ms, 130_000);
1758 assert!(player.finish_play().is_none());
1759 }
1760
1761 #[test]
1762 fn a_track_that_is_not_in_the_library_is_not_recorded() {
1763 let mut player = Player::new();
1764 let id = QueueItemId::new();
1765 player.on_track_changed(id);
1766 listen(&mut player, 0, 200_000);
1767 assert!(player.finish_play().is_none());
1768 }
1769
1770 #[test]
1771 fn stopping_closes_out_what_was_heard() {
1772 let mut player = Player::new();
1773 start(&mut player, 7);
1774 listen(&mut player, 0, 150_000);
1775
1776 player.stop_playback_and_clear_state();
1777 assert!(player.in_flight.is_none(), "the stop consumed it");
1778 }
1779
1780 #[test]
1781 fn removing_the_playing_track_resumes_at_its_successor() {
1782 let mut player = Player::new();
1783 let ids = seed(&mut player, 5);
1784 player.shared_state.set_cursor(Some(ids[2]));
1785
1786 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
1787
1788 assert_eq!(
1789 player.shared_state.cursor(),
1790 Some(ids[3]),
1791 "playback must continue at the next track, not restart the queue"
1792 );
1793 assert_eq!(player.playback_starts, 1);
1794 }
1795
1796 #[test]
1797 fn removing_the_first_playing_track_resumes_at_the_new_first() {
1798 let mut player = Player::new();
1799 let ids = seed(&mut player, 3);
1800 player.shared_state.set_cursor(Some(ids[0]));
1801
1802 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
1803
1804 assert_eq!(player.shared_state.cursor(), Some(ids[1]));
1805 }
1806
1807 #[test]
1808 fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
1809 let mut player = Player::new();
1810 let playing = make_item("playing");
1811 let waiting = pending_item("waiting");
1812 let later = make_item("later");
1813 let (playing_id, waiting_id) = (playing.id, waiting.id);
1814 player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
1815 player.shared_state.set_cursor(Some(playing_id));
1816
1817 player.process_command(PlayerCommand::DecodeFinished);
1818
1819 assert_eq!(
1820 player.shared_state.cursor(),
1821 Some(waiting_id),
1822 "the cursor parks on the track being fetched"
1823 );
1824 assert_eq!(
1825 player.playback_starts, 0,
1826 "nothing to play until its bytes land"
1827 );
1828
1829 player
1832 .shared_state
1833 .update_item_state(waiting_id, ItemState::Ready);
1834 player.process_command(PlayerCommand::TrackReady(waiting_id));
1835
1836 assert_eq!(player.playback_starts, 1);
1837 assert_eq!(player.shared_state.cursor(), Some(waiting_id));
1838 }
1839
1840 #[test]
1841 fn a_download_that_cannot_land_moves_the_cursor_on() {
1842 let mut player = Player::new();
1843 let waiting = pending_item("waiting");
1844 let later = make_item("later");
1845 let (waiting_id, later_id) = (waiting.id, later.id);
1846 player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, later]));
1847
1848 player.process_command(PlayerCommand::Play(waiting_id));
1849 assert_eq!(player.playback_starts, 0, "nothing to play yet");
1850
1851 player
1853 .shared_state
1854 .update_item_state(waiting_id, ItemState::Failed("remote unavailable".into()));
1855 player.process_command(PlayerCommand::TrackFailed(waiting_id));
1856
1857 assert_eq!(
1858 player.shared_state.cursor(),
1859 Some(later_id),
1860 "the queue moves past a track that can never load"
1861 );
1862 assert_eq!(player.playback_starts, 1);
1863 }
1864
1865 #[test]
1866 fn a_queue_that_can_never_load_stops_rather_than_waiting() {
1867 let mut player = Player::new();
1868 let first = pending_item("first");
1869 let second = pending_item("second");
1870 let (first_id, second_id) = (first.id, second.id);
1871 player.process_command(PlayerCommand::AddToPlaylist(vec![first, second]));
1872
1873 player.process_command(PlayerCommand::Play(first_id));
1874 for id in [first_id, second_id] {
1875 player
1876 .shared_state
1877 .update_item_state(id, ItemState::Failed("remote unavailable".into()));
1878 player.process_command(PlayerCommand::TrackFailed(id));
1879 }
1880
1881 assert_eq!(player.playback_starts, 0);
1882 assert_eq!(
1883 player.shared_state.playback_state(),
1884 PlaybackState::Stopped,
1885 "a stop the UI can see, not an indefinite wait for TrackReady"
1886 );
1887 }
1888
1889 #[test]
1890 fn a_failure_elsewhere_in_the_queue_leaves_the_cursor_alone() {
1891 let mut player = Player::new();
1892 let waiting = pending_item("waiting");
1893 let other = pending_item("other");
1894 let (waiting_id, other_id) = (waiting.id, other.id);
1895 player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, other]));
1896 player.process_command(PlayerCommand::Play(waiting_id));
1897
1898 player
1899 .shared_state
1900 .update_item_state(other_id, ItemState::Failed("remote unavailable".into()));
1901 player.process_command(PlayerCommand::TrackFailed(other_id));
1902
1903 assert_eq!(
1904 player.shared_state.cursor(),
1905 Some(waiting_id),
1906 "a track still downloading keeps the cursor"
1907 );
1908 }
1909
1910 #[test]
1911 fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
1912 let mut player = Player::new();
1913 let ids = seed(&mut player, 5);
1914 player.shared_state.set_cursor(Some(ids[2]));
1915
1916 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
1917 ids[1], ids[2], ids[3],
1918 ]));
1919
1920 assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
1921 assert_eq!(player.shared_state.cursor(), Some(ids[4]));
1922 assert_eq!(
1923 player.playback_starts, 1,
1924 "one resume for the whole selection, not one per deleted track"
1925 );
1926 }
1927
1928 #[test]
1929 fn batch_delete_below_the_cursor_leaves_playback_alone() {
1930 let mut player = Player::new();
1931 let ids = seed(&mut player, 4);
1932 player.shared_state.set_cursor(Some(ids[0]));
1933
1934 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
1935
1936 assert_eq!(player.shared_state.cursor(), Some(ids[0]));
1937 assert_eq!(player.playback_starts, 0);
1938 }
1939
1940 #[test]
1941 fn undo_of_a_batch_delete_restores_the_original_order() {
1942 let mut player = Player::new();
1946 let items = vec![
1947 make_item("A"),
1948 make_item("B"),
1949 make_item("C"),
1950 make_item("D"),
1951 ];
1952 let (b_id, c_id) = (items[1].id, items[2].id);
1953 player.process_command(PlayerCommand::AddToPlaylist(items));
1954
1955 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
1956 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1957
1958 player.process_command(PlayerCommand::Undo);
1959 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1960 }
1961
1962 #[test]
1965 fn undo_add_removes_items() {
1966 let mut player = Player::new();
1967 let items = vec![make_item("A"), make_item("B")];
1968 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1969
1970 player.process_command(PlayerCommand::AddToPlaylist(items));
1971 assert_eq!(playlist_ids(&player), ids);
1972 assert!(player.undo_stack().can_undo());
1973
1974 player.process_command(PlayerCommand::Undo);
1975 assert!(playlist_ids(&player).is_empty());
1976 assert!(player.undo_stack().can_redo());
1977 }
1978
1979 #[test]
1980 fn redo_add_restores_items() {
1981 let mut player = Player::new();
1982 let items = vec![make_item("A"), make_item("B")];
1983
1984 player.process_command(PlayerCommand::AddToPlaylist(items));
1985 player.process_command(PlayerCommand::Undo);
1986 assert!(playlist_ids(&player).is_empty());
1987
1988 player.process_command(PlayerCommand::Redo);
1989 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1990 }
1991
1992 #[test]
1995 fn undo_remove_restores_item_at_position() {
1996 let mut player = Player::new();
1997 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1998 let b_id = items[1].id;
1999
2000 player.process_command(PlayerCommand::AddToPlaylist(items));
2001 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2002 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2003
2004 player.process_command(PlayerCommand::Undo);
2005 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2006 }
2007
2008 #[test]
2009 fn undo_remove_first_item() {
2010 let mut player = Player::new();
2011 let items = vec![make_item("A"), make_item("B")];
2012 let a_id = items[0].id;
2013
2014 player.process_command(PlayerCommand::AddToPlaylist(items));
2015 player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
2016 assert_eq!(playlist_titles(&player), vec!["B"]);
2017
2018 player.process_command(PlayerCommand::Undo);
2019 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2020 }
2021
2022 #[test]
2023 fn undo_batch_remove_restores_all() {
2024 let mut player = Player::new();
2025 let items = vec![
2026 make_item("A"),
2027 make_item("B"),
2028 make_item("C"),
2029 make_item("D"),
2030 ];
2031 let b_id = items[1].id;
2032 let c_id = items[2].id;
2033
2034 player.process_command(PlayerCommand::AddToPlaylist(items));
2035 let version_before = player.shared_state.playlist_version();
2036 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
2037 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
2038 assert_eq!(
2041 player.shared_state.playlist_version(),
2042 version_before + 1,
2043 "batch removal must bump the playlist version exactly once"
2044 );
2045
2046 player.process_command(PlayerCommand::Undo);
2048 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
2049 }
2050
2051 #[test]
2052 fn redo_batch_remove() {
2053 let mut player = Player::new();
2054 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2055 let a_id = items[0].id;
2056 let b_id = items[1].id;
2057
2058 player.process_command(PlayerCommand::AddToPlaylist(items));
2059 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
2060 player.process_command(PlayerCommand::Undo);
2061 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2062
2063 player.process_command(PlayerCommand::Redo);
2064 assert_eq!(playlist_titles(&player), vec!["C"]);
2065 }
2066
2067 #[test]
2068 fn redo_remove() {
2069 let mut player = Player::new();
2070 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2071 let b_id = items[1].id;
2072
2073 player.process_command(PlayerCommand::AddToPlaylist(items));
2074 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2075 player.process_command(PlayerCommand::Undo);
2076 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2077
2078 player.process_command(PlayerCommand::Redo);
2079 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2080 }
2081
2082 #[test]
2085 fn undo_insert_removes_inserted_items() {
2086 let mut player = Player::new();
2087 let items = vec![make_item("A"), make_item("C")];
2088 let a_id = items[0].id;
2089
2090 player.process_command(PlayerCommand::AddToPlaylist(items));
2091
2092 let inserted = vec![make_item("B")];
2093 player.process_command(PlayerCommand::InsertInPlaylist {
2094 items: inserted,
2095 after: a_id,
2096 });
2097 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2098
2099 player.process_command(PlayerCommand::Undo);
2100 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2101 }
2102
2103 #[test]
2106 fn undo_move_restores_position() {
2107 let mut player = Player::new();
2108 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2109 let a_id = items[0].id;
2110 let c_id = items[2].id;
2111
2112 player.process_command(PlayerCommand::AddToPlaylist(items));
2113
2114 player.process_command(PlayerCommand::MoveInPlaylist {
2116 id: a_id,
2117 target: c_id,
2118 after: true,
2119 });
2120 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2121
2122 player.process_command(PlayerCommand::Undo);
2123 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2124 }
2125
2126 #[test]
2127 fn redo_move() {
2128 let mut player = Player::new();
2129 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2130 let a_id = items[0].id;
2131 let c_id = items[2].id;
2132
2133 player.process_command(PlayerCommand::AddToPlaylist(items));
2134 player.process_command(PlayerCommand::MoveInPlaylist {
2135 id: a_id,
2136 target: c_id,
2137 after: true,
2138 });
2139 player.process_command(PlayerCommand::Undo);
2140 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2141
2142 player.process_command(PlayerCommand::Redo);
2143 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2144 }
2145
2146 #[test]
2149 fn undo_batch_move() {
2150 let mut player = Player::new();
2151 let items = vec![
2152 make_item("A"),
2153 make_item("B"),
2154 make_item("C"),
2155 make_item("D"),
2156 ];
2157 let a_id = items[0].id;
2158 let b_id = items[1].id;
2159 let d_id = items[3].id;
2160
2161 player.process_command(PlayerCommand::AddToPlaylist(items));
2162
2163 player.process_command(PlayerCommand::MoveItemsInPlaylist {
2165 ids: vec![a_id, b_id],
2166 target: d_id,
2167 after: true,
2168 });
2169 assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
2170
2171 player.process_command(PlayerCommand::Undo);
2172 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
2173 }
2174
2175 #[test]
2178 fn undo_clear_restores_playlist() {
2179 let mut player = Player::new();
2180 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2181
2182 player.process_command(PlayerCommand::AddToPlaylist(items));
2183 player.process_command(PlayerCommand::ClearPlaylist);
2184 assert!(playlist_ids(&player).is_empty());
2185
2186 player.process_command(PlayerCommand::Undo);
2187 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2188 }
2189
2190 #[test]
2195 fn undoing_a_replace_does_not_leave_the_engine_on_an_orphaned_track() {
2196 let mut player = Player::new();
2197 let original = seed(&mut player, 3);
2198 player.shared_state.set_cursor(Some(original[0]));
2199 pretend_playing(&mut player, original[0]);
2200
2201 let replacement = vec![make_item("something else")];
2202 let orphan = replacement[0].id;
2203 player.process_command(PlayerCommand::ReplacePlaylist {
2204 items: replacement,
2205 start: 0,
2206 });
2207 pretend_playing(&mut player, orphan);
2209
2210 player.process_command(PlayerCommand::Undo);
2211
2212 assert_eq!(playlist_ids(&player), original, "the queue comes back");
2213 assert!(
2214 player.shared_state.get_item(orphan).is_none(),
2215 "and the replacement is gone from it"
2216 );
2217 assert!(
2218 playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
2219 "so nothing may still be playing out of it"
2220 );
2221 }
2222
2223 #[test]
2225 fn undoing_an_add_does_not_leave_the_engine_on_a_removed_track() {
2226 let mut player = Player::new();
2227 seed(&mut player, 2);
2228 let added = seed(&mut player, 1);
2229 pretend_playing(&mut player, added[0]);
2230
2231 player.process_command(PlayerCommand::Undo);
2232
2233 assert!(player.shared_state.get_item(added[0]).is_none());
2234 assert!(
2235 playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
2236 "the engine cannot be left on the item the undo removed"
2237 );
2238 }
2239
2240 #[test]
2242 fn undoing_a_move_leaves_playback_alone() {
2243 let mut player = Player::new();
2244 let ids = seed(&mut player, 3);
2245 player.shared_state.set_cursor(Some(ids[0]));
2246 pretend_playing(&mut player, ids[0]);
2247 let starts = player.playback_starts;
2248
2249 player.process_command(PlayerCommand::MoveInPlaylist {
2250 id: ids[2],
2251 target: ids[0],
2252 after: false,
2253 });
2254 player.process_command(PlayerCommand::Undo);
2255
2256 assert_eq!(playlist_ids(&player), ids);
2257 assert_eq!(playing_id(&player), Some(ids[0]), "still on the same track");
2258 assert_eq!(player.playback_starts, starts, "and not restarted");
2259 }
2260
2261 #[test]
2262 fn redo_clear() {
2263 let mut player = Player::new();
2264 let items = vec![make_item("A"), make_item("B")];
2265
2266 player.process_command(PlayerCommand::AddToPlaylist(items));
2267 player.process_command(PlayerCommand::ClearPlaylist);
2268 player.process_command(PlayerCommand::Undo);
2269 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2270
2271 player.process_command(PlayerCommand::Redo);
2272 assert!(playlist_ids(&player).is_empty());
2273 }
2274
2275 #[test]
2278 fn multiple_undos_in_sequence() {
2279 let mut player = Player::new();
2280
2281 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
2282 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
2283 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
2284 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2285
2286 player.process_command(PlayerCommand::Undo);
2287 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2288
2289 player.process_command(PlayerCommand::Undo);
2290 assert_eq!(playlist_titles(&player), vec!["A"]);
2291
2292 player.process_command(PlayerCommand::Undo);
2293 assert!(playlist_ids(&player).is_empty());
2294 }
2295
2296 #[test]
2297 fn undo_redo_undo_cycle() {
2298 let mut player = Player::new();
2299 let items = vec![make_item("A"), make_item("B")];
2300
2301 player.process_command(PlayerCommand::AddToPlaylist(items));
2302 player.process_command(PlayerCommand::Undo);
2303 assert!(playlist_ids(&player).is_empty());
2304
2305 player.process_command(PlayerCommand::Redo);
2306 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2307
2308 player.process_command(PlayerCommand::Undo);
2309 assert!(playlist_ids(&player).is_empty());
2310 }
2311
2312 #[test]
2313 fn new_action_clears_redo_stack() {
2314 let mut player = Player::new();
2315 let items = vec![make_item("A")];
2316
2317 player.process_command(PlayerCommand::AddToPlaylist(items));
2318 player.process_command(PlayerCommand::Undo);
2319 assert!(player.undo_stack().can_redo());
2320
2321 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
2323 assert!(!player.undo_stack().can_redo());
2324 }
2325
2326 #[test]
2327 fn undo_on_empty_stack_is_noop() {
2328 let mut player = Player::new();
2329 player.process_command(PlayerCommand::Undo);
2330 assert!(playlist_ids(&player).is_empty());
2331 }
2332
2333 #[test]
2334 fn redo_on_empty_stack_is_noop() {
2335 let mut player = Player::new();
2336 player.process_command(PlayerCommand::Redo);
2337 assert!(playlist_ids(&player).is_empty());
2338 }
2339
2340 #[test]
2343 fn playback_commands_not_undoable() {
2344 let mut player = Player::new();
2345 player.process_command(PlayerCommand::Pause);
2346 player.process_command(PlayerCommand::Resume);
2347 player.process_command(PlayerCommand::NextTrack);
2348 player.process_command(PlayerCommand::PrevTrack);
2349 assert!(!player.undo_stack().can_undo());
2350 }
2351
2352 #[test]
2353 fn update_paths_not_undoable() {
2354 let mut player = Player::new();
2355 let items = vec![make_item("A")];
2356 let id = items[0].id;
2357 player.process_command(PlayerCommand::AddToPlaylist(items));
2358
2359 let undo_count = player.undo_stack().undo_len();
2360 player.process_command(PlayerCommand::UpdatePaths(vec![(
2361 id,
2362 PathBuf::from("/new/path.flac"),
2363 )]));
2364 assert_eq!(player.undo_stack().undo_len(), undo_count);
2365 }
2366
2367 #[test]
2370 fn add_remove_undo_undo_produces_original() {
2371 let mut player = Player::new();
2372 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2373 let b_id = items[1].id;
2374 let original_titles = vec!["A", "B", "C"];
2375
2376 player.process_command(PlayerCommand::AddToPlaylist(items));
2377 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2378 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2379
2380 player.process_command(PlayerCommand::Undo);
2382 assert_eq!(playlist_titles(&player), original_titles);
2383
2384 player.process_command(PlayerCommand::Undo);
2386 assert!(playlist_ids(&player).is_empty());
2387 }
2388
2389 #[test]
2390 fn interleaved_adds_and_moves_undo() {
2391 let mut player = Player::new();
2392 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2393 let a_id = items[0].id;
2394 let c_id = items[2].id;
2395
2396 player.process_command(PlayerCommand::AddToPlaylist(items));
2397
2398 player.process_command(PlayerCommand::MoveInPlaylist {
2400 id: a_id,
2401 target: c_id,
2402 after: true,
2403 });
2404 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2405
2406 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
2408 assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
2409
2410 player.process_command(PlayerCommand::Undo);
2412 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2413
2414 player.process_command(PlayerCommand::Undo);
2416 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2417 }
2418
2419 #[test]
2424 fn stop_engine_drops_engine_synchronously() {
2425 use std::sync::atomic::{AtomicBool, Ordering};
2426
2427 struct MockEngine {
2428 dropped: Arc<AtomicBool>,
2429 }
2430 impl AudioEngineHandle for MockEngine {
2431 fn start(&self) -> Result<(), BackendError> {
2432 Ok(())
2433 }
2434 fn stop(&self) -> Result<(), BackendError> {
2435 Ok(())
2436 }
2437 fn is_running(&self) -> bool {
2438 false
2439 }
2440 }
2441 impl Drop for MockEngine {
2442 fn drop(&mut self) {
2443 self.dropped.store(true, Ordering::SeqCst);
2444 }
2445 }
2446
2447 let dropped = Arc::new(AtomicBool::new(false));
2448
2449 let stop_flag = Arc::new(AtomicBool::new(false));
2451 let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
2452
2453 let mut player = Player::new();
2454 player.active_playback = Some(ActivePlayback {
2455 engine: Box::new(MockEngine {
2456 dropped: dropped.clone(),
2457 }),
2458 decode_handle,
2459 _rate_watch: None,
2460 });
2461
2462 player.stop_engine();
2463
2464 assert!(
2468 dropped.load(Ordering::SeqCst),
2469 "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
2470 );
2471 }
2472
2473 struct StuckBackend {
2478 rate: f64,
2479 asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
2480 }
2481
2482 struct NullEngine;
2483 impl AudioEngineHandle for NullEngine {
2484 fn start(&self) -> Result<(), BackendError> {
2485 Ok(())
2486 }
2487 fn stop(&self) -> Result<(), BackendError> {
2488 Ok(())
2489 }
2490 fn is_running(&self) -> bool {
2491 false
2492 }
2493 }
2494
2495 impl AudioBackend for StuckBackend {
2496 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2497 Ok(vec![self.default_device()?])
2498 }
2499 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2500 Ok(backend::DeviceInfo {
2501 name: "Stuck DAC".into(),
2502 sample_rates: vec![self.rate],
2503 platform_id: 0,
2504 })
2505 }
2506 fn supported_sample_rates(
2507 &self,
2508 _device: &backend::DeviceInfo,
2509 ) -> Result<Vec<f64>, BackendError> {
2510 Ok(vec![self.rate])
2511 }
2512 fn get_device_sample_rate(
2513 &self,
2514 _device: &backend::DeviceInfo,
2515 ) -> Result<f64, BackendError> {
2516 Ok(self.rate)
2517 }
2518 fn set_device_sample_rate(
2519 &self,
2520 _device: &backend::DeviceInfo,
2521 rate: f64,
2522 ) -> Result<f64, BackendError> {
2523 Err(BackendError::UnsupportedSampleRate(rate))
2524 }
2525 fn create_engine(
2526 &self,
2527 _device: &backend::DeviceInfo,
2528 sample_rate: f64,
2529 channels: u32,
2530 _consumer: rtrb::Consumer<f32>,
2531 _samples_played: Arc<AtomicU64>,
2532 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2533 *self.asked.lock().unwrap() = Some((sample_rate, channels));
2534 Ok(Box::new(NullEngine))
2535 }
2536 }
2537
2538 fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
2539 let asked = Arc::new(std::sync::Mutex::new(None));
2540 let mut player = Player::new();
2541 player.backend = Box::new(StuckBackend {
2542 rate: device_rate,
2543 asked: asked.clone(),
2544 });
2545
2546 let info = buffer::StreamInfo {
2547 codec: "MP3".into(),
2548 sample_rate: source_rate,
2549 channels,
2550 bit_depth: Some(16),
2551 bitrate_kbps: None,
2552 duration_ms: 1000,
2553 };
2554 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2555 player
2556 .create_engine_for(&info, consumer)
2557 .expect("engine creation should succeed");
2558 let asked = *asked.lock().unwrap();
2559 asked.expect("engine was never created")
2560 }
2561
2562 #[test]
2563 fn engine_uses_source_rate_when_device_refuses_switch() {
2564 assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
2567 assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
2568 }
2569
2570 #[test]
2571 fn engine_uses_source_channel_count() {
2572 assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
2573 }
2574
2575 fn settled_rate_for(source_rate: u32, device_rate: f64) -> Option<u32> {
2577 let mut player = Player::new();
2578 player.backend = Box::new(StuckBackend {
2579 rate: device_rate,
2580 asked: Arc::new(std::sync::Mutex::new(None)),
2581 });
2582 let state = player.shared_state.clone();
2583
2584 let info = buffer::StreamInfo {
2585 codec: "MP3".into(),
2586 sample_rate: source_rate,
2587 channels: 2,
2588 bit_depth: Some(16),
2589 bitrate_kbps: None,
2590 duration_ms: 1000,
2591 };
2592 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2593 player
2594 .create_engine_for(&info, consumer)
2595 .expect("engine creation should succeed");
2596 state.output_sample_rate()
2597 }
2598
2599 #[test]
2600 fn settled_device_rate_reaches_the_shared_state() {
2601 assert_eq!(settled_rate_for(22050, 48000.0), Some(48000));
2605 assert_eq!(settled_rate_for(44100, 44100.0), Some(44100));
2607 }
2608
2609 struct SlowBackend {
2611 observed: Arc<std::sync::Mutex<Vec<Option<u32>>>>,
2612 state: Arc<SharedPlayerState>,
2613 }
2614
2615 impl AudioBackend for SlowBackend {
2616 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2617 Ok(vec![self.default_device()?])
2618 }
2619 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2620 Ok(backend::DeviceInfo {
2621 name: "Slow DAC".into(),
2622 sample_rates: vec![44100.0, 48000.0],
2623 platform_id: 0,
2624 })
2625 }
2626 fn supported_sample_rates(
2627 &self,
2628 _device: &backend::DeviceInfo,
2629 ) -> Result<Vec<f64>, BackendError> {
2630 Ok(vec![44100.0, 48000.0])
2631 }
2632 fn get_device_sample_rate(
2633 &self,
2634 _device: &backend::DeviceInfo,
2635 ) -> Result<f64, BackendError> {
2636 Ok(48000.0)
2637 }
2638 fn set_device_sample_rate(
2639 &self,
2640 _device: &backend::DeviceInfo,
2641 rate: f64,
2642 ) -> Result<f64, BackendError> {
2643 self.observed
2645 .lock()
2646 .unwrap()
2647 .push(self.state.output_sample_rate());
2648 Ok(rate)
2649 }
2650 fn create_engine(
2651 &self,
2652 _device: &backend::DeviceInfo,
2653 _sample_rate: f64,
2654 _channels: u32,
2655 _consumer: rtrb::Consumer<f32>,
2656 _samples_played: Arc<AtomicU64>,
2657 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2658 Ok(Box::new(NullEngine))
2659 }
2660 }
2661
2662 #[test]
2663 fn the_previous_rate_is_not_published_while_the_device_reclocks() {
2664 let mut player = Player::new();
2670 let state = player.shared_state.clone();
2671 state.set_output_sample_rate(48000);
2672
2673 let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
2674 player.backend = Box::new(SlowBackend {
2675 observed: observed.clone(),
2676 state: state.clone(),
2677 });
2678
2679 let info = buffer::StreamInfo {
2680 codec: "FLAC".into(),
2681 sample_rate: 44100,
2682 channels: 2,
2683 bit_depth: Some(16),
2684 bitrate_kbps: None,
2685 duration_ms: 1000,
2686 };
2687 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2688 player
2689 .create_engine_for(&info, consumer)
2690 .expect("engine creation should succeed");
2691
2692 assert_eq!(
2693 *observed.lock().unwrap(),
2694 vec![None],
2695 "mid-switch the output rate must read as unknown, not as the last track's"
2696 );
2697 assert_eq!(state.output_sample_rate(), Some(44100));
2698 }
2699
2700 struct WatchedBackend {
2702 inner: StuckBackend,
2703 #[allow(clippy::type_complexity)]
2704 captured: Arc<std::sync::Mutex<Option<Box<dyn Fn(f64) + Send + Sync>>>>,
2705 }
2706
2707 struct NullWatch;
2708 impl backend::SampleRateWatch for NullWatch {}
2709
2710 impl AudioBackend for WatchedBackend {
2711 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2712 self.inner.list_devices()
2713 }
2714 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2715 self.inner.default_device()
2716 }
2717 fn supported_sample_rates(
2718 &self,
2719 device: &backend::DeviceInfo,
2720 ) -> Result<Vec<f64>, BackendError> {
2721 self.inner.supported_sample_rates(device)
2722 }
2723 fn get_device_sample_rate(
2724 &self,
2725 device: &backend::DeviceInfo,
2726 ) -> Result<f64, BackendError> {
2727 self.inner.get_device_sample_rate(device)
2728 }
2729 fn set_device_sample_rate(
2730 &self,
2731 device: &backend::DeviceInfo,
2732 rate: f64,
2733 ) -> Result<f64, BackendError> {
2734 self.inner.set_device_sample_rate(device, rate)
2735 }
2736 fn watch_device_sample_rate(
2737 &self,
2738 _device: &backend::DeviceInfo,
2739 on_change: Box<dyn Fn(f64) + Send + Sync>,
2740 ) -> Option<Box<dyn backend::SampleRateWatch>> {
2741 *self.captured.lock().unwrap() = Some(on_change);
2742 Some(Box::new(NullWatch))
2743 }
2744 fn create_engine(
2745 &self,
2746 device: &backend::DeviceInfo,
2747 sample_rate: f64,
2748 channels: u32,
2749 consumer: rtrb::Consumer<f32>,
2750 samples_played: Arc<AtomicU64>,
2751 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2752 self.inner
2753 .create_engine(device, sample_rate, channels, consumer, samples_played)
2754 }
2755 }
2756
2757 #[test]
2758 fn external_rate_change_reaches_the_shared_state() {
2759 let captured = Arc::new(std::sync::Mutex::new(None));
2763 let mut player = Player::new();
2764 player.backend = Box::new(WatchedBackend {
2765 inner: StuckBackend {
2766 rate: 44100.0,
2767 asked: Arc::new(std::sync::Mutex::new(None)),
2768 },
2769 captured: captured.clone(),
2770 });
2771 let state = player.shared_state.clone();
2772
2773 let info = buffer::StreamInfo {
2774 codec: "FLAC".into(),
2775 sample_rate: 44100,
2776 channels: 2,
2777 bit_depth: Some(16),
2778 bitrate_kbps: None,
2779 duration_ms: 1000,
2780 };
2781 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2782 player
2783 .create_engine_for(&info, consumer)
2784 .expect("engine creation should succeed");
2785 assert_eq!(state.output_sample_rate(), Some(44100));
2786
2787 let on_change = captured.lock().unwrap().take().expect("watch registered");
2788 on_change(48000.0);
2789 assert_eq!(state.output_sample_rate(), Some(48000));
2790 }
2791}