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 fn on_decode_finished(&mut self) {
1030 log::info!("decode finished, checking for next track");
1031 match self.shared_state.advance_cursor_loadable() {
1032 Some(id) => self.play(id),
1033 None => {
1034 log::info!("no more tracks — stopping");
1035 self.stop_playback_and_clear_state();
1036 }
1037 }
1038 }
1039
1040 fn snapshot_for_undo(
1044 &self,
1045 ids: &[QueueItemId],
1046 ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
1047 self.shared_state
1048 .items_before(ids)
1049 .into_iter()
1050 .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
1051 .collect()
1052 }
1053
1054 fn push_undo(&mut self, entry: UndoEntry) {
1056 if let Some(ref mut batch) = self.batch_buffer {
1057 batch.push(entry);
1058 } else {
1059 self.undo_stack.push(entry);
1060 }
1061 }
1062
1063 pub fn process_command(&mut self, cmd: PlayerCommand) {
1065 match cmd {
1066 PlayerCommand::Play(id) => self.play(id),
1067 PlayerCommand::Pause => self.pause(),
1068 PlayerCommand::Resume => self.resume(),
1069 PlayerCommand::Stop => self.stop(),
1070 PlayerCommand::Seek(pos) => self.seek(pos),
1071 PlayerCommand::NextTrack => {
1072 let now = std::time::Instant::now();
1074 if now.duration_since(self.last_skip).as_millis() >= 150 {
1075 self.last_skip = now;
1076 self.next_track();
1077 }
1078 }
1079 PlayerCommand::PrevTrack => {
1080 let now = std::time::Instant::now();
1081 if now.duration_since(self.last_skip).as_millis() >= 150 {
1082 self.last_skip = now;
1083 self.prev_track();
1084 }
1085 }
1086 PlayerCommand::AddToPlaylist(items) => {
1087 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1088 self.shared_state.add_items(items);
1089 self.push_undo(UndoEntry::Added { ids });
1090 }
1091 PlayerCommand::UpdatePaths(updates) => {
1092 self.shared_state.update_paths(&updates);
1093 if let Some(info) = self.shared_state.track_info()
1094 && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
1095 {
1096 self.shared_state.set_track_info(Some(TrackInfo {
1097 path: new_path.clone(),
1098 ..info
1099 }));
1100 }
1101 }
1102 PlayerCommand::InsertInPlaylist { items, after } => {
1103 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1104 self.shared_state.insert_items_after(items, after);
1105 self.push_undo(UndoEntry::Inserted { ids });
1106 }
1107 PlayerCommand::ClearPlaylist => {
1108 self.stop_playback_and_clear_state();
1112 let (items, cursor) = self.shared_state.snapshot_playlist();
1113 self.shared_state.clear_playlist();
1114 self.push_undo(UndoEntry::Replaced { items, cursor });
1115 }
1116 PlayerCommand::ReplacePlaylist { items, start } => {
1117 self.stop_playback_and_clear_state();
1121 let (old_items, cursor) = self.shared_state.snapshot_playlist();
1122 self.shared_state.clear_playlist();
1123 self.push_undo(UndoEntry::Replaced {
1124 items: old_items,
1125 cursor,
1126 });
1127
1128 if items.is_empty() {
1129 return;
1130 }
1131 let start_id = items.get(start).unwrap_or(&items[0]).id;
1132 self.shared_state.add_items(items);
1133 self.play(start_id);
1134 }
1135 PlayerCommand::RemoveFromPlaylist(id) => {
1136 let item = self.shared_state.get_item(id);
1137 let after = self.shared_state.item_before(id);
1138 self.remove_from_playlist(id);
1139 if let Some(item) = item {
1140 self.push_undo(UndoEntry::Removed {
1141 items: vec![(Box::new(item), after)],
1142 });
1143 }
1144 }
1145 PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1146 let items_with_pos = self.snapshot_for_undo(&ids);
1150 let resume_after = match self.shared_state.cursor() {
1151 Some(cursor) if ids.contains(&cursor) => {
1152 Some(self.shared_state.surviving_item_before(cursor, &ids))
1153 }
1154 _ => None,
1155 };
1156
1157 self.shared_state.remove_items(&ids);
1158
1159 if let Some(resume_after) = resume_after {
1160 self.shared_state.set_cursor(resume_after);
1161 self.next_track();
1162 }
1163
1164 if !items_with_pos.is_empty() {
1165 self.push_undo(UndoEntry::Removed {
1166 items: items_with_pos,
1167 });
1168 }
1169 }
1170 PlayerCommand::MoveInPlaylist { id, target, after } => {
1171 let was_after = self.shared_state.item_before(id);
1172 self.shared_state.move_item(id, target, after);
1173 self.push_undo(UndoEntry::Moved { id, was_after });
1174 }
1175 PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1176 let entries = self.shared_state.items_before(&ids);
1177 self.shared_state.move_items(&ids, target, after);
1178 self.push_undo(UndoEntry::MovedBatch { entries });
1179 }
1180 PlayerCommand::TrackReady(id) => self.track_ready(id),
1181 PlayerCommand::DecodeFinished => self.on_decode_finished(),
1182 PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1183 PlayerCommand::Undo => self.execute_undo(),
1184 PlayerCommand::Redo => self.execute_redo(),
1185 PlayerCommand::BeginUndoBatch => {
1186 self.batch_buffer = Some(Vec::new());
1187 }
1188 PlayerCommand::EndUndoBatch => {
1189 if let Some(entries) = self.batch_buffer.take() {
1190 if entries.len() == 1 {
1191 self.undo_stack.push(entries.into_iter().next().unwrap());
1193 } else if !entries.is_empty() {
1194 self.undo_stack.push(UndoEntry::Batch(entries));
1195 }
1196 }
1197 }
1198 PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1199 PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1200 }
1201 }
1202
1203 fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1205 match entry {
1206 UndoEntry::Added { ids } => {
1207 let items_with_pos = self.snapshot_for_undo(&ids);
1209 self.shared_state.remove_items(&ids);
1210 Some(UndoEntry::Removed {
1211 items: items_with_pos,
1212 })
1213 }
1214 UndoEntry::Removed { items } => {
1215 let mut ids = Vec::with_capacity(items.len());
1217 for (item, after) in items {
1218 ids.push(item.id);
1219 self.shared_state.insert_item_at(*item, after);
1220 }
1221 Some(UndoEntry::Added { ids })
1222 }
1223 UndoEntry::Inserted { ids } => {
1224 let items_with_pos = self.snapshot_for_undo(&ids);
1226 self.shared_state.remove_items(&ids);
1227 Some(UndoEntry::Removed {
1228 items: items_with_pos,
1229 })
1230 }
1231 UndoEntry::Moved { id, was_after } => {
1232 let current_after = self.shared_state.item_before(id);
1233 self.shared_state.move_item_to(id, was_after);
1234 Some(UndoEntry::Moved {
1235 id,
1236 was_after: current_after,
1237 })
1238 }
1239 UndoEntry::MovedBatch { entries } => {
1240 let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1241 let current_positions = self.shared_state.items_before(&ids);
1242 self.shared_state.move_items_to(&entries);
1243 Some(UndoEntry::MovedBatch {
1244 entries: current_positions,
1245 })
1246 }
1247 UndoEntry::Replaced { items, cursor } => {
1248 let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1249 self.shared_state.restore_playlist(items, cursor);
1250 Some(UndoEntry::Replaced {
1251 items: current_items,
1252 cursor: current_cursor,
1253 })
1254 }
1255 UndoEntry::Batch(entries) => {
1256 let mut inverses = Vec::with_capacity(entries.len());
1258 for entry in entries.into_iter().rev() {
1259 if let Some(inverse) = self.apply_entry(entry) {
1260 inverses.push(inverse);
1261 }
1262 }
1263 inverses.reverse();
1264 Some(UndoEntry::Batch(inverses))
1265 }
1266 }
1267 }
1268
1269 fn execute_undo(&mut self) {
1271 let Some(entry) = self.undo_stack.pop_undo() else {
1272 return;
1273 };
1274 if let Some(inverse) = self.apply_entry(entry) {
1275 self.undo_stack.push_redo(inverse);
1276 }
1277 }
1278
1279 fn execute_redo(&mut self) {
1281 let Some(entry) = self.undo_stack.pop_redo() else {
1282 return;
1283 };
1284 if let Some(inverse) = self.apply_entry(entry) {
1285 self.undo_stack.push_undo_keep_redo(inverse);
1286 }
1287 }
1288
1289 pub fn run(&mut self) {
1291 use std::time::Duration;
1292
1293 let rx = self.commands.rx.clone();
1294 loop {
1295 match rx.recv_timeout(Duration::from_millis(50)) {
1297 Ok(cmd) => self.process_command(cmd),
1298 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1299 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1300 }
1301 self.update_playback_state();
1302 }
1303 self.stop();
1304 }
1305
1306 pub fn spawn() -> (
1309 Arc<SharedPlayerState>,
1310 Arc<PlaybackTimeline>,
1311 Arc<VizSnapshot>,
1312 crossbeam_channel::Sender<PlayerCommand>,
1313 ) {
1314 let mut player = Self::new();
1315 player.history = PlayRecorder::spawn();
1316 let state = player.shared_state();
1317 let timeline = player.timeline();
1318 let viz_snapshot = player.viz_snapshot();
1319 let tx = player.command_sender();
1320
1321 thread::Builder::new()
1322 .name("koan-player".into())
1323 .spawn(move || player.run())
1324 .expect("failed to spawn player thread");
1325
1326 (state, timeline, viz_snapshot, tx)
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use state::PlaylistItem;
1334 use std::path::PathBuf;
1335
1336 fn make_item(title: &str) -> PlaylistItem {
1337 PlaylistItem {
1338 id: QueueItemId::new(),
1339 db_id: None,
1340 path: PathBuf::from(format!("/music/{title}.flac")),
1341 title: title.to_string(),
1342 artist: String::new(),
1343 album_artist: String::new(),
1344 album: String::new(),
1345 year: None,
1346 codec: None,
1347 track_number: None,
1348 disc: None,
1349 duration_ms: None,
1350 load_state: LoadState::Ready,
1351 }
1352 }
1353
1354 fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1355 let (items, _) = player.shared_state.snapshot_playlist();
1356 items.iter().map(|i| i.id).collect()
1357 }
1358
1359 fn playlist_titles(player: &Player) -> Vec<String> {
1360 let (items, _) = player.shared_state.snapshot_playlist();
1361 items.iter().map(|i| i.title.clone()).collect()
1362 }
1363
1364 fn pending_item(title: &str) -> PlaylistItem {
1365 PlaylistItem {
1366 load_state: LoadState::Pending,
1367 ..make_item(title)
1368 }
1369 }
1370
1371 fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
1373 let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
1374 let ids = items.iter().map(|i| i.id).collect();
1375 player.process_command(PlayerCommand::AddToPlaylist(items));
1376 ids
1377 }
1378
1379 fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
1383 let mut at = from_ms;
1384 if let Some(f) = player.in_flight.as_mut() {
1385 f.advance(at); }
1387 while at < to_ms {
1388 at = (at + 50).min(to_ms);
1389 if let Some(f) = player.in_flight.as_mut() {
1390 f.advance(at);
1391 }
1392 }
1393 }
1394
1395 fn start(player: &mut Player, track_id: i64) -> QueueItemId {
1396 let id = QueueItemId::new();
1397 player.on_track_changed(id);
1398 player
1400 .in_flight
1401 .as_mut()
1402 .unwrap()
1403 .track_id_for_test(track_id);
1404 id
1405 }
1406
1407 #[test]
1408 fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
1409 let mut player = Player::new();
1410 start(&mut player, 11);
1411 listen(&mut player, 0, 200_000);
1412
1413 let b = QueueItemId::new();
1414 player.on_track_changed(b);
1415 let f = player
1416 .in_flight
1417 .as_ref()
1418 .expect("the next track is counting");
1419 assert_eq!(f.item, b);
1420 assert_eq!(f.listened_ms(), 0, "and starts from nothing");
1421 }
1422
1423 #[test]
1424 fn a_track_skipped_seconds_in_is_still_history() {
1425 let mut player = Player::new();
1426 start(&mut player, 7);
1427 listen(&mut player, 0, 2_000);
1428
1429 let event = player
1430 .finish_play()
1431 .expect("putting something on is a thing you did, however briefly");
1432 assert!(matches!(
1433 event,
1434 history::PlayEvent::Finished {
1435 track_id: 7,
1436 listened_ms: 2_000
1437 }
1438 ));
1439 }
1440
1441 #[test]
1442 fn a_track_is_closed_out_once() {
1443 let mut player = Player::new();
1444 start(&mut player, 7);
1445 listen(&mut player, 0, 200_000);
1446
1447 assert!(player.finish_play().is_some());
1448 assert!(player.finish_play().is_none());
1449 }
1450
1451 #[test]
1452 fn seeking_around_a_track_does_not_enter_it_twice() {
1453 let mut player = Player::new();
1454 let id = start(&mut player, 7);
1455 listen(&mut player, 0, 120_000);
1456
1457 player.on_track_changed(id);
1459 assert_eq!(
1460 player.in_flight.as_ref().unwrap().listened_ms(),
1461 120_000,
1462 "the seek kept the count rather than restarting it"
1463 );
1464 listen(&mut player, 30_000, 40_000);
1465
1466 let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
1467 panic!("still one play");
1468 };
1469 assert_eq!(listened_ms, 130_000);
1470 assert!(player.finish_play().is_none());
1471 }
1472
1473 #[test]
1474 fn a_track_that_is_not_in_the_library_is_not_recorded() {
1475 let mut player = Player::new();
1476 let id = QueueItemId::new();
1477 player.on_track_changed(id);
1478 listen(&mut player, 0, 200_000);
1479 assert!(player.finish_play().is_none());
1480 }
1481
1482 #[test]
1483 fn stopping_closes_out_what_was_heard() {
1484 let mut player = Player::new();
1485 start(&mut player, 7);
1486 listen(&mut player, 0, 150_000);
1487
1488 player.stop_playback_and_clear_state();
1489 assert!(player.in_flight.is_none(), "the stop consumed it");
1490 }
1491
1492 #[test]
1493 fn removing_the_playing_track_resumes_at_its_successor() {
1494 let mut player = Player::new();
1495 let ids = seed(&mut player, 5);
1496 player.shared_state.set_cursor(Some(ids[2]));
1497
1498 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
1499
1500 assert_eq!(
1501 player.shared_state.cursor(),
1502 Some(ids[3]),
1503 "playback must continue at the next track, not restart the queue"
1504 );
1505 assert_eq!(player.playback_starts, 1);
1506 }
1507
1508 #[test]
1509 fn removing_the_first_playing_track_resumes_at_the_new_first() {
1510 let mut player = Player::new();
1511 let ids = seed(&mut player, 3);
1512 player.shared_state.set_cursor(Some(ids[0]));
1513
1514 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
1515
1516 assert_eq!(player.shared_state.cursor(), Some(ids[1]));
1517 }
1518
1519 #[test]
1520 fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
1521 let mut player = Player::new();
1522 let playing = make_item("playing");
1523 let waiting = pending_item("waiting");
1524 let later = make_item("later");
1525 let (playing_id, waiting_id) = (playing.id, waiting.id);
1526 player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
1527 player.shared_state.set_cursor(Some(playing_id));
1528
1529 player.process_command(PlayerCommand::DecodeFinished);
1530
1531 assert_eq!(
1532 player.shared_state.cursor(),
1533 Some(waiting_id),
1534 "the cursor parks on the track being fetched"
1535 );
1536 assert_eq!(
1537 player.playback_starts, 0,
1538 "nothing to play until its bytes land"
1539 );
1540
1541 player
1544 .shared_state
1545 .update_load_state(waiting_id, LoadState::Ready);
1546 player.process_command(PlayerCommand::TrackReady(waiting_id));
1547
1548 assert_eq!(player.playback_starts, 1);
1549 assert_eq!(player.shared_state.cursor(), Some(waiting_id));
1550 }
1551
1552 #[test]
1553 fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
1554 let mut player = Player::new();
1555 let ids = seed(&mut player, 5);
1556 player.shared_state.set_cursor(Some(ids[2]));
1557
1558 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
1559 ids[1], ids[2], ids[3],
1560 ]));
1561
1562 assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
1563 assert_eq!(player.shared_state.cursor(), Some(ids[4]));
1564 assert_eq!(
1565 player.playback_starts, 1,
1566 "one resume for the whole selection, not one per deleted track"
1567 );
1568 }
1569
1570 #[test]
1571 fn batch_delete_below_the_cursor_leaves_playback_alone() {
1572 let mut player = Player::new();
1573 let ids = seed(&mut player, 4);
1574 player.shared_state.set_cursor(Some(ids[0]));
1575
1576 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
1577
1578 assert_eq!(player.shared_state.cursor(), Some(ids[0]));
1579 assert_eq!(player.playback_starts, 0);
1580 }
1581
1582 #[test]
1583 fn undo_of_a_batch_delete_restores_the_original_order() {
1584 let mut player = Player::new();
1588 let items = vec![
1589 make_item("A"),
1590 make_item("B"),
1591 make_item("C"),
1592 make_item("D"),
1593 ];
1594 let (b_id, c_id) = (items[1].id, items[2].id);
1595 player.process_command(PlayerCommand::AddToPlaylist(items));
1596
1597 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
1598 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1599
1600 player.process_command(PlayerCommand::Undo);
1601 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1602 }
1603
1604 #[test]
1607 fn undo_add_removes_items() {
1608 let mut player = Player::new();
1609 let items = vec![make_item("A"), make_item("B")];
1610 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1611
1612 player.process_command(PlayerCommand::AddToPlaylist(items));
1613 assert_eq!(playlist_ids(&player), ids);
1614 assert!(player.undo_stack().can_undo());
1615
1616 player.process_command(PlayerCommand::Undo);
1617 assert!(playlist_ids(&player).is_empty());
1618 assert!(player.undo_stack().can_redo());
1619 }
1620
1621 #[test]
1622 fn redo_add_restores_items() {
1623 let mut player = Player::new();
1624 let items = vec![make_item("A"), make_item("B")];
1625
1626 player.process_command(PlayerCommand::AddToPlaylist(items));
1627 player.process_command(PlayerCommand::Undo);
1628 assert!(playlist_ids(&player).is_empty());
1629
1630 player.process_command(PlayerCommand::Redo);
1631 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1632 }
1633
1634 #[test]
1637 fn undo_remove_restores_item_at_position() {
1638 let mut player = Player::new();
1639 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1640 let b_id = items[1].id;
1641
1642 player.process_command(PlayerCommand::AddToPlaylist(items));
1643 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1644 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1645
1646 player.process_command(PlayerCommand::Undo);
1647 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1648 }
1649
1650 #[test]
1651 fn undo_remove_first_item() {
1652 let mut player = Player::new();
1653 let items = vec![make_item("A"), make_item("B")];
1654 let a_id = items[0].id;
1655
1656 player.process_command(PlayerCommand::AddToPlaylist(items));
1657 player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1658 assert_eq!(playlist_titles(&player), vec!["B"]);
1659
1660 player.process_command(PlayerCommand::Undo);
1661 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1662 }
1663
1664 #[test]
1665 fn undo_batch_remove_restores_all() {
1666 let mut player = Player::new();
1667 let items = vec![
1668 make_item("A"),
1669 make_item("B"),
1670 make_item("C"),
1671 make_item("D"),
1672 ];
1673 let b_id = items[1].id;
1674 let c_id = items[2].id;
1675
1676 player.process_command(PlayerCommand::AddToPlaylist(items));
1677 let version_before = player.shared_state.playlist_version();
1678 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
1679 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1680 assert_eq!(
1683 player.shared_state.playlist_version(),
1684 version_before + 1,
1685 "batch removal must bump the playlist version exactly once"
1686 );
1687
1688 player.process_command(PlayerCommand::Undo);
1690 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1691 }
1692
1693 #[test]
1694 fn redo_batch_remove() {
1695 let mut player = Player::new();
1696 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1697 let a_id = items[0].id;
1698 let b_id = items[1].id;
1699
1700 player.process_command(PlayerCommand::AddToPlaylist(items));
1701 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
1702 player.process_command(PlayerCommand::Undo);
1703 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1704
1705 player.process_command(PlayerCommand::Redo);
1706 assert_eq!(playlist_titles(&player), vec!["C"]);
1707 }
1708
1709 #[test]
1710 fn redo_remove() {
1711 let mut player = Player::new();
1712 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1713 let b_id = items[1].id;
1714
1715 player.process_command(PlayerCommand::AddToPlaylist(items));
1716 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1717 player.process_command(PlayerCommand::Undo);
1718 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1719
1720 player.process_command(PlayerCommand::Redo);
1721 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1722 }
1723
1724 #[test]
1727 fn undo_insert_removes_inserted_items() {
1728 let mut player = Player::new();
1729 let items = vec![make_item("A"), make_item("C")];
1730 let a_id = items[0].id;
1731
1732 player.process_command(PlayerCommand::AddToPlaylist(items));
1733
1734 let inserted = vec![make_item("B")];
1735 player.process_command(PlayerCommand::InsertInPlaylist {
1736 items: inserted,
1737 after: a_id,
1738 });
1739 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1740
1741 player.process_command(PlayerCommand::Undo);
1742 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1743 }
1744
1745 #[test]
1748 fn undo_move_restores_position() {
1749 let mut player = Player::new();
1750 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1751 let a_id = items[0].id;
1752 let c_id = items[2].id;
1753
1754 player.process_command(PlayerCommand::AddToPlaylist(items));
1755
1756 player.process_command(PlayerCommand::MoveInPlaylist {
1758 id: a_id,
1759 target: c_id,
1760 after: true,
1761 });
1762 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1763
1764 player.process_command(PlayerCommand::Undo);
1765 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1766 }
1767
1768 #[test]
1769 fn redo_move() {
1770 let mut player = Player::new();
1771 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1772 let a_id = items[0].id;
1773 let c_id = items[2].id;
1774
1775 player.process_command(PlayerCommand::AddToPlaylist(items));
1776 player.process_command(PlayerCommand::MoveInPlaylist {
1777 id: a_id,
1778 target: c_id,
1779 after: true,
1780 });
1781 player.process_command(PlayerCommand::Undo);
1782 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1783
1784 player.process_command(PlayerCommand::Redo);
1785 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1786 }
1787
1788 #[test]
1791 fn undo_batch_move() {
1792 let mut player = Player::new();
1793 let items = vec![
1794 make_item("A"),
1795 make_item("B"),
1796 make_item("C"),
1797 make_item("D"),
1798 ];
1799 let a_id = items[0].id;
1800 let b_id = items[1].id;
1801 let d_id = items[3].id;
1802
1803 player.process_command(PlayerCommand::AddToPlaylist(items));
1804
1805 player.process_command(PlayerCommand::MoveItemsInPlaylist {
1807 ids: vec![a_id, b_id],
1808 target: d_id,
1809 after: true,
1810 });
1811 assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
1812
1813 player.process_command(PlayerCommand::Undo);
1814 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1815 }
1816
1817 #[test]
1820 fn undo_clear_restores_playlist() {
1821 let mut player = Player::new();
1822 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1823
1824 player.process_command(PlayerCommand::AddToPlaylist(items));
1825 player.process_command(PlayerCommand::ClearPlaylist);
1826 assert!(playlist_ids(&player).is_empty());
1827
1828 player.process_command(PlayerCommand::Undo);
1829 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1830 }
1831
1832 #[test]
1833 fn redo_clear() {
1834 let mut player = Player::new();
1835 let items = vec![make_item("A"), make_item("B")];
1836
1837 player.process_command(PlayerCommand::AddToPlaylist(items));
1838 player.process_command(PlayerCommand::ClearPlaylist);
1839 player.process_command(PlayerCommand::Undo);
1840 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1841
1842 player.process_command(PlayerCommand::Redo);
1843 assert!(playlist_ids(&player).is_empty());
1844 }
1845
1846 #[test]
1849 fn multiple_undos_in_sequence() {
1850 let mut player = Player::new();
1851
1852 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
1853 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1854 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
1855 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1856
1857 player.process_command(PlayerCommand::Undo);
1858 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1859
1860 player.process_command(PlayerCommand::Undo);
1861 assert_eq!(playlist_titles(&player), vec!["A"]);
1862
1863 player.process_command(PlayerCommand::Undo);
1864 assert!(playlist_ids(&player).is_empty());
1865 }
1866
1867 #[test]
1868 fn undo_redo_undo_cycle() {
1869 let mut player = Player::new();
1870 let items = vec![make_item("A"), make_item("B")];
1871
1872 player.process_command(PlayerCommand::AddToPlaylist(items));
1873 player.process_command(PlayerCommand::Undo);
1874 assert!(playlist_ids(&player).is_empty());
1875
1876 player.process_command(PlayerCommand::Redo);
1877 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1878
1879 player.process_command(PlayerCommand::Undo);
1880 assert!(playlist_ids(&player).is_empty());
1881 }
1882
1883 #[test]
1884 fn new_action_clears_redo_stack() {
1885 let mut player = Player::new();
1886 let items = vec![make_item("A")];
1887
1888 player.process_command(PlayerCommand::AddToPlaylist(items));
1889 player.process_command(PlayerCommand::Undo);
1890 assert!(player.undo_stack().can_redo());
1891
1892 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1894 assert!(!player.undo_stack().can_redo());
1895 }
1896
1897 #[test]
1898 fn undo_on_empty_stack_is_noop() {
1899 let mut player = Player::new();
1900 player.process_command(PlayerCommand::Undo);
1901 assert!(playlist_ids(&player).is_empty());
1902 }
1903
1904 #[test]
1905 fn redo_on_empty_stack_is_noop() {
1906 let mut player = Player::new();
1907 player.process_command(PlayerCommand::Redo);
1908 assert!(playlist_ids(&player).is_empty());
1909 }
1910
1911 #[test]
1914 fn playback_commands_not_undoable() {
1915 let mut player = Player::new();
1916 player.process_command(PlayerCommand::Pause);
1917 player.process_command(PlayerCommand::Resume);
1918 player.process_command(PlayerCommand::NextTrack);
1919 player.process_command(PlayerCommand::PrevTrack);
1920 assert!(!player.undo_stack().can_undo());
1921 }
1922
1923 #[test]
1924 fn update_paths_not_undoable() {
1925 let mut player = Player::new();
1926 let items = vec![make_item("A")];
1927 let id = items[0].id;
1928 player.process_command(PlayerCommand::AddToPlaylist(items));
1929
1930 let undo_count = player.undo_stack().undo_len();
1931 player.process_command(PlayerCommand::UpdatePaths(vec![(
1932 id,
1933 PathBuf::from("/new/path.flac"),
1934 )]));
1935 assert_eq!(player.undo_stack().undo_len(), undo_count);
1936 }
1937
1938 #[test]
1941 fn add_remove_undo_undo_produces_original() {
1942 let mut player = Player::new();
1943 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1944 let b_id = items[1].id;
1945 let original_titles = vec!["A", "B", "C"];
1946
1947 player.process_command(PlayerCommand::AddToPlaylist(items));
1948 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1949 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1950
1951 player.process_command(PlayerCommand::Undo);
1953 assert_eq!(playlist_titles(&player), original_titles);
1954
1955 player.process_command(PlayerCommand::Undo);
1957 assert!(playlist_ids(&player).is_empty());
1958 }
1959
1960 #[test]
1961 fn interleaved_adds_and_moves_undo() {
1962 let mut player = Player::new();
1963 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1964 let a_id = items[0].id;
1965 let c_id = items[2].id;
1966
1967 player.process_command(PlayerCommand::AddToPlaylist(items));
1968
1969 player.process_command(PlayerCommand::MoveInPlaylist {
1971 id: a_id,
1972 target: c_id,
1973 after: true,
1974 });
1975 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1976
1977 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
1979 assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
1980
1981 player.process_command(PlayerCommand::Undo);
1983 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1984
1985 player.process_command(PlayerCommand::Undo);
1987 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1988 }
1989
1990 #[test]
1995 fn stop_engine_drops_engine_synchronously() {
1996 use std::sync::atomic::AtomicBool;
1997
1998 struct MockEngine {
1999 dropped: Arc<AtomicBool>,
2000 }
2001 impl AudioEngineHandle for MockEngine {
2002 fn start(&self) -> Result<(), BackendError> {
2003 Ok(())
2004 }
2005 fn stop(&self) -> Result<(), BackendError> {
2006 Ok(())
2007 }
2008 fn is_running(&self) -> bool {
2009 false
2010 }
2011 }
2012 impl Drop for MockEngine {
2013 fn drop(&mut self) {
2014 self.dropped.store(true, Ordering::SeqCst);
2015 }
2016 }
2017
2018 let dropped = Arc::new(AtomicBool::new(false));
2019
2020 let stop_flag = Arc::new(AtomicBool::new(false));
2022 let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
2023
2024 let mut player = Player::new();
2025 player.active_playback = Some(ActivePlayback {
2026 engine: Box::new(MockEngine {
2027 dropped: dropped.clone(),
2028 }),
2029 decode_handle,
2030 });
2031
2032 player.stop_engine();
2033
2034 assert!(
2038 dropped.load(Ordering::SeqCst),
2039 "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
2040 );
2041 }
2042
2043 struct StuckBackend {
2048 rate: f64,
2049 asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
2050 }
2051
2052 struct NullEngine;
2053 impl AudioEngineHandle for NullEngine {
2054 fn start(&self) -> Result<(), BackendError> {
2055 Ok(())
2056 }
2057 fn stop(&self) -> Result<(), BackendError> {
2058 Ok(())
2059 }
2060 fn is_running(&self) -> bool {
2061 false
2062 }
2063 }
2064
2065 impl AudioBackend for StuckBackend {
2066 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2067 Ok(vec![self.default_device()?])
2068 }
2069 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2070 Ok(backend::DeviceInfo {
2071 name: "Stuck DAC".into(),
2072 sample_rates: vec![self.rate],
2073 platform_id: 0,
2074 })
2075 }
2076 fn supported_sample_rates(
2077 &self,
2078 _device: &backend::DeviceInfo,
2079 ) -> Result<Vec<f64>, BackendError> {
2080 Ok(vec![self.rate])
2081 }
2082 fn get_device_sample_rate(
2083 &self,
2084 _device: &backend::DeviceInfo,
2085 ) -> Result<f64, BackendError> {
2086 Ok(self.rate)
2087 }
2088 fn set_device_sample_rate(
2089 &self,
2090 _device: &backend::DeviceInfo,
2091 rate: f64,
2092 ) -> Result<f64, BackendError> {
2093 Err(BackendError::UnsupportedSampleRate(rate))
2094 }
2095 fn create_engine(
2096 &self,
2097 _device: &backend::DeviceInfo,
2098 sample_rate: f64,
2099 channels: u32,
2100 _consumer: rtrb::Consumer<f32>,
2101 _samples_played: Arc<AtomicU64>,
2102 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2103 *self.asked.lock().unwrap() = Some((sample_rate, channels));
2104 Ok(Box::new(NullEngine))
2105 }
2106 }
2107
2108 fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
2109 let asked = Arc::new(std::sync::Mutex::new(None));
2110 let mut player = Player::new();
2111 player.backend = Box::new(StuckBackend {
2112 rate: device_rate,
2113 asked: asked.clone(),
2114 });
2115
2116 let info = buffer::StreamInfo {
2117 codec: "MP3".into(),
2118 sample_rate: source_rate,
2119 channels,
2120 bit_depth: Some(16),
2121 bitrate_kbps: None,
2122 duration_ms: 1000,
2123 };
2124 let (_producer, consumer) = rtrb::RingBuffer::new(16);
2125 player
2126 .create_engine_for(&info, consumer)
2127 .expect("engine creation should succeed");
2128 let asked = *asked.lock().unwrap();
2129 asked.expect("engine was never created")
2130 }
2131
2132 #[test]
2133 fn engine_uses_source_rate_when_device_refuses_switch() {
2134 assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
2137 assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
2138 }
2139
2140 #[test]
2141 fn engine_uses_source_channel_count() {
2142 assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
2143 }
2144}