Skip to main content

koan_core/player/
mod.rs

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
25/// Ring buffer size in samples. ~1s at 192kHz stereo.
26pub(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
36/// The player controller. Owns the audio pipeline and processes commands.
37pub 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    /// Background FFT analysis thread. Held for its lifetime; dropped on Player drop.
45    _viz_analyzer: VizAnalyzer,
46    undo_stack: UndoStack,
47    /// When Some, undo entries are collected into this buffer instead of pushed
48    /// directly onto the undo stack. Flushed on EndUndoBatch.
49    batch_buffer: Option<Vec<UndoEntry>>,
50    /// Configured output device name. None = system default.
51    output_device_name: Option<String>,
52    /// Platform audio backend (CoreAudio on macOS, cpal on Linux).
53    backend: Box<dyn AudioBackend>,
54    /// Debounce: timestamp of last NextTrack/PrevTrack to suppress key repeat.
55    last_skip: std::time::Instant,
56    /// Writes plays away from this thread. None when there is no database to
57    /// write to, and in tests, which must not touch the real library.
58    history: Option<PlayRecorder>,
59    /// How much of the current track has been heard so far.
60    in_flight: Option<InFlight>,
61    /// Playback sessions started — lets tests assert how many engine restarts
62    /// an operation costs.
63    #[cfg(test)]
64    playback_starts: usize,
65}
66
67/// Holds the resources for an active playback session.
68struct 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    /// Get a clone of the shared state for UI reads.
113    pub fn shared_state(&self) -> Arc<SharedPlayerState> {
114        self.shared_state.clone()
115    }
116
117    /// Get the playback timeline for UI reads.
118    pub fn timeline(&self) -> Arc<PlaybackTimeline> {
119        self.timeline.clone()
120    }
121
122    /// Get the visualization buffer for the TUI.
123    pub fn viz_buffer(&self) -> Arc<VizBuffer> {
124        self.viz_buffer.clone()
125    }
126
127    /// Get the shared analysis snapshot for the TUI.
128    /// The analysis thread writes here; the UI thread reads a clone each frame.
129    pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
130        self.viz_snapshot.clone()
131    }
132
133    /// Access undo stack (for tests and UI state queries).
134    pub fn undo_stack(&self) -> &UndoStack {
135        &self.undo_stack
136    }
137
138    /// Create an audio engine for a stream, switching the output device to the
139    /// source rate first so output is bit-perfect.
140    ///
141    /// The engine is always configured with the source's own rate and channel
142    /// count — the format the decode thread writes into the ring buffer. A
143    /// device that cannot take the requested rate (MPEG-2/2.5 MP3 rates are
144    /// commonly refused) resamples instead of playing at the wrong speed.
145    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    /// Resolve the output device: use configured device name if set,
186    /// falling back to system default if not set or if the named device is unavailable.
187    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    /// Switch the output device. Persists to config and restarts the engine
208    /// on the current track if playing.
209    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        // Persist to config.toml (not the merged config — avoids leaking secrets).
214        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    /// Clear the configured output device, reverting to system default.
224    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    /// If a track is currently playing or paused, restart playback at the
238    /// current position (e.g. after switching output devices). Preserves pause state.
239    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    /// Get the current output device name (if configured).
254    pub fn output_device_name(&self) -> Option<&str> {
255        self.output_device_name.as_deref()
256    }
257
258    /// Get a command sender for the UI layer.
259    pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
260        self.commands.tx.clone()
261    }
262
263    /// Play a specific item in the playlist by ID.
264    /// Sets cursor, starts playback if Ready or streaming-ready, otherwise waits for TrackReady.
265    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                    // The cursor stays here, so TrackReady starts it once the
281                    // whole file has landed.
282                    log::error!("streaming play failed, waiting for full download: {}", e);
283                }
284            }
285            None => {
286                // Item not ready — stop current playback, wait for TrackReady.
287                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    /// Internal: start playback of a file.
295    ///
296    /// A failure leaves the player cleanly stopped. Displaying a track that no
297    /// engine is playing freezes the position and makes the transport lie.
298    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        // Set track_info + position immediately so the UI never sees a gap.
326        // For seeks, this keeps the bar at the target position instead of
327        // flashing to 0 while the new timeline spins up.
328        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        // Reset timeline for new playback session and start decode.
358        self.timeline.reset();
359
360        // Gapless lookahead: the decode thread maintains its own cursor
361        // (separate from the UI cursor) so it can look ahead through the
362        // playlist without affecting what the UI shows as "now playing".
363        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        // Load ReplayGain config for this playback session.
376        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    /// Internal: start streaming playback from a partially-downloaded file.
410    ///
411    /// Creates a StreamBuffer and a pump thread that reads from the on-disk partial
412    /// file as bytes become available (tracked via `bytes_written`). The decode thread
413    /// reads from a StreamingSource backed by that buffer, blocking briefly when it
414    /// catches up to the write head.
415    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        // Create a StreamBuffer with known total length.
439        let stream_buf = streaming::StreamBuffer::new(if total > 0 { Some(total) } else { None });
440
441        // Spawn a pump thread: reads bytes from the on-disk partial file as they
442        // become available (per bytes_written) and pushes them into StreamBuffer.
443        // This bridges the disk-based download with StreamingSource's in-memory design.
444        // The playlist item's path points to the .part file during download, so the
445        // pump opens the correct file. After download completes, the .part is renamed
446        // to the final path and the item path is updated — but the pump's open FD
447        // remains valid (Unix rename semantics).
448        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                /// No new bytes for this long and the download is treated as dead.
460                /// `bytes_written` simply stops advancing when one dies, so without
461                /// a deadline the pump spins and the decode thread parks forever.
462                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                    // Nothing left to read the bytes, so nothing left to write them for.
477                    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                        // The download landed: drain to EOF rather than trusting
487                        // `total`, which is 0 for a chunked transfer.
488                        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; // Download complete.
508                        }
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                            // File data may lag behind bytes_written (OS buffer flush timing).
525                            // Only treat as true EOF if we've pumped all expected data.
526                            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                            // Data not yet visible on disk — back off and retry.
534                            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        // Probe via a streaming reader — blocks (via condvar) until enough header data arrives.
554        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        // Gapless lookahead after streaming: next track uses normal file path.
593        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        // Decode using a fresh StreamingSource reader — reads from the StreamBuffer
606        // that the pump thread feeds. The decode thread blocks when it catches up to
607        // the write head, resuming as more data arrives.
608        // Build a SourceEntry using a fresh StreamingSource reader for the decode thread.
609        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        // Load ReplayGain config for this streaming session.
633        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    /// Seek within the current track. Clamps to just before the end to avoid
669    /// accidentally skipping. Preserves pause state.
670    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        // Clamp to just before the end so we don't skip to the next track.
680        let mut clamped = if duration > 0 {
681            position_ms.min(duration.saturating_sub(500))
682        } else {
683            position_ms
684        };
685
686        // Clamp to downloaded portion if streaming to prevent seeking into
687        // data that hasn't arrived yet.
688        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    /// Skip to next track in playlist.
708    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    /// Go back to previous track.
719    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                // No previous track — restart current from the beginning.
732                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    /// Pause playback.
742    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    /// Resume playback.
753    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    /// Stop playback and clear playlist.
764    pub fn stop(&mut self) {
765        self.shared_state.clear_playlist();
766        self.stop_playback_and_clear_state();
767    }
768
769    /// Stop the audio engine and decode thread without touching shared state.
770    ///
771    /// The engine is stopped synchronously (silence begins immediately), but
772    /// the heavy teardown (decode thread join + AudioUnit dispose) is moved to
773    /// a background thread so the player command loop never blocks — preventing
774    /// UI freezes when CoreAudio or the decode thread is slow to shut down.
775    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        // Stop audio output first, then get the decode thread gone *before* the
785        // engine is dropped.
786        //
787        // The old order signalled the decode thread and dropped the engine
788        // immediately, joining the thread afterwards on a background thread —
789        // so the engine's teardown ran while the decode thread was still alive
790        // and still writing into the ring buffer that the render callback
791        // reads. Tearing CoreAudio down underneath a live producer is exactly
792        // the shape of the end-of-queue crash (#89), and the overlap buys
793        // nothing: `stop()` has already silenced the output.
794        let _ = engine.stop();
795        decode_handle.stop();
796        drop(engine);
797    }
798
799    /// Full stop: tear down engine + clear all display state.
800    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    /// Remove a track from the playlist. If it was the cursor, resume at the
810    /// track that followed it.
811    ///
812    /// `remove_item` clears the cursor, and an unset cursor means "start from the
813    /// top" — so the successor is pinned down by parking the cursor on the removed
814    /// track's predecessor first. `None` is correct only when it was the first item.
815    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    /// A download finished — if cursor is waiting on this item, start playback.
828    /// If already streaming this item, trigger progressive metadata enhancement.
829    pub fn track_ready(&mut self, id: QueueItemId) {
830        // Mark as Ready (download thread already did this, but be safe).
831        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            // Already streaming this track — download just finished.
842            // Trigger progressive enhancement: re-read full lofty metadata and update state.
843            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        // Cursor is on this item but not yet playing — start playback now.
852        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    /// Called when enough data has been buffered for streaming playback.
861    /// If the cursor is waiting on this track and nothing is playing, start streaming.
862    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; // Already playing something — don't interrupt.
870        }
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                // Download finished between threshold and now — just play normally.
888                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 => {} // Not enough data yet — wait.
897        }
898    }
899
900    /// Re-read full lofty metadata for a track after its download completes.
901    /// Updates the playlist item's tags and track_info with complete metadata.
902    /// Called from track_ready() when a streaming track finishes downloading.
903    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                // Update playlist item with full lofty tags (title, artist, album, duration).
914                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                // Re-probe the complete file for accurate duration + stream info.
924                // The initial probe was done on partial streaming data and may have
925                // underestimated duration, causing premature seek clamping or wrong
926                // progress bar display.
927                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                // Signal UI to re-read cover art and update souvlaki media controls.
944                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    /// Poll the timeline and update shared state with current track/position.
954    /// Called from the command loop on each tick.
955    /// The needle has moved to `id`. Close out the outgoing track and write
956    /// the new one to history straight away, so history reads in play order
957    /// even for a track that is skipped a moment later.
958    ///
959    /// A seek restarts playback of the same track, so identity is checked
960    /// rather than closing unconditionally — otherwise scrubbing around a
961    /// track would enter it into history once per seek.
962    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    /// Tell history how long the current track was heard for. Returns what was
975    /// reported, which is how the tests see it.
976    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            // A gapless transition moves the needle without anything on this
997            // thread having asked it to, so the play is banked from here.
998            self.on_track_changed(id);
999            if let Some(f) = self.in_flight.as_mut() {
1000                f.advance(position_ms);
1001            }
1002
1003            // Update track_info + cursor if the timeline shows a different track
1004            // (gapless transition happened).
1005            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    /// Decode thread naturally finished (playlist exhausted or error).
1024    /// Advance to the next playable track; otherwise stop cleanly.
1025    ///
1026    /// A track that has not finished downloading parks the cursor on it, so its
1027    /// `TrackReady`/`TrackStreamReady` resumes the queue instead of being
1028    /// discarded as "not the cursor".
1029    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    /// Snapshot items with their predecessors for an undo of "these were removed".
1041    /// In playlist order, so undo re-inserts each item after a predecessor that
1042    /// is already back in place.
1043    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    /// Route an undo entry to the batch buffer (if batching) or the undo stack.
1055    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    /// Process a single command.
1064    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                // Debounce: suppress key repeat from terminal (150ms window).
1073                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                // Stop engine + clear display state WITHOUT touching the playlist,
1109                // then snapshot, then clear. This avoids the race where stop()
1110                // would clear the playlist before we capture it for undo.
1111                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                // Same order as ClearPlaylist: stop and clear display state
1118                // before snapshotting, or the snapshot captures an already
1119                // emptied playlist and undo restores nothing.
1120                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                // Snapshot before removing anything, and resolve the resume point
1147                // once: removing one at a time would restart the engine for every
1148                // deleted track that the cursor lands on along the way.
1149                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                        // Single entry — push directly, no wrapping.
1192                        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    /// Apply an undo/redo entry: mutate the playlist and return the inverse entry.
1204    fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1205        match entry {
1206            UndoEntry::Added { ids } => {
1207                // Undo of "items were added": snapshot them with positions, then remove.
1208                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                // Undo of "items were removed": re-insert each at its position.
1216                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                // Same as Added — snapshot positions, remove items.
1225                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                // Apply entries in reverse order, collect inverses.
1257                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    /// Execute an undo operation, pushing the inverse onto the redo stack.
1270    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    /// Execute a redo operation, pushing the inverse onto the undo stack.
1280    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    /// Run the command loop. Blocks until the sender is dropped.
1290    pub fn run(&mut self) {
1291        use std::time::Duration;
1292
1293        let rx = self.commands.rx.clone();
1294        loop {
1295            // Poll with timeout so we update position even without commands.
1296            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    /// Spawn the player on a background thread, returning the shared state,
1307    /// timeline, visualization snapshot, and command sender.
1308    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    /// Build `n` ready items, add them, and return their IDs.
1372    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    // --- cursor transitions ---
1380
1381    /// Feed the player a track's worth of playback ticks, as the 50ms poll would.
1382    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); // the position the needle landed on
1386        }
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        // The item is not in a playlist here, so there is no db_id to find.
1399        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        // A seek restarts playback of the same item.
1458        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        // The download completes. Because the cursor is parked here, the
1542        // TrackReady actually reaches the player and the queue resumes.
1543        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        // The TUI collects a selection from a HashSet, so the IDs arrive in
1585        // arbitrary order — scrambled here so a snapshot that trusts that order
1586        // re-inserts C before B and lands it at the end of the playlist.
1587        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    // --- AddToPlaylist undo/redo ---
1605
1606    #[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    // --- RemoveFromPlaylist undo/redo ---
1635
1636    #[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        // One bump for the whole batch. Bumping per item is what made clearing
1681        // a large queue crawl, and every bump wakes every client watching.
1682        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        // Single undo restores both
1689        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    // --- InsertInPlaylist undo/redo ---
1725
1726    #[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    // --- MoveInPlaylist undo/redo ---
1746
1747    #[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        // Move A after C: [B, C, A]
1757        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    // --- MoveItemsInPlaylist (batch) undo/redo ---
1789
1790    #[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        // Move A,B after D: [C, D, A, B]
1806        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    // --- ClearPlaylist undo/redo ---
1818
1819    #[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    // --- Multi-step undo/redo ---
1847
1848    #[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        // New action should clear redo
1893        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    // --- Non-undoable commands don't push entries ---
1912
1913    #[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    // --- Complex scenarios ---
1939
1940    #[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        // Undo remove → back to A, B, C
1952        player.process_command(PlayerCommand::Undo);
1953        assert_eq!(playlist_titles(&player), original_titles);
1954
1955        // Undo add → empty
1956        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        // Move A after C: [B, C, A]
1970        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        // Add D: [B, C, A, D]
1978        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
1979        assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
1980
1981        // Undo add D: [B, C, A]
1982        player.process_command(PlayerCommand::Undo);
1983        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1984
1985        // Undo move: [A, B, C]
1986        player.process_command(PlayerCommand::Undo);
1987        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1988    }
1989
1990    /// Regression test for GitHub #89: AudioEngine must be dropped synchronously
1991    /// in stop_engine() before the caller changes sample rates. If the engine is
1992    /// dropped on a background thread, CoreAudio's internal buffer list can be
1993    /// freed while AudioUnitUninitialize is still tearing it down → crash.
1994    #[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        // Build a minimal decode handle that won't block.
2021        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        // The engine must already be dropped when stop_engine returns.
2035        // If this fails, the engine was moved to a background thread — the
2036        // exact race condition that causes the #89 crash.
2037        assert!(
2038            dropped.load(Ordering::SeqCst),
2039            "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
2040        );
2041    }
2042
2043    // --- Engine format matches the decoded PCM ---
2044
2045    /// Backend pinned to one sample rate that refuses every switch, recording
2046    /// the format the engine is asked for.
2047    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        // MPEG-2 MP3 rates are routinely rejected by output devices. The engine
2135        // must still be told the rate the PCM actually is.
2136        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}