1pub mod commands;
2pub mod history;
3pub mod state;
4pub mod undo;
5
6use std::path::Path;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::thread;
10
11use thiserror::Error;
12
13use crate::audio::{
14 analyzer::VizAnalyzer,
15 backend::{self, AudioBackend, AudioEngineHandle, BackendError},
16 buffer, streaming,
17 viz::{VizBuffer, VizSnapshot},
18};
19use buffer::PlaybackTimeline;
20use commands::{CommandChannel, PlayerCommand};
21use history::{InFlight, PlayEvent, PlayRecorder};
22use state::{LoadState, PlaybackSource, PlaybackState, QueueItemId, SharedPlayerState, TrackInfo};
23use undo::{UndoEntry, UndoStack};
24
25pub(crate) const RING_BUFFER_SIZE: usize = 192_000 * 2;
27
28#[derive(Debug, Error)]
29pub enum PlayerError {
30 #[error("backend error: {0}")]
31 Backend(#[from] BackendError),
32 #[error("decode error: {0}")]
33 Decode(#[from] buffer::DecodeError),
34}
35
36pub struct Player {
38 shared_state: Arc<SharedPlayerState>,
39 commands: CommandChannel,
40 active_playback: Option<ActivePlayback>,
41 timeline: Arc<PlaybackTimeline>,
42 viz_buffer: Arc<VizBuffer>,
43 viz_snapshot: Arc<VizSnapshot>,
44 _viz_analyzer: VizAnalyzer,
46 undo_stack: UndoStack,
47 batch_buffer: Option<Vec<UndoEntry>>,
50 output_device_name: Option<String>,
52 backend: Box<dyn AudioBackend>,
54 last_skip: std::time::Instant,
56 history: Option<PlayRecorder>,
59 in_flight: Option<InFlight>,
61 #[cfg(test)]
64 playback_starts: usize,
65}
66
67struct ActivePlayback {
69 engine: Box<dyn AudioEngineHandle>,
70 decode_handle: buffer::DecodeHandle,
71}
72
73impl Default for Player {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl Player {
80 pub fn new() -> Self {
81 let viz_buffer = VizBuffer::new();
82 let viz_snapshot = VizSnapshot::new();
83 let timeline = PlaybackTimeline::new();
84 let cfg = crate::config::Config::load_or_default();
85 let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
86 Arc::clone(&viz_buffer),
87 &cfg.visualizer,
88 Arc::clone(&viz_snapshot),
89 timeline.samples_played_counter(),
90 );
91
92 Self {
93 shared_state: SharedPlayerState::new(),
94 commands: CommandChannel::new(),
95 active_playback: None,
96 timeline,
97 viz_buffer,
98 viz_snapshot,
99 _viz_analyzer: viz_analyzer,
100 undo_stack: UndoStack::new(),
101 batch_buffer: None,
102 output_device_name: cfg.playback.output_device.clone(),
103 backend: crate::audio::platform_backend(),
104 last_skip: std::time::Instant::now(),
105 history: None,
106 in_flight: None,
107 #[cfg(test)]
108 playback_starts: 0,
109 }
110 }
111
112 pub fn shared_state(&self) -> Arc<SharedPlayerState> {
114 self.shared_state.clone()
115 }
116
117 pub fn timeline(&self) -> Arc<PlaybackTimeline> {
119 self.timeline.clone()
120 }
121
122 pub fn viz_buffer(&self) -> Arc<VizBuffer> {
124 self.viz_buffer.clone()
125 }
126
127 pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
130 self.viz_snapshot.clone()
131 }
132
133 pub fn undo_stack(&self) -> &UndoStack {
135 &self.undo_stack
136 }
137
138 fn create_engine_for(
146 &self,
147 info: &buffer::StreamInfo,
148 consumer: rtrb::Consumer<f32>,
149 ) -> Result<Box<dyn AudioEngineHandle>, PlayerError> {
150 let device = self.resolve_device()?;
151 let device_rate = self.backend.get_device_sample_rate(&device)?;
152 let source_rate = info.sample_rate as f64;
153
154 let settled = if (device_rate - source_rate).abs() > 0.1 {
155 log::info!(
156 "switching device sample rate: {}Hz → {}Hz",
157 device_rate,
158 source_rate
159 );
160 match self.backend.set_device_sample_rate(&device, source_rate) {
161 Ok(rate) => rate,
162 Err(e) => {
163 log::warn!("failed to set device sample rate: {}", e);
164 device_rate
165 }
166 }
167 } else {
168 device_rate
169 };
170
171 if (settled - source_rate).abs() > 0.1 {
172 log::warn!(
173 "device stayed at {}Hz (wanted {}Hz) — output is resampled, not bit-perfect",
174 settled,
175 source_rate
176 );
177 }
178
179 self.shared_state
182 .set_output_sample_rate(settled.round() as u32);
183
184 Ok(self.backend.create_engine(
185 &device,
186 source_rate,
187 info.channels as u32,
188 consumer,
189 self.timeline.samples_played_counter(),
190 )?)
191 }
192
193 fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
196 if let Some(ref name) = self.output_device_name {
197 match self.backend.list_devices() {
198 Ok(devices) => {
199 if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
200 return Ok(dev);
201 }
202 log::warn!(
203 "configured output device '{}' not found, falling back to default",
204 name,
205 );
206 }
207 Err(e) => {
208 log::warn!("failed to list devices while resolving '{}': {}", name, e);
209 }
210 }
211 }
212 Ok(self.backend.default_device()?)
213 }
214
215 pub fn set_output_device(&mut self, name: String) {
218 log::info!("switching output device to: {}", name);
219 self.output_device_name = Some(name.clone());
220
221 if let Err(e) = crate::config::Config::update_base(|cfg| {
223 cfg.playback.output_device = Some(name);
224 }) {
225 log::error!("failed to save output device config: {}", e);
226 }
227
228 self.restart_on_current_track();
229 }
230
231 pub fn clear_output_device(&mut self) {
233 log::info!("reverting to system default output device");
234 self.output_device_name = None;
235
236 if let Err(e) = crate::config::Config::update_base(|cfg| {
237 cfg.playback.output_device = None;
238 }) {
239 log::error!("failed to save output device config: {}", e);
240 }
241
242 self.restart_on_current_track();
243 }
244
245 fn restart_on_current_track(&mut self) {
248 if let Some(info) = self.shared_state.track_info() {
249 let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
250 let position_ms = self.shared_state.position_ms();
251 if let Err(e) = self.start_playback(info.id, &info.path, position_ms) {
252 log::error!("failed to restart playback on device switch: {}", e);
253 return;
254 }
255 if was_paused {
256 self.pause();
257 }
258 }
259 }
260
261 pub fn output_device_name(&self) -> Option<&str> {
263 self.output_device_name.as_deref()
264 }
265
266 pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
268 self.commands.tx.clone()
269 }
270
271 pub fn play(&mut self, id: QueueItemId) {
274 self.shared_state.set_cursor(Some(id));
275
276 match self.shared_state.item_playback_source(id) {
277 Some(PlaybackSource::Ready(path)) => {
278 if let Err(e) = self.start_playback(id, &path, 0) {
279 log::error!("play failed: {}", e);
280 }
281 }
282 Some(PlaybackSource::Streaming {
283 path,
284 bytes_written,
285 total,
286 }) => {
287 if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
288 log::error!("streaming play failed, waiting for full download: {}", e);
291 }
292 }
293 None => {
294 self.stop_engine();
296 self.shared_state.set_playback_state(PlaybackState::Stopped);
297 log::info!("play: item {:?} not ready, waiting for TrackReady", id);
298 }
299 }
300 }
301
302 fn start_playback(
307 &mut self,
308 id: QueueItemId,
309 path: &Path,
310 seek_ms: u64,
311 ) -> Result<(), PlayerError> {
312 #[cfg(test)]
313 {
314 self.playback_starts += 1;
315 }
316 let result = self.open_playback(id, path, seek_ms);
317 if result.is_err() {
318 self.stop_playback_and_clear_state();
319 }
320 result
321 }
322
323 fn open_playback(
324 &mut self,
325 id: QueueItemId,
326 path: &Path,
327 seek_ms: u64,
328 ) -> Result<(), PlayerError> {
329 self.stop_engine();
330
331 let info = buffer::probe_file(path)?;
332
333 self.shared_state.set_track_info(Some(TrackInfo {
337 id,
338 path: path.to_path_buf(),
339 codec: info.codec.clone(),
340 sample_rate: info.sample_rate,
341 bit_depth: info.bit_depth,
342 bitrate_kbps: info.bitrate_kbps,
343 channels: info.channels,
344 duration_ms: info.duration_ms,
345 }));
346 self.shared_state.set_position_ms(seek_ms);
347 self.on_track_changed(id);
348 log::info!(
349 "playing: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
350 path.display(),
351 id,
352 info.codec,
353 info.sample_rate,
354 info.channels,
355 info.duration_ms,
356 if seek_ms > 0 {
357 format!(" @{}ms", seek_ms)
358 } else {
359 String::new()
360 }
361 );
362
363 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
364
365 self.timeline.reset();
367
368 let advance_state = self.shared_state.clone();
372 let decode_cursor = parking_lot::Mutex::new(Some(id));
373 let next_track = move || {
374 let current = decode_cursor.lock().take()?;
375 let next = advance_state.peek_next_ready_after(current);
376 if let Some((next_id, _)) = &next {
377 let mut guard = decode_cursor.lock();
378 *guard = Some(*next_id);
379 }
380 next
381 };
382
383 let cfg = crate::config::Config::load_or_default();
385 let rg_mode = cfg.playback.replaygain;
386 let pre_amp_db = cfg.playback.pre_amp_db;
387
388 let finish_tx = self.commands.tx.clone();
389 let (_stream_info, decode_handle) = buffer::start_decode_file(
390 id,
391 path,
392 producer,
393 seek_ms,
394 next_track,
395 self.timeline.clone(),
396 Some(self.viz_buffer.clone()),
397 rg_mode,
398 pre_amp_db,
399 move || {
400 finish_tx.send(PlayerCommand::DecodeFinished).ok();
401 },
402 )?;
403
404 let engine = self.create_engine_for(&info, consumer)?;
405 engine.start()?;
406
407 self.shared_state.set_playback_state(PlaybackState::Playing);
408
409 self.active_playback = Some(ActivePlayback {
410 engine,
411 decode_handle,
412 });
413
414 Ok(())
415 }
416
417 fn start_streaming_playback(
424 &mut self,
425 id: QueueItemId,
426 path: &Path,
427 bytes_written: Arc<AtomicU64>,
428 total: u64,
429 ) -> Result<(), PlayerError> {
430 let result = self.open_streaming_playback(id, path, bytes_written, total);
431 if result.is_err() {
432 self.stop_playback_and_clear_state();
433 }
434 result
435 }
436
437 fn open_streaming_playback(
438 &mut self,
439 id: QueueItemId,
440 path: &Path,
441 bytes_written: Arc<AtomicU64>,
442 total: u64,
443 ) -> Result<(), PlayerError> {
444 self.stop_engine();
445
446 let stream_buf = streaming::StreamBuffer::new(if total > 0 { Some(total) } else { None });
448
449 let pump_path = path.to_path_buf();
457 let pump_buf = stream_buf.clone();
458 let pump_written = bytes_written.clone();
459 let pump_state = self.shared_state.clone();
460 thread::Builder::new()
461 .name("koan-stream-pump".into())
462 .spawn(move || {
463 use std::fs::File;
464 use std::io::Read;
465 use std::time::{Duration, Instant};
466
467 const STALL_LIMIT: Duration = Duration::from_secs(30);
471
472 let mut file = match File::open(&pump_path) {
473 Ok(f) => f,
474 Err(e) => {
475 log::error!("stream pump: failed to open {}: {}", pump_path.display(), e);
476 pump_buf.fail();
477 return;
478 }
479 };
480 let mut buf = vec![0u8; 65536];
481 let mut offset: u64 = 0;
482 let mut last_progress = Instant::now();
483 loop {
484 if pump_buf.is_abandoned() {
486 return;
487 }
488 match pump_state.item_load_state(id) {
489 Some(LoadState::Failed(e)) => {
490 log::warn!("stream pump: download of {:?} failed: {}", id, e);
491 pump_buf.fail();
492 return;
493 }
494 Some(LoadState::Ready) => match file.read(&mut buf) {
497 Ok(0) => break,
498 Ok(n) => {
499 pump_buf.push(&buf[..n]);
500 offset += n as u64;
501 continue;
502 }
503 Err(e) => {
504 log::warn!("stream pump read error: {}", e);
505 pump_buf.fail();
506 return;
507 }
508 },
509 _ => {}
510 }
511
512 let available = pump_written.load(Ordering::Acquire);
513 if offset >= available {
514 if total > 0 && available >= total {
515 break; }
517 if last_progress.elapsed() >= STALL_LIMIT {
518 log::warn!(
519 "stream pump: no data for {}s, abandoning {}",
520 STALL_LIMIT.as_secs(),
521 pump_path.display()
522 );
523 pump_buf.fail();
524 return;
525 }
526 thread::sleep(Duration::from_millis(10));
527 continue;
528 }
529 let to_read = ((available - offset) as usize).min(buf.len());
530 match file.read(&mut buf[..to_read]) {
531 Ok(0) => {
532 if total > 0 && offset >= total {
535 break;
536 }
537 let latest = pump_written.load(Ordering::Acquire);
538 if total > 0 && latest >= total && offset >= latest {
539 break;
540 }
541 thread::sleep(Duration::from_millis(1));
543 continue;
544 }
545 Ok(n) => {
546 pump_buf.push(&buf[..n]);
547 offset += n as u64;
548 last_progress = Instant::now();
549 }
550 Err(e) => {
551 log::warn!("stream pump read error: {}", e);
552 pump_buf.fail();
553 return;
554 }
555 }
556 }
557 pump_buf.finish();
558 })
559 .map_err(|e| PlayerError::Decode(buffer::DecodeError::Io(e)))?;
560
561 let probe_reader = stream_buf.reader();
563 let probe_hint = {
564 let mut h = symphonia::core::formats::probe::Hint::new();
565 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
566 h.with_extension(ext);
567 }
568 h
569 };
570 let probe_mss =
571 symphonia::core::io::MediaSourceStream::new(Box::new(probe_reader), Default::default());
572 let info = buffer::probe_source(probe_mss, &probe_hint)?;
573
574 self.shared_state.set_track_info(Some(TrackInfo {
575 id,
576 path: path.to_path_buf(),
577 codec: info.codec.clone(),
578 sample_rate: info.sample_rate,
579 bit_depth: info.bit_depth,
580 bitrate_kbps: info.bitrate_kbps,
581 channels: info.channels,
582 duration_ms: info.duration_ms,
583 }));
584 self.shared_state.set_position_ms(0);
585 self.on_track_changed(id);
586 log::info!(
587 "streaming: {} ({:?}) — {} {}Hz/{}ch, {}ms",
588 path.display(),
589 id,
590 info.codec,
591 info.sample_rate,
592 info.channels,
593 info.duration_ms,
594 );
595
596 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
597
598 self.timeline.reset();
599
600 let advance_state = self.shared_state.clone();
602 let decode_cursor = parking_lot::Mutex::new(Some(id));
603 let next_track = move || {
604 let current = decode_cursor.lock().take()?;
605 let next = advance_state.peek_next_ready_after(current);
606 if let Some((next_id, _)) = &next {
607 let mut guard = decode_cursor.lock();
608 *guard = Some(*next_id);
609 }
610 next
611 };
612
613 let decode_reader = stream_buf.reader();
618 let path_buf = path.to_path_buf();
619 let ext = path
620 .extension()
621 .and_then(|e| e.to_str())
622 .unwrap_or("")
623 .to_string();
624 let mut decode_hint = symphonia::core::formats::probe::Hint::new();
625 if !ext.is_empty() {
626 decode_hint.with_extension(&ext);
627 }
628 let first = buffer::SourceEntry {
629 id,
630 path: path_buf,
631 hint: decode_hint,
632 make_mss: Box::new(move || {
633 Ok(symphonia::core::io::MediaSourceStream::new(
634 Box::new(decode_reader),
635 Default::default(),
636 ))
637 }),
638 };
639
640 let cfg = crate::config::Config::load_or_default();
642 let rg_mode = cfg.playback.replaygain;
643 let pre_amp_db = cfg.playback.pre_amp_db;
644
645 let finish_tx = self.commands.tx.clone();
646 let (_stream_info, decode_handle) = buffer::start_decode(
647 first,
648 producer,
649 0,
650 move || {
651 let (next_id, next_path) = next_track()?;
652 Some(buffer::SourceEntry::from_file(next_id, next_path))
653 },
654 self.timeline.clone(),
655 Some(self.viz_buffer.clone()),
656 rg_mode,
657 pre_amp_db,
658 move || {
659 finish_tx.send(PlayerCommand::DecodeFinished).ok();
660 },
661 )?;
662
663 let engine = self.create_engine_for(&info, consumer)?;
664 engine.start()?;
665
666 self.shared_state.set_playback_state(PlaybackState::Playing);
667
668 self.active_playback = Some(ActivePlayback {
669 engine,
670 decode_handle,
671 });
672
673 Ok(())
674 }
675
676 pub fn seek(&mut self, position_ms: u64) {
679 let info = match self.shared_state.track_info() {
680 Some(info) => info,
681 None => return,
682 };
683 let id = info.id;
684 let path = info.path.clone();
685 let duration = info.duration_ms;
686
687 let mut clamped = if duration > 0 {
689 position_ms.min(duration.saturating_sub(500))
690 } else {
691 position_ms
692 };
693
694 if let Some(dl_frac) = self.shared_state.current_download_fraction() {
697 let max_ms = (dl_frac * duration as f64) as u64;
698 if max_ms > 5_000 {
699 clamped = clamped.min(max_ms - 5_000);
700 }
701 }
702
703 let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
704
705 if let Err(e) = self.start_playback(id, &path, clamped) {
706 log::error!("seek failed: {}", e);
707 return;
708 }
709
710 if was_paused {
711 self.pause();
712 }
713 }
714
715 pub fn next_track(&mut self) {
717 match self.shared_state.advance_cursor_loadable() {
718 Some(id) => self.play(id),
719 None => {
720 log::info!("no more tracks in playlist");
721 self.stop_playback_and_clear_state();
722 }
723 }
724 }
725
726 pub fn prev_track(&mut self) {
728 match self.shared_state.retreat_cursor() {
729 Some((id, path)) => {
730 if matches!(path.try_exists(), Ok(true)) {
731 if let Err(e) = self.start_playback(id, &path, 0) {
732 log::error!("prev track failed: {}", e);
733 }
734 } else {
735 log::warn!("prev track path doesn't exist: {}", path.display());
736 }
737 }
738 None => {
739 if let Some(info) = self.shared_state.track_info()
741 && let Err(e) = self.start_playback(info.id, &info.path, 0)
742 {
743 log::error!("restart failed: {}", e);
744 }
745 }
746 }
747 }
748
749 pub fn pause(&mut self) {
751 if let Some(ref playback) = self.active_playback {
752 if let Err(e) = playback.engine.stop() {
753 log::error!("pause failed: {}", e);
754 return;
755 }
756 self.shared_state.set_playback_state(PlaybackState::Paused);
757 }
758 }
759
760 pub fn resume(&mut self) {
762 if let Some(ref playback) = self.active_playback {
763 if let Err(e) = playback.engine.start() {
764 log::error!("resume failed: {}", e);
765 return;
766 }
767 self.shared_state.set_playback_state(PlaybackState::Playing);
768 }
769 }
770
771 pub fn stop(&mut self) {
773 self.shared_state.clear_playlist();
774 self.stop_playback_and_clear_state();
775 }
776
777 fn stop_engine(&mut self) {
784 let Some(playback) = self.active_playback.take() else {
785 return;
786 };
787 let ActivePlayback {
788 engine,
789 mut decode_handle,
790 } = playback;
791
792 let _ = engine.stop();
803 decode_handle.stop();
804 drop(engine);
805 }
806
807 fn stop_playback_and_clear_state(&mut self) {
809 self.finish_play();
810 self.stop_engine();
811 self.timeline.reset();
812 self.shared_state.set_playback_state(PlaybackState::Stopped);
813 self.shared_state.set_position_ms(0);
814 self.shared_state.set_track_info(None);
815 }
816
817 pub fn remove_from_playlist(&mut self, id: QueueItemId) {
824 let was_cursor = self.shared_state.is_cursor(id);
825 let resume_after = was_cursor
826 .then(|| self.shared_state.item_before(id))
827 .flatten();
828 self.shared_state.remove_item(id);
829 if was_cursor {
830 self.shared_state.set_cursor(resume_after);
831 self.next_track();
832 }
833 }
834
835 pub fn track_ready(&mut self, id: QueueItemId) {
838 self.shared_state.update_load_state(id, LoadState::Ready);
840
841 if !self.shared_state.is_cursor(id) {
842 return;
843 }
844
845 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
846 let current_track_id = self.shared_state.track_info().map(|t| t.id);
847
848 if is_playing && current_track_id == Some(id) {
849 log::info!(
852 "track_ready: download complete while streaming {:?}, refreshing metadata",
853 id
854 );
855 self.refresh_track_metadata(id);
856 return;
857 }
858
859 if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
861 log::info!("track_ready: starting playback for {:?}", id);
862 if let Err(e) = self.start_playback(id, &path, 0) {
863 log::error!("track_ready playback failed: {}", e);
864 }
865 }
866 }
867
868 pub fn track_stream_ready(&mut self, id: QueueItemId) {
871 if !self.shared_state.is_cursor(id) {
872 return;
873 }
874
875 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
876 if is_playing {
877 return; }
879
880 match self.shared_state.item_playback_source(id) {
881 Some(PlaybackSource::Streaming {
882 path,
883 bytes_written,
884 total,
885 }) => {
886 log::info!(
887 "track_stream_ready: starting streaming playback for {:?}",
888 id
889 );
890 if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
891 log::error!("track_stream_ready streaming failed: {}", e);
892 }
893 }
894 Some(PlaybackSource::Ready(path)) => {
895 log::info!(
897 "track_stream_ready: track already ready, starting normal playback for {:?}",
898 id
899 );
900 if let Err(e) = self.start_playback(id, &path, 0) {
901 log::error!("track_stream_ready playback failed: {}", e);
902 }
903 }
904 None => {} }
906 }
907
908 fn refresh_track_metadata(&mut self, id: QueueItemId) {
912 use crate::index::metadata;
913
914 let path = match self.shared_state.item_path_if_ready(id) {
915 Some(p) => p,
916 None => return,
917 };
918
919 match metadata::read_metadata(&path) {
920 Ok(meta) => {
921 self.shared_state.update_item_metadata(
923 id,
924 meta.title,
925 meta.artist,
926 meta.album_artist.unwrap_or_default(),
927 meta.album,
928 meta.duration_ms.map(|d| d as u64),
929 );
930
931 if let Ok(stream_info) = buffer::probe_file(&path)
936 && let Some(current) = self.shared_state.track_info()
937 && current.id == id
938 && stream_info.duration_ms > current.duration_ms
939 {
940 log::info!(
941 "track_ready: duration corrected {}ms → {}ms",
942 current.duration_ms,
943 stream_info.duration_ms
944 );
945 self.shared_state.set_track_info(Some(TrackInfo {
946 duration_ms: stream_info.duration_ms,
947 ..current
948 }));
949 }
950
951 self.shared_state.signal_metadata_refresh();
953 log::info!("track_ready: metadata refreshed for {:?}", id);
954 }
955 Err(e) => {
956 log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
957 }
958 }
959 }
960
961 fn on_track_changed(&mut self, id: QueueItemId) {
971 if self.in_flight.as_ref().is_some_and(|f| f.item == id) {
972 return;
973 }
974 self.finish_play();
975 let track_id = self.shared_state.item_db_id(id);
976 self.in_flight = Some(InFlight::new(id, track_id));
977 if let (Some(track_id), Some(recorder)) = (track_id, self.history.as_ref()) {
978 recorder.record(PlayEvent::Started { track_id });
979 }
980 }
981
982 fn finish_play(&mut self) -> Option<PlayEvent> {
985 let flight = self.in_flight.take()?;
986 let event = PlayEvent::Finished {
987 track_id: flight.track_id()?,
988 listened_ms: flight.listened_ms(),
989 };
990 if let Some(recorder) = self.history.as_ref() {
991 recorder.record(event);
992 }
993 Some(event)
994 }
995
996 pub fn update_playback_state(&mut self) {
997 if self.active_playback.is_none() {
998 return;
999 }
1000
1001 if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
1002 self.shared_state.set_position_ms(position_ms);
1003
1004 self.on_track_changed(id);
1007 if let Some(f) = self.in_flight.as_mut() {
1008 f.advance(position_ms);
1009 }
1010
1011 let current_id = self.shared_state.track_info().map(|t| t.id);
1014 if current_id != Some(id) {
1015 log::info!("timeline: now playing {:?}", id);
1016 self.shared_state.set_track_info(Some(TrackInfo {
1017 id,
1018 path,
1019 codec: info.codec,
1020 sample_rate: info.sample_rate,
1021 bit_depth: info.bit_depth,
1022 bitrate_kbps: info.bitrate_kbps,
1023 channels: info.channels,
1024 duration_ms: info.duration_ms,
1025 }));
1026 self.shared_state.set_cursor(Some(id));
1027 }
1028 }
1029 }
1030
1031 pub fn track_failed(&mut self, id: QueueItemId) {
1038 if !self.shared_state.is_cursor(id) {
1039 return;
1040 }
1041 if self.shared_state.playback_state() != PlaybackState::Stopped {
1045 return;
1046 }
1047 log::info!("track {:?} cannot load, moving on", id);
1048 self.next_track();
1049 }
1050
1051 fn on_decode_finished(&mut self) {
1058 log::info!("decode finished, checking for next track");
1059 match self.shared_state.advance_cursor_loadable() {
1060 Some(id) => self.play(id),
1061 None => {
1062 log::info!("no more tracks — stopping");
1063 self.stop_playback_and_clear_state();
1064 }
1065 }
1066 }
1067
1068 fn snapshot_for_undo(
1072 &self,
1073 ids: &[QueueItemId],
1074 ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
1075 self.shared_state
1076 .items_before(ids)
1077 .into_iter()
1078 .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
1079 .collect()
1080 }
1081
1082 fn push_undo(&mut self, entry: UndoEntry) {
1084 if let Some(ref mut batch) = self.batch_buffer {
1085 batch.push(entry);
1086 } else {
1087 self.undo_stack.push(entry);
1088 }
1089 }
1090
1091 pub fn process_command(&mut self, cmd: PlayerCommand) {
1093 match cmd {
1094 PlayerCommand::Play(id) => self.play(id),
1095 PlayerCommand::Pause => self.pause(),
1096 PlayerCommand::Resume => self.resume(),
1097 PlayerCommand::Stop => self.stop(),
1098 PlayerCommand::Seek(pos) => self.seek(pos),
1099 PlayerCommand::NextTrack => {
1100 let now = std::time::Instant::now();
1102 if now.duration_since(self.last_skip).as_millis() >= 150 {
1103 self.last_skip = now;
1104 self.next_track();
1105 }
1106 }
1107 PlayerCommand::PrevTrack => {
1108 let now = std::time::Instant::now();
1109 if now.duration_since(self.last_skip).as_millis() >= 150 {
1110 self.last_skip = now;
1111 self.prev_track();
1112 }
1113 }
1114 PlayerCommand::AddToPlaylist(items) => {
1115 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1116 self.shared_state.add_items(items);
1117 self.push_undo(UndoEntry::Added { ids });
1118 }
1119 PlayerCommand::UpdatePaths(updates) => {
1120 self.shared_state.update_paths(&updates);
1121 if let Some(info) = self.shared_state.track_info()
1122 && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
1123 {
1124 self.shared_state.set_track_info(Some(TrackInfo {
1125 path: new_path.clone(),
1126 ..info
1127 }));
1128 }
1129 }
1130 PlayerCommand::InsertInPlaylist { items, after } => {
1131 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1132 self.shared_state.insert_items_after(items, after);
1133 self.push_undo(UndoEntry::Inserted { ids });
1134 }
1135 PlayerCommand::ClearPlaylist => {
1136 self.stop_playback_and_clear_state();
1140 let (items, cursor) = self.shared_state.snapshot_playlist();
1141 self.shared_state.clear_playlist();
1142 self.push_undo(UndoEntry::Replaced { items, cursor });
1143 }
1144 PlayerCommand::ReplacePlaylist { items, start } => {
1145 self.stop_playback_and_clear_state();
1149 let (old_items, cursor) = self.shared_state.snapshot_playlist();
1150 self.shared_state.clear_playlist();
1151 self.push_undo(UndoEntry::Replaced {
1152 items: old_items,
1153 cursor,
1154 });
1155
1156 if items.is_empty() {
1157 return;
1158 }
1159 let start_id = items.get(start).unwrap_or(&items[0]).id;
1160 self.shared_state.add_items(items);
1161 self.play(start_id);
1162 }
1163 PlayerCommand::RemoveFromPlaylist(id) => {
1164 let item = self.shared_state.get_item(id);
1165 let after = self.shared_state.item_before(id);
1166 self.remove_from_playlist(id);
1167 if let Some(item) = item {
1168 self.push_undo(UndoEntry::Removed {
1169 items: vec![(Box::new(item), after)],
1170 });
1171 }
1172 }
1173 PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1174 let items_with_pos = self.snapshot_for_undo(&ids);
1178 let resume_after = match self.shared_state.cursor() {
1179 Some(cursor) if ids.contains(&cursor) => {
1180 Some(self.shared_state.surviving_item_before(cursor, &ids))
1181 }
1182 _ => None,
1183 };
1184
1185 self.shared_state.remove_items(&ids);
1186
1187 if let Some(resume_after) = resume_after {
1188 self.shared_state.set_cursor(resume_after);
1189 self.next_track();
1190 }
1191
1192 if !items_with_pos.is_empty() {
1193 self.push_undo(UndoEntry::Removed {
1194 items: items_with_pos,
1195 });
1196 }
1197 }
1198 PlayerCommand::MoveInPlaylist { id, target, after } => {
1199 let was_after = self.shared_state.item_before(id);
1200 self.shared_state.move_item(id, target, after);
1201 self.push_undo(UndoEntry::Moved { id, was_after });
1202 }
1203 PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1204 let entries = self.shared_state.items_before(&ids);
1205 self.shared_state.move_items(&ids, target, after);
1206 self.push_undo(UndoEntry::MovedBatch { entries });
1207 }
1208 PlayerCommand::TrackReady(id) => self.track_ready(id),
1209 PlayerCommand::DecodeFinished => self.on_decode_finished(),
1210 PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1211 PlayerCommand::TrackFailed(id) => self.track_failed(id),
1212 PlayerCommand::Undo => self.execute_undo(),
1213 PlayerCommand::Redo => self.execute_redo(),
1214 PlayerCommand::BeginUndoBatch => {
1215 self.batch_buffer = Some(Vec::new());
1216 }
1217 PlayerCommand::EndUndoBatch => {
1218 if let Some(entries) = self.batch_buffer.take() {
1219 if entries.len() == 1 {
1220 self.undo_stack.push(entries.into_iter().next().unwrap());
1222 } else if !entries.is_empty() {
1223 self.undo_stack.push(UndoEntry::Batch(entries));
1224 }
1225 }
1226 }
1227 PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1228 PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1229 }
1230 }
1231
1232 fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1234 match entry {
1235 UndoEntry::Added { ids } => {
1236 let items_with_pos = self.snapshot_for_undo(&ids);
1238 self.shared_state.remove_items(&ids);
1239 Some(UndoEntry::Removed {
1240 items: items_with_pos,
1241 })
1242 }
1243 UndoEntry::Removed { items } => {
1244 let mut ids = Vec::with_capacity(items.len());
1246 for (item, after) in items {
1247 ids.push(item.id);
1248 self.shared_state.insert_item_at(*item, after);
1249 }
1250 Some(UndoEntry::Added { ids })
1251 }
1252 UndoEntry::Inserted { ids } => {
1253 let items_with_pos = self.snapshot_for_undo(&ids);
1255 self.shared_state.remove_items(&ids);
1256 Some(UndoEntry::Removed {
1257 items: items_with_pos,
1258 })
1259 }
1260 UndoEntry::Moved { id, was_after } => {
1261 let current_after = self.shared_state.item_before(id);
1262 self.shared_state.move_item_to(id, was_after);
1263 Some(UndoEntry::Moved {
1264 id,
1265 was_after: current_after,
1266 })
1267 }
1268 UndoEntry::MovedBatch { entries } => {
1269 let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1270 let current_positions = self.shared_state.items_before(&ids);
1271 self.shared_state.move_items_to(&entries);
1272 Some(UndoEntry::MovedBatch {
1273 entries: current_positions,
1274 })
1275 }
1276 UndoEntry::Replaced { items, cursor } => {
1277 let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1278 self.shared_state.restore_playlist(items, cursor);
1279 Some(UndoEntry::Replaced {
1280 items: current_items,
1281 cursor: current_cursor,
1282 })
1283 }
1284 UndoEntry::Batch(entries) => {
1285 let mut inverses = Vec::with_capacity(entries.len());
1287 for entry in entries.into_iter().rev() {
1288 if let Some(inverse) = self.apply_entry(entry) {
1289 inverses.push(inverse);
1290 }
1291 }
1292 inverses.reverse();
1293 Some(UndoEntry::Batch(inverses))
1294 }
1295 }
1296 }
1297
1298 fn execute_undo(&mut self) {
1300 let Some(entry) = self.undo_stack.pop_undo() else {
1301 return;
1302 };
1303 if let Some(inverse) = self.apply_entry(entry) {
1304 self.undo_stack.push_redo(inverse);
1305 }
1306 }
1307
1308 fn execute_redo(&mut self) {
1310 let Some(entry) = self.undo_stack.pop_redo() else {
1311 return;
1312 };
1313 if let Some(inverse) = self.apply_entry(entry) {
1314 self.undo_stack.push_undo_keep_redo(inverse);
1315 }
1316 }
1317
1318 pub fn run(&mut self) {
1320 use std::time::Duration;
1321
1322 let rx = self.commands.rx.clone();
1323 loop {
1324 match rx.recv_timeout(Duration::from_millis(50)) {
1326 Ok(cmd) => self.process_command(cmd),
1327 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1328 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1329 }
1330 self.update_playback_state();
1331 }
1332 self.stop();
1333 }
1334
1335 pub fn spawn() -> (
1338 Arc<SharedPlayerState>,
1339 Arc<PlaybackTimeline>,
1340 Arc<VizSnapshot>,
1341 crossbeam_channel::Sender<PlayerCommand>,
1342 ) {
1343 let mut player = Self::new();
1344 player.history = PlayRecorder::spawn();
1345 let state = player.shared_state();
1346 let timeline = player.timeline();
1347 let viz_snapshot = player.viz_snapshot();
1348 let tx = player.command_sender();
1349
1350 thread::Builder::new()
1351 .name("koan-player".into())
1352 .spawn(move || player.run())
1353 .expect("failed to spawn player thread");
1354
1355 (state, timeline, viz_snapshot, tx)
1356 }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::*;
1362 use state::PlaylistItem;
1363 use std::path::PathBuf;
1364
1365 fn make_item(title: &str) -> PlaylistItem {
1366 PlaylistItem {
1367 id: QueueItemId::new(),
1368 db_id: None,
1369 path: PathBuf::from(format!("/music/{title}.flac")),
1370 title: title.to_string(),
1371 artist: String::new(),
1372 album_artist: String::new(),
1373 album: String::new(),
1374 year: None,
1375 codec: None,
1376 track_number: None,
1377 disc: None,
1378 duration_ms: None,
1379 load_state: LoadState::Ready,
1380 }
1381 }
1382
1383 fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1384 let (items, _) = player.shared_state.snapshot_playlist();
1385 items.iter().map(|i| i.id).collect()
1386 }
1387
1388 fn playlist_titles(player: &Player) -> Vec<String> {
1389 let (items, _) = player.shared_state.snapshot_playlist();
1390 items.iter().map(|i| i.title.clone()).collect()
1391 }
1392
1393 fn pending_item(title: &str) -> PlaylistItem {
1394 PlaylistItem {
1395 load_state: LoadState::Pending,
1396 ..make_item(title)
1397 }
1398 }
1399
1400 fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
1402 let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
1403 let ids = items.iter().map(|i| i.id).collect();
1404 player.process_command(PlayerCommand::AddToPlaylist(items));
1405 ids
1406 }
1407
1408 fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
1412 let mut at = from_ms;
1413 if let Some(f) = player.in_flight.as_mut() {
1414 f.advance(at); }
1416 while at < to_ms {
1417 at = (at + 50).min(to_ms);
1418 if let Some(f) = player.in_flight.as_mut() {
1419 f.advance(at);
1420 }
1421 }
1422 }
1423
1424 fn start(player: &mut Player, track_id: i64) -> QueueItemId {
1425 let id = QueueItemId::new();
1426 player.on_track_changed(id);
1427 player
1429 .in_flight
1430 .as_mut()
1431 .unwrap()
1432 .track_id_for_test(track_id);
1433 id
1434 }
1435
1436 #[test]
1437 fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
1438 let mut player = Player::new();
1439 start(&mut player, 11);
1440 listen(&mut player, 0, 200_000);
1441
1442 let b = QueueItemId::new();
1443 player.on_track_changed(b);
1444 let f = player
1445 .in_flight
1446 .as_ref()
1447 .expect("the next track is counting");
1448 assert_eq!(f.item, b);
1449 assert_eq!(f.listened_ms(), 0, "and starts from nothing");
1450 }
1451
1452 #[test]
1453 fn a_track_skipped_seconds_in_is_still_history() {
1454 let mut player = Player::new();
1455 start(&mut player, 7);
1456 listen(&mut player, 0, 2_000);
1457
1458 let event = player
1459 .finish_play()
1460 .expect("putting something on is a thing you did, however briefly");
1461 assert!(matches!(
1462 event,
1463 history::PlayEvent::Finished {
1464 track_id: 7,
1465 listened_ms: 2_000
1466 }
1467 ));
1468 }
1469
1470 #[test]
1471 fn a_track_is_closed_out_once() {
1472 let mut player = Player::new();
1473 start(&mut player, 7);
1474 listen(&mut player, 0, 200_000);
1475
1476 assert!(player.finish_play().is_some());
1477 assert!(player.finish_play().is_none());
1478 }
1479
1480 #[test]
1481 fn seeking_around_a_track_does_not_enter_it_twice() {
1482 let mut player = Player::new();
1483 let id = start(&mut player, 7);
1484 listen(&mut player, 0, 120_000);
1485
1486 player.on_track_changed(id);
1488 assert_eq!(
1489 player.in_flight.as_ref().unwrap().listened_ms(),
1490 120_000,
1491 "the seek kept the count rather than restarting it"
1492 );
1493 listen(&mut player, 30_000, 40_000);
1494
1495 let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
1496 panic!("still one play");
1497 };
1498 assert_eq!(listened_ms, 130_000);
1499 assert!(player.finish_play().is_none());
1500 }
1501
1502 #[test]
1503 fn a_track_that_is_not_in_the_library_is_not_recorded() {
1504 let mut player = Player::new();
1505 let id = QueueItemId::new();
1506 player.on_track_changed(id);
1507 listen(&mut player, 0, 200_000);
1508 assert!(player.finish_play().is_none());
1509 }
1510
1511 #[test]
1512 fn stopping_closes_out_what_was_heard() {
1513 let mut player = Player::new();
1514 start(&mut player, 7);
1515 listen(&mut player, 0, 150_000);
1516
1517 player.stop_playback_and_clear_state();
1518 assert!(player.in_flight.is_none(), "the stop consumed it");
1519 }
1520
1521 #[test]
1522 fn removing_the_playing_track_resumes_at_its_successor() {
1523 let mut player = Player::new();
1524 let ids = seed(&mut player, 5);
1525 player.shared_state.set_cursor(Some(ids[2]));
1526
1527 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
1528
1529 assert_eq!(
1530 player.shared_state.cursor(),
1531 Some(ids[3]),
1532 "playback must continue at the next track, not restart the queue"
1533 );
1534 assert_eq!(player.playback_starts, 1);
1535 }
1536
1537 #[test]
1538 fn removing_the_first_playing_track_resumes_at_the_new_first() {
1539 let mut player = Player::new();
1540 let ids = seed(&mut player, 3);
1541 player.shared_state.set_cursor(Some(ids[0]));
1542
1543 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
1544
1545 assert_eq!(player.shared_state.cursor(), Some(ids[1]));
1546 }
1547
1548 #[test]
1549 fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
1550 let mut player = Player::new();
1551 let playing = make_item("playing");
1552 let waiting = pending_item("waiting");
1553 let later = make_item("later");
1554 let (playing_id, waiting_id) = (playing.id, waiting.id);
1555 player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
1556 player.shared_state.set_cursor(Some(playing_id));
1557
1558 player.process_command(PlayerCommand::DecodeFinished);
1559
1560 assert_eq!(
1561 player.shared_state.cursor(),
1562 Some(waiting_id),
1563 "the cursor parks on the track being fetched"
1564 );
1565 assert_eq!(
1566 player.playback_starts, 0,
1567 "nothing to play until its bytes land"
1568 );
1569
1570 player
1573 .shared_state
1574 .update_load_state(waiting_id, LoadState::Ready);
1575 player.process_command(PlayerCommand::TrackReady(waiting_id));
1576
1577 assert_eq!(player.playback_starts, 1);
1578 assert_eq!(player.shared_state.cursor(), Some(waiting_id));
1579 }
1580
1581 #[test]
1582 fn a_download_that_cannot_land_moves_the_cursor_on() {
1583 let mut player = Player::new();
1584 let waiting = pending_item("waiting");
1585 let later = make_item("later");
1586 let (waiting_id, later_id) = (waiting.id, later.id);
1587 player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, later]));
1588
1589 player.process_command(PlayerCommand::Play(waiting_id));
1590 assert_eq!(player.playback_starts, 0, "nothing to play yet");
1591
1592 player
1594 .shared_state
1595 .update_load_state(waiting_id, LoadState::Failed("remote unavailable".into()));
1596 player.process_command(PlayerCommand::TrackFailed(waiting_id));
1597
1598 assert_eq!(
1599 player.shared_state.cursor(),
1600 Some(later_id),
1601 "the queue moves past a track that can never load"
1602 );
1603 assert_eq!(player.playback_starts, 1);
1604 }
1605
1606 #[test]
1607 fn a_queue_that_can_never_load_stops_rather_than_waiting() {
1608 let mut player = Player::new();
1609 let first = pending_item("first");
1610 let second = pending_item("second");
1611 let (first_id, second_id) = (first.id, second.id);
1612 player.process_command(PlayerCommand::AddToPlaylist(vec![first, second]));
1613
1614 player.process_command(PlayerCommand::Play(first_id));
1615 for id in [first_id, second_id] {
1616 player
1617 .shared_state
1618 .update_load_state(id, LoadState::Failed("remote unavailable".into()));
1619 player.process_command(PlayerCommand::TrackFailed(id));
1620 }
1621
1622 assert_eq!(player.playback_starts, 0);
1623 assert_eq!(
1624 player.shared_state.playback_state(),
1625 PlaybackState::Stopped,
1626 "a stop the UI can see, not an indefinite wait for TrackReady"
1627 );
1628 }
1629
1630 #[test]
1631 fn a_failure_elsewhere_in_the_queue_leaves_the_cursor_alone() {
1632 let mut player = Player::new();
1633 let waiting = pending_item("waiting");
1634 let other = pending_item("other");
1635 let (waiting_id, other_id) = (waiting.id, other.id);
1636 player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, other]));
1637 player.process_command(PlayerCommand::Play(waiting_id));
1638
1639 player
1640 .shared_state
1641 .update_load_state(other_id, LoadState::Failed("remote unavailable".into()));
1642 player.process_command(PlayerCommand::TrackFailed(other_id));
1643
1644 assert_eq!(
1645 player.shared_state.cursor(),
1646 Some(waiting_id),
1647 "a track still downloading keeps the cursor"
1648 );
1649 }
1650
1651 #[test]
1652 fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
1653 let mut player = Player::new();
1654 let ids = seed(&mut player, 5);
1655 player.shared_state.set_cursor(Some(ids[2]));
1656
1657 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
1658 ids[1], ids[2], ids[3],
1659 ]));
1660
1661 assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
1662 assert_eq!(player.shared_state.cursor(), Some(ids[4]));
1663 assert_eq!(
1664 player.playback_starts, 1,
1665 "one resume for the whole selection, not one per deleted track"
1666 );
1667 }
1668
1669 #[test]
1670 fn batch_delete_below_the_cursor_leaves_playback_alone() {
1671 let mut player = Player::new();
1672 let ids = seed(&mut player, 4);
1673 player.shared_state.set_cursor(Some(ids[0]));
1674
1675 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
1676
1677 assert_eq!(player.shared_state.cursor(), Some(ids[0]));
1678 assert_eq!(player.playback_starts, 0);
1679 }
1680
1681 #[test]
1682 fn undo_of_a_batch_delete_restores_the_original_order() {
1683 let mut player = Player::new();
1687 let items = vec![
1688 make_item("A"),
1689 make_item("B"),
1690 make_item("C"),
1691 make_item("D"),
1692 ];
1693 let (b_id, c_id) = (items[1].id, items[2].id);
1694 player.process_command(PlayerCommand::AddToPlaylist(items));
1695
1696 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
1697 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1698
1699 player.process_command(PlayerCommand::Undo);
1700 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1701 }
1702
1703 #[test]
1706 fn undo_add_removes_items() {
1707 let mut player = Player::new();
1708 let items = vec![make_item("A"), make_item("B")];
1709 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1710
1711 player.process_command(PlayerCommand::AddToPlaylist(items));
1712 assert_eq!(playlist_ids(&player), ids);
1713 assert!(player.undo_stack().can_undo());
1714
1715 player.process_command(PlayerCommand::Undo);
1716 assert!(playlist_ids(&player).is_empty());
1717 assert!(player.undo_stack().can_redo());
1718 }
1719
1720 #[test]
1721 fn redo_add_restores_items() {
1722 let mut player = Player::new();
1723 let items = vec![make_item("A"), make_item("B")];
1724
1725 player.process_command(PlayerCommand::AddToPlaylist(items));
1726 player.process_command(PlayerCommand::Undo);
1727 assert!(playlist_ids(&player).is_empty());
1728
1729 player.process_command(PlayerCommand::Redo);
1730 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1731 }
1732
1733 #[test]
1736 fn undo_remove_restores_item_at_position() {
1737 let mut player = Player::new();
1738 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1739 let b_id = items[1].id;
1740
1741 player.process_command(PlayerCommand::AddToPlaylist(items));
1742 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1743 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1744
1745 player.process_command(PlayerCommand::Undo);
1746 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1747 }
1748
1749 #[test]
1750 fn undo_remove_first_item() {
1751 let mut player = Player::new();
1752 let items = vec![make_item("A"), make_item("B")];
1753 let a_id = items[0].id;
1754
1755 player.process_command(PlayerCommand::AddToPlaylist(items));
1756 player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1757 assert_eq!(playlist_titles(&player), vec!["B"]);
1758
1759 player.process_command(PlayerCommand::Undo);
1760 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1761 }
1762
1763 #[test]
1764 fn undo_batch_remove_restores_all() {
1765 let mut player = Player::new();
1766 let items = vec![
1767 make_item("A"),
1768 make_item("B"),
1769 make_item("C"),
1770 make_item("D"),
1771 ];
1772 let b_id = items[1].id;
1773 let c_id = items[2].id;
1774
1775 player.process_command(PlayerCommand::AddToPlaylist(items));
1776 let version_before = player.shared_state.playlist_version();
1777 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
1778 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1779 assert_eq!(
1782 player.shared_state.playlist_version(),
1783 version_before + 1,
1784 "batch removal must bump the playlist version exactly once"
1785 );
1786
1787 player.process_command(PlayerCommand::Undo);
1789 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1790 }
1791
1792 #[test]
1793 fn redo_batch_remove() {
1794 let mut player = Player::new();
1795 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1796 let a_id = items[0].id;
1797 let b_id = items[1].id;
1798
1799 player.process_command(PlayerCommand::AddToPlaylist(items));
1800 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
1801 player.process_command(PlayerCommand::Undo);
1802 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1803
1804 player.process_command(PlayerCommand::Redo);
1805 assert_eq!(playlist_titles(&player), vec!["C"]);
1806 }
1807
1808 #[test]
1809 fn redo_remove() {
1810 let mut player = Player::new();
1811 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1812 let b_id = items[1].id;
1813
1814 player.process_command(PlayerCommand::AddToPlaylist(items));
1815 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1816 player.process_command(PlayerCommand::Undo);
1817 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1818
1819 player.process_command(PlayerCommand::Redo);
1820 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1821 }
1822
1823 #[test]
1826 fn undo_insert_removes_inserted_items() {
1827 let mut player = Player::new();
1828 let items = vec![make_item("A"), make_item("C")];
1829 let a_id = items[0].id;
1830
1831 player.process_command(PlayerCommand::AddToPlaylist(items));
1832
1833 let inserted = vec![make_item("B")];
1834 player.process_command(PlayerCommand::InsertInPlaylist {
1835 items: inserted,
1836 after: a_id,
1837 });
1838 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1839
1840 player.process_command(PlayerCommand::Undo);
1841 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1842 }
1843
1844 #[test]
1847 fn undo_move_restores_position() {
1848 let mut player = Player::new();
1849 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1850 let a_id = items[0].id;
1851 let c_id = items[2].id;
1852
1853 player.process_command(PlayerCommand::AddToPlaylist(items));
1854
1855 player.process_command(PlayerCommand::MoveInPlaylist {
1857 id: a_id,
1858 target: c_id,
1859 after: true,
1860 });
1861 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1862
1863 player.process_command(PlayerCommand::Undo);
1864 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1865 }
1866
1867 #[test]
1868 fn redo_move() {
1869 let mut player = Player::new();
1870 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1871 let a_id = items[0].id;
1872 let c_id = items[2].id;
1873
1874 player.process_command(PlayerCommand::AddToPlaylist(items));
1875 player.process_command(PlayerCommand::MoveInPlaylist {
1876 id: a_id,
1877 target: c_id,
1878 after: true,
1879 });
1880 player.process_command(PlayerCommand::Undo);
1881 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1882
1883 player.process_command(PlayerCommand::Redo);
1884 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1885 }
1886
1887 #[test]
1890 fn undo_batch_move() {
1891 let mut player = Player::new();
1892 let items = vec![
1893 make_item("A"),
1894 make_item("B"),
1895 make_item("C"),
1896 make_item("D"),
1897 ];
1898 let a_id = items[0].id;
1899 let b_id = items[1].id;
1900 let d_id = items[3].id;
1901
1902 player.process_command(PlayerCommand::AddToPlaylist(items));
1903
1904 player.process_command(PlayerCommand::MoveItemsInPlaylist {
1906 ids: vec![a_id, b_id],
1907 target: d_id,
1908 after: true,
1909 });
1910 assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
1911
1912 player.process_command(PlayerCommand::Undo);
1913 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1914 }
1915
1916 #[test]
1919 fn undo_clear_restores_playlist() {
1920 let mut player = Player::new();
1921 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1922
1923 player.process_command(PlayerCommand::AddToPlaylist(items));
1924 player.process_command(PlayerCommand::ClearPlaylist);
1925 assert!(playlist_ids(&player).is_empty());
1926
1927 player.process_command(PlayerCommand::Undo);
1928 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1929 }
1930
1931 #[test]
1932 fn redo_clear() {
1933 let mut player = Player::new();
1934 let items = vec![make_item("A"), make_item("B")];
1935
1936 player.process_command(PlayerCommand::AddToPlaylist(items));
1937 player.process_command(PlayerCommand::ClearPlaylist);
1938 player.process_command(PlayerCommand::Undo);
1939 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1940
1941 player.process_command(PlayerCommand::Redo);
1942 assert!(playlist_ids(&player).is_empty());
1943 }
1944
1945 #[test]
1948 fn multiple_undos_in_sequence() {
1949 let mut player = Player::new();
1950
1951 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
1952 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1953 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
1954 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1955
1956 player.process_command(PlayerCommand::Undo);
1957 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1958
1959 player.process_command(PlayerCommand::Undo);
1960 assert_eq!(playlist_titles(&player), vec!["A"]);
1961
1962 player.process_command(PlayerCommand::Undo);
1963 assert!(playlist_ids(&player).is_empty());
1964 }
1965
1966 #[test]
1967 fn undo_redo_undo_cycle() {
1968 let mut player = Player::new();
1969 let items = vec![make_item("A"), make_item("B")];
1970
1971 player.process_command(PlayerCommand::AddToPlaylist(items));
1972 player.process_command(PlayerCommand::Undo);
1973 assert!(playlist_ids(&player).is_empty());
1974
1975 player.process_command(PlayerCommand::Redo);
1976 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1977
1978 player.process_command(PlayerCommand::Undo);
1979 assert!(playlist_ids(&player).is_empty());
1980 }
1981
1982 #[test]
1983 fn new_action_clears_redo_stack() {
1984 let mut player = Player::new();
1985 let items = vec![make_item("A")];
1986
1987 player.process_command(PlayerCommand::AddToPlaylist(items));
1988 player.process_command(PlayerCommand::Undo);
1989 assert!(player.undo_stack().can_redo());
1990
1991 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1993 assert!(!player.undo_stack().can_redo());
1994 }
1995
1996 #[test]
1997 fn undo_on_empty_stack_is_noop() {
1998 let mut player = Player::new();
1999 player.process_command(PlayerCommand::Undo);
2000 assert!(playlist_ids(&player).is_empty());
2001 }
2002
2003 #[test]
2004 fn redo_on_empty_stack_is_noop() {
2005 let mut player = Player::new();
2006 player.process_command(PlayerCommand::Redo);
2007 assert!(playlist_ids(&player).is_empty());
2008 }
2009
2010 #[test]
2013 fn playback_commands_not_undoable() {
2014 let mut player = Player::new();
2015 player.process_command(PlayerCommand::Pause);
2016 player.process_command(PlayerCommand::Resume);
2017 player.process_command(PlayerCommand::NextTrack);
2018 player.process_command(PlayerCommand::PrevTrack);
2019 assert!(!player.undo_stack().can_undo());
2020 }
2021
2022 #[test]
2023 fn update_paths_not_undoable() {
2024 let mut player = Player::new();
2025 let items = vec![make_item("A")];
2026 let id = items[0].id;
2027 player.process_command(PlayerCommand::AddToPlaylist(items));
2028
2029 let undo_count = player.undo_stack().undo_len();
2030 player.process_command(PlayerCommand::UpdatePaths(vec![(
2031 id,
2032 PathBuf::from("/new/path.flac"),
2033 )]));
2034 assert_eq!(player.undo_stack().undo_len(), undo_count);
2035 }
2036
2037 #[test]
2040 fn add_remove_undo_undo_produces_original() {
2041 let mut player = Player::new();
2042 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2043 let b_id = items[1].id;
2044 let original_titles = vec!["A", "B", "C"];
2045
2046 player.process_command(PlayerCommand::AddToPlaylist(items));
2047 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2048 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2049
2050 player.process_command(PlayerCommand::Undo);
2052 assert_eq!(playlist_titles(&player), original_titles);
2053
2054 player.process_command(PlayerCommand::Undo);
2056 assert!(playlist_ids(&player).is_empty());
2057 }
2058
2059 #[test]
2060 fn interleaved_adds_and_moves_undo() {
2061 let mut player = Player::new();
2062 let items = vec![make_item("A"), make_item("B"), make_item("C")];
2063 let a_id = items[0].id;
2064 let c_id = items[2].id;
2065
2066 player.process_command(PlayerCommand::AddToPlaylist(items));
2067
2068 player.process_command(PlayerCommand::MoveInPlaylist {
2070 id: a_id,
2071 target: c_id,
2072 after: true,
2073 });
2074 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2075
2076 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
2078 assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
2079
2080 player.process_command(PlayerCommand::Undo);
2082 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2083
2084 player.process_command(PlayerCommand::Undo);
2086 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2087 }
2088
2089 #[test]
2094 fn stop_engine_drops_engine_synchronously() {
2095 use std::sync::atomic::AtomicBool;
2096
2097 struct MockEngine {
2098 dropped: Arc<AtomicBool>,
2099 }
2100 impl AudioEngineHandle for MockEngine {
2101 fn start(&self) -> Result<(), BackendError> {
2102 Ok(())
2103 }
2104 fn stop(&self) -> Result<(), BackendError> {
2105 Ok(())
2106 }
2107 fn is_running(&self) -> bool {
2108 false
2109 }
2110 }
2111 impl Drop for MockEngine {
2112 fn drop(&mut self) {
2113 self.dropped.store(true, Ordering::SeqCst);
2114 }
2115 }
2116
2117 let dropped = Arc::new(AtomicBool::new(false));
2118
2119 let stop_flag = Arc::new(AtomicBool::new(false));
2121 let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
2122
2123 let mut player = Player::new();
2124 player.active_playback = Some(ActivePlayback {
2125 engine: Box::new(MockEngine {
2126 dropped: dropped.clone(),
2127 }),
2128 decode_handle,
2129 });
2130
2131 player.stop_engine();
2132
2133 assert!(
2137 dropped.load(Ordering::SeqCst),
2138 "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
2139 );
2140 }
2141
2142 struct StuckBackend {
2147 rate: f64,
2148 asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
2149 }
2150
2151 struct NullEngine;
2152 impl AudioEngineHandle for NullEngine {
2153 fn start(&self) -> Result<(), BackendError> {
2154 Ok(())
2155 }
2156 fn stop(&self) -> Result<(), BackendError> {
2157 Ok(())
2158 }
2159 fn is_running(&self) -> bool {
2160 false
2161 }
2162 }
2163
2164 impl AudioBackend for StuckBackend {
2165 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2166 Ok(vec![self.default_device()?])
2167 }
2168 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2169 Ok(backend::DeviceInfo {
2170 name: "Stuck DAC".into(),
2171 sample_rates: vec![self.rate],
2172 platform_id: 0,
2173 })
2174 }
2175 fn supported_sample_rates(
2176 &self,
2177 _device: &backend::DeviceInfo,
2178 ) -> Result<Vec<f64>, BackendError> {
2179 Ok(vec![self.rate])
2180 }
2181 fn get_device_sample_rate(
2182 &self,
2183 _device: &backend::DeviceInfo,
2184 ) -> Result<f64, BackendError> {
2185 Ok(self.rate)
2186 }
2187 fn set_device_sample_rate(
2188 &self,
2189 _device: &backend::DeviceInfo,
2190 rate: f64,
2191 ) -> Result<f64, BackendError> {
2192 Err(BackendError::UnsupportedSampleRate(rate))
2193 }
2194 fn create_engine(
2195 &self,
2196 _device: &backend::DeviceInfo,
2197 sample_rate: f64,
2198 channels: u32,
2199 _consumer: rtrb::Consumer<f32>,
2200 _samples_played: Arc<AtomicU64>,
2201 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2202 *self.asked.lock().unwrap() = Some((sample_rate, channels));
2203 Ok(Box::new(NullEngine))
2204 }
2205 }
2206
2207 fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
2208 let asked = Arc::new(std::sync::Mutex::new(None));
2209 let mut player = Player::new();
2210 player.backend = Box::new(StuckBackend {
2211 rate: device_rate,
2212 asked: asked.clone(),
2213 });
2214
2215 let info = buffer::StreamInfo {
2216 codec: "MP3".into(),
2217 sample_rate: source_rate,
2218 channels,
2219 bit_depth: Some(16),
2220 bitrate_kbps: None,
2221 duration_ms: 1000,
2222 };
2223 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2224 player
2225 .create_engine_for(&info, consumer)
2226 .expect("engine creation should succeed");
2227 let asked = *asked.lock().unwrap();
2228 asked.expect("engine was never created")
2229 }
2230
2231 #[test]
2232 fn engine_uses_source_rate_when_device_refuses_switch() {
2233 assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
2236 assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
2237 }
2238
2239 #[test]
2240 fn engine_uses_source_channel_count() {
2241 assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
2242 }
2243
2244 fn settled_rate_for(source_rate: u32, device_rate: f64) -> Option<u32> {
2246 let mut player = Player::new();
2247 player.backend = Box::new(StuckBackend {
2248 rate: device_rate,
2249 asked: Arc::new(std::sync::Mutex::new(None)),
2250 });
2251 let state = player.shared_state.clone();
2252
2253 let info = buffer::StreamInfo {
2254 codec: "MP3".into(),
2255 sample_rate: source_rate,
2256 channels: 2,
2257 bit_depth: Some(16),
2258 bitrate_kbps: None,
2259 duration_ms: 1000,
2260 };
2261 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2262 player
2263 .create_engine_for(&info, consumer)
2264 .expect("engine creation should succeed");
2265 state.output_sample_rate()
2266 }
2267
2268 #[test]
2269 fn settled_device_rate_reaches_the_shared_state() {
2270 assert_eq!(settled_rate_for(22050, 48000.0), Some(48000));
2274 assert_eq!(settled_rate_for(44100, 44100.0), Some(44100));
2276 }
2277}