1pub mod commands;
2pub mod history;
3pub mod state;
4pub mod undo;
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::thread;
9
10use thiserror::Error;
11
12use crate::audio::{
13 analyzer::VizAnalyzer,
14 backend::{self, AudioBackend, AudioEngineHandle, BackendError, SampleRateWatch},
15 buffer, streaming,
16 viz::{VizBuffer, VizSnapshot},
17};
18use buffer::PlaybackTimeline;
19use commands::{CommandChannel, PlayerCommand};
20use history::{InFlight, PlayEvent, PlayRecorder};
21use state::{
22 ItemState, LoadState, PlaybackSource, PlaybackState, QueueItemId, SharedPlayerState, TrackInfo,
23};
24use undo::{UndoEntry, UndoStack};
25
26pub(crate) const RING_BUFFER_SIZE: usize = 192_000 * 2;
28
29const SEEK_END_GUARD_MS: u64 = 500;
32
33#[derive(Debug, Error)]
34pub enum PlayerError {
35 #[error("backend error: {0}")]
36 Backend(#[from] BackendError),
37 #[error("decode error: {0}")]
38 Decode(#[from] buffer::DecodeError),
39}
40
41#[derive(Clone)]
44struct StreamSource {
45 path: PathBuf,
46 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
47 total: u64,
48 mode: streaming::ProbeMode,
49}
50
51fn hint_for(path: &Path) -> symphonia::core::formats::probe::Hint {
53 let mut hint = symphonia::core::formats::probe::Hint::new();
54 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
55 hint.with_extension(ext);
56 }
57 hint
58}
59
60fn lengthless_mode_for(path: &Path) -> streaming::ProbeMode {
68 let ogg = path
69 .extension()
70 .and_then(|e| e.to_str())
71 .is_some_and(|ext| {
72 matches!(
73 ext.to_ascii_lowercase().as_str(),
74 "ogg" | "oga" | "opus" | "spx"
75 )
76 });
77 if ogg {
78 streaming::ProbeMode::LengthlessWholeEnd
79 } else {
80 streaming::ProbeMode::Lengthless
81 }
82}
83
84pub struct Player {
86 shared_state: Arc<SharedPlayerState>,
87 commands: CommandChannel,
88 active_playback: Option<ActivePlayback>,
89 timeline: Arc<PlaybackTimeline>,
90 viz_buffer: Arc<VizBuffer>,
91 viz_snapshot: Arc<VizSnapshot>,
92 _viz_analyzer: VizAnalyzer,
94 undo_stack: UndoStack,
95 batch_buffer: Option<Vec<UndoEntry>>,
98 output_device_name: Option<String>,
100 backend: Box<dyn AudioBackend>,
102 last_skip: std::time::Instant,
104 stream_mode: streaming::ProbeMode,
107 history: Option<PlayRecorder>,
110 in_flight: Option<InFlight>,
112 #[cfg(test)]
115 playback_starts: usize,
116}
117
118struct ActivePlayback {
120 engine: Box<dyn AudioEngineHandle>,
121 decode_handle: buffer::DecodeHandle,
122 _rate_watch: Option<Box<dyn SampleRateWatch>>,
125}
126
127impl Default for Player {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133impl Player {
134 pub fn new() -> Self {
135 let viz_buffer = VizBuffer::new();
136 let viz_snapshot = VizSnapshot::new();
137 let timeline = PlaybackTimeline::new();
138 let cfg = crate::config::Config::load_or_default();
139 let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
140 Arc::clone(&viz_buffer),
141 &cfg.visualizer,
142 Arc::clone(&viz_snapshot),
143 timeline.samples_played_counter(),
144 );
145
146 Self {
147 shared_state: SharedPlayerState::new(),
148 commands: CommandChannel::new(),
149 active_playback: None,
150 timeline,
151 viz_buffer,
152 viz_snapshot,
153 _viz_analyzer: viz_analyzer,
154 undo_stack: UndoStack::new(),
155 batch_buffer: None,
156 output_device_name: cfg.playback.output_device.clone(),
157 backend: crate::audio::platform_backend(),
158 last_skip: std::time::Instant::now(),
159 stream_mode: streaming::ProbeMode::Full,
160 history: None,
161 in_flight: None,
162 #[cfg(test)]
163 playback_starts: 0,
164 }
165 }
166
167 pub fn shared_state(&self) -> Arc<SharedPlayerState> {
169 self.shared_state.clone()
170 }
171
172 pub fn timeline(&self) -> Arc<PlaybackTimeline> {
174 self.timeline.clone()
175 }
176
177 pub fn viz_buffer(&self) -> Arc<VizBuffer> {
179 self.viz_buffer.clone()
180 }
181
182 pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
185 self.viz_snapshot.clone()
186 }
187
188 pub fn undo_stack(&self) -> &UndoStack {
190 &self.undo_stack
191 }
192
193 #[allow(clippy::type_complexity)]
201 fn create_engine_for(
202 &self,
203 info: &buffer::StreamInfo,
204 consumer: rtrb::Consumer<f32>,
205 ) -> Result<(Box<dyn AudioEngineHandle>, Option<Box<dyn SampleRateWatch>>), PlayerError> {
206 let device = self.resolve_device()?;
207 let device_rate = self.backend.get_device_sample_rate(&device)?;
208 let source_rate = info.sample_rate as f64;
209
210 self.shared_state.clear_output_sample_rate();
214
215 let settled = if (device_rate - source_rate).abs() > 0.1 {
216 log::info!(
217 "switching device sample rate: {}Hz → {}Hz",
218 device_rate,
219 source_rate
220 );
221 match self.backend.set_device_sample_rate(&device, source_rate) {
222 Ok(rate) => rate,
223 Err(e) => {
224 log::warn!("failed to set device sample rate: {}", e);
225 device_rate
226 }
227 }
228 } else {
229 device_rate
230 };
231
232 if (settled - source_rate).abs() > 0.1 {
233 log::warn!(
234 "device stayed at {}Hz (wanted {}Hz) — output is resampled, not bit-perfect",
235 settled,
236 source_rate
237 );
238 }
239
240 self.shared_state
243 .set_output_sample_rate(settled.round() as u32);
244
245 let watch_state = self.shared_state.clone();
249 let watch_name = device.name.clone();
250 let rate_watch = self.backend.watch_device_sample_rate(
251 &device,
252 Box::new(move |rate| {
253 log::info!("device sample rate changed externally: {rate}Hz on '{watch_name}'");
254 watch_state.set_output_sample_rate(rate.round() as u32);
255 }),
256 );
257
258 let engine = self.backend.create_engine(
259 &device,
260 source_rate,
261 info.channels as u32,
262 consumer,
263 self.timeline.samples_played_counter(),
264 )?;
265
266 Ok((engine, rate_watch))
267 }
268
269 fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
272 if let Some(ref name) = self.output_device_name {
273 match self.backend.list_devices() {
274 Ok(devices) => {
275 if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
276 return Ok(dev);
277 }
278 log::warn!(
279 "configured output device '{}' not found, falling back to default",
280 name,
281 );
282 }
283 Err(e) => {
284 log::warn!("failed to list devices while resolving '{}': {}", name, e);
285 }
286 }
287 }
288 Ok(self.backend.default_device()?)
289 }
290
291 pub fn set_output_device(&mut self, name: String) {
294 log::info!("switching output device to: {}", name);
295 self.output_device_name = Some(name.clone());
296
297 if let Err(e) = crate::config::Config::persist(|cfg| {
298 cfg.playback.output_device = Some(name);
299 }) {
300 log::error!("failed to save output device config: {}", e);
301 }
302
303 self.restart_on_current_track();
304 }
305
306 pub fn clear_output_device(&mut self) {
308 log::info!("reverting to system default output device");
309 self.output_device_name = None;
310
311 if let Err(e) = crate::config::Config::persist(|cfg| {
312 cfg.playback.output_device = None;
313 }) {
314 log::error!("failed to save output device config: {}", e);
315 }
316
317 self.restart_on_current_track();
318 }
319
320 fn restart_on_current_track(&mut self) {
323 if let Some(info) = self.shared_state.track_info() {
324 let position_ms = self.shared_state.position_ms();
325 if let Err(e) = self.restart_current(&info, position_ms) {
326 log::error!("failed to restart playback on device switch: {}", e);
327 }
328 }
329 }
330
331 pub fn output_device_name(&self) -> Option<&str> {
333 self.output_device_name.as_deref()
334 }
335
336 pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
338 self.commands.tx.clone()
339 }
340
341 pub fn play(&mut self, id: QueueItemId) {
344 self.shared_state.set_cursor(Some(id));
345
346 match self.shared_state.item_playback_source(id) {
347 Some(PlaybackSource::Ready(path)) => {
348 if let Err(e) = self.start_playback(id, &path, 0) {
349 log::error!("play failed: {}", e);
350 }
351 }
352 Some(PlaybackSource::Streaming {
353 path,
354 bytes_written,
355 total,
356 }) => {
357 self.stop_engine();
361 self.shared_state.set_playback_state(PlaybackState::Stopped);
362 self.probe_stream_for_playback(id, &path, bytes_written, total);
363 }
364 None => {
365 self.stop_engine();
367 self.shared_state.set_playback_state(PlaybackState::Stopped);
368 log::info!("play: item {:?} not ready, waiting for TrackReady", id);
369 }
370 }
371 }
372
373 fn start_playback(
378 &mut self,
379 id: QueueItemId,
380 path: &Path,
381 seek_ms: u64,
382 ) -> Result<(), PlayerError> {
383 #[cfg(test)]
384 {
385 self.playback_starts += 1;
386 }
387 let result = self.open_playback(id, path, seek_ms);
388 if result.is_err() {
389 self.stop_playback_and_clear_state();
390 }
391 self.wake_analyzer();
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<crate::remote::downloads::ByteFeed>,
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 self.wake_analyzer();
949 }
950 }
951
952 fn wake_analyzer(&self) {
959 self.viz_snapshot.wake();
960 }
961
962 pub fn stop(&mut self) {
964 self.shared_state.clear_playlist();
965 self.stop_playback_and_clear_state();
966 }
967
968 fn stop_engine(&mut self) {
975 let Some(playback) = self.active_playback.take() else {
976 return;
977 };
978 let ActivePlayback {
979 engine,
980 mut decode_handle,
981 _rate_watch,
982 } = playback;
983
984 let _ = engine.stop();
995 decode_handle.stop();
996 drop(engine);
997 }
998
999 fn stop_playback_and_clear_state(&mut self) {
1001 self.finish_play();
1002 self.stop_engine();
1003 self.timeline.reset();
1004 self.shared_state.set_playback_state(PlaybackState::Stopped);
1005 self.shared_state.set_position_ms(0);
1006 self.shared_state.set_track_info(None);
1007 }
1008
1009 pub fn remove_from_playlist(&mut self, id: QueueItemId) {
1016 let was_cursor = self.shared_state.is_cursor(id);
1017 let resume_after = was_cursor
1018 .then(|| self.shared_state.item_before(id))
1019 .flatten();
1020 self.shared_state.remove_item(id);
1021 if was_cursor {
1022 self.shared_state.set_cursor(resume_after);
1023 self.next_track();
1024 }
1025 }
1026
1027 pub fn track_ready(&mut self, id: QueueItemId) {
1030 self.shared_state.update_item_state(id, ItemState::Ready);
1032
1033 if !self.shared_state.is_cursor(id) {
1034 return;
1035 }
1036
1037 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
1038 let current_track_id = self.shared_state.track_info().map(|t| t.id);
1039
1040 if is_playing && current_track_id == Some(id) {
1041 log::info!(
1044 "track_ready: download complete while streaming {:?}, refreshing metadata",
1045 id
1046 );
1047 self.refresh_track_metadata(id);
1048 return;
1049 }
1050
1051 if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
1053 log::info!("track_ready: starting playback for {:?}", id);
1054 if let Err(e) = self.start_playback(id, &path, 0) {
1055 log::error!("track_ready playback failed: {}", e);
1056 }
1057 }
1058 }
1059
1060 pub fn track_stream_ready(&mut self, id: QueueItemId) {
1063 if !self.shared_state.is_cursor(id) {
1064 return;
1065 }
1066
1067 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
1068 if is_playing {
1069 return; }
1071
1072 match self.shared_state.item_playback_source(id) {
1073 Some(PlaybackSource::Streaming {
1074 path,
1075 bytes_written,
1076 total,
1077 }) => {
1078 log::info!("track_stream_ready: probing partial file for {:?}", id);
1079 self.probe_stream_for_playback(id, &path, bytes_written, total);
1080 }
1081 Some(PlaybackSource::Ready(path)) => {
1082 log::info!(
1084 "track_stream_ready: track already ready, starting normal playback for {:?}",
1085 id
1086 );
1087 if let Err(e) = self.start_playback(id, &path, 0) {
1088 log::error!("track_stream_ready playback failed: {}", e);
1089 }
1090 }
1091 None => {} }
1093 }
1094
1095 fn refresh_track_metadata(&mut self, id: QueueItemId) {
1099 use crate::index::metadata;
1100
1101 let path = match self.shared_state.item_path_if_ready(id) {
1102 Some(p) => p,
1103 None => return,
1104 };
1105
1106 match metadata::read_metadata(&path) {
1107 Ok(meta) => {
1108 self.shared_state.update_item_metadata(
1109 id,
1110 meta.title,
1111 meta.artist,
1112 meta.album_artist.unwrap_or_default(),
1113 meta.album,
1114 meta.duration_ms.map(|d| d as u64),
1115 );
1116
1117 if let Some(current) = self.shared_state.track_info()
1126 && current.id == id
1127 {
1128 let probed = buffer::probe_file(&path).ok();
1129 let duration_ms = probed
1130 .as_ref()
1131 .map(|s| s.duration_ms)
1132 .filter(|d| *d > current.duration_ms)
1133 .unwrap_or(current.duration_ms);
1134 if duration_ms != current.duration_ms {
1135 log::info!(
1136 "track_ready: duration corrected {}ms → {}ms",
1137 current.duration_ms,
1138 duration_ms
1139 );
1140 }
1141 self.shared_state.set_track_info(Some(TrackInfo {
1142 duration_ms,
1143 path: path.clone(),
1144 ..current
1145 }));
1146 }
1147
1148 self.shared_state.signal_metadata_refresh();
1150 log::info!("track_ready: metadata refreshed for {:?}", id);
1151 }
1152 Err(e) => {
1153 log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
1154 }
1155 }
1156 }
1157
1158 fn on_track_changed(&mut self, id: QueueItemId) {
1168 if self.in_flight.as_ref().is_some_and(|f| f.item == id) {
1169 return;
1170 }
1171 self.finish_play();
1172 let track_id = self.shared_state.item_db_id(id);
1173 self.in_flight = Some(InFlight::new(id, track_id));
1174 if let (Some(track_id), Some(recorder)) = (track_id, self.history.as_ref()) {
1175 recorder.record(PlayEvent::Started { track_id });
1176 }
1177 }
1178
1179 fn finish_play(&mut self) -> Option<PlayEvent> {
1182 let flight = self.in_flight.take()?;
1183 let event = PlayEvent::Finished {
1184 track_id: flight.track_id()?,
1185 listened_ms: flight.listened_ms(),
1186 };
1187 if let Some(recorder) = self.history.as_ref() {
1188 recorder.record(event);
1189 }
1190 Some(event)
1191 }
1192
1193 pub fn update_playback_state(&mut self) {
1194 if self.active_playback.is_none() {
1195 return;
1196 }
1197
1198 if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
1199 self.shared_state.set_position_ms(position_ms);
1200
1201 self.on_track_changed(id);
1204 if let Some(f) = self.in_flight.as_mut() {
1205 f.advance(position_ms);
1206 }
1207
1208 let current_id = self.shared_state.track_info().map(|t| t.id);
1211 if current_id != Some(id) {
1212 log::info!("timeline: now playing {:?}", id);
1213 self.shared_state.set_track_info(Some(TrackInfo {
1214 id,
1215 path,
1216 codec: info.codec,
1217 sample_rate: info.sample_rate,
1218 bit_depth: info.bit_depth,
1219 bitrate_kbps: info.bitrate_kbps,
1220 channels: info.channels,
1221 duration_ms: info.duration_ms,
1222 }));
1223 self.shared_state.set_cursor(Some(id));
1224 }
1225 }
1226 }
1227
1228 pub fn track_failed(&mut self, id: QueueItemId) {
1235 if !self.shared_state.is_cursor(id) {
1236 return;
1237 }
1238 if self.shared_state.playback_state() != PlaybackState::Stopped {
1242 return;
1243 }
1244 log::info!("track {:?} cannot load, moving on", id);
1245 self.next_track();
1246 }
1247
1248 fn on_decode_finished(&mut self) {
1255 log::info!("decode finished, checking for next track");
1256 match self.shared_state.advance_cursor_loadable() {
1257 Some(id) => self.play(id),
1258 None => {
1259 log::info!("no more tracks — stopping");
1260 self.stop_playback_and_clear_state();
1261 }
1262 }
1263 }
1264
1265 fn snapshot_for_undo(
1269 &self,
1270 ids: &[QueueItemId],
1271 ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
1272 self.shared_state
1273 .items_before(ids)
1274 .into_iter()
1275 .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
1276 .collect()
1277 }
1278
1279 fn push_undo(&mut self, entry: UndoEntry) {
1281 if let Some(ref mut batch) = self.batch_buffer {
1282 batch.push(entry);
1283 } else {
1284 self.undo_stack.push(entry);
1285 }
1286 }
1287
1288 pub fn process_command(&mut self, cmd: PlayerCommand) {
1290 match cmd {
1291 PlayerCommand::Play(id) => self.play(id),
1292 PlayerCommand::Pause => self.pause(),
1293 PlayerCommand::Resume => self.resume(),
1294 PlayerCommand::Stop => self.stop(),
1295 PlayerCommand::Seek(pos) => self.seek(pos),
1296 PlayerCommand::NextTrack => {
1297 let now = std::time::Instant::now();
1299 if now.duration_since(self.last_skip).as_millis() >= 150 {
1300 self.last_skip = now;
1301 self.next_track();
1302 }
1303 }
1304 PlayerCommand::PrevTrack => {
1305 let now = std::time::Instant::now();
1306 if now.duration_since(self.last_skip).as_millis() >= 150 {
1307 self.last_skip = now;
1308 self.prev_track();
1309 }
1310 }
1311 PlayerCommand::AddToPlaylist(items) => {
1312 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1313 self.shared_state.add_items(items);
1314 self.push_undo(UndoEntry::Added { ids });
1315 }
1316 PlayerCommand::UpdatePaths(updates) => {
1317 self.shared_state.update_paths(&updates);
1318 if let Some(info) = self.shared_state.track_info()
1319 && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
1320 {
1321 self.shared_state.set_track_info(Some(TrackInfo {
1322 path: new_path.clone(),
1323 ..info
1324 }));
1325 }
1326 }
1327 PlayerCommand::InsertInPlaylist { items, after } => {
1328 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1329 self.shared_state.insert_items_after(items, after);
1330 self.push_undo(UndoEntry::Inserted { ids });
1331 }
1332 PlayerCommand::ClearPlaylist => {
1333 self.stop_playback_and_clear_state();
1337 let (items, cursor) = self.shared_state.snapshot_playlist();
1338 self.shared_state.clear_playlist();
1339 self.push_undo(UndoEntry::Replaced { items, cursor });
1340 }
1341 PlayerCommand::ReplacePlaylist { items, start } => {
1342 self.stop_playback_and_clear_state();
1346 let (old_items, cursor) = self.shared_state.snapshot_playlist();
1347 self.shared_state.clear_playlist();
1348 self.push_undo(UndoEntry::Replaced {
1349 items: old_items,
1350 cursor,
1351 });
1352
1353 if items.is_empty() {
1354 return;
1355 }
1356 let start_id = items.get(start).unwrap_or(&items[0]).id;
1357 self.shared_state.add_items(items);
1358 self.play(start_id);
1359 }
1360 PlayerCommand::RemoveFromPlaylist(id) => {
1361 let item = self.shared_state.get_item(id);
1362 let after = self.shared_state.item_before(id);
1363 self.remove_from_playlist(id);
1364 if let Some(item) = item {
1365 self.push_undo(UndoEntry::Removed {
1366 items: vec![(Box::new(item), after)],
1367 });
1368 }
1369 }
1370 PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1371 let items_with_pos = self.snapshot_for_undo(&ids);
1375 let resume_after = match self.shared_state.cursor() {
1376 Some(cursor) if ids.contains(&cursor) => {
1377 Some(self.shared_state.surviving_item_before(cursor, &ids))
1378 }
1379 _ => None,
1380 };
1381
1382 self.shared_state.remove_items(&ids);
1383
1384 if let Some(resume_after) = resume_after {
1385 self.shared_state.set_cursor(resume_after);
1386 self.next_track();
1387 }
1388
1389 if !items_with_pos.is_empty() {
1390 self.push_undo(UndoEntry::Removed {
1391 items: items_with_pos,
1392 });
1393 }
1394 }
1395 PlayerCommand::MoveInPlaylist { id, target, after } => {
1396 let was_after = self.shared_state.item_before(id);
1397 self.shared_state.move_item(id, target, after);
1398 self.push_undo(UndoEntry::Moved { id, was_after });
1399 }
1400 PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1401 let entries = self.shared_state.items_before(&ids);
1402 self.shared_state.move_items(&ids, target, after);
1403 self.push_undo(UndoEntry::MovedBatch { entries });
1404 }
1405 PlayerCommand::ReorderPlaylist(order) => {
1406 let entries = self.shared_state.items_before(&order);
1410 self.shared_state.reorder_to(&order);
1411 self.push_undo(UndoEntry::MovedBatch { entries });
1412 }
1413 PlayerCommand::TrackReady(id) => self.track_ready(id),
1414 PlayerCommand::DecodeFinished => self.on_decode_finished(),
1415 PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1416 PlayerCommand::StreamProbed { id, info, mode } => self.stream_probed(id, *info, mode),
1417 PlayerCommand::TrackFailed(id) => self.track_failed(id),
1418 PlayerCommand::Undo => self.execute_undo(),
1419 PlayerCommand::Redo => self.execute_redo(),
1420 PlayerCommand::BeginUndoBatch => {
1421 self.batch_buffer = Some(Vec::new());
1422 }
1423 PlayerCommand::EndUndoBatch => {
1424 if let Some(entries) = self.batch_buffer.take() {
1425 if entries.len() == 1 {
1426 self.undo_stack.push(entries.into_iter().next().unwrap());
1428 } else if !entries.is_empty() {
1429 self.undo_stack.push(UndoEntry::Batch(entries));
1430 }
1431 }
1432 }
1433 PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1434 PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1435 }
1436 }
1437
1438 fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1440 match entry {
1441 UndoEntry::Added { ids } => {
1442 let items_with_pos = self.snapshot_for_undo(&ids);
1444 self.shared_state.remove_items(&ids);
1445 Some(UndoEntry::Removed {
1446 items: items_with_pos,
1447 })
1448 }
1449 UndoEntry::Removed { items } => {
1450 let mut ids = Vec::with_capacity(items.len());
1452 for (item, after) in items {
1453 ids.push(item.id);
1454 self.shared_state.insert_item_at(*item, after);
1455 }
1456 Some(UndoEntry::Added { ids })
1457 }
1458 UndoEntry::Inserted { ids } => {
1459 let items_with_pos = self.snapshot_for_undo(&ids);
1461 self.shared_state.remove_items(&ids);
1462 Some(UndoEntry::Removed {
1463 items: items_with_pos,
1464 })
1465 }
1466 UndoEntry::Moved { id, was_after } => {
1467 let current_after = self.shared_state.item_before(id);
1468 self.shared_state.move_item_to(id, was_after);
1469 Some(UndoEntry::Moved {
1470 id,
1471 was_after: current_after,
1472 })
1473 }
1474 UndoEntry::MovedBatch { entries } => {
1475 let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1476 let current_positions = self.shared_state.items_before(&ids);
1477 self.shared_state.move_items_to(&entries);
1478 Some(UndoEntry::MovedBatch {
1479 entries: current_positions,
1480 })
1481 }
1482 UndoEntry::Replaced { items, cursor } => {
1483 let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1484 self.shared_state.restore_playlist(items, cursor);
1485 Some(UndoEntry::Replaced {
1486 items: current_items,
1487 cursor: current_cursor,
1488 })
1489 }
1490 UndoEntry::Batch(entries) => {
1491 let mut inverses = Vec::with_capacity(entries.len());
1493 for entry in entries.into_iter().rev() {
1494 if let Some(inverse) = self.apply_entry(entry) {
1495 inverses.push(inverse);
1496 }
1497 }
1498 inverses.reverse();
1499 Some(UndoEntry::Batch(inverses))
1500 }
1501 }
1502 }
1503
1504 fn reconcile_playback(&mut self) {
1518 let Some(playing) = self.shared_state.track_info().map(|t| t.id) else {
1519 return;
1520 };
1521 if self.shared_state.get_item(playing).is_some() {
1522 return;
1523 }
1524 let resume = (self.shared_state.playback_state() == PlaybackState::Playing)
1529 .then(|| self.shared_state.cursor())
1530 .flatten();
1531 self.stop_playback_and_clear_state();
1532 if let Some(id) = resume {
1533 self.play(id);
1534 }
1535 }
1536
1537 fn execute_undo(&mut self) {
1539 let Some(entry) = self.undo_stack.pop_undo() else {
1540 return;
1541 };
1542 if let Some(inverse) = self.apply_entry(entry) {
1543 self.undo_stack.push_redo(inverse);
1544 }
1545 self.reconcile_playback();
1546 }
1547
1548 fn execute_redo(&mut self) {
1550 let Some(entry) = self.undo_stack.pop_redo() else {
1551 return;
1552 };
1553 if let Some(inverse) = self.apply_entry(entry) {
1554 self.undo_stack.push_undo_keep_redo(inverse);
1555 }
1556 self.reconcile_playback();
1557 }
1558
1559 pub fn run(&mut self) {
1561 use std::time::Duration;
1562
1563 let rx = self.commands.rx.clone();
1564 loop {
1565 match rx.recv_timeout(Duration::from_millis(50)) {
1567 Ok(cmd) => self.process_command(cmd),
1568 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1569 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1570 }
1571 self.update_playback_state();
1572 }
1573 self.stop();
1574 }
1575
1576 pub fn spawn() -> (
1579 Arc<SharedPlayerState>,
1580 Arc<PlaybackTimeline>,
1581 Arc<VizSnapshot>,
1582 crossbeam_channel::Sender<PlayerCommand>,
1583 ) {
1584 let mut player = Self::new();
1585 player.history = PlayRecorder::spawn();
1586 let state = player.shared_state();
1587 let timeline = player.timeline();
1588 let viz_snapshot = player.viz_snapshot();
1589 let tx = player.command_sender();
1590
1591 thread::Builder::new()
1592 .name("koan-player".into())
1593 .spawn(move || player.run())
1594 .expect("failed to spawn player thread");
1595
1596 (state, timeline, viz_snapshot, tx)
1597 }
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602 use super::*;
1603 use state::PlaylistItem;
1604 use std::path::PathBuf;
1605 use std::sync::atomic::AtomicU64;
1606
1607 fn make_item(title: &str) -> PlaylistItem {
1608 PlaylistItem {
1609 playlist_entry_id: None,
1610 id: QueueItemId::new(),
1611 db_id: None,
1612 path: PathBuf::from(format!("/music/{title}.flac")),
1613 title: title.to_string(),
1614 artist: String::new(),
1615 album_artist: String::new(),
1616 album: String::new(),
1617 year: None,
1618 codec: None,
1619 track_number: None,
1620 disc: None,
1621 duration_ms: None,
1622 state: ItemState::Ready,
1623 }
1624 }
1625
1626 fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1627 let (items, _) = player.shared_state.snapshot_playlist();
1628 items.iter().map(|i| i.id).collect()
1629 }
1630
1631 fn playlist_titles(player: &Player) -> Vec<String> {
1632 let (items, _) = player.shared_state.snapshot_playlist();
1633 items.iter().map(|i| i.title.clone()).collect()
1634 }
1635
1636 fn pending_item(title: &str) -> PlaylistItem {
1637 PlaylistItem {
1638 playlist_entry_id: None,
1639 state: ItemState::Pending,
1640 ..make_item(title)
1641 }
1642 }
1643
1644 fn pretend_playing(player: &mut Player, id: QueueItemId) {
1648 let item = player
1649 .shared_state
1650 .get_item(id)
1651 .expect("item is in the queue");
1652 player.shared_state.set_track_info(Some(TrackInfo {
1653 id,
1654 path: item.path,
1655 codec: String::new(),
1656 sample_rate: 44_100,
1657 bit_depth: None,
1658 bitrate_kbps: None,
1659 channels: 2,
1660 duration_ms: 1_000,
1661 }));
1662 player
1663 .shared_state
1664 .set_playback_state(PlaybackState::Playing);
1665 }
1666
1667 fn playing_id(player: &Player) -> Option<QueueItemId> {
1668 player.shared_state.track_info().map(|t| t.id)
1669 }
1670
1671 fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
1673 let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
1674 let ids = items.iter().map(|i| i.id).collect();
1675 player.process_command(PlayerCommand::AddToPlaylist(items));
1676 ids
1677 }
1678
1679 fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
1683 let mut at = from_ms;
1684 if let Some(f) = player.in_flight.as_mut() {
1685 f.advance(at); }
1687 while at < to_ms {
1688 at = (at + 50).min(to_ms);
1689 if let Some(f) = player.in_flight.as_mut() {
1690 f.advance(at);
1691 }
1692 }
1693 }
1694
1695 fn start(player: &mut Player, track_id: i64) -> QueueItemId {
1696 let id = QueueItemId::new();
1697 player.on_track_changed(id);
1698 player
1700 .in_flight
1701 .as_mut()
1702 .unwrap()
1703 .track_id_for_test(track_id);
1704 id
1705 }
1706
1707 #[test]
1708 fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
1709 let mut player = Player::new();
1710 start(&mut player, 11);
1711 listen(&mut player, 0, 200_000);
1712
1713 let b = QueueItemId::new();
1714 player.on_track_changed(b);
1715 let f = player
1716 .in_flight
1717 .as_ref()
1718 .expect("the next track is counting");
1719 assert_eq!(f.item, b);
1720 assert_eq!(f.listened_ms(), 0, "and starts from nothing");
1721 }
1722
1723 #[test]
1724 fn a_track_skipped_seconds_in_is_still_history() {
1725 let mut player = Player::new();
1726 start(&mut player, 7);
1727 listen(&mut player, 0, 2_000);
1728
1729 let event = player
1730 .finish_play()
1731 .expect("putting something on is a thing you did, however briefly");
1732 assert!(matches!(
1733 event,
1734 history::PlayEvent::Finished {
1735 track_id: 7,
1736 listened_ms: 2_000
1737 }
1738 ));
1739 }
1740
1741 #[test]
1742 fn a_track_is_closed_out_once() {
1743 let mut player = Player::new();
1744 start(&mut player, 7);
1745 listen(&mut player, 0, 200_000);
1746
1747 assert!(player.finish_play().is_some());
1748 assert!(player.finish_play().is_none());
1749 }
1750
1751 #[test]
1752 fn seeking_around_a_track_does_not_enter_it_twice() {
1753 let mut player = Player::new();
1754 let id = start(&mut player, 7);
1755 listen(&mut player, 0, 120_000);
1756
1757 player.on_track_changed(id);
1759 assert_eq!(
1760 player.in_flight.as_ref().unwrap().listened_ms(),
1761 120_000,
1762 "the seek kept the count rather than restarting it"
1763 );
1764 listen(&mut player, 30_000, 40_000);
1765
1766 let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
1767 panic!("still one play");
1768 };
1769 assert_eq!(listened_ms, 130_000);
1770 assert!(player.finish_play().is_none());
1771 }
1772
1773 #[test]
1774 fn a_track_that_is_not_in_the_library_is_not_recorded() {
1775 let mut player = Player::new();
1776 let id = QueueItemId::new();
1777 player.on_track_changed(id);
1778 listen(&mut player, 0, 200_000);
1779 assert!(player.finish_play().is_none());
1780 }
1781
1782 #[test]
1783 fn stopping_closes_out_what_was_heard() {
1784 let mut player = Player::new();
1785 start(&mut player, 7);
1786 listen(&mut player, 0, 150_000);
1787
1788 player.stop_playback_and_clear_state();
1789 assert!(player.in_flight.is_none(), "the stop consumed it");
1790 }
1791
1792 #[test]
1793 fn removing_the_playing_track_resumes_at_its_successor() {
1794 let mut player = Player::new();
1795 let ids = seed(&mut player, 5);
1796 player.shared_state.set_cursor(Some(ids[2]));
1797
1798 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
1799
1800 assert_eq!(
1801 player.shared_state.cursor(),
1802 Some(ids[3]),
1803 "playback must continue at the next track, not restart the queue"
1804 );
1805 assert_eq!(player.playback_starts, 1);
1806 }
1807
1808 #[test]
1809 fn removing_the_first_playing_track_resumes_at_the_new_first() {
1810 let mut player = Player::new();
1811 let ids = seed(&mut player, 3);
1812 player.shared_state.set_cursor(Some(ids[0]));
1813
1814 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
1815
1816 assert_eq!(player.shared_state.cursor(), Some(ids[1]));
1817 }
1818
1819 #[test]
1820 fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
1821 let mut player = Player::new();
1822 let playing = make_item("playing");
1823 let waiting = pending_item("waiting");
1824 let later = make_item("later");
1825 let (playing_id, waiting_id) = (playing.id, waiting.id);
1826 player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
1827 player.shared_state.set_cursor(Some(playing_id));
1828
1829 player.process_command(PlayerCommand::DecodeFinished);
1830
1831 assert_eq!(
1832 player.shared_state.cursor(),
1833 Some(waiting_id),
1834 "the cursor parks on the track being fetched"
1835 );
1836 assert_eq!(
1837 player.playback_starts, 0,
1838 "nothing to play until its bytes land"
1839 );
1840
1841 player
1844 .shared_state
1845 .update_item_state(waiting_id, ItemState::Ready);
1846 player.process_command(PlayerCommand::TrackReady(waiting_id));
1847
1848 assert_eq!(player.playback_starts, 1);
1849 assert_eq!(player.shared_state.cursor(), Some(waiting_id));
1850 }
1851
1852 #[test]
1853 fn a_download_that_cannot_land_moves_the_cursor_on() {
1854 let mut player = Player::new();
1855 let waiting = pending_item("waiting");
1856 let later = make_item("later");
1857 let (waiting_id, later_id) = (waiting.id, later.id);
1858 player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, later]));
1859
1860 player.process_command(PlayerCommand::Play(waiting_id));
1861 assert_eq!(player.playback_starts, 0, "nothing to play yet");
1862
1863 player
1865 .shared_state
1866 .update_item_state(waiting_id, ItemState::Failed("remote unavailable".into()));
1867 player.process_command(PlayerCommand::TrackFailed(waiting_id));
1868
1869 assert_eq!(
1870 player.shared_state.cursor(),
1871 Some(later_id),
1872 "the queue moves past a track that can never load"
1873 );
1874 assert_eq!(player.playback_starts, 1);
1875 }
1876
1877 #[test]
1878 fn a_queue_that_can_never_load_stops_rather_than_waiting() {
1879 let mut player = Player::new();
1880 let first = pending_item("first");
1881 let second = pending_item("second");
1882 let (first_id, second_id) = (first.id, second.id);
1883 player.process_command(PlayerCommand::AddToPlaylist(vec![first, second]));
1884
1885 player.process_command(PlayerCommand::Play(first_id));
1886 for id in [first_id, second_id] {
1887 player
1888 .shared_state
1889 .update_item_state(id, ItemState::Failed("remote unavailable".into()));
1890 player.process_command(PlayerCommand::TrackFailed(id));
1891 }
1892
1893 assert_eq!(player.playback_starts, 0);
1894 assert_eq!(
1895 player.shared_state.playback_state(),
1896 PlaybackState::Stopped,
1897 "a stop the UI can see, not an indefinite wait for TrackReady"
1898 );
1899 }
1900
1901 #[test]
1902 fn a_failure_elsewhere_in_the_queue_leaves_the_cursor_alone() {
1903 let mut player = Player::new();
1904 let waiting = pending_item("waiting");
1905 let other = pending_item("other");
1906 let (waiting_id, other_id) = (waiting.id, other.id);
1907 player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, other]));
1908 player.process_command(PlayerCommand::Play(waiting_id));
1909
1910 player
1911 .shared_state
1912 .update_item_state(other_id, ItemState::Failed("remote unavailable".into()));
1913 player.process_command(PlayerCommand::TrackFailed(other_id));
1914
1915 assert_eq!(
1916 player.shared_state.cursor(),
1917 Some(waiting_id),
1918 "a track still downloading keeps the cursor"
1919 );
1920 }
1921
1922 #[test]
1923 fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
1924 let mut player = Player::new();
1925 let ids = seed(&mut player, 5);
1926 player.shared_state.set_cursor(Some(ids[2]));
1927
1928 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
1929 ids[1], ids[2], ids[3],
1930 ]));
1931
1932 assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
1933 assert_eq!(player.shared_state.cursor(), Some(ids[4]));
1934 assert_eq!(
1935 player.playback_starts, 1,
1936 "one resume for the whole selection, not one per deleted track"
1937 );
1938 }
1939
1940 #[test]
1941 fn batch_delete_below_the_cursor_leaves_playback_alone() {
1942 let mut player = Player::new();
1943 let ids = seed(&mut player, 4);
1944 player.shared_state.set_cursor(Some(ids[0]));
1945
1946 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
1947
1948 assert_eq!(player.shared_state.cursor(), Some(ids[0]));
1949 assert_eq!(player.playback_starts, 0);
1950 }
1951
1952 #[test]
1953 fn undo_of_a_batch_delete_restores_the_original_order() {
1954 let mut player = Player::new();
1958 let items = vec![
1959 make_item("A"),
1960 make_item("B"),
1961 make_item("C"),
1962 make_item("D"),
1963 ];
1964 let (b_id, c_id) = (items[1].id, items[2].id);
1965 player.process_command(PlayerCommand::AddToPlaylist(items));
1966
1967 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
1968 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1969
1970 player.process_command(PlayerCommand::Undo);
1971 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1972 }
1973
1974 #[test]
1977 fn undo_add_removes_items() {
1978 let mut player = Player::new();
1979 let items = vec![make_item("A"), make_item("B")];
1980 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1981
1982 player.process_command(PlayerCommand::AddToPlaylist(items));
1983 assert_eq!(playlist_ids(&player), ids);
1984 assert!(player.undo_stack().can_undo());
1985
1986 player.process_command(PlayerCommand::Undo);
1987 assert!(playlist_ids(&player).is_empty());
1988 assert!(player.undo_stack().can_redo());
1989 }
1990
1991 #[test]
1992 fn redo_add_restores_items() {
1993 let mut player = Player::new();
1994 let items = vec![make_item("A"), make_item("B")];
1995
1996 player.process_command(PlayerCommand::AddToPlaylist(items));
1997 player.process_command(PlayerCommand::Undo);
1998 assert!(playlist_ids(&player).is_empty());
1999
2000 player.process_command(PlayerCommand::Redo);
2001 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2002 }
2003
2004 #[test]
2007 fn undo_remove_restores_item_at_position() {
2008 let mut player = Player::new();
2009 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2010 let b_id = items[1].id;
2011
2012 player.process_command(PlayerCommand::AddToPlaylist(items));
2013 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2014 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2015
2016 player.process_command(PlayerCommand::Undo);
2017 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2018 }
2019
2020 #[test]
2021 fn undo_remove_first_item() {
2022 let mut player = Player::new();
2023 let items = vec![make_item("A"), make_item("B")];
2024 let a_id = items[0].id;
2025
2026 player.process_command(PlayerCommand::AddToPlaylist(items));
2027 player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
2028 assert_eq!(playlist_titles(&player), vec!["B"]);
2029
2030 player.process_command(PlayerCommand::Undo);
2031 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2032 }
2033
2034 #[test]
2035 fn undo_batch_remove_restores_all() {
2036 let mut player = Player::new();
2037 let items = vec![
2038 make_item("A"),
2039 make_item("B"),
2040 make_item("C"),
2041 make_item("D"),
2042 ];
2043 let b_id = items[1].id;
2044 let c_id = items[2].id;
2045
2046 player.process_command(PlayerCommand::AddToPlaylist(items));
2047 let version_before = player.shared_state.playlist_version();
2048 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
2049 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
2050 assert_eq!(
2053 player.shared_state.playlist_version(),
2054 version_before + 1,
2055 "batch removal must bump the playlist version exactly once"
2056 );
2057
2058 player.process_command(PlayerCommand::Undo);
2060 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
2061 }
2062
2063 #[test]
2064 fn redo_batch_remove() {
2065 let mut player = Player::new();
2066 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2067 let a_id = items[0].id;
2068 let b_id = items[1].id;
2069
2070 player.process_command(PlayerCommand::AddToPlaylist(items));
2071 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
2072 player.process_command(PlayerCommand::Undo);
2073 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2074
2075 player.process_command(PlayerCommand::Redo);
2076 assert_eq!(playlist_titles(&player), vec!["C"]);
2077 }
2078
2079 #[test]
2080 fn redo_remove() {
2081 let mut player = Player::new();
2082 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2083 let b_id = items[1].id;
2084
2085 player.process_command(PlayerCommand::AddToPlaylist(items));
2086 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2087 player.process_command(PlayerCommand::Undo);
2088 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2089
2090 player.process_command(PlayerCommand::Redo);
2091 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2092 }
2093
2094 #[test]
2097 fn undo_insert_removes_inserted_items() {
2098 let mut player = Player::new();
2099 let items = vec![make_item("A"), make_item("C")];
2100 let a_id = items[0].id;
2101
2102 player.process_command(PlayerCommand::AddToPlaylist(items));
2103
2104 let inserted = vec![make_item("B")];
2105 player.process_command(PlayerCommand::InsertInPlaylist {
2106 items: inserted,
2107 after: a_id,
2108 });
2109 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2110
2111 player.process_command(PlayerCommand::Undo);
2112 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2113 }
2114
2115 #[test]
2118 fn undo_move_restores_position() {
2119 let mut player = Player::new();
2120 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2121 let a_id = items[0].id;
2122 let c_id = items[2].id;
2123
2124 player.process_command(PlayerCommand::AddToPlaylist(items));
2125
2126 player.process_command(PlayerCommand::MoveInPlaylist {
2128 id: a_id,
2129 target: c_id,
2130 after: true,
2131 });
2132 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2133
2134 player.process_command(PlayerCommand::Undo);
2135 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2136 }
2137
2138 #[test]
2139 fn redo_move() {
2140 let mut player = Player::new();
2141 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2142 let a_id = items[0].id;
2143 let c_id = items[2].id;
2144
2145 player.process_command(PlayerCommand::AddToPlaylist(items));
2146 player.process_command(PlayerCommand::MoveInPlaylist {
2147 id: a_id,
2148 target: c_id,
2149 after: true,
2150 });
2151 player.process_command(PlayerCommand::Undo);
2152 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2153
2154 player.process_command(PlayerCommand::Redo);
2155 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2156 }
2157
2158 #[test]
2161 fn undo_batch_move() {
2162 let mut player = Player::new();
2163 let items = vec![
2164 make_item("A"),
2165 make_item("B"),
2166 make_item("C"),
2167 make_item("D"),
2168 ];
2169 let a_id = items[0].id;
2170 let b_id = items[1].id;
2171 let d_id = items[3].id;
2172
2173 player.process_command(PlayerCommand::AddToPlaylist(items));
2174
2175 player.process_command(PlayerCommand::MoveItemsInPlaylist {
2177 ids: vec![a_id, b_id],
2178 target: d_id,
2179 after: true,
2180 });
2181 assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
2182
2183 player.process_command(PlayerCommand::Undo);
2184 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
2185 }
2186
2187 #[test]
2190 fn undo_clear_restores_playlist() {
2191 let mut player = Player::new();
2192 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2193
2194 player.process_command(PlayerCommand::AddToPlaylist(items));
2195 player.process_command(PlayerCommand::ClearPlaylist);
2196 assert!(playlist_ids(&player).is_empty());
2197
2198 player.process_command(PlayerCommand::Undo);
2199 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2200 }
2201
2202 #[test]
2207 fn undoing_a_replace_does_not_leave_the_engine_on_an_orphaned_track() {
2208 let mut player = Player::new();
2209 let original = seed(&mut player, 3);
2210 player.shared_state.set_cursor(Some(original[0]));
2211 pretend_playing(&mut player, original[0]);
2212
2213 let replacement = vec![make_item("something else")];
2214 let orphan = replacement[0].id;
2215 player.process_command(PlayerCommand::ReplacePlaylist {
2216 items: replacement,
2217 start: 0,
2218 });
2219 pretend_playing(&mut player, orphan);
2221
2222 player.process_command(PlayerCommand::Undo);
2223
2224 assert_eq!(playlist_ids(&player), original, "the queue comes back");
2225 assert!(
2226 player.shared_state.get_item(orphan).is_none(),
2227 "and the replacement is gone from it"
2228 );
2229 assert!(
2230 playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
2231 "so nothing may still be playing out of it"
2232 );
2233 }
2234
2235 #[test]
2237 fn undoing_an_add_does_not_leave_the_engine_on_a_removed_track() {
2238 let mut player = Player::new();
2239 seed(&mut player, 2);
2240 let added = seed(&mut player, 1);
2241 pretend_playing(&mut player, added[0]);
2242
2243 player.process_command(PlayerCommand::Undo);
2244
2245 assert!(player.shared_state.get_item(added[0]).is_none());
2246 assert!(
2247 playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
2248 "the engine cannot be left on the item the undo removed"
2249 );
2250 }
2251
2252 #[test]
2254 fn undoing_a_move_leaves_playback_alone() {
2255 let mut player = Player::new();
2256 let ids = seed(&mut player, 3);
2257 player.shared_state.set_cursor(Some(ids[0]));
2258 pretend_playing(&mut player, ids[0]);
2259 let starts = player.playback_starts;
2260
2261 player.process_command(PlayerCommand::MoveInPlaylist {
2262 id: ids[2],
2263 target: ids[0],
2264 after: false,
2265 });
2266 player.process_command(PlayerCommand::Undo);
2267
2268 assert_eq!(playlist_ids(&player), ids);
2269 assert_eq!(playing_id(&player), Some(ids[0]), "still on the same track");
2270 assert_eq!(player.playback_starts, starts, "and not restarted");
2271 }
2272
2273 #[test]
2274 fn redo_clear() {
2275 let mut player = Player::new();
2276 let items = vec![make_item("A"), make_item("B")];
2277
2278 player.process_command(PlayerCommand::AddToPlaylist(items));
2279 player.process_command(PlayerCommand::ClearPlaylist);
2280 player.process_command(PlayerCommand::Undo);
2281 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2282
2283 player.process_command(PlayerCommand::Redo);
2284 assert!(playlist_ids(&player).is_empty());
2285 }
2286
2287 #[test]
2290 fn multiple_undos_in_sequence() {
2291 let mut player = Player::new();
2292
2293 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
2294 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
2295 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
2296 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2297
2298 player.process_command(PlayerCommand::Undo);
2299 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2300
2301 player.process_command(PlayerCommand::Undo);
2302 assert_eq!(playlist_titles(&player), vec!["A"]);
2303
2304 player.process_command(PlayerCommand::Undo);
2305 assert!(playlist_ids(&player).is_empty());
2306 }
2307
2308 #[test]
2309 fn undo_redo_undo_cycle() {
2310 let mut player = Player::new();
2311 let items = vec![make_item("A"), make_item("B")];
2312
2313 player.process_command(PlayerCommand::AddToPlaylist(items));
2314 player.process_command(PlayerCommand::Undo);
2315 assert!(playlist_ids(&player).is_empty());
2316
2317 player.process_command(PlayerCommand::Redo);
2318 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2319
2320 player.process_command(PlayerCommand::Undo);
2321 assert!(playlist_ids(&player).is_empty());
2322 }
2323
2324 #[test]
2325 fn new_action_clears_redo_stack() {
2326 let mut player = Player::new();
2327 let items = vec![make_item("A")];
2328
2329 player.process_command(PlayerCommand::AddToPlaylist(items));
2330 player.process_command(PlayerCommand::Undo);
2331 assert!(player.undo_stack().can_redo());
2332
2333 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
2335 assert!(!player.undo_stack().can_redo());
2336 }
2337
2338 #[test]
2339 fn undo_on_empty_stack_is_noop() {
2340 let mut player = Player::new();
2341 player.process_command(PlayerCommand::Undo);
2342 assert!(playlist_ids(&player).is_empty());
2343 }
2344
2345 #[test]
2346 fn redo_on_empty_stack_is_noop() {
2347 let mut player = Player::new();
2348 player.process_command(PlayerCommand::Redo);
2349 assert!(playlist_ids(&player).is_empty());
2350 }
2351
2352 #[test]
2355 fn playback_commands_not_undoable() {
2356 let mut player = Player::new();
2357 player.process_command(PlayerCommand::Pause);
2358 player.process_command(PlayerCommand::Resume);
2359 player.process_command(PlayerCommand::NextTrack);
2360 player.process_command(PlayerCommand::PrevTrack);
2361 assert!(!player.undo_stack().can_undo());
2362 }
2363
2364 #[test]
2365 fn update_paths_not_undoable() {
2366 let mut player = Player::new();
2367 let items = vec![make_item("A")];
2368 let id = items[0].id;
2369 player.process_command(PlayerCommand::AddToPlaylist(items));
2370
2371 let undo_count = player.undo_stack().undo_len();
2372 player.process_command(PlayerCommand::UpdatePaths(vec![(
2373 id,
2374 PathBuf::from("/new/path.flac"),
2375 )]));
2376 assert_eq!(player.undo_stack().undo_len(), undo_count);
2377 }
2378
2379 #[test]
2382 fn add_remove_undo_undo_produces_original() {
2383 let mut player = Player::new();
2384 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2385 let b_id = items[1].id;
2386 let original_titles = vec!["A", "B", "C"];
2387
2388 player.process_command(PlayerCommand::AddToPlaylist(items));
2389 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2390 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2391
2392 player.process_command(PlayerCommand::Undo);
2394 assert_eq!(playlist_titles(&player), original_titles);
2395
2396 player.process_command(PlayerCommand::Undo);
2398 assert!(playlist_ids(&player).is_empty());
2399 }
2400
2401 #[test]
2402 fn interleaved_adds_and_moves_undo() {
2403 let mut player = Player::new();
2404 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2405 let a_id = items[0].id;
2406 let c_id = items[2].id;
2407
2408 player.process_command(PlayerCommand::AddToPlaylist(items));
2409
2410 player.process_command(PlayerCommand::MoveInPlaylist {
2412 id: a_id,
2413 target: c_id,
2414 after: true,
2415 });
2416 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2417
2418 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
2420 assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
2421
2422 player.process_command(PlayerCommand::Undo);
2424 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2425
2426 player.process_command(PlayerCommand::Undo);
2428 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2429 }
2430
2431 #[test]
2436 fn stop_engine_drops_engine_synchronously() {
2437 use std::sync::atomic::{AtomicBool, Ordering};
2438
2439 struct MockEngine {
2440 dropped: Arc<AtomicBool>,
2441 }
2442 impl AudioEngineHandle for MockEngine {
2443 fn start(&self) -> Result<(), BackendError> {
2444 Ok(())
2445 }
2446 fn stop(&self) -> Result<(), BackendError> {
2447 Ok(())
2448 }
2449 fn is_running(&self) -> bool {
2450 false
2451 }
2452 }
2453 impl Drop for MockEngine {
2454 fn drop(&mut self) {
2455 self.dropped.store(true, Ordering::SeqCst);
2456 }
2457 }
2458
2459 let dropped = Arc::new(AtomicBool::new(false));
2460
2461 let stop_flag = Arc::new(AtomicBool::new(false));
2463 let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
2464
2465 let mut player = Player::new();
2466 player.active_playback = Some(ActivePlayback {
2467 engine: Box::new(MockEngine {
2468 dropped: dropped.clone(),
2469 }),
2470 decode_handle,
2471 _rate_watch: None,
2472 });
2473
2474 player.stop_engine();
2475
2476 assert!(
2480 dropped.load(Ordering::SeqCst),
2481 "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
2482 );
2483 }
2484
2485 struct StuckBackend {
2490 rate: f64,
2491 asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
2492 }
2493
2494 struct NullEngine;
2495 impl AudioEngineHandle for NullEngine {
2496 fn start(&self) -> Result<(), BackendError> {
2497 Ok(())
2498 }
2499 fn stop(&self) -> Result<(), BackendError> {
2500 Ok(())
2501 }
2502 fn is_running(&self) -> bool {
2503 false
2504 }
2505 }
2506
2507 impl AudioBackend for StuckBackend {
2508 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2509 Ok(vec![self.default_device()?])
2510 }
2511 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2512 Ok(backend::DeviceInfo {
2513 name: "Stuck DAC".into(),
2514 sample_rates: vec![self.rate],
2515 platform_id: 0,
2516 })
2517 }
2518 fn supported_sample_rates(
2519 &self,
2520 _device: &backend::DeviceInfo,
2521 ) -> Result<Vec<f64>, BackendError> {
2522 Ok(vec![self.rate])
2523 }
2524 fn get_device_sample_rate(
2525 &self,
2526 _device: &backend::DeviceInfo,
2527 ) -> Result<f64, BackendError> {
2528 Ok(self.rate)
2529 }
2530 fn set_device_sample_rate(
2531 &self,
2532 _device: &backend::DeviceInfo,
2533 rate: f64,
2534 ) -> Result<f64, BackendError> {
2535 Err(BackendError::UnsupportedSampleRate(rate))
2536 }
2537 fn create_engine(
2538 &self,
2539 _device: &backend::DeviceInfo,
2540 sample_rate: f64,
2541 channels: u32,
2542 _consumer: rtrb::Consumer<f32>,
2543 _samples_played: Arc<AtomicU64>,
2544 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2545 *self.asked.lock().unwrap() = Some((sample_rate, channels));
2546 Ok(Box::new(NullEngine))
2547 }
2548 }
2549
2550 fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
2551 let asked = Arc::new(std::sync::Mutex::new(None));
2552 let mut player = Player::new();
2553 player.backend = Box::new(StuckBackend {
2554 rate: device_rate,
2555 asked: asked.clone(),
2556 });
2557
2558 let info = buffer::StreamInfo {
2559 codec: "MP3".into(),
2560 sample_rate: source_rate,
2561 channels,
2562 bit_depth: Some(16),
2563 bitrate_kbps: None,
2564 duration_ms: 1000,
2565 };
2566 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2567 player
2568 .create_engine_for(&info, consumer)
2569 .expect("engine creation should succeed");
2570 let asked = *asked.lock().unwrap();
2571 asked.expect("engine was never created")
2572 }
2573
2574 #[test]
2575 fn engine_uses_source_rate_when_device_refuses_switch() {
2576 assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
2579 assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
2580 }
2581
2582 #[test]
2583 fn engine_uses_source_channel_count() {
2584 assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
2585 }
2586
2587 fn settled_rate_for(source_rate: u32, device_rate: f64) -> Option<u32> {
2589 let mut player = Player::new();
2590 player.backend = Box::new(StuckBackend {
2591 rate: device_rate,
2592 asked: Arc::new(std::sync::Mutex::new(None)),
2593 });
2594 let state = player.shared_state.clone();
2595
2596 let info = buffer::StreamInfo {
2597 codec: "MP3".into(),
2598 sample_rate: source_rate,
2599 channels: 2,
2600 bit_depth: Some(16),
2601 bitrate_kbps: None,
2602 duration_ms: 1000,
2603 };
2604 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2605 player
2606 .create_engine_for(&info, consumer)
2607 .expect("engine creation should succeed");
2608 state.output_sample_rate()
2609 }
2610
2611 #[test]
2612 fn settled_device_rate_reaches_the_shared_state() {
2613 assert_eq!(settled_rate_for(22050, 48000.0), Some(48000));
2617 assert_eq!(settled_rate_for(44100, 44100.0), Some(44100));
2619 }
2620
2621 struct SlowBackend {
2623 observed: Arc<std::sync::Mutex<Vec<Option<u32>>>>,
2624 state: Arc<SharedPlayerState>,
2625 }
2626
2627 impl AudioBackend for SlowBackend {
2628 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2629 Ok(vec![self.default_device()?])
2630 }
2631 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2632 Ok(backend::DeviceInfo {
2633 name: "Slow DAC".into(),
2634 sample_rates: vec![44100.0, 48000.0],
2635 platform_id: 0,
2636 })
2637 }
2638 fn supported_sample_rates(
2639 &self,
2640 _device: &backend::DeviceInfo,
2641 ) -> Result<Vec<f64>, BackendError> {
2642 Ok(vec![44100.0, 48000.0])
2643 }
2644 fn get_device_sample_rate(
2645 &self,
2646 _device: &backend::DeviceInfo,
2647 ) -> Result<f64, BackendError> {
2648 Ok(48000.0)
2649 }
2650 fn set_device_sample_rate(
2651 &self,
2652 _device: &backend::DeviceInfo,
2653 rate: f64,
2654 ) -> Result<f64, BackendError> {
2655 self.observed
2657 .lock()
2658 .unwrap()
2659 .push(self.state.output_sample_rate());
2660 Ok(rate)
2661 }
2662 fn create_engine(
2663 &self,
2664 _device: &backend::DeviceInfo,
2665 _sample_rate: f64,
2666 _channels: u32,
2667 _consumer: rtrb::Consumer<f32>,
2668 _samples_played: Arc<AtomicU64>,
2669 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2670 Ok(Box::new(NullEngine))
2671 }
2672 }
2673
2674 #[test]
2675 fn the_previous_rate_is_not_published_while_the_device_reclocks() {
2676 let mut player = Player::new();
2682 let state = player.shared_state.clone();
2683 state.set_output_sample_rate(48000);
2684
2685 let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
2686 player.backend = Box::new(SlowBackend {
2687 observed: observed.clone(),
2688 state: state.clone(),
2689 });
2690
2691 let info = buffer::StreamInfo {
2692 codec: "FLAC".into(),
2693 sample_rate: 44100,
2694 channels: 2,
2695 bit_depth: Some(16),
2696 bitrate_kbps: None,
2697 duration_ms: 1000,
2698 };
2699 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2700 player
2701 .create_engine_for(&info, consumer)
2702 .expect("engine creation should succeed");
2703
2704 assert_eq!(
2705 *observed.lock().unwrap(),
2706 vec![None],
2707 "mid-switch the output rate must read as unknown, not as the last track's"
2708 );
2709 assert_eq!(state.output_sample_rate(), Some(44100));
2710 }
2711
2712 struct WatchedBackend {
2714 inner: StuckBackend,
2715 #[allow(clippy::type_complexity)]
2716 captured: Arc<std::sync::Mutex<Option<Box<dyn Fn(f64) + Send + Sync>>>>,
2717 }
2718
2719 struct NullWatch;
2720 impl backend::SampleRateWatch for NullWatch {}
2721
2722 impl AudioBackend for WatchedBackend {
2723 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2724 self.inner.list_devices()
2725 }
2726 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2727 self.inner.default_device()
2728 }
2729 fn supported_sample_rates(
2730 &self,
2731 device: &backend::DeviceInfo,
2732 ) -> Result<Vec<f64>, BackendError> {
2733 self.inner.supported_sample_rates(device)
2734 }
2735 fn get_device_sample_rate(
2736 &self,
2737 device: &backend::DeviceInfo,
2738 ) -> Result<f64, BackendError> {
2739 self.inner.get_device_sample_rate(device)
2740 }
2741 fn set_device_sample_rate(
2742 &self,
2743 device: &backend::DeviceInfo,
2744 rate: f64,
2745 ) -> Result<f64, BackendError> {
2746 self.inner.set_device_sample_rate(device, rate)
2747 }
2748 fn watch_device_sample_rate(
2749 &self,
2750 _device: &backend::DeviceInfo,
2751 on_change: Box<dyn Fn(f64) + Send + Sync>,
2752 ) -> Option<Box<dyn backend::SampleRateWatch>> {
2753 *self.captured.lock().unwrap() = Some(on_change);
2754 Some(Box::new(NullWatch))
2755 }
2756 fn create_engine(
2757 &self,
2758 device: &backend::DeviceInfo,
2759 sample_rate: f64,
2760 channels: u32,
2761 consumer: rtrb::Consumer<f32>,
2762 samples_played: Arc<AtomicU64>,
2763 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2764 self.inner
2765 .create_engine(device, sample_rate, channels, consumer, samples_played)
2766 }
2767 }
2768
2769 #[test]
2770 fn external_rate_change_reaches_the_shared_state() {
2771 let captured = Arc::new(std::sync::Mutex::new(None));
2775 let mut player = Player::new();
2776 player.backend = Box::new(WatchedBackend {
2777 inner: StuckBackend {
2778 rate: 44100.0,
2779 asked: Arc::new(std::sync::Mutex::new(None)),
2780 },
2781 captured: captured.clone(),
2782 });
2783 let state = player.shared_state.clone();
2784
2785 let info = buffer::StreamInfo {
2786 codec: "FLAC".into(),
2787 sample_rate: 44100,
2788 channels: 2,
2789 bit_depth: Some(16),
2790 bitrate_kbps: None,
2791 duration_ms: 1000,
2792 };
2793 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2794 player
2795 .create_engine_for(&info, consumer)
2796 .expect("engine creation should succeed");
2797 assert_eq!(state.output_sample_rate(), Some(44100));
2798
2799 let on_change = captured.lock().unwrap().take().expect("watch registered");
2800 on_change(48000.0);
2801 assert_eq!(state.output_sample_rate(), Some(48000));
2802 }
2803}