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