1pub mod commands;
2pub mod state;
3pub mod undo;
4
5use std::path::Path;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::thread;
9
10use thiserror::Error;
11
12use crate::audio::{
13 analyzer::VizAnalyzer,
14 backend::{self, AudioBackend, AudioEngineHandle, BackendError},
15 buffer, streaming,
16 viz::{VizBuffer, VizSnapshot},
17};
18use buffer::PlaybackTimeline;
19use commands::{CommandChannel, PlayerCommand};
20use state::{LoadState, PlaybackSource, PlaybackState, QueueItemId, SharedPlayerState, TrackInfo};
21use undo::{UndoEntry, UndoStack};
22
23pub(crate) const RING_BUFFER_SIZE: usize = 192_000 * 2;
25
26#[derive(Debug, Error)]
27pub enum PlayerError {
28 #[error("backend error: {0}")]
29 Backend(#[from] BackendError),
30 #[error("decode error: {0}")]
31 Decode(#[from] buffer::DecodeError),
32}
33
34pub struct Player {
36 shared_state: Arc<SharedPlayerState>,
37 commands: CommandChannel,
38 active_playback: Option<ActivePlayback>,
39 timeline: Arc<PlaybackTimeline>,
40 viz_buffer: Arc<VizBuffer>,
41 viz_snapshot: Arc<VizSnapshot>,
42 _viz_analyzer: VizAnalyzer,
44 undo_stack: UndoStack,
45 batch_buffer: Option<Vec<UndoEntry>>,
48 output_device_name: Option<String>,
50 backend: Box<dyn AudioBackend>,
52 last_skip: std::time::Instant,
54 #[cfg(test)]
57 playback_starts: usize,
58}
59
60struct ActivePlayback {
62 engine: Box<dyn AudioEngineHandle>,
63 decode_handle: buffer::DecodeHandle,
64}
65
66impl Default for Player {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72impl Player {
73 pub fn new() -> Self {
74 let viz_buffer = VizBuffer::new();
75 let viz_snapshot = VizSnapshot::new();
76 let timeline = PlaybackTimeline::new();
77 let cfg = crate::config::Config::load_or_default();
78 let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
79 Arc::clone(&viz_buffer),
80 &cfg.visualizer,
81 Arc::clone(&viz_snapshot),
82 timeline.samples_played_counter(),
83 );
84
85 Self {
86 shared_state: SharedPlayerState::new(),
87 commands: CommandChannel::new(),
88 active_playback: None,
89 timeline,
90 viz_buffer,
91 viz_snapshot,
92 _viz_analyzer: viz_analyzer,
93 undo_stack: UndoStack::new(),
94 batch_buffer: None,
95 output_device_name: cfg.playback.output_device.clone(),
96 backend: crate::audio::platform_backend(),
97 last_skip: std::time::Instant::now(),
98 #[cfg(test)]
99 playback_starts: 0,
100 }
101 }
102
103 pub fn shared_state(&self) -> Arc<SharedPlayerState> {
105 self.shared_state.clone()
106 }
107
108 pub fn timeline(&self) -> Arc<PlaybackTimeline> {
110 self.timeline.clone()
111 }
112
113 pub fn viz_buffer(&self) -> Arc<VizBuffer> {
115 self.viz_buffer.clone()
116 }
117
118 pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
121 self.viz_snapshot.clone()
122 }
123
124 pub fn undo_stack(&self) -> &UndoStack {
126 &self.undo_stack
127 }
128
129 fn create_engine_for(
137 &self,
138 info: &buffer::StreamInfo,
139 consumer: rtrb::Consumer<f32>,
140 ) -> Result<Box<dyn AudioEngineHandle>, PlayerError> {
141 let device = self.resolve_device()?;
142 let device_rate = self.backend.get_device_sample_rate(&device)?;
143 let source_rate = info.sample_rate as f64;
144
145 if (device_rate - source_rate).abs() > 0.1 {
146 log::info!(
147 "switching device sample rate: {}Hz → {}Hz",
148 device_rate,
149 source_rate
150 );
151 let settled = match self.backend.set_device_sample_rate(&device, source_rate) {
152 Ok(rate) => rate,
153 Err(e) => {
154 log::warn!("failed to set device sample rate: {}", e);
155 device_rate
156 }
157 };
158 if (settled - source_rate).abs() > 0.1 {
159 log::warn!(
160 "device stayed at {}Hz (wanted {}Hz) — output is resampled, not bit-perfect",
161 settled,
162 source_rate
163 );
164 }
165 }
166
167 Ok(self.backend.create_engine(
168 &device,
169 source_rate,
170 info.channels as u32,
171 consumer,
172 self.timeline.samples_played_counter(),
173 )?)
174 }
175
176 fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
179 if let Some(ref name) = self.output_device_name {
180 match self.backend.list_devices() {
181 Ok(devices) => {
182 if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
183 return Ok(dev);
184 }
185 log::warn!(
186 "configured output device '{}' not found, falling back to default",
187 name,
188 );
189 }
190 Err(e) => {
191 log::warn!("failed to list devices while resolving '{}': {}", name, e);
192 }
193 }
194 }
195 Ok(self.backend.default_device()?)
196 }
197
198 pub fn set_output_device(&mut self, name: String) {
201 log::info!("switching output device to: {}", name);
202 self.output_device_name = Some(name.clone());
203
204 if let Err(e) = crate::config::Config::update_base(|cfg| {
206 cfg.playback.output_device = Some(name);
207 }) {
208 log::error!("failed to save output device config: {}", e);
209 }
210
211 self.restart_on_current_track();
212 }
213
214 pub fn clear_output_device(&mut self) {
216 log::info!("reverting to system default output device");
217 self.output_device_name = None;
218
219 if let Err(e) = crate::config::Config::update_base(|cfg| {
220 cfg.playback.output_device = None;
221 }) {
222 log::error!("failed to save output device config: {}", e);
223 }
224
225 self.restart_on_current_track();
226 }
227
228 fn restart_on_current_track(&mut self) {
231 if let Some(info) = self.shared_state.track_info() {
232 let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
233 let position_ms = self.shared_state.position_ms();
234 if let Err(e) = self.start_playback(info.id, &info.path, position_ms) {
235 log::error!("failed to restart playback on device switch: {}", e);
236 return;
237 }
238 if was_paused {
239 self.pause();
240 }
241 }
242 }
243
244 pub fn output_device_name(&self) -> Option<&str> {
246 self.output_device_name.as_deref()
247 }
248
249 pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
251 self.commands.tx.clone()
252 }
253
254 pub fn play(&mut self, id: QueueItemId) {
257 self.shared_state.set_cursor(Some(id));
258
259 match self.shared_state.item_playback_source(id) {
260 Some(PlaybackSource::Ready(path)) => {
261 if let Err(e) = self.start_playback(id, &path, 0) {
262 log::error!("play failed: {}", e);
263 }
264 }
265 Some(PlaybackSource::Streaming {
266 path,
267 bytes_written,
268 total,
269 }) => {
270 if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
271 log::error!("streaming play failed, waiting for full download: {}", e);
274 }
275 }
276 None => {
277 self.stop_engine();
279 self.shared_state.set_playback_state(PlaybackState::Stopped);
280 log::info!("play: item {:?} not ready, waiting for TrackReady", id);
281 }
282 }
283 }
284
285 fn start_playback(
290 &mut self,
291 id: QueueItemId,
292 path: &Path,
293 seek_ms: u64,
294 ) -> Result<(), PlayerError> {
295 #[cfg(test)]
296 {
297 self.playback_starts += 1;
298 }
299 let result = self.open_playback(id, path, seek_ms);
300 if result.is_err() {
301 self.stop_playback_and_clear_state();
302 }
303 result
304 }
305
306 fn open_playback(
307 &mut self,
308 id: QueueItemId,
309 path: &Path,
310 seek_ms: u64,
311 ) -> Result<(), PlayerError> {
312 self.stop_engine();
313
314 let info = buffer::probe_file(path)?;
315
316 self.shared_state.set_track_info(Some(TrackInfo {
320 id,
321 path: path.to_path_buf(),
322 codec: info.codec.clone(),
323 sample_rate: info.sample_rate,
324 bit_depth: info.bit_depth,
325 bitrate_kbps: info.bitrate_kbps,
326 channels: info.channels,
327 duration_ms: info.duration_ms,
328 }));
329 self.shared_state.set_position_ms(seek_ms);
330 log::info!(
331 "playing: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
332 path.display(),
333 id,
334 info.codec,
335 info.sample_rate,
336 info.channels,
337 info.duration_ms,
338 if seek_ms > 0 {
339 format!(" @{}ms", seek_ms)
340 } else {
341 String::new()
342 }
343 );
344
345 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
346
347 self.timeline.reset();
349
350 let advance_state = self.shared_state.clone();
354 let decode_cursor = parking_lot::Mutex::new(Some(id));
355 let next_track = move || {
356 let current = decode_cursor.lock().take()?;
357 let next = advance_state.peek_next_ready_after(current);
358 if let Some((next_id, _)) = &next {
359 let mut guard = decode_cursor.lock();
360 *guard = Some(*next_id);
361 }
362 next
363 };
364
365 let cfg = crate::config::Config::load_or_default();
367 let rg_mode = cfg.playback.replaygain;
368 let pre_amp_db = cfg.playback.pre_amp_db;
369
370 let finish_tx = self.commands.tx.clone();
371 let (_stream_info, decode_handle) = buffer::start_decode_file(
372 id,
373 path,
374 producer,
375 seek_ms,
376 next_track,
377 self.timeline.clone(),
378 Some(self.viz_buffer.clone()),
379 rg_mode,
380 pre_amp_db,
381 move || {
382 finish_tx.send(PlayerCommand::DecodeFinished).ok();
383 },
384 )?;
385
386 let engine = self.create_engine_for(&info, consumer)?;
387 engine.start()?;
388
389 self.shared_state.set_playback_state(PlaybackState::Playing);
390
391 self.active_playback = Some(ActivePlayback {
392 engine,
393 decode_handle,
394 });
395
396 Ok(())
397 }
398
399 fn start_streaming_playback(
406 &mut self,
407 id: QueueItemId,
408 path: &Path,
409 bytes_written: Arc<AtomicU64>,
410 total: u64,
411 ) -> Result<(), PlayerError> {
412 let result = self.open_streaming_playback(id, path, bytes_written, total);
413 if result.is_err() {
414 self.stop_playback_and_clear_state();
415 }
416 result
417 }
418
419 fn open_streaming_playback(
420 &mut self,
421 id: QueueItemId,
422 path: &Path,
423 bytes_written: Arc<AtomicU64>,
424 total: u64,
425 ) -> Result<(), PlayerError> {
426 self.stop_engine();
427
428 let stream_buf = streaming::StreamBuffer::new(if total > 0 { Some(total) } else { None });
430
431 let pump_path = path.to_path_buf();
439 let pump_buf = stream_buf.clone();
440 let pump_written = bytes_written.clone();
441 let pump_state = self.shared_state.clone();
442 thread::Builder::new()
443 .name("koan-stream-pump".into())
444 .spawn(move || {
445 use std::fs::File;
446 use std::io::Read;
447 use std::time::{Duration, Instant};
448
449 const STALL_LIMIT: Duration = Duration::from_secs(30);
453
454 let mut file = match File::open(&pump_path) {
455 Ok(f) => f,
456 Err(e) => {
457 log::error!("stream pump: failed to open {}: {}", pump_path.display(), e);
458 pump_buf.fail();
459 return;
460 }
461 };
462 let mut buf = vec![0u8; 65536];
463 let mut offset: u64 = 0;
464 let mut last_progress = Instant::now();
465 loop {
466 if pump_buf.is_abandoned() {
468 return;
469 }
470 match pump_state.item_load_state(id) {
471 Some(LoadState::Failed(e)) => {
472 log::warn!("stream pump: download of {:?} failed: {}", id, e);
473 pump_buf.fail();
474 return;
475 }
476 Some(LoadState::Ready) => match file.read(&mut buf) {
479 Ok(0) => break,
480 Ok(n) => {
481 pump_buf.push(&buf[..n]);
482 offset += n as u64;
483 continue;
484 }
485 Err(e) => {
486 log::warn!("stream pump read error: {}", e);
487 pump_buf.fail();
488 return;
489 }
490 },
491 _ => {}
492 }
493
494 let available = pump_written.load(Ordering::Acquire);
495 if offset >= available {
496 if total > 0 && available >= total {
497 break; }
499 if last_progress.elapsed() >= STALL_LIMIT {
500 log::warn!(
501 "stream pump: no data for {}s, abandoning {}",
502 STALL_LIMIT.as_secs(),
503 pump_path.display()
504 );
505 pump_buf.fail();
506 return;
507 }
508 thread::sleep(Duration::from_millis(10));
509 continue;
510 }
511 let to_read = ((available - offset) as usize).min(buf.len());
512 match file.read(&mut buf[..to_read]) {
513 Ok(0) => {
514 if total > 0 && offset >= total {
517 break;
518 }
519 let latest = pump_written.load(Ordering::Acquire);
520 if total > 0 && latest >= total && offset >= latest {
521 break;
522 }
523 thread::sleep(Duration::from_millis(1));
525 continue;
526 }
527 Ok(n) => {
528 pump_buf.push(&buf[..n]);
529 offset += n as u64;
530 last_progress = Instant::now();
531 }
532 Err(e) => {
533 log::warn!("stream pump read error: {}", e);
534 pump_buf.fail();
535 return;
536 }
537 }
538 }
539 pump_buf.finish();
540 })
541 .map_err(|e| PlayerError::Decode(buffer::DecodeError::Io(e)))?;
542
543 let probe_reader = stream_buf.reader();
545 let probe_hint = {
546 let mut h = symphonia::core::formats::probe::Hint::new();
547 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
548 h.with_extension(ext);
549 }
550 h
551 };
552 let probe_mss =
553 symphonia::core::io::MediaSourceStream::new(Box::new(probe_reader), Default::default());
554 let info = buffer::probe_source(probe_mss, &probe_hint)?;
555
556 self.shared_state.set_track_info(Some(TrackInfo {
557 id,
558 path: path.to_path_buf(),
559 codec: info.codec.clone(),
560 sample_rate: info.sample_rate,
561 bit_depth: info.bit_depth,
562 bitrate_kbps: info.bitrate_kbps,
563 channels: info.channels,
564 duration_ms: info.duration_ms,
565 }));
566 self.shared_state.set_position_ms(0);
567 log::info!(
568 "streaming: {} ({:?}) — {} {}Hz/{}ch, {}ms",
569 path.display(),
570 id,
571 info.codec,
572 info.sample_rate,
573 info.channels,
574 info.duration_ms,
575 );
576
577 let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
578
579 self.timeline.reset();
580
581 let advance_state = self.shared_state.clone();
583 let decode_cursor = parking_lot::Mutex::new(Some(id));
584 let next_track = move || {
585 let current = decode_cursor.lock().take()?;
586 let next = advance_state.peek_next_ready_after(current);
587 if let Some((next_id, _)) = &next {
588 let mut guard = decode_cursor.lock();
589 *guard = Some(*next_id);
590 }
591 next
592 };
593
594 let decode_reader = stream_buf.reader();
599 let path_buf = path.to_path_buf();
600 let ext = path
601 .extension()
602 .and_then(|e| e.to_str())
603 .unwrap_or("")
604 .to_string();
605 let mut decode_hint = symphonia::core::formats::probe::Hint::new();
606 if !ext.is_empty() {
607 decode_hint.with_extension(&ext);
608 }
609 let first = buffer::SourceEntry {
610 id,
611 path: path_buf,
612 hint: decode_hint,
613 make_mss: Box::new(move || {
614 Ok(symphonia::core::io::MediaSourceStream::new(
615 Box::new(decode_reader),
616 Default::default(),
617 ))
618 }),
619 };
620
621 let cfg = crate::config::Config::load_or_default();
623 let rg_mode = cfg.playback.replaygain;
624 let pre_amp_db = cfg.playback.pre_amp_db;
625
626 let finish_tx = self.commands.tx.clone();
627 let (_stream_info, decode_handle) = buffer::start_decode(
628 first,
629 producer,
630 0,
631 move || {
632 let (next_id, next_path) = next_track()?;
633 Some(buffer::SourceEntry::from_file(next_id, next_path))
634 },
635 self.timeline.clone(),
636 Some(self.viz_buffer.clone()),
637 rg_mode,
638 pre_amp_db,
639 move || {
640 finish_tx.send(PlayerCommand::DecodeFinished).ok();
641 },
642 )?;
643
644 let engine = self.create_engine_for(&info, consumer)?;
645 engine.start()?;
646
647 self.shared_state.set_playback_state(PlaybackState::Playing);
648
649 self.active_playback = Some(ActivePlayback {
650 engine,
651 decode_handle,
652 });
653
654 Ok(())
655 }
656
657 pub fn seek(&mut self, position_ms: u64) {
660 let info = match self.shared_state.track_info() {
661 Some(info) => info,
662 None => return,
663 };
664 let id = info.id;
665 let path = info.path.clone();
666 let duration = info.duration_ms;
667
668 let mut clamped = if duration > 0 {
670 position_ms.min(duration.saturating_sub(500))
671 } else {
672 position_ms
673 };
674
675 if let Some(dl_frac) = self.shared_state.current_download_fraction() {
678 let max_ms = (dl_frac * duration as f64) as u64;
679 if max_ms > 5_000 {
680 clamped = clamped.min(max_ms - 5_000);
681 }
682 }
683
684 let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
685
686 if let Err(e) = self.start_playback(id, &path, clamped) {
687 log::error!("seek failed: {}", e);
688 return;
689 }
690
691 if was_paused {
692 self.pause();
693 }
694 }
695
696 pub fn next_track(&mut self) {
698 match self.shared_state.advance_cursor_loadable() {
699 Some(id) => self.play(id),
700 None => {
701 log::info!("no more tracks in playlist");
702 self.stop_playback_and_clear_state();
703 }
704 }
705 }
706
707 pub fn prev_track(&mut self) {
709 match self.shared_state.retreat_cursor() {
710 Some((id, path)) => {
711 if matches!(path.try_exists(), Ok(true)) {
712 if let Err(e) = self.start_playback(id, &path, 0) {
713 log::error!("prev track failed: {}", e);
714 }
715 } else {
716 log::warn!("prev track path doesn't exist: {}", path.display());
717 }
718 }
719 None => {
720 if let Some(info) = self.shared_state.track_info()
722 && let Err(e) = self.start_playback(info.id, &info.path, 0)
723 {
724 log::error!("restart failed: {}", e);
725 }
726 }
727 }
728 }
729
730 pub fn pause(&mut self) {
732 if let Some(ref playback) = self.active_playback {
733 if let Err(e) = playback.engine.stop() {
734 log::error!("pause failed: {}", e);
735 return;
736 }
737 self.shared_state.set_playback_state(PlaybackState::Paused);
738 }
739 }
740
741 pub fn resume(&mut self) {
743 if let Some(ref playback) = self.active_playback {
744 if let Err(e) = playback.engine.start() {
745 log::error!("resume failed: {}", e);
746 return;
747 }
748 self.shared_state.set_playback_state(PlaybackState::Playing);
749 }
750 }
751
752 pub fn stop(&mut self) {
754 self.shared_state.clear_playlist();
755 self.stop_playback_and_clear_state();
756 }
757
758 fn stop_engine(&mut self) {
765 let Some(playback) = self.active_playback.take() else {
766 return;
767 };
768 let ActivePlayback {
769 engine,
770 mut decode_handle,
771 } = playback;
772
773 let _ = engine.stop();
784 decode_handle.stop();
785 drop(engine);
786 }
787
788 fn stop_playback_and_clear_state(&mut self) {
790 self.stop_engine();
791 self.timeline.reset();
792 self.shared_state.set_playback_state(PlaybackState::Stopped);
793 self.shared_state.set_position_ms(0);
794 self.shared_state.set_track_info(None);
795 }
796
797 pub fn remove_from_playlist(&mut self, id: QueueItemId) {
804 let was_cursor = self.shared_state.is_cursor(id);
805 let resume_after = was_cursor
806 .then(|| self.shared_state.item_before(id))
807 .flatten();
808 self.shared_state.remove_item(id);
809 if was_cursor {
810 self.shared_state.set_cursor(resume_after);
811 self.next_track();
812 }
813 }
814
815 pub fn track_ready(&mut self, id: QueueItemId) {
818 self.shared_state.update_load_state(id, LoadState::Ready);
820
821 if !self.shared_state.is_cursor(id) {
822 return;
823 }
824
825 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
826 let current_track_id = self.shared_state.track_info().map(|t| t.id);
827
828 if is_playing && current_track_id == Some(id) {
829 log::info!(
832 "track_ready: download complete while streaming {:?}, refreshing metadata",
833 id
834 );
835 self.refresh_track_metadata(id);
836 return;
837 }
838
839 if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
841 log::info!("track_ready: starting playback for {:?}", id);
842 if let Err(e) = self.start_playback(id, &path, 0) {
843 log::error!("track_ready playback failed: {}", e);
844 }
845 }
846 }
847
848 pub fn track_stream_ready(&mut self, id: QueueItemId) {
851 if !self.shared_state.is_cursor(id) {
852 return;
853 }
854
855 let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
856 if is_playing {
857 return; }
859
860 match self.shared_state.item_playback_source(id) {
861 Some(PlaybackSource::Streaming {
862 path,
863 bytes_written,
864 total,
865 }) => {
866 log::info!(
867 "track_stream_ready: starting streaming playback for {:?}",
868 id
869 );
870 if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
871 log::error!("track_stream_ready streaming failed: {}", e);
872 }
873 }
874 Some(PlaybackSource::Ready(path)) => {
875 log::info!(
877 "track_stream_ready: track already ready, starting normal playback for {:?}",
878 id
879 );
880 if let Err(e) = self.start_playback(id, &path, 0) {
881 log::error!("track_stream_ready playback failed: {}", e);
882 }
883 }
884 None => {} }
886 }
887
888 fn refresh_track_metadata(&mut self, id: QueueItemId) {
892 use crate::index::metadata;
893
894 let path = match self.shared_state.item_path_if_ready(id) {
895 Some(p) => p,
896 None => return,
897 };
898
899 match metadata::read_metadata(&path) {
900 Ok(meta) => {
901 self.shared_state.update_item_metadata(
903 id,
904 meta.title,
905 meta.artist,
906 meta.album_artist.unwrap_or_default(),
907 meta.album,
908 meta.duration_ms.map(|d| d as u64),
909 );
910
911 if let Ok(stream_info) = buffer::probe_file(&path)
916 && let Some(current) = self.shared_state.track_info()
917 && current.id == id
918 && stream_info.duration_ms > current.duration_ms
919 {
920 log::info!(
921 "track_ready: duration corrected {}ms → {}ms",
922 current.duration_ms,
923 stream_info.duration_ms
924 );
925 self.shared_state.set_track_info(Some(TrackInfo {
926 duration_ms: stream_info.duration_ms,
927 ..current
928 }));
929 }
930
931 self.shared_state.signal_metadata_refresh();
933 log::info!("track_ready: metadata refreshed for {:?}", id);
934 }
935 Err(e) => {
936 log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
937 }
938 }
939 }
940
941 pub fn update_playback_state(&self) {
944 if self.active_playback.is_none() {
945 return;
946 }
947
948 if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
949 self.shared_state.set_position_ms(position_ms);
950
951 let current_id = self.shared_state.track_info().map(|t| t.id);
954 if current_id != Some(id) {
955 log::info!("timeline: now playing {:?}", id);
956 self.shared_state.set_track_info(Some(TrackInfo {
957 id,
958 path,
959 codec: info.codec,
960 sample_rate: info.sample_rate,
961 bit_depth: info.bit_depth,
962 bitrate_kbps: info.bitrate_kbps,
963 channels: info.channels,
964 duration_ms: info.duration_ms,
965 }));
966 self.shared_state.set_cursor(Some(id));
967 }
968 }
969 }
970
971 fn on_decode_finished(&mut self) {
978 log::info!("decode finished, checking for next track");
979 match self.shared_state.advance_cursor_loadable() {
980 Some(id) => self.play(id),
981 None => {
982 log::info!("no more tracks — stopping");
983 self.stop_playback_and_clear_state();
984 }
985 }
986 }
987
988 fn snapshot_for_undo(
992 &self,
993 ids: &[QueueItemId],
994 ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
995 self.shared_state
996 .items_before(ids)
997 .into_iter()
998 .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
999 .collect()
1000 }
1001
1002 fn push_undo(&mut self, entry: UndoEntry) {
1004 if let Some(ref mut batch) = self.batch_buffer {
1005 batch.push(entry);
1006 } else {
1007 self.undo_stack.push(entry);
1008 }
1009 }
1010
1011 pub fn process_command(&mut self, cmd: PlayerCommand) {
1013 match cmd {
1014 PlayerCommand::Play(id) => self.play(id),
1015 PlayerCommand::Pause => self.pause(),
1016 PlayerCommand::Resume => self.resume(),
1017 PlayerCommand::Stop => self.stop(),
1018 PlayerCommand::Seek(pos) => self.seek(pos),
1019 PlayerCommand::NextTrack => {
1020 let now = std::time::Instant::now();
1022 if now.duration_since(self.last_skip).as_millis() >= 150 {
1023 self.last_skip = now;
1024 self.next_track();
1025 }
1026 }
1027 PlayerCommand::PrevTrack => {
1028 let now = std::time::Instant::now();
1029 if now.duration_since(self.last_skip).as_millis() >= 150 {
1030 self.last_skip = now;
1031 self.prev_track();
1032 }
1033 }
1034 PlayerCommand::AddToPlaylist(items) => {
1035 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1036 self.shared_state.add_items(items);
1037 self.push_undo(UndoEntry::Added { ids });
1038 }
1039 PlayerCommand::UpdatePaths(updates) => {
1040 self.shared_state.update_paths(&updates);
1041 if let Some(info) = self.shared_state.track_info()
1042 && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
1043 {
1044 self.shared_state.set_track_info(Some(TrackInfo {
1045 path: new_path.clone(),
1046 ..info
1047 }));
1048 }
1049 }
1050 PlayerCommand::InsertInPlaylist { items, after } => {
1051 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1052 self.shared_state.insert_items_after(items, after);
1053 self.push_undo(UndoEntry::Inserted { ids });
1054 }
1055 PlayerCommand::ClearPlaylist => {
1056 self.stop_playback_and_clear_state();
1060 let (items, cursor) = self.shared_state.snapshot_playlist();
1061 self.shared_state.clear_playlist();
1062 self.push_undo(UndoEntry::Replaced { items, cursor });
1063 }
1064 PlayerCommand::ReplacePlaylist { items, start } => {
1065 self.stop_playback_and_clear_state();
1069 let (old_items, cursor) = self.shared_state.snapshot_playlist();
1070 self.shared_state.clear_playlist();
1071 self.push_undo(UndoEntry::Replaced {
1072 items: old_items,
1073 cursor,
1074 });
1075
1076 if items.is_empty() {
1077 return;
1078 }
1079 let start_id = items.get(start).unwrap_or(&items[0]).id;
1080 self.shared_state.add_items(items);
1081 self.play(start_id);
1082 }
1083 PlayerCommand::RemoveFromPlaylist(id) => {
1084 let item = self.shared_state.get_item(id);
1085 let after = self.shared_state.item_before(id);
1086 self.remove_from_playlist(id);
1087 if let Some(item) = item {
1088 self.push_undo(UndoEntry::Removed {
1089 items: vec![(Box::new(item), after)],
1090 });
1091 }
1092 }
1093 PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1094 let items_with_pos = self.snapshot_for_undo(&ids);
1098 let resume_after = match self.shared_state.cursor() {
1099 Some(cursor) if ids.contains(&cursor) => {
1100 Some(self.shared_state.surviving_item_before(cursor, &ids))
1101 }
1102 _ => None,
1103 };
1104
1105 self.shared_state.remove_items(&ids);
1106
1107 if let Some(resume_after) = resume_after {
1108 self.shared_state.set_cursor(resume_after);
1109 self.next_track();
1110 }
1111
1112 if !items_with_pos.is_empty() {
1113 self.push_undo(UndoEntry::Removed {
1114 items: items_with_pos,
1115 });
1116 }
1117 }
1118 PlayerCommand::MoveInPlaylist { id, target, after } => {
1119 let was_after = self.shared_state.item_before(id);
1120 self.shared_state.move_item(id, target, after);
1121 self.push_undo(UndoEntry::Moved { id, was_after });
1122 }
1123 PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1124 let entries = self.shared_state.items_before(&ids);
1125 self.shared_state.move_items(&ids, target, after);
1126 self.push_undo(UndoEntry::MovedBatch { entries });
1127 }
1128 PlayerCommand::TrackReady(id) => self.track_ready(id),
1129 PlayerCommand::DecodeFinished => self.on_decode_finished(),
1130 PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1131 PlayerCommand::Undo => self.execute_undo(),
1132 PlayerCommand::Redo => self.execute_redo(),
1133 PlayerCommand::BeginUndoBatch => {
1134 self.batch_buffer = Some(Vec::new());
1135 }
1136 PlayerCommand::EndUndoBatch => {
1137 if let Some(entries) = self.batch_buffer.take() {
1138 if entries.len() == 1 {
1139 self.undo_stack.push(entries.into_iter().next().unwrap());
1141 } else if !entries.is_empty() {
1142 self.undo_stack.push(UndoEntry::Batch(entries));
1143 }
1144 }
1145 }
1146 PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1147 PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1148 }
1149 }
1150
1151 fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1153 match entry {
1154 UndoEntry::Added { ids } => {
1155 let items_with_pos = self.snapshot_for_undo(&ids);
1157 self.shared_state.remove_items(&ids);
1158 Some(UndoEntry::Removed {
1159 items: items_with_pos,
1160 })
1161 }
1162 UndoEntry::Removed { items } => {
1163 let mut ids = Vec::with_capacity(items.len());
1165 for (item, after) in items {
1166 ids.push(item.id);
1167 self.shared_state.insert_item_at(*item, after);
1168 }
1169 Some(UndoEntry::Added { ids })
1170 }
1171 UndoEntry::Inserted { ids } => {
1172 let items_with_pos = self.snapshot_for_undo(&ids);
1174 self.shared_state.remove_items(&ids);
1175 Some(UndoEntry::Removed {
1176 items: items_with_pos,
1177 })
1178 }
1179 UndoEntry::Moved { id, was_after } => {
1180 let current_after = self.shared_state.item_before(id);
1181 self.shared_state.move_item_to(id, was_after);
1182 Some(UndoEntry::Moved {
1183 id,
1184 was_after: current_after,
1185 })
1186 }
1187 UndoEntry::MovedBatch { entries } => {
1188 let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1189 let current_positions = self.shared_state.items_before(&ids);
1190 self.shared_state.move_items_to(&entries);
1191 Some(UndoEntry::MovedBatch {
1192 entries: current_positions,
1193 })
1194 }
1195 UndoEntry::Replaced { items, cursor } => {
1196 let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1197 self.shared_state.restore_playlist(items, cursor);
1198 Some(UndoEntry::Replaced {
1199 items: current_items,
1200 cursor: current_cursor,
1201 })
1202 }
1203 UndoEntry::Batch(entries) => {
1204 let mut inverses = Vec::with_capacity(entries.len());
1206 for entry in entries.into_iter().rev() {
1207 if let Some(inverse) = self.apply_entry(entry) {
1208 inverses.push(inverse);
1209 }
1210 }
1211 inverses.reverse();
1212 Some(UndoEntry::Batch(inverses))
1213 }
1214 }
1215 }
1216
1217 fn execute_undo(&mut self) {
1219 let Some(entry) = self.undo_stack.pop_undo() else {
1220 return;
1221 };
1222 if let Some(inverse) = self.apply_entry(entry) {
1223 self.undo_stack.push_redo(inverse);
1224 }
1225 }
1226
1227 fn execute_redo(&mut self) {
1229 let Some(entry) = self.undo_stack.pop_redo() else {
1230 return;
1231 };
1232 if let Some(inverse) = self.apply_entry(entry) {
1233 self.undo_stack.push_undo_keep_redo(inverse);
1234 }
1235 }
1236
1237 pub fn run(&mut self) {
1239 use std::time::Duration;
1240
1241 let rx = self.commands.rx.clone();
1242 loop {
1243 match rx.recv_timeout(Duration::from_millis(50)) {
1245 Ok(cmd) => self.process_command(cmd),
1246 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1247 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1248 }
1249 self.update_playback_state();
1250 }
1251 self.stop();
1252 }
1253
1254 pub fn spawn() -> (
1257 Arc<SharedPlayerState>,
1258 Arc<PlaybackTimeline>,
1259 Arc<VizSnapshot>,
1260 crossbeam_channel::Sender<PlayerCommand>,
1261 ) {
1262 let mut player = Self::new();
1263 let state = player.shared_state();
1264 let timeline = player.timeline();
1265 let viz_snapshot = player.viz_snapshot();
1266 let tx = player.command_sender();
1267
1268 thread::Builder::new()
1269 .name("koan-player".into())
1270 .spawn(move || player.run())
1271 .expect("failed to spawn player thread");
1272
1273 (state, timeline, viz_snapshot, tx)
1274 }
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279 use super::*;
1280 use state::PlaylistItem;
1281 use std::path::PathBuf;
1282
1283 fn make_item(title: &str) -> PlaylistItem {
1284 PlaylistItem {
1285 id: QueueItemId::new(),
1286 db_id: None,
1287 path: PathBuf::from(format!("/music/{title}.flac")),
1288 title: title.to_string(),
1289 artist: String::new(),
1290 album_artist: String::new(),
1291 album: String::new(),
1292 year: None,
1293 codec: None,
1294 track_number: None,
1295 disc: None,
1296 duration_ms: None,
1297 load_state: LoadState::Ready,
1298 }
1299 }
1300
1301 fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1302 let (items, _) = player.shared_state.snapshot_playlist();
1303 items.iter().map(|i| i.id).collect()
1304 }
1305
1306 fn playlist_titles(player: &Player) -> Vec<String> {
1307 let (items, _) = player.shared_state.snapshot_playlist();
1308 items.iter().map(|i| i.title.clone()).collect()
1309 }
1310
1311 fn pending_item(title: &str) -> PlaylistItem {
1312 PlaylistItem {
1313 load_state: LoadState::Pending,
1314 ..make_item(title)
1315 }
1316 }
1317
1318 fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
1320 let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
1321 let ids = items.iter().map(|i| i.id).collect();
1322 player.process_command(PlayerCommand::AddToPlaylist(items));
1323 ids
1324 }
1325
1326 #[test]
1329 fn removing_the_playing_track_resumes_at_its_successor() {
1330 let mut player = Player::new();
1331 let ids = seed(&mut player, 5);
1332 player.shared_state.set_cursor(Some(ids[2]));
1333
1334 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
1335
1336 assert_eq!(
1337 player.shared_state.cursor(),
1338 Some(ids[3]),
1339 "playback must continue at the next track, not restart the queue"
1340 );
1341 assert_eq!(player.playback_starts, 1);
1342 }
1343
1344 #[test]
1345 fn removing_the_first_playing_track_resumes_at_the_new_first() {
1346 let mut player = Player::new();
1347 let ids = seed(&mut player, 3);
1348 player.shared_state.set_cursor(Some(ids[0]));
1349
1350 player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
1351
1352 assert_eq!(player.shared_state.cursor(), Some(ids[1]));
1353 }
1354
1355 #[test]
1356 fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
1357 let mut player = Player::new();
1358 let playing = make_item("playing");
1359 let waiting = pending_item("waiting");
1360 let later = make_item("later");
1361 let (playing_id, waiting_id) = (playing.id, waiting.id);
1362 player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
1363 player.shared_state.set_cursor(Some(playing_id));
1364
1365 player.process_command(PlayerCommand::DecodeFinished);
1366
1367 assert_eq!(
1368 player.shared_state.cursor(),
1369 Some(waiting_id),
1370 "the cursor parks on the track being fetched"
1371 );
1372 assert_eq!(
1373 player.playback_starts, 0,
1374 "nothing to play until its bytes land"
1375 );
1376
1377 player
1380 .shared_state
1381 .update_load_state(waiting_id, LoadState::Ready);
1382 player.process_command(PlayerCommand::TrackReady(waiting_id));
1383
1384 assert_eq!(player.playback_starts, 1);
1385 assert_eq!(player.shared_state.cursor(), Some(waiting_id));
1386 }
1387
1388 #[test]
1389 fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
1390 let mut player = Player::new();
1391 let ids = seed(&mut player, 5);
1392 player.shared_state.set_cursor(Some(ids[2]));
1393
1394 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
1395 ids[1], ids[2], ids[3],
1396 ]));
1397
1398 assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
1399 assert_eq!(player.shared_state.cursor(), Some(ids[4]));
1400 assert_eq!(
1401 player.playback_starts, 1,
1402 "one resume for the whole selection, not one per deleted track"
1403 );
1404 }
1405
1406 #[test]
1407 fn batch_delete_below_the_cursor_leaves_playback_alone() {
1408 let mut player = Player::new();
1409 let ids = seed(&mut player, 4);
1410 player.shared_state.set_cursor(Some(ids[0]));
1411
1412 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
1413
1414 assert_eq!(player.shared_state.cursor(), Some(ids[0]));
1415 assert_eq!(player.playback_starts, 0);
1416 }
1417
1418 #[test]
1419 fn undo_of_a_batch_delete_restores_the_original_order() {
1420 let mut player = Player::new();
1424 let items = vec![
1425 make_item("A"),
1426 make_item("B"),
1427 make_item("C"),
1428 make_item("D"),
1429 ];
1430 let (b_id, c_id) = (items[1].id, items[2].id);
1431 player.process_command(PlayerCommand::AddToPlaylist(items));
1432
1433 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
1434 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1435
1436 player.process_command(PlayerCommand::Undo);
1437 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1438 }
1439
1440 #[test]
1443 fn undo_add_removes_items() {
1444 let mut player = Player::new();
1445 let items = vec![make_item("A"), make_item("B")];
1446 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1447
1448 player.process_command(PlayerCommand::AddToPlaylist(items));
1449 assert_eq!(playlist_ids(&player), ids);
1450 assert!(player.undo_stack().can_undo());
1451
1452 player.process_command(PlayerCommand::Undo);
1453 assert!(playlist_ids(&player).is_empty());
1454 assert!(player.undo_stack().can_redo());
1455 }
1456
1457 #[test]
1458 fn redo_add_restores_items() {
1459 let mut player = Player::new();
1460 let items = vec![make_item("A"), make_item("B")];
1461
1462 player.process_command(PlayerCommand::AddToPlaylist(items));
1463 player.process_command(PlayerCommand::Undo);
1464 assert!(playlist_ids(&player).is_empty());
1465
1466 player.process_command(PlayerCommand::Redo);
1467 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1468 }
1469
1470 #[test]
1473 fn undo_remove_restores_item_at_position() {
1474 let mut player = Player::new();
1475 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1476 let b_id = items[1].id;
1477
1478 player.process_command(PlayerCommand::AddToPlaylist(items));
1479 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1480 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1481
1482 player.process_command(PlayerCommand::Undo);
1483 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1484 }
1485
1486 #[test]
1487 fn undo_remove_first_item() {
1488 let mut player = Player::new();
1489 let items = vec![make_item("A"), make_item("B")];
1490 let a_id = items[0].id;
1491
1492 player.process_command(PlayerCommand::AddToPlaylist(items));
1493 player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1494 assert_eq!(playlist_titles(&player), vec!["B"]);
1495
1496 player.process_command(PlayerCommand::Undo);
1497 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1498 }
1499
1500 #[test]
1501 fn undo_batch_remove_restores_all() {
1502 let mut player = Player::new();
1503 let items = vec![
1504 make_item("A"),
1505 make_item("B"),
1506 make_item("C"),
1507 make_item("D"),
1508 ];
1509 let b_id = items[1].id;
1510 let c_id = items[2].id;
1511
1512 player.process_command(PlayerCommand::AddToPlaylist(items));
1513 let version_before = player.shared_state.playlist_version();
1514 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
1515 assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1516 assert_eq!(
1519 player.shared_state.playlist_version(),
1520 version_before + 1,
1521 "batch removal must bump the playlist version exactly once"
1522 );
1523
1524 player.process_command(PlayerCommand::Undo);
1526 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1527 }
1528
1529 #[test]
1530 fn redo_batch_remove() {
1531 let mut player = Player::new();
1532 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1533 let a_id = items[0].id;
1534 let b_id = items[1].id;
1535
1536 player.process_command(PlayerCommand::AddToPlaylist(items));
1537 player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
1538 player.process_command(PlayerCommand::Undo);
1539 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1540
1541 player.process_command(PlayerCommand::Redo);
1542 assert_eq!(playlist_titles(&player), vec!["C"]);
1543 }
1544
1545 #[test]
1546 fn redo_remove() {
1547 let mut player = Player::new();
1548 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1549 let b_id = items[1].id;
1550
1551 player.process_command(PlayerCommand::AddToPlaylist(items));
1552 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1553 player.process_command(PlayerCommand::Undo);
1554 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1555
1556 player.process_command(PlayerCommand::Redo);
1557 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1558 }
1559
1560 #[test]
1563 fn undo_insert_removes_inserted_items() {
1564 let mut player = Player::new();
1565 let items = vec![make_item("A"), make_item("C")];
1566 let a_id = items[0].id;
1567
1568 player.process_command(PlayerCommand::AddToPlaylist(items));
1569
1570 let inserted = vec![make_item("B")];
1571 player.process_command(PlayerCommand::InsertInPlaylist {
1572 items: inserted,
1573 after: a_id,
1574 });
1575 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1576
1577 player.process_command(PlayerCommand::Undo);
1578 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1579 }
1580
1581 #[test]
1584 fn undo_move_restores_position() {
1585 let mut player = Player::new();
1586 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1587 let a_id = items[0].id;
1588 let c_id = items[2].id;
1589
1590 player.process_command(PlayerCommand::AddToPlaylist(items));
1591
1592 player.process_command(PlayerCommand::MoveInPlaylist {
1594 id: a_id,
1595 target: c_id,
1596 after: true,
1597 });
1598 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1599
1600 player.process_command(PlayerCommand::Undo);
1601 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1602 }
1603
1604 #[test]
1605 fn redo_move() {
1606 let mut player = Player::new();
1607 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1608 let a_id = items[0].id;
1609 let c_id = items[2].id;
1610
1611 player.process_command(PlayerCommand::AddToPlaylist(items));
1612 player.process_command(PlayerCommand::MoveInPlaylist {
1613 id: a_id,
1614 target: c_id,
1615 after: true,
1616 });
1617 player.process_command(PlayerCommand::Undo);
1618 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1619
1620 player.process_command(PlayerCommand::Redo);
1621 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1622 }
1623
1624 #[test]
1627 fn undo_batch_move() {
1628 let mut player = Player::new();
1629 let items = vec![
1630 make_item("A"),
1631 make_item("B"),
1632 make_item("C"),
1633 make_item("D"),
1634 ];
1635 let a_id = items[0].id;
1636 let b_id = items[1].id;
1637 let d_id = items[3].id;
1638
1639 player.process_command(PlayerCommand::AddToPlaylist(items));
1640
1641 player.process_command(PlayerCommand::MoveItemsInPlaylist {
1643 ids: vec![a_id, b_id],
1644 target: d_id,
1645 after: true,
1646 });
1647 assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
1648
1649 player.process_command(PlayerCommand::Undo);
1650 assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1651 }
1652
1653 #[test]
1656 fn undo_clear_restores_playlist() {
1657 let mut player = Player::new();
1658 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1659
1660 player.process_command(PlayerCommand::AddToPlaylist(items));
1661 player.process_command(PlayerCommand::ClearPlaylist);
1662 assert!(playlist_ids(&player).is_empty());
1663
1664 player.process_command(PlayerCommand::Undo);
1665 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1666 }
1667
1668 #[test]
1669 fn redo_clear() {
1670 let mut player = Player::new();
1671 let items = vec![make_item("A"), make_item("B")];
1672
1673 player.process_command(PlayerCommand::AddToPlaylist(items));
1674 player.process_command(PlayerCommand::ClearPlaylist);
1675 player.process_command(PlayerCommand::Undo);
1676 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1677
1678 player.process_command(PlayerCommand::Redo);
1679 assert!(playlist_ids(&player).is_empty());
1680 }
1681
1682 #[test]
1685 fn multiple_undos_in_sequence() {
1686 let mut player = Player::new();
1687
1688 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
1689 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1690 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
1691 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1692
1693 player.process_command(PlayerCommand::Undo);
1694 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1695
1696 player.process_command(PlayerCommand::Undo);
1697 assert_eq!(playlist_titles(&player), vec!["A"]);
1698
1699 player.process_command(PlayerCommand::Undo);
1700 assert!(playlist_ids(&player).is_empty());
1701 }
1702
1703 #[test]
1704 fn undo_redo_undo_cycle() {
1705 let mut player = Player::new();
1706 let items = vec![make_item("A"), make_item("B")];
1707
1708 player.process_command(PlayerCommand::AddToPlaylist(items));
1709 player.process_command(PlayerCommand::Undo);
1710 assert!(playlist_ids(&player).is_empty());
1711
1712 player.process_command(PlayerCommand::Redo);
1713 assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1714
1715 player.process_command(PlayerCommand::Undo);
1716 assert!(playlist_ids(&player).is_empty());
1717 }
1718
1719 #[test]
1720 fn new_action_clears_redo_stack() {
1721 let mut player = Player::new();
1722 let items = vec![make_item("A")];
1723
1724 player.process_command(PlayerCommand::AddToPlaylist(items));
1725 player.process_command(PlayerCommand::Undo);
1726 assert!(player.undo_stack().can_redo());
1727
1728 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1730 assert!(!player.undo_stack().can_redo());
1731 }
1732
1733 #[test]
1734 fn undo_on_empty_stack_is_noop() {
1735 let mut player = Player::new();
1736 player.process_command(PlayerCommand::Undo);
1737 assert!(playlist_ids(&player).is_empty());
1738 }
1739
1740 #[test]
1741 fn redo_on_empty_stack_is_noop() {
1742 let mut player = Player::new();
1743 player.process_command(PlayerCommand::Redo);
1744 assert!(playlist_ids(&player).is_empty());
1745 }
1746
1747 #[test]
1750 fn playback_commands_not_undoable() {
1751 let mut player = Player::new();
1752 player.process_command(PlayerCommand::Pause);
1753 player.process_command(PlayerCommand::Resume);
1754 player.process_command(PlayerCommand::NextTrack);
1755 player.process_command(PlayerCommand::PrevTrack);
1756 assert!(!player.undo_stack().can_undo());
1757 }
1758
1759 #[test]
1760 fn update_paths_not_undoable() {
1761 let mut player = Player::new();
1762 let items = vec![make_item("A")];
1763 let id = items[0].id;
1764 player.process_command(PlayerCommand::AddToPlaylist(items));
1765
1766 let undo_count = player.undo_stack().undo_len();
1767 player.process_command(PlayerCommand::UpdatePaths(vec![(
1768 id,
1769 PathBuf::from("/new/path.flac"),
1770 )]));
1771 assert_eq!(player.undo_stack().undo_len(), undo_count);
1772 }
1773
1774 #[test]
1777 fn add_remove_undo_undo_produces_original() {
1778 let mut player = Player::new();
1779 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1780 let b_id = items[1].id;
1781 let original_titles = vec!["A", "B", "C"];
1782
1783 player.process_command(PlayerCommand::AddToPlaylist(items));
1784 player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1785 assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1786
1787 player.process_command(PlayerCommand::Undo);
1789 assert_eq!(playlist_titles(&player), original_titles);
1790
1791 player.process_command(PlayerCommand::Undo);
1793 assert!(playlist_ids(&player).is_empty());
1794 }
1795
1796 #[test]
1797 fn interleaved_adds_and_moves_undo() {
1798 let mut player = Player::new();
1799 let items = vec![make_item("A"), make_item("B"), make_item("C")];
1800 let a_id = items[0].id;
1801 let c_id = items[2].id;
1802
1803 player.process_command(PlayerCommand::AddToPlaylist(items));
1804
1805 player.process_command(PlayerCommand::MoveInPlaylist {
1807 id: a_id,
1808 target: c_id,
1809 after: true,
1810 });
1811 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1812
1813 player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
1815 assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
1816
1817 player.process_command(PlayerCommand::Undo);
1819 assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1820
1821 player.process_command(PlayerCommand::Undo);
1823 assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1824 }
1825
1826 #[test]
1831 fn stop_engine_drops_engine_synchronously() {
1832 use std::sync::atomic::AtomicBool;
1833
1834 struct MockEngine {
1835 dropped: Arc<AtomicBool>,
1836 }
1837 impl AudioEngineHandle for MockEngine {
1838 fn start(&self) -> Result<(), BackendError> {
1839 Ok(())
1840 }
1841 fn stop(&self) -> Result<(), BackendError> {
1842 Ok(())
1843 }
1844 fn is_running(&self) -> bool {
1845 false
1846 }
1847 }
1848 impl Drop for MockEngine {
1849 fn drop(&mut self) {
1850 self.dropped.store(true, Ordering::SeqCst);
1851 }
1852 }
1853
1854 let dropped = Arc::new(AtomicBool::new(false));
1855
1856 let stop_flag = Arc::new(AtomicBool::new(false));
1858 let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
1859
1860 let mut player = Player::new();
1861 player.active_playback = Some(ActivePlayback {
1862 engine: Box::new(MockEngine {
1863 dropped: dropped.clone(),
1864 }),
1865 decode_handle,
1866 });
1867
1868 player.stop_engine();
1869
1870 assert!(
1874 dropped.load(Ordering::SeqCst),
1875 "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
1876 );
1877 }
1878
1879 struct StuckBackend {
1884 rate: f64,
1885 asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
1886 }
1887
1888 struct NullEngine;
1889 impl AudioEngineHandle for NullEngine {
1890 fn start(&self) -> Result<(), BackendError> {
1891 Ok(())
1892 }
1893 fn stop(&self) -> Result<(), BackendError> {
1894 Ok(())
1895 }
1896 fn is_running(&self) -> bool {
1897 false
1898 }
1899 }
1900
1901 impl AudioBackend for StuckBackend {
1902 fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
1903 Ok(vec![self.default_device()?])
1904 }
1905 fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
1906 Ok(backend::DeviceInfo {
1907 name: "Stuck DAC".into(),
1908 sample_rates: vec![self.rate],
1909 platform_id: 0,
1910 })
1911 }
1912 fn supported_sample_rates(
1913 &self,
1914 _device: &backend::DeviceInfo,
1915 ) -> Result<Vec<f64>, BackendError> {
1916 Ok(vec![self.rate])
1917 }
1918 fn get_device_sample_rate(
1919 &self,
1920 _device: &backend::DeviceInfo,
1921 ) -> Result<f64, BackendError> {
1922 Ok(self.rate)
1923 }
1924 fn set_device_sample_rate(
1925 &self,
1926 _device: &backend::DeviceInfo,
1927 rate: f64,
1928 ) -> Result<f64, BackendError> {
1929 Err(BackendError::UnsupportedSampleRate(rate))
1930 }
1931 fn create_engine(
1932 &self,
1933 _device: &backend::DeviceInfo,
1934 sample_rate: f64,
1935 channels: u32,
1936 _consumer: rtrb::Consumer<f32>,
1937 _samples_played: Arc<AtomicU64>,
1938 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
1939 *self.asked.lock().unwrap() = Some((sample_rate, channels));
1940 Ok(Box::new(NullEngine))
1941 }
1942 }
1943
1944 fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
1945 let asked = Arc::new(std::sync::Mutex::new(None));
1946 let mut player = Player::new();
1947 player.backend = Box::new(StuckBackend {
1948 rate: device_rate,
1949 asked: asked.clone(),
1950 });
1951
1952 let info = buffer::StreamInfo {
1953 codec: "MP3".into(),
1954 sample_rate: source_rate,
1955 channels,
1956 bit_depth: Some(16),
1957 bitrate_kbps: None,
1958 duration_ms: 1000,
1959 };
1960 let (_producer, consumer) = rtrb::RingBuffer::new(16);
1961 player
1962 .create_engine_for(&info, consumer)
1963 .expect("engine creation should succeed");
1964 let asked = *asked.lock().unwrap();
1965 asked.expect("engine was never created")
1966 }
1967
1968 #[test]
1969 fn engine_uses_source_rate_when_device_refuses_switch() {
1970 assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
1973 assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
1974 }
1975
1976 #[test]
1977 fn engine_uses_source_channel_count() {
1978 assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
1979 }
1980}