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        let settled = if (device_rate - source_rate).abs() > 0.1 {
155            log::info!(
156                "switching device sample rate: {}Hz → {}Hz",
157                device_rate,
158                source_rate
159            );
160            match self.backend.set_device_sample_rate(&device, source_rate) {
161                Ok(rate) => rate,
162                Err(e) => {
163                    log::warn!("failed to set device sample rate: {}", e);
164                    device_rate
165                }
166            }
167        } else {
168            device_rate
169        };
170
171        if (settled - source_rate).abs() > 0.1 {
172            log::warn!(
173                "device stayed at {}Hz (wanted {}Hz) — output is resampled, not bit-perfect",
174                settled,
175                source_rate
176            );
177        }
178
179        // The front ends compare this against the source rate to say whether
180        // anything had to resample. A log line was the only place it went.
181        self.shared_state
182            .set_output_sample_rate(settled.round() as u32);
183
184        Ok(self.backend.create_engine(
185            &device,
186            source_rate,
187            info.channels as u32,
188            consumer,
189            self.timeline.samples_played_counter(),
190        )?)
191    }
192
193    /// Resolve the output device: use configured device name if set,
194    /// falling back to system default if not set or if the named device is unavailable.
195    fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
196        if let Some(ref name) = self.output_device_name {
197            match self.backend.list_devices() {
198                Ok(devices) => {
199                    if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
200                        return Ok(dev);
201                    }
202                    log::warn!(
203                        "configured output device '{}' not found, falling back to default",
204                        name,
205                    );
206                }
207                Err(e) => {
208                    log::warn!("failed to list devices while resolving '{}': {}", name, e);
209                }
210            }
211        }
212        Ok(self.backend.default_device()?)
213    }
214
215    /// Switch the output device. Persists to config and restarts the engine
216    /// on the current track if playing.
217    pub fn set_output_device(&mut self, name: String) {
218        log::info!("switching output device to: {}", name);
219        self.output_device_name = Some(name.clone());
220
221        // Persist to config.toml (not the merged config — avoids leaking secrets).
222        if let Err(e) = crate::config::Config::update_base(|cfg| {
223            cfg.playback.output_device = Some(name);
224        }) {
225            log::error!("failed to save output device config: {}", e);
226        }
227
228        self.restart_on_current_track();
229    }
230
231    /// Clear the configured output device, reverting to system default.
232    pub fn clear_output_device(&mut self) {
233        log::info!("reverting to system default output device");
234        self.output_device_name = None;
235
236        if let Err(e) = crate::config::Config::update_base(|cfg| {
237            cfg.playback.output_device = None;
238        }) {
239            log::error!("failed to save output device config: {}", e);
240        }
241
242        self.restart_on_current_track();
243    }
244
245    /// If a track is currently playing or paused, restart playback at the
246    /// current position (e.g. after switching output devices). Preserves pause state.
247    fn restart_on_current_track(&mut self) {
248        if let Some(info) = self.shared_state.track_info() {
249            let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
250            let position_ms = self.shared_state.position_ms();
251            if let Err(e) = self.start_playback(info.id, &info.path, position_ms) {
252                log::error!("failed to restart playback on device switch: {}", e);
253                return;
254            }
255            if was_paused {
256                self.pause();
257            }
258        }
259    }
260
261    /// Get the current output device name (if configured).
262    pub fn output_device_name(&self) -> Option<&str> {
263        self.output_device_name.as_deref()
264    }
265
266    /// Get a command sender for the UI layer.
267    pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
268        self.commands.tx.clone()
269    }
270
271    /// Play a specific item in the playlist by ID.
272    /// Sets cursor, starts playback if Ready or streaming-ready, otherwise waits for TrackReady.
273    pub fn play(&mut self, id: QueueItemId) {
274        self.shared_state.set_cursor(Some(id));
275
276        match self.shared_state.item_playback_source(id) {
277            Some(PlaybackSource::Ready(path)) => {
278                if let Err(e) = self.start_playback(id, &path, 0) {
279                    log::error!("play failed: {}", e);
280                }
281            }
282            Some(PlaybackSource::Streaming {
283                path,
284                bytes_written,
285                total,
286            }) => {
287                if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
288                    // The cursor stays here, so TrackReady starts it once the
289                    // whole file has landed.
290                    log::error!("streaming play failed, waiting for full download: {}", e);
291                }
292            }
293            None => {
294                // Item not ready — stop current playback, wait for TrackReady.
295                self.stop_engine();
296                self.shared_state.set_playback_state(PlaybackState::Stopped);
297                log::info!("play: item {:?} not ready, waiting for TrackReady", id);
298            }
299        }
300    }
301
302    /// Internal: start playback of a file.
303    ///
304    /// A failure leaves the player cleanly stopped. Displaying a track that no
305    /// engine is playing freezes the position and makes the transport lie.
306    fn start_playback(
307        &mut self,
308        id: QueueItemId,
309        path: &Path,
310        seek_ms: u64,
311    ) -> Result<(), PlayerError> {
312        #[cfg(test)]
313        {
314            self.playback_starts += 1;
315        }
316        let result = self.open_playback(id, path, seek_ms);
317        if result.is_err() {
318            self.stop_playback_and_clear_state();
319        }
320        result
321    }
322
323    fn open_playback(
324        &mut self,
325        id: QueueItemId,
326        path: &Path,
327        seek_ms: u64,
328    ) -> Result<(), PlayerError> {
329        self.stop_engine();
330
331        let info = buffer::probe_file(path)?;
332
333        // Set track_info + position immediately so the UI never sees a gap.
334        // For seeks, this keeps the bar at the target position instead of
335        // flashing to 0 while the new timeline spins up.
336        self.shared_state.set_track_info(Some(TrackInfo {
337            id,
338            path: path.to_path_buf(),
339            codec: info.codec.clone(),
340            sample_rate: info.sample_rate,
341            bit_depth: info.bit_depth,
342            bitrate_kbps: info.bitrate_kbps,
343            channels: info.channels,
344            duration_ms: info.duration_ms,
345        }));
346        self.shared_state.set_position_ms(seek_ms);
347        self.on_track_changed(id);
348        log::info!(
349            "playing: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
350            path.display(),
351            id,
352            info.codec,
353            info.sample_rate,
354            info.channels,
355            info.duration_ms,
356            if seek_ms > 0 {
357                format!(" @{}ms", seek_ms)
358            } else {
359                String::new()
360            }
361        );
362
363        let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
364
365        // Reset timeline for new playback session and start decode.
366        self.timeline.reset();
367
368        // Gapless lookahead: the decode thread maintains its own cursor
369        // (separate from the UI cursor) so it can look ahead through the
370        // playlist without affecting what the UI shows as "now playing".
371        let advance_state = self.shared_state.clone();
372        let decode_cursor = parking_lot::Mutex::new(Some(id));
373        let next_track = move || {
374            let current = decode_cursor.lock().take()?;
375            let next = advance_state.peek_next_ready_after(current);
376            if let Some((next_id, _)) = &next {
377                let mut guard = decode_cursor.lock();
378                *guard = Some(*next_id);
379            }
380            next
381        };
382
383        // Load ReplayGain config for this playback session.
384        let cfg = crate::config::Config::load_or_default();
385        let rg_mode = cfg.playback.replaygain;
386        let pre_amp_db = cfg.playback.pre_amp_db;
387
388        let finish_tx = self.commands.tx.clone();
389        let (_stream_info, decode_handle) = buffer::start_decode_file(
390            id,
391            path,
392            producer,
393            seek_ms,
394            next_track,
395            self.timeline.clone(),
396            Some(self.viz_buffer.clone()),
397            rg_mode,
398            pre_amp_db,
399            move || {
400                finish_tx.send(PlayerCommand::DecodeFinished).ok();
401            },
402        )?;
403
404        let engine = self.create_engine_for(&info, consumer)?;
405        engine.start()?;
406
407        self.shared_state.set_playback_state(PlaybackState::Playing);
408
409        self.active_playback = Some(ActivePlayback {
410            engine,
411            decode_handle,
412        });
413
414        Ok(())
415    }
416
417    /// Internal: start streaming playback from a partially-downloaded file.
418    ///
419    /// Creates a StreamBuffer and a pump thread that reads from the on-disk partial
420    /// file as bytes become available (tracked via `bytes_written`). The decode thread
421    /// reads from a StreamingSource backed by that buffer, blocking briefly when it
422    /// catches up to the write head.
423    fn start_streaming_playback(
424        &mut self,
425        id: QueueItemId,
426        path: &Path,
427        bytes_written: Arc<AtomicU64>,
428        total: u64,
429    ) -> Result<(), PlayerError> {
430        let result = self.open_streaming_playback(id, path, bytes_written, total);
431        if result.is_err() {
432            self.stop_playback_and_clear_state();
433        }
434        result
435    }
436
437    fn open_streaming_playback(
438        &mut self,
439        id: QueueItemId,
440        path: &Path,
441        bytes_written: Arc<AtomicU64>,
442        total: u64,
443    ) -> Result<(), PlayerError> {
444        self.stop_engine();
445
446        // Create a StreamBuffer with known total length.
447        let stream_buf = streaming::StreamBuffer::new(if total > 0 { Some(total) } else { None });
448
449        // Spawn a pump thread: reads bytes from the on-disk partial file as they
450        // become available (per bytes_written) and pushes them into StreamBuffer.
451        // This bridges the disk-based download with StreamingSource's in-memory design.
452        // The playlist item's path points to the .part file during download, so the
453        // pump opens the correct file. After download completes, the .part is renamed
454        // to the final path and the item path is updated — but the pump's open FD
455        // remains valid (Unix rename semantics).
456        let pump_path = path.to_path_buf();
457        let pump_buf = stream_buf.clone();
458        let pump_written = bytes_written.clone();
459        let pump_state = self.shared_state.clone();
460        thread::Builder::new()
461            .name("koan-stream-pump".into())
462            .spawn(move || {
463                use std::fs::File;
464                use std::io::Read;
465                use std::time::{Duration, Instant};
466
467                /// No new bytes for this long and the download is treated as dead.
468                /// `bytes_written` simply stops advancing when one dies, so without
469                /// a deadline the pump spins and the decode thread parks forever.
470                const STALL_LIMIT: Duration = Duration::from_secs(30);
471
472                let mut file = match File::open(&pump_path) {
473                    Ok(f) => f,
474                    Err(e) => {
475                        log::error!("stream pump: failed to open {}: {}", pump_path.display(), e);
476                        pump_buf.fail();
477                        return;
478                    }
479                };
480                let mut buf = vec![0u8; 65536];
481                let mut offset: u64 = 0;
482                let mut last_progress = Instant::now();
483                loop {
484                    // Nothing left to read the bytes, so nothing left to write them for.
485                    if pump_buf.is_abandoned() {
486                        return;
487                    }
488                    match pump_state.item_load_state(id) {
489                        Some(LoadState::Failed(e)) => {
490                            log::warn!("stream pump: download of {:?} failed: {}", id, e);
491                            pump_buf.fail();
492                            return;
493                        }
494                        // The download landed: drain to EOF rather than trusting
495                        // `total`, which is 0 for a chunked transfer.
496                        Some(LoadState::Ready) => match file.read(&mut buf) {
497                            Ok(0) => break,
498                            Ok(n) => {
499                                pump_buf.push(&buf[..n]);
500                                offset += n as u64;
501                                continue;
502                            }
503                            Err(e) => {
504                                log::warn!("stream pump read error: {}", e);
505                                pump_buf.fail();
506                                return;
507                            }
508                        },
509                        _ => {}
510                    }
511
512                    let available = pump_written.load(Ordering::Acquire);
513                    if offset >= available {
514                        if total > 0 && available >= total {
515                            break; // Download complete.
516                        }
517                        if last_progress.elapsed() >= STALL_LIMIT {
518                            log::warn!(
519                                "stream pump: no data for {}s, abandoning {}",
520                                STALL_LIMIT.as_secs(),
521                                pump_path.display()
522                            );
523                            pump_buf.fail();
524                            return;
525                        }
526                        thread::sleep(Duration::from_millis(10));
527                        continue;
528                    }
529                    let to_read = ((available - offset) as usize).min(buf.len());
530                    match file.read(&mut buf[..to_read]) {
531                        Ok(0) => {
532                            // File data may lag behind bytes_written (OS buffer flush timing).
533                            // Only treat as true EOF if we've pumped all expected data.
534                            if total > 0 && offset >= total {
535                                break;
536                            }
537                            let latest = pump_written.load(Ordering::Acquire);
538                            if total > 0 && latest >= total && offset >= latest {
539                                break;
540                            }
541                            // Data not yet visible on disk — back off and retry.
542                            thread::sleep(Duration::from_millis(1));
543                            continue;
544                        }
545                        Ok(n) => {
546                            pump_buf.push(&buf[..n]);
547                            offset += n as u64;
548                            last_progress = Instant::now();
549                        }
550                        Err(e) => {
551                            log::warn!("stream pump read error: {}", e);
552                            pump_buf.fail();
553                            return;
554                        }
555                    }
556                }
557                pump_buf.finish();
558            })
559            .map_err(|e| PlayerError::Decode(buffer::DecodeError::Io(e)))?;
560
561        // Probe via a streaming reader — blocks (via condvar) until enough header data arrives.
562        let probe_reader = stream_buf.reader();
563        let probe_hint = {
564            let mut h = symphonia::core::formats::probe::Hint::new();
565            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
566                h.with_extension(ext);
567            }
568            h
569        };
570        let probe_mss =
571            symphonia::core::io::MediaSourceStream::new(Box::new(probe_reader), Default::default());
572        let info = buffer::probe_source(probe_mss, &probe_hint)?;
573
574        self.shared_state.set_track_info(Some(TrackInfo {
575            id,
576            path: path.to_path_buf(),
577            codec: info.codec.clone(),
578            sample_rate: info.sample_rate,
579            bit_depth: info.bit_depth,
580            bitrate_kbps: info.bitrate_kbps,
581            channels: info.channels,
582            duration_ms: info.duration_ms,
583        }));
584        self.shared_state.set_position_ms(0);
585        self.on_track_changed(id);
586        log::info!(
587            "streaming: {} ({:?}) — {} {}Hz/{}ch, {}ms",
588            path.display(),
589            id,
590            info.codec,
591            info.sample_rate,
592            info.channels,
593            info.duration_ms,
594        );
595
596        let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
597
598        self.timeline.reset();
599
600        // Gapless lookahead after streaming: next track uses normal file path.
601        let advance_state = self.shared_state.clone();
602        let decode_cursor = parking_lot::Mutex::new(Some(id));
603        let next_track = move || {
604            let current = decode_cursor.lock().take()?;
605            let next = advance_state.peek_next_ready_after(current);
606            if let Some((next_id, _)) = &next {
607                let mut guard = decode_cursor.lock();
608                *guard = Some(*next_id);
609            }
610            next
611        };
612
613        // Decode using a fresh StreamingSource reader — reads from the StreamBuffer
614        // that the pump thread feeds. The decode thread blocks when it catches up to
615        // the write head, resuming as more data arrives.
616        // Build a SourceEntry using a fresh StreamingSource reader for the decode thread.
617        let decode_reader = stream_buf.reader();
618        let path_buf = path.to_path_buf();
619        let ext = path
620            .extension()
621            .and_then(|e| e.to_str())
622            .unwrap_or("")
623            .to_string();
624        let mut decode_hint = symphonia::core::formats::probe::Hint::new();
625        if !ext.is_empty() {
626            decode_hint.with_extension(&ext);
627        }
628        let first = buffer::SourceEntry {
629            id,
630            path: path_buf,
631            hint: decode_hint,
632            make_mss: Box::new(move || {
633                Ok(symphonia::core::io::MediaSourceStream::new(
634                    Box::new(decode_reader),
635                    Default::default(),
636                ))
637            }),
638        };
639
640        // Load ReplayGain config for this streaming session.
641        let cfg = crate::config::Config::load_or_default();
642        let rg_mode = cfg.playback.replaygain;
643        let pre_amp_db = cfg.playback.pre_amp_db;
644
645        let finish_tx = self.commands.tx.clone();
646        let (_stream_info, decode_handle) = buffer::start_decode(
647            first,
648            producer,
649            0,
650            move || {
651                let (next_id, next_path) = next_track()?;
652                Some(buffer::SourceEntry::from_file(next_id, next_path))
653            },
654            self.timeline.clone(),
655            Some(self.viz_buffer.clone()),
656            rg_mode,
657            pre_amp_db,
658            move || {
659                finish_tx.send(PlayerCommand::DecodeFinished).ok();
660            },
661        )?;
662
663        let engine = self.create_engine_for(&info, consumer)?;
664        engine.start()?;
665
666        self.shared_state.set_playback_state(PlaybackState::Playing);
667
668        self.active_playback = Some(ActivePlayback {
669            engine,
670            decode_handle,
671        });
672
673        Ok(())
674    }
675
676    /// Seek within the current track. Clamps to just before the end to avoid
677    /// accidentally skipping. Preserves pause state.
678    pub fn seek(&mut self, position_ms: u64) {
679        let info = match self.shared_state.track_info() {
680            Some(info) => info,
681            None => return,
682        };
683        let id = info.id;
684        let path = info.path.clone();
685        let duration = info.duration_ms;
686
687        // Clamp to just before the end so we don't skip to the next track.
688        let mut clamped = if duration > 0 {
689            position_ms.min(duration.saturating_sub(500))
690        } else {
691            position_ms
692        };
693
694        // Clamp to downloaded portion if streaming to prevent seeking into
695        // data that hasn't arrived yet.
696        if let Some(dl_frac) = self.shared_state.current_download_fraction() {
697            let max_ms = (dl_frac * duration as f64) as u64;
698            if max_ms > 5_000 {
699                clamped = clamped.min(max_ms - 5_000);
700            }
701        }
702
703        let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
704
705        if let Err(e) = self.start_playback(id, &path, clamped) {
706            log::error!("seek failed: {}", e);
707            return;
708        }
709
710        if was_paused {
711            self.pause();
712        }
713    }
714
715    /// Skip to next track in playlist.
716    pub fn next_track(&mut self) {
717        match self.shared_state.advance_cursor_loadable() {
718            Some(id) => self.play(id),
719            None => {
720                log::info!("no more tracks in playlist");
721                self.stop_playback_and_clear_state();
722            }
723        }
724    }
725
726    /// Go back to previous track.
727    pub fn prev_track(&mut self) {
728        match self.shared_state.retreat_cursor() {
729            Some((id, path)) => {
730                if matches!(path.try_exists(), Ok(true)) {
731                    if let Err(e) = self.start_playback(id, &path, 0) {
732                        log::error!("prev track failed: {}", e);
733                    }
734                } else {
735                    log::warn!("prev track path doesn't exist: {}", path.display());
736                }
737            }
738            None => {
739                // No previous track — restart current from the beginning.
740                if let Some(info) = self.shared_state.track_info()
741                    && let Err(e) = self.start_playback(info.id, &info.path, 0)
742                {
743                    log::error!("restart failed: {}", e);
744                }
745            }
746        }
747    }
748
749    /// Pause playback.
750    pub fn pause(&mut self) {
751        if let Some(ref playback) = self.active_playback {
752            if let Err(e) = playback.engine.stop() {
753                log::error!("pause failed: {}", e);
754                return;
755            }
756            self.shared_state.set_playback_state(PlaybackState::Paused);
757        }
758    }
759
760    /// Resume playback.
761    pub fn resume(&mut self) {
762        if let Some(ref playback) = self.active_playback {
763            if let Err(e) = playback.engine.start() {
764                log::error!("resume failed: {}", e);
765                return;
766            }
767            self.shared_state.set_playback_state(PlaybackState::Playing);
768        }
769    }
770
771    /// Stop playback and clear playlist.
772    pub fn stop(&mut self) {
773        self.shared_state.clear_playlist();
774        self.stop_playback_and_clear_state();
775    }
776
777    /// Stop the audio engine and decode thread without touching shared state.
778    ///
779    /// The engine is stopped synchronously (silence begins immediately), but
780    /// the heavy teardown (decode thread join + AudioUnit dispose) is moved to
781    /// a background thread so the player command loop never blocks — preventing
782    /// UI freezes when CoreAudio or the decode thread is slow to shut down.
783    fn stop_engine(&mut self) {
784        let Some(playback) = self.active_playback.take() else {
785            return;
786        };
787        let ActivePlayback {
788            engine,
789            mut decode_handle,
790        } = playback;
791
792        // Stop audio output first, then get the decode thread gone *before* the
793        // engine is dropped.
794        //
795        // The old order signalled the decode thread and dropped the engine
796        // immediately, joining the thread afterwards on a background thread —
797        // so the engine's teardown ran while the decode thread was still alive
798        // and still writing into the ring buffer that the render callback
799        // reads. Tearing CoreAudio down underneath a live producer is exactly
800        // the shape of the end-of-queue crash (#89), and the overlap buys
801        // nothing: `stop()` has already silenced the output.
802        let _ = engine.stop();
803        decode_handle.stop();
804        drop(engine);
805    }
806
807    /// Full stop: tear down engine + clear all display state.
808    fn stop_playback_and_clear_state(&mut self) {
809        self.finish_play();
810        self.stop_engine();
811        self.timeline.reset();
812        self.shared_state.set_playback_state(PlaybackState::Stopped);
813        self.shared_state.set_position_ms(0);
814        self.shared_state.set_track_info(None);
815    }
816
817    /// Remove a track from the playlist. If it was the cursor, resume at the
818    /// track that followed it.
819    ///
820    /// `remove_item` clears the cursor, and an unset cursor means "start from the
821    /// top" — so the successor is pinned down by parking the cursor on the removed
822    /// track's predecessor first. `None` is correct only when it was the first item.
823    pub fn remove_from_playlist(&mut self, id: QueueItemId) {
824        let was_cursor = self.shared_state.is_cursor(id);
825        let resume_after = was_cursor
826            .then(|| self.shared_state.item_before(id))
827            .flatten();
828        self.shared_state.remove_item(id);
829        if was_cursor {
830            self.shared_state.set_cursor(resume_after);
831            self.next_track();
832        }
833    }
834
835    /// A download finished — if cursor is waiting on this item, start playback.
836    /// If already streaming this item, trigger progressive metadata enhancement.
837    pub fn track_ready(&mut self, id: QueueItemId) {
838        // Mark as Ready (download thread already did this, but be safe).
839        self.shared_state.update_load_state(id, LoadState::Ready);
840
841        if !self.shared_state.is_cursor(id) {
842            return;
843        }
844
845        let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
846        let current_track_id = self.shared_state.track_info().map(|t| t.id);
847
848        if is_playing && current_track_id == Some(id) {
849            // Already streaming this track — download just finished.
850            // Trigger progressive enhancement: re-read full lofty metadata and update state.
851            log::info!(
852                "track_ready: download complete while streaming {:?}, refreshing metadata",
853                id
854            );
855            self.refresh_track_metadata(id);
856            return;
857        }
858
859        // Cursor is on this item but not yet playing — start playback now.
860        if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
861            log::info!("track_ready: starting playback for {:?}", id);
862            if let Err(e) = self.start_playback(id, &path, 0) {
863                log::error!("track_ready playback failed: {}", e);
864            }
865        }
866    }
867
868    /// Called when enough data has been buffered for streaming playback.
869    /// If the cursor is waiting on this track and nothing is playing, start streaming.
870    pub fn track_stream_ready(&mut self, id: QueueItemId) {
871        if !self.shared_state.is_cursor(id) {
872            return;
873        }
874
875        let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
876        if is_playing {
877            return; // Already playing something — don't interrupt.
878        }
879
880        match self.shared_state.item_playback_source(id) {
881            Some(PlaybackSource::Streaming {
882                path,
883                bytes_written,
884                total,
885            }) => {
886                log::info!(
887                    "track_stream_ready: starting streaming playback for {:?}",
888                    id
889                );
890                if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
891                    log::error!("track_stream_ready streaming failed: {}", e);
892                }
893            }
894            Some(PlaybackSource::Ready(path)) => {
895                // Download finished between threshold and now — just play normally.
896                log::info!(
897                    "track_stream_ready: track already ready, starting normal playback for {:?}",
898                    id
899                );
900                if let Err(e) = self.start_playback(id, &path, 0) {
901                    log::error!("track_stream_ready playback failed: {}", e);
902                }
903            }
904            None => {} // Not enough data yet — wait.
905        }
906    }
907
908    /// Re-read full lofty metadata for a track after its download completes.
909    /// Updates the playlist item's tags and track_info with complete metadata.
910    /// Called from track_ready() when a streaming track finishes downloading.
911    fn refresh_track_metadata(&mut self, id: QueueItemId) {
912        use crate::index::metadata;
913
914        let path = match self.shared_state.item_path_if_ready(id) {
915            Some(p) => p,
916            None => return,
917        };
918
919        match metadata::read_metadata(&path) {
920            Ok(meta) => {
921                // Update playlist item with full lofty tags (title, artist, album, duration).
922                self.shared_state.update_item_metadata(
923                    id,
924                    meta.title,
925                    meta.artist,
926                    meta.album_artist.unwrap_or_default(),
927                    meta.album,
928                    meta.duration_ms.map(|d| d as u64),
929                );
930
931                // Re-probe the complete file for accurate duration + stream info.
932                // The initial probe was done on partial streaming data and may have
933                // underestimated duration, causing premature seek clamping or wrong
934                // progress bar display.
935                if let Ok(stream_info) = buffer::probe_file(&path)
936                    && let Some(current) = self.shared_state.track_info()
937                    && current.id == id
938                    && stream_info.duration_ms > current.duration_ms
939                {
940                    log::info!(
941                        "track_ready: duration corrected {}ms → {}ms",
942                        current.duration_ms,
943                        stream_info.duration_ms
944                    );
945                    self.shared_state.set_track_info(Some(TrackInfo {
946                        duration_ms: stream_info.duration_ms,
947                        ..current
948                    }));
949                }
950
951                // Signal UI to re-read cover art and update souvlaki media controls.
952                self.shared_state.signal_metadata_refresh();
953                log::info!("track_ready: metadata refreshed for {:?}", id);
954            }
955            Err(e) => {
956                log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
957            }
958        }
959    }
960
961    /// Poll the timeline and update shared state with current track/position.
962    /// Called from the command loop on each tick.
963    /// The needle has moved to `id`. Close out the outgoing track and write
964    /// the new one to history straight away, so history reads in play order
965    /// even for a track that is skipped a moment later.
966    ///
967    /// A seek restarts playback of the same track, so identity is checked
968    /// rather than closing unconditionally — otherwise scrubbing around a
969    /// track would enter it into history once per seek.
970    fn on_track_changed(&mut self, id: QueueItemId) {
971        if self.in_flight.as_ref().is_some_and(|f| f.item == id) {
972            return;
973        }
974        self.finish_play();
975        let track_id = self.shared_state.item_db_id(id);
976        self.in_flight = Some(InFlight::new(id, track_id));
977        if let (Some(track_id), Some(recorder)) = (track_id, self.history.as_ref()) {
978            recorder.record(PlayEvent::Started { track_id });
979        }
980    }
981
982    /// Tell history how long the current track was heard for. Returns what was
983    /// reported, which is how the tests see it.
984    fn finish_play(&mut self) -> Option<PlayEvent> {
985        let flight = self.in_flight.take()?;
986        let event = PlayEvent::Finished {
987            track_id: flight.track_id()?,
988            listened_ms: flight.listened_ms(),
989        };
990        if let Some(recorder) = self.history.as_ref() {
991            recorder.record(event);
992        }
993        Some(event)
994    }
995
996    pub fn update_playback_state(&mut self) {
997        if self.active_playback.is_none() {
998            return;
999        }
1000
1001        if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
1002            self.shared_state.set_position_ms(position_ms);
1003
1004            // A gapless transition moves the needle without anything on this
1005            // thread having asked it to, so the play is banked from here.
1006            self.on_track_changed(id);
1007            if let Some(f) = self.in_flight.as_mut() {
1008                f.advance(position_ms);
1009            }
1010
1011            // Update track_info + cursor if the timeline shows a different track
1012            // (gapless transition happened).
1013            let current_id = self.shared_state.track_info().map(|t| t.id);
1014            if current_id != Some(id) {
1015                log::info!("timeline: now playing {:?}", id);
1016                self.shared_state.set_track_info(Some(TrackInfo {
1017                    id,
1018                    path,
1019                    codec: info.codec,
1020                    sample_rate: info.sample_rate,
1021                    bit_depth: info.bit_depth,
1022                    bitrate_kbps: info.bitrate_kbps,
1023                    channels: info.channels,
1024                    duration_ms: info.duration_ms,
1025                }));
1026                self.shared_state.set_cursor(Some(id));
1027            }
1028        }
1029    }
1030
1031    /// A download the cursor is parked on will never land.
1032    ///
1033    /// `play()` leaves the cursor on an item that is not yet Ready and stops,
1034    /// waiting for `TrackReady`. When the download fails instead, that wait has
1035    /// no end — so walk on to the next item that can still load, or stop
1036    /// cleanly if there is none.
1037    pub fn track_failed(&mut self, id: QueueItemId) {
1038        if !self.shared_state.is_cursor(id) {
1039            return;
1040        }
1041        // Only a parked cursor is waiting on this. Playing means it is being
1042        // streamed from the partial file — the pump sees the failure and ends
1043        // the decode, which advances the queue — and paused is the user's.
1044        if self.shared_state.playback_state() != PlaybackState::Stopped {
1045            return;
1046        }
1047        log::info!("track {:?} cannot load, moving on", id);
1048        self.next_track();
1049    }
1050
1051    /// Decode thread naturally finished (playlist exhausted or error).
1052    /// Advance to the next playable track; otherwise stop cleanly.
1053    ///
1054    /// A track that has not finished downloading parks the cursor on it, so its
1055    /// `TrackReady`/`TrackStreamReady` resumes the queue instead of being
1056    /// discarded as "not the cursor".
1057    fn on_decode_finished(&mut self) {
1058        log::info!("decode finished, checking for next track");
1059        match self.shared_state.advance_cursor_loadable() {
1060            Some(id) => self.play(id),
1061            None => {
1062                log::info!("no more tracks — stopping");
1063                self.stop_playback_and_clear_state();
1064            }
1065        }
1066    }
1067
1068    /// Snapshot items with their predecessors for an undo of "these were removed".
1069    /// In playlist order, so undo re-inserts each item after a predecessor that
1070    /// is already back in place.
1071    fn snapshot_for_undo(
1072        &self,
1073        ids: &[QueueItemId],
1074    ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
1075        self.shared_state
1076            .items_before(ids)
1077            .into_iter()
1078            .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
1079            .collect()
1080    }
1081
1082    /// Route an undo entry to the batch buffer (if batching) or the undo stack.
1083    fn push_undo(&mut self, entry: UndoEntry) {
1084        if let Some(ref mut batch) = self.batch_buffer {
1085            batch.push(entry);
1086        } else {
1087            self.undo_stack.push(entry);
1088        }
1089    }
1090
1091    /// Process a single command.
1092    pub fn process_command(&mut self, cmd: PlayerCommand) {
1093        match cmd {
1094            PlayerCommand::Play(id) => self.play(id),
1095            PlayerCommand::Pause => self.pause(),
1096            PlayerCommand::Resume => self.resume(),
1097            PlayerCommand::Stop => self.stop(),
1098            PlayerCommand::Seek(pos) => self.seek(pos),
1099            PlayerCommand::NextTrack => {
1100                // Debounce: suppress key repeat from terminal (150ms window).
1101                let now = std::time::Instant::now();
1102                if now.duration_since(self.last_skip).as_millis() >= 150 {
1103                    self.last_skip = now;
1104                    self.next_track();
1105                }
1106            }
1107            PlayerCommand::PrevTrack => {
1108                let now = std::time::Instant::now();
1109                if now.duration_since(self.last_skip).as_millis() >= 150 {
1110                    self.last_skip = now;
1111                    self.prev_track();
1112                }
1113            }
1114            PlayerCommand::AddToPlaylist(items) => {
1115                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1116                self.shared_state.add_items(items);
1117                self.push_undo(UndoEntry::Added { ids });
1118            }
1119            PlayerCommand::UpdatePaths(updates) => {
1120                self.shared_state.update_paths(&updates);
1121                if let Some(info) = self.shared_state.track_info()
1122                    && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
1123                {
1124                    self.shared_state.set_track_info(Some(TrackInfo {
1125                        path: new_path.clone(),
1126                        ..info
1127                    }));
1128                }
1129            }
1130            PlayerCommand::InsertInPlaylist { items, after } => {
1131                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1132                self.shared_state.insert_items_after(items, after);
1133                self.push_undo(UndoEntry::Inserted { ids });
1134            }
1135            PlayerCommand::ClearPlaylist => {
1136                // Stop engine + clear display state WITHOUT touching the playlist,
1137                // then snapshot, then clear. This avoids the race where stop()
1138                // would clear the playlist before we capture it for undo.
1139                self.stop_playback_and_clear_state();
1140                let (items, cursor) = self.shared_state.snapshot_playlist();
1141                self.shared_state.clear_playlist();
1142                self.push_undo(UndoEntry::Replaced { items, cursor });
1143            }
1144            PlayerCommand::ReplacePlaylist { items, start } => {
1145                // Same order as ClearPlaylist: stop and clear display state
1146                // before snapshotting, or the snapshot captures an already
1147                // emptied playlist and undo restores nothing.
1148                self.stop_playback_and_clear_state();
1149                let (old_items, cursor) = self.shared_state.snapshot_playlist();
1150                self.shared_state.clear_playlist();
1151                self.push_undo(UndoEntry::Replaced {
1152                    items: old_items,
1153                    cursor,
1154                });
1155
1156                if items.is_empty() {
1157                    return;
1158                }
1159                let start_id = items.get(start).unwrap_or(&items[0]).id;
1160                self.shared_state.add_items(items);
1161                self.play(start_id);
1162            }
1163            PlayerCommand::RemoveFromPlaylist(id) => {
1164                let item = self.shared_state.get_item(id);
1165                let after = self.shared_state.item_before(id);
1166                self.remove_from_playlist(id);
1167                if let Some(item) = item {
1168                    self.push_undo(UndoEntry::Removed {
1169                        items: vec![(Box::new(item), after)],
1170                    });
1171                }
1172            }
1173            PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1174                // Snapshot before removing anything, and resolve the resume point
1175                // once: removing one at a time would restart the engine for every
1176                // deleted track that the cursor lands on along the way.
1177                let items_with_pos = self.snapshot_for_undo(&ids);
1178                let resume_after = match self.shared_state.cursor() {
1179                    Some(cursor) if ids.contains(&cursor) => {
1180                        Some(self.shared_state.surviving_item_before(cursor, &ids))
1181                    }
1182                    _ => None,
1183                };
1184
1185                self.shared_state.remove_items(&ids);
1186
1187                if let Some(resume_after) = resume_after {
1188                    self.shared_state.set_cursor(resume_after);
1189                    self.next_track();
1190                }
1191
1192                if !items_with_pos.is_empty() {
1193                    self.push_undo(UndoEntry::Removed {
1194                        items: items_with_pos,
1195                    });
1196                }
1197            }
1198            PlayerCommand::MoveInPlaylist { id, target, after } => {
1199                let was_after = self.shared_state.item_before(id);
1200                self.shared_state.move_item(id, target, after);
1201                self.push_undo(UndoEntry::Moved { id, was_after });
1202            }
1203            PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1204                let entries = self.shared_state.items_before(&ids);
1205                self.shared_state.move_items(&ids, target, after);
1206                self.push_undo(UndoEntry::MovedBatch { entries });
1207            }
1208            PlayerCommand::TrackReady(id) => self.track_ready(id),
1209            PlayerCommand::DecodeFinished => self.on_decode_finished(),
1210            PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1211            PlayerCommand::TrackFailed(id) => self.track_failed(id),
1212            PlayerCommand::Undo => self.execute_undo(),
1213            PlayerCommand::Redo => self.execute_redo(),
1214            PlayerCommand::BeginUndoBatch => {
1215                self.batch_buffer = Some(Vec::new());
1216            }
1217            PlayerCommand::EndUndoBatch => {
1218                if let Some(entries) = self.batch_buffer.take() {
1219                    if entries.len() == 1 {
1220                        // Single entry — push directly, no wrapping.
1221                        self.undo_stack.push(entries.into_iter().next().unwrap());
1222                    } else if !entries.is_empty() {
1223                        self.undo_stack.push(UndoEntry::Batch(entries));
1224                    }
1225                }
1226            }
1227            PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1228            PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1229        }
1230    }
1231
1232    /// Apply an undo/redo entry: mutate the playlist and return the inverse entry.
1233    fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1234        match entry {
1235            UndoEntry::Added { ids } => {
1236                // Undo of "items were added": snapshot them with positions, then remove.
1237                let items_with_pos = self.snapshot_for_undo(&ids);
1238                self.shared_state.remove_items(&ids);
1239                Some(UndoEntry::Removed {
1240                    items: items_with_pos,
1241                })
1242            }
1243            UndoEntry::Removed { items } => {
1244                // Undo of "items were removed": re-insert each at its position.
1245                let mut ids = Vec::with_capacity(items.len());
1246                for (item, after) in items {
1247                    ids.push(item.id);
1248                    self.shared_state.insert_item_at(*item, after);
1249                }
1250                Some(UndoEntry::Added { ids })
1251            }
1252            UndoEntry::Inserted { ids } => {
1253                // Same as Added — snapshot positions, remove items.
1254                let items_with_pos = self.snapshot_for_undo(&ids);
1255                self.shared_state.remove_items(&ids);
1256                Some(UndoEntry::Removed {
1257                    items: items_with_pos,
1258                })
1259            }
1260            UndoEntry::Moved { id, was_after } => {
1261                let current_after = self.shared_state.item_before(id);
1262                self.shared_state.move_item_to(id, was_after);
1263                Some(UndoEntry::Moved {
1264                    id,
1265                    was_after: current_after,
1266                })
1267            }
1268            UndoEntry::MovedBatch { entries } => {
1269                let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1270                let current_positions = self.shared_state.items_before(&ids);
1271                self.shared_state.move_items_to(&entries);
1272                Some(UndoEntry::MovedBatch {
1273                    entries: current_positions,
1274                })
1275            }
1276            UndoEntry::Replaced { items, cursor } => {
1277                let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1278                self.shared_state.restore_playlist(items, cursor);
1279                Some(UndoEntry::Replaced {
1280                    items: current_items,
1281                    cursor: current_cursor,
1282                })
1283            }
1284            UndoEntry::Batch(entries) => {
1285                // Apply entries in reverse order, collect inverses.
1286                let mut inverses = Vec::with_capacity(entries.len());
1287                for entry in entries.into_iter().rev() {
1288                    if let Some(inverse) = self.apply_entry(entry) {
1289                        inverses.push(inverse);
1290                    }
1291                }
1292                inverses.reverse();
1293                Some(UndoEntry::Batch(inverses))
1294            }
1295        }
1296    }
1297
1298    /// Execute an undo operation, pushing the inverse onto the redo stack.
1299    fn execute_undo(&mut self) {
1300        let Some(entry) = self.undo_stack.pop_undo() else {
1301            return;
1302        };
1303        if let Some(inverse) = self.apply_entry(entry) {
1304            self.undo_stack.push_redo(inverse);
1305        }
1306    }
1307
1308    /// Execute a redo operation, pushing the inverse onto the undo stack.
1309    fn execute_redo(&mut self) {
1310        let Some(entry) = self.undo_stack.pop_redo() else {
1311            return;
1312        };
1313        if let Some(inverse) = self.apply_entry(entry) {
1314            self.undo_stack.push_undo_keep_redo(inverse);
1315        }
1316    }
1317
1318    /// Run the command loop. Blocks until the sender is dropped.
1319    pub fn run(&mut self) {
1320        use std::time::Duration;
1321
1322        let rx = self.commands.rx.clone();
1323        loop {
1324            // Poll with timeout so we update position even without commands.
1325            match rx.recv_timeout(Duration::from_millis(50)) {
1326                Ok(cmd) => self.process_command(cmd),
1327                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1328                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1329            }
1330            self.update_playback_state();
1331        }
1332        self.stop();
1333    }
1334
1335    /// Spawn the player on a background thread, returning the shared state,
1336    /// timeline, visualization snapshot, and command sender.
1337    pub fn spawn() -> (
1338        Arc<SharedPlayerState>,
1339        Arc<PlaybackTimeline>,
1340        Arc<VizSnapshot>,
1341        crossbeam_channel::Sender<PlayerCommand>,
1342    ) {
1343        let mut player = Self::new();
1344        player.history = PlayRecorder::spawn();
1345        let state = player.shared_state();
1346        let timeline = player.timeline();
1347        let viz_snapshot = player.viz_snapshot();
1348        let tx = player.command_sender();
1349
1350        thread::Builder::new()
1351            .name("koan-player".into())
1352            .spawn(move || player.run())
1353            .expect("failed to spawn player thread");
1354
1355        (state, timeline, viz_snapshot, tx)
1356    }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361    use super::*;
1362    use state::PlaylistItem;
1363    use std::path::PathBuf;
1364
1365    fn make_item(title: &str) -> PlaylistItem {
1366        PlaylistItem {
1367            id: QueueItemId::new(),
1368            db_id: None,
1369            path: PathBuf::from(format!("/music/{title}.flac")),
1370            title: title.to_string(),
1371            artist: String::new(),
1372            album_artist: String::new(),
1373            album: String::new(),
1374            year: None,
1375            codec: None,
1376            track_number: None,
1377            disc: None,
1378            duration_ms: None,
1379            load_state: LoadState::Ready,
1380        }
1381    }
1382
1383    fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1384        let (items, _) = player.shared_state.snapshot_playlist();
1385        items.iter().map(|i| i.id).collect()
1386    }
1387
1388    fn playlist_titles(player: &Player) -> Vec<String> {
1389        let (items, _) = player.shared_state.snapshot_playlist();
1390        items.iter().map(|i| i.title.clone()).collect()
1391    }
1392
1393    fn pending_item(title: &str) -> PlaylistItem {
1394        PlaylistItem {
1395            load_state: LoadState::Pending,
1396            ..make_item(title)
1397        }
1398    }
1399
1400    /// Build `n` ready items, add them, and return their IDs.
1401    fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
1402        let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
1403        let ids = items.iter().map(|i| i.id).collect();
1404        player.process_command(PlayerCommand::AddToPlaylist(items));
1405        ids
1406    }
1407
1408    // --- cursor transitions ---
1409
1410    /// Feed the player a track's worth of playback ticks, as the 50ms poll would.
1411    fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
1412        let mut at = from_ms;
1413        if let Some(f) = player.in_flight.as_mut() {
1414            f.advance(at); // the position the needle landed on
1415        }
1416        while at < to_ms {
1417            at = (at + 50).min(to_ms);
1418            if let Some(f) = player.in_flight.as_mut() {
1419                f.advance(at);
1420            }
1421        }
1422    }
1423
1424    fn start(player: &mut Player, track_id: i64) -> QueueItemId {
1425        let id = QueueItemId::new();
1426        player.on_track_changed(id);
1427        // The item is not in a playlist here, so there is no db_id to find.
1428        player
1429            .in_flight
1430            .as_mut()
1431            .unwrap()
1432            .track_id_for_test(track_id);
1433        id
1434    }
1435
1436    #[test]
1437    fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
1438        let mut player = Player::new();
1439        start(&mut player, 11);
1440        listen(&mut player, 0, 200_000);
1441
1442        let b = QueueItemId::new();
1443        player.on_track_changed(b);
1444        let f = player
1445            .in_flight
1446            .as_ref()
1447            .expect("the next track is counting");
1448        assert_eq!(f.item, b);
1449        assert_eq!(f.listened_ms(), 0, "and starts from nothing");
1450    }
1451
1452    #[test]
1453    fn a_track_skipped_seconds_in_is_still_history() {
1454        let mut player = Player::new();
1455        start(&mut player, 7);
1456        listen(&mut player, 0, 2_000);
1457
1458        let event = player
1459            .finish_play()
1460            .expect("putting something on is a thing you did, however briefly");
1461        assert!(matches!(
1462            event,
1463            history::PlayEvent::Finished {
1464                track_id: 7,
1465                listened_ms: 2_000
1466            }
1467        ));
1468    }
1469
1470    #[test]
1471    fn a_track_is_closed_out_once() {
1472        let mut player = Player::new();
1473        start(&mut player, 7);
1474        listen(&mut player, 0, 200_000);
1475
1476        assert!(player.finish_play().is_some());
1477        assert!(player.finish_play().is_none());
1478    }
1479
1480    #[test]
1481    fn seeking_around_a_track_does_not_enter_it_twice() {
1482        let mut player = Player::new();
1483        let id = start(&mut player, 7);
1484        listen(&mut player, 0, 120_000);
1485
1486        // A seek restarts playback of the same item.
1487        player.on_track_changed(id);
1488        assert_eq!(
1489            player.in_flight.as_ref().unwrap().listened_ms(),
1490            120_000,
1491            "the seek kept the count rather than restarting it"
1492        );
1493        listen(&mut player, 30_000, 40_000);
1494
1495        let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
1496            panic!("still one play");
1497        };
1498        assert_eq!(listened_ms, 130_000);
1499        assert!(player.finish_play().is_none());
1500    }
1501
1502    #[test]
1503    fn a_track_that_is_not_in_the_library_is_not_recorded() {
1504        let mut player = Player::new();
1505        let id = QueueItemId::new();
1506        player.on_track_changed(id);
1507        listen(&mut player, 0, 200_000);
1508        assert!(player.finish_play().is_none());
1509    }
1510
1511    #[test]
1512    fn stopping_closes_out_what_was_heard() {
1513        let mut player = Player::new();
1514        start(&mut player, 7);
1515        listen(&mut player, 0, 150_000);
1516
1517        player.stop_playback_and_clear_state();
1518        assert!(player.in_flight.is_none(), "the stop consumed it");
1519    }
1520
1521    #[test]
1522    fn removing_the_playing_track_resumes_at_its_successor() {
1523        let mut player = Player::new();
1524        let ids = seed(&mut player, 5);
1525        player.shared_state.set_cursor(Some(ids[2]));
1526
1527        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
1528
1529        assert_eq!(
1530            player.shared_state.cursor(),
1531            Some(ids[3]),
1532            "playback must continue at the next track, not restart the queue"
1533        );
1534        assert_eq!(player.playback_starts, 1);
1535    }
1536
1537    #[test]
1538    fn removing_the_first_playing_track_resumes_at_the_new_first() {
1539        let mut player = Player::new();
1540        let ids = seed(&mut player, 3);
1541        player.shared_state.set_cursor(Some(ids[0]));
1542
1543        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
1544
1545        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
1546    }
1547
1548    #[test]
1549    fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
1550        let mut player = Player::new();
1551        let playing = make_item("playing");
1552        let waiting = pending_item("waiting");
1553        let later = make_item("later");
1554        let (playing_id, waiting_id) = (playing.id, waiting.id);
1555        player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
1556        player.shared_state.set_cursor(Some(playing_id));
1557
1558        player.process_command(PlayerCommand::DecodeFinished);
1559
1560        assert_eq!(
1561            player.shared_state.cursor(),
1562            Some(waiting_id),
1563            "the cursor parks on the track being fetched"
1564        );
1565        assert_eq!(
1566            player.playback_starts, 0,
1567            "nothing to play until its bytes land"
1568        );
1569
1570        // The download completes. Because the cursor is parked here, the
1571        // TrackReady actually reaches the player and the queue resumes.
1572        player
1573            .shared_state
1574            .update_load_state(waiting_id, LoadState::Ready);
1575        player.process_command(PlayerCommand::TrackReady(waiting_id));
1576
1577        assert_eq!(player.playback_starts, 1);
1578        assert_eq!(player.shared_state.cursor(), Some(waiting_id));
1579    }
1580
1581    #[test]
1582    fn a_download_that_cannot_land_moves_the_cursor_on() {
1583        let mut player = Player::new();
1584        let waiting = pending_item("waiting");
1585        let later = make_item("later");
1586        let (waiting_id, later_id) = (waiting.id, later.id);
1587        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, later]));
1588
1589        player.process_command(PlayerCommand::Play(waiting_id));
1590        assert_eq!(player.playback_starts, 0, "nothing to play yet");
1591
1592        // The download gives up. Ready will never come.
1593        player
1594            .shared_state
1595            .update_load_state(waiting_id, LoadState::Failed("remote unavailable".into()));
1596        player.process_command(PlayerCommand::TrackFailed(waiting_id));
1597
1598        assert_eq!(
1599            player.shared_state.cursor(),
1600            Some(later_id),
1601            "the queue moves past a track that can never load"
1602        );
1603        assert_eq!(player.playback_starts, 1);
1604    }
1605
1606    #[test]
1607    fn a_queue_that_can_never_load_stops_rather_than_waiting() {
1608        let mut player = Player::new();
1609        let first = pending_item("first");
1610        let second = pending_item("second");
1611        let (first_id, second_id) = (first.id, second.id);
1612        player.process_command(PlayerCommand::AddToPlaylist(vec![first, second]));
1613
1614        player.process_command(PlayerCommand::Play(first_id));
1615        for id in [first_id, second_id] {
1616            player
1617                .shared_state
1618                .update_load_state(id, LoadState::Failed("remote unavailable".into()));
1619            player.process_command(PlayerCommand::TrackFailed(id));
1620        }
1621
1622        assert_eq!(player.playback_starts, 0);
1623        assert_eq!(
1624            player.shared_state.playback_state(),
1625            PlaybackState::Stopped,
1626            "a stop the UI can see, not an indefinite wait for TrackReady"
1627        );
1628    }
1629
1630    #[test]
1631    fn a_failure_elsewhere_in_the_queue_leaves_the_cursor_alone() {
1632        let mut player = Player::new();
1633        let waiting = pending_item("waiting");
1634        let other = pending_item("other");
1635        let (waiting_id, other_id) = (waiting.id, other.id);
1636        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, other]));
1637        player.process_command(PlayerCommand::Play(waiting_id));
1638
1639        player
1640            .shared_state
1641            .update_load_state(other_id, LoadState::Failed("remote unavailable".into()));
1642        player.process_command(PlayerCommand::TrackFailed(other_id));
1643
1644        assert_eq!(
1645            player.shared_state.cursor(),
1646            Some(waiting_id),
1647            "a track still downloading keeps the cursor"
1648        );
1649    }
1650
1651    #[test]
1652    fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
1653        let mut player = Player::new();
1654        let ids = seed(&mut player, 5);
1655        player.shared_state.set_cursor(Some(ids[2]));
1656
1657        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
1658            ids[1], ids[2], ids[3],
1659        ]));
1660
1661        assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
1662        assert_eq!(player.shared_state.cursor(), Some(ids[4]));
1663        assert_eq!(
1664            player.playback_starts, 1,
1665            "one resume for the whole selection, not one per deleted track"
1666        );
1667    }
1668
1669    #[test]
1670    fn batch_delete_below_the_cursor_leaves_playback_alone() {
1671        let mut player = Player::new();
1672        let ids = seed(&mut player, 4);
1673        player.shared_state.set_cursor(Some(ids[0]));
1674
1675        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
1676
1677        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
1678        assert_eq!(player.playback_starts, 0);
1679    }
1680
1681    #[test]
1682    fn undo_of_a_batch_delete_restores_the_original_order() {
1683        // The TUI collects a selection from a HashSet, so the IDs arrive in
1684        // arbitrary order — scrambled here so a snapshot that trusts that order
1685        // re-inserts C before B and lands it at the end of the playlist.
1686        let mut player = Player::new();
1687        let items = vec![
1688            make_item("A"),
1689            make_item("B"),
1690            make_item("C"),
1691            make_item("D"),
1692        ];
1693        let (b_id, c_id) = (items[1].id, items[2].id);
1694        player.process_command(PlayerCommand::AddToPlaylist(items));
1695
1696        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
1697        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1698
1699        player.process_command(PlayerCommand::Undo);
1700        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1701    }
1702
1703    // --- AddToPlaylist undo/redo ---
1704
1705    #[test]
1706    fn undo_add_removes_items() {
1707        let mut player = Player::new();
1708        let items = vec![make_item("A"), make_item("B")];
1709        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1710
1711        player.process_command(PlayerCommand::AddToPlaylist(items));
1712        assert_eq!(playlist_ids(&player), ids);
1713        assert!(player.undo_stack().can_undo());
1714
1715        player.process_command(PlayerCommand::Undo);
1716        assert!(playlist_ids(&player).is_empty());
1717        assert!(player.undo_stack().can_redo());
1718    }
1719
1720    #[test]
1721    fn redo_add_restores_items() {
1722        let mut player = Player::new();
1723        let items = vec![make_item("A"), make_item("B")];
1724
1725        player.process_command(PlayerCommand::AddToPlaylist(items));
1726        player.process_command(PlayerCommand::Undo);
1727        assert!(playlist_ids(&player).is_empty());
1728
1729        player.process_command(PlayerCommand::Redo);
1730        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1731    }
1732
1733    // --- RemoveFromPlaylist undo/redo ---
1734
1735    #[test]
1736    fn undo_remove_restores_item_at_position() {
1737        let mut player = Player::new();
1738        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1739        let b_id = items[1].id;
1740
1741        player.process_command(PlayerCommand::AddToPlaylist(items));
1742        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1743        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1744
1745        player.process_command(PlayerCommand::Undo);
1746        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1747    }
1748
1749    #[test]
1750    fn undo_remove_first_item() {
1751        let mut player = Player::new();
1752        let items = vec![make_item("A"), make_item("B")];
1753        let a_id = items[0].id;
1754
1755        player.process_command(PlayerCommand::AddToPlaylist(items));
1756        player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1757        assert_eq!(playlist_titles(&player), vec!["B"]);
1758
1759        player.process_command(PlayerCommand::Undo);
1760        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1761    }
1762
1763    #[test]
1764    fn undo_batch_remove_restores_all() {
1765        let mut player = Player::new();
1766        let items = vec![
1767            make_item("A"),
1768            make_item("B"),
1769            make_item("C"),
1770            make_item("D"),
1771        ];
1772        let b_id = items[1].id;
1773        let c_id = items[2].id;
1774
1775        player.process_command(PlayerCommand::AddToPlaylist(items));
1776        let version_before = player.shared_state.playlist_version();
1777        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
1778        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1779        // One bump for the whole batch. Bumping per item is what made clearing
1780        // a large queue crawl, and every bump wakes every client watching.
1781        assert_eq!(
1782            player.shared_state.playlist_version(),
1783            version_before + 1,
1784            "batch removal must bump the playlist version exactly once"
1785        );
1786
1787        // Single undo restores both
1788        player.process_command(PlayerCommand::Undo);
1789        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1790    }
1791
1792    #[test]
1793    fn redo_batch_remove() {
1794        let mut player = Player::new();
1795        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1796        let a_id = items[0].id;
1797        let b_id = items[1].id;
1798
1799        player.process_command(PlayerCommand::AddToPlaylist(items));
1800        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
1801        player.process_command(PlayerCommand::Undo);
1802        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1803
1804        player.process_command(PlayerCommand::Redo);
1805        assert_eq!(playlist_titles(&player), vec!["C"]);
1806    }
1807
1808    #[test]
1809    fn redo_remove() {
1810        let mut player = Player::new();
1811        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1812        let b_id = items[1].id;
1813
1814        player.process_command(PlayerCommand::AddToPlaylist(items));
1815        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1816        player.process_command(PlayerCommand::Undo);
1817        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1818
1819        player.process_command(PlayerCommand::Redo);
1820        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1821    }
1822
1823    // --- InsertInPlaylist undo/redo ---
1824
1825    #[test]
1826    fn undo_insert_removes_inserted_items() {
1827        let mut player = Player::new();
1828        let items = vec![make_item("A"), make_item("C")];
1829        let a_id = items[0].id;
1830
1831        player.process_command(PlayerCommand::AddToPlaylist(items));
1832
1833        let inserted = vec![make_item("B")];
1834        player.process_command(PlayerCommand::InsertInPlaylist {
1835            items: inserted,
1836            after: a_id,
1837        });
1838        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1839
1840        player.process_command(PlayerCommand::Undo);
1841        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1842    }
1843
1844    // --- MoveInPlaylist undo/redo ---
1845
1846    #[test]
1847    fn undo_move_restores_position() {
1848        let mut player = Player::new();
1849        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1850        let a_id = items[0].id;
1851        let c_id = items[2].id;
1852
1853        player.process_command(PlayerCommand::AddToPlaylist(items));
1854
1855        // Move A after C: [B, C, A]
1856        player.process_command(PlayerCommand::MoveInPlaylist {
1857            id: a_id,
1858            target: c_id,
1859            after: true,
1860        });
1861        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1862
1863        player.process_command(PlayerCommand::Undo);
1864        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1865    }
1866
1867    #[test]
1868    fn redo_move() {
1869        let mut player = Player::new();
1870        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1871        let a_id = items[0].id;
1872        let c_id = items[2].id;
1873
1874        player.process_command(PlayerCommand::AddToPlaylist(items));
1875        player.process_command(PlayerCommand::MoveInPlaylist {
1876            id: a_id,
1877            target: c_id,
1878            after: true,
1879        });
1880        player.process_command(PlayerCommand::Undo);
1881        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1882
1883        player.process_command(PlayerCommand::Redo);
1884        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1885    }
1886
1887    // --- MoveItemsInPlaylist (batch) undo/redo ---
1888
1889    #[test]
1890    fn undo_batch_move() {
1891        let mut player = Player::new();
1892        let items = vec![
1893            make_item("A"),
1894            make_item("B"),
1895            make_item("C"),
1896            make_item("D"),
1897        ];
1898        let a_id = items[0].id;
1899        let b_id = items[1].id;
1900        let d_id = items[3].id;
1901
1902        player.process_command(PlayerCommand::AddToPlaylist(items));
1903
1904        // Move A,B after D: [C, D, A, B]
1905        player.process_command(PlayerCommand::MoveItemsInPlaylist {
1906            ids: vec![a_id, b_id],
1907            target: d_id,
1908            after: true,
1909        });
1910        assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
1911
1912        player.process_command(PlayerCommand::Undo);
1913        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1914    }
1915
1916    // --- ClearPlaylist undo/redo ---
1917
1918    #[test]
1919    fn undo_clear_restores_playlist() {
1920        let mut player = Player::new();
1921        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1922
1923        player.process_command(PlayerCommand::AddToPlaylist(items));
1924        player.process_command(PlayerCommand::ClearPlaylist);
1925        assert!(playlist_ids(&player).is_empty());
1926
1927        player.process_command(PlayerCommand::Undo);
1928        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1929    }
1930
1931    #[test]
1932    fn redo_clear() {
1933        let mut player = Player::new();
1934        let items = vec![make_item("A"), make_item("B")];
1935
1936        player.process_command(PlayerCommand::AddToPlaylist(items));
1937        player.process_command(PlayerCommand::ClearPlaylist);
1938        player.process_command(PlayerCommand::Undo);
1939        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1940
1941        player.process_command(PlayerCommand::Redo);
1942        assert!(playlist_ids(&player).is_empty());
1943    }
1944
1945    // --- Multi-step undo/redo ---
1946
1947    #[test]
1948    fn multiple_undos_in_sequence() {
1949        let mut player = Player::new();
1950
1951        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
1952        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1953        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
1954        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1955
1956        player.process_command(PlayerCommand::Undo);
1957        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1958
1959        player.process_command(PlayerCommand::Undo);
1960        assert_eq!(playlist_titles(&player), vec!["A"]);
1961
1962        player.process_command(PlayerCommand::Undo);
1963        assert!(playlist_ids(&player).is_empty());
1964    }
1965
1966    #[test]
1967    fn undo_redo_undo_cycle() {
1968        let mut player = Player::new();
1969        let items = vec![make_item("A"), make_item("B")];
1970
1971        player.process_command(PlayerCommand::AddToPlaylist(items));
1972        player.process_command(PlayerCommand::Undo);
1973        assert!(playlist_ids(&player).is_empty());
1974
1975        player.process_command(PlayerCommand::Redo);
1976        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1977
1978        player.process_command(PlayerCommand::Undo);
1979        assert!(playlist_ids(&player).is_empty());
1980    }
1981
1982    #[test]
1983    fn new_action_clears_redo_stack() {
1984        let mut player = Player::new();
1985        let items = vec![make_item("A")];
1986
1987        player.process_command(PlayerCommand::AddToPlaylist(items));
1988        player.process_command(PlayerCommand::Undo);
1989        assert!(player.undo_stack().can_redo());
1990
1991        // New action should clear redo
1992        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1993        assert!(!player.undo_stack().can_redo());
1994    }
1995
1996    #[test]
1997    fn undo_on_empty_stack_is_noop() {
1998        let mut player = Player::new();
1999        player.process_command(PlayerCommand::Undo);
2000        assert!(playlist_ids(&player).is_empty());
2001    }
2002
2003    #[test]
2004    fn redo_on_empty_stack_is_noop() {
2005        let mut player = Player::new();
2006        player.process_command(PlayerCommand::Redo);
2007        assert!(playlist_ids(&player).is_empty());
2008    }
2009
2010    // --- Non-undoable commands don't push entries ---
2011
2012    #[test]
2013    fn playback_commands_not_undoable() {
2014        let mut player = Player::new();
2015        player.process_command(PlayerCommand::Pause);
2016        player.process_command(PlayerCommand::Resume);
2017        player.process_command(PlayerCommand::NextTrack);
2018        player.process_command(PlayerCommand::PrevTrack);
2019        assert!(!player.undo_stack().can_undo());
2020    }
2021
2022    #[test]
2023    fn update_paths_not_undoable() {
2024        let mut player = Player::new();
2025        let items = vec![make_item("A")];
2026        let id = items[0].id;
2027        player.process_command(PlayerCommand::AddToPlaylist(items));
2028
2029        let undo_count = player.undo_stack().undo_len();
2030        player.process_command(PlayerCommand::UpdatePaths(vec![(
2031            id,
2032            PathBuf::from("/new/path.flac"),
2033        )]));
2034        assert_eq!(player.undo_stack().undo_len(), undo_count);
2035    }
2036
2037    // --- Complex scenarios ---
2038
2039    #[test]
2040    fn add_remove_undo_undo_produces_original() {
2041        let mut player = Player::new();
2042        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2043        let b_id = items[1].id;
2044        let original_titles = vec!["A", "B", "C"];
2045
2046        player.process_command(PlayerCommand::AddToPlaylist(items));
2047        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2048        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2049
2050        // Undo remove → back to A, B, C
2051        player.process_command(PlayerCommand::Undo);
2052        assert_eq!(playlist_titles(&player), original_titles);
2053
2054        // Undo add → empty
2055        player.process_command(PlayerCommand::Undo);
2056        assert!(playlist_ids(&player).is_empty());
2057    }
2058
2059    #[test]
2060    fn interleaved_adds_and_moves_undo() {
2061        let mut player = Player::new();
2062        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2063        let a_id = items[0].id;
2064        let c_id = items[2].id;
2065
2066        player.process_command(PlayerCommand::AddToPlaylist(items));
2067
2068        // Move A after C: [B, C, A]
2069        player.process_command(PlayerCommand::MoveInPlaylist {
2070            id: a_id,
2071            target: c_id,
2072            after: true,
2073        });
2074        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2075
2076        // Add D: [B, C, A, D]
2077        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
2078        assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
2079
2080        // Undo add D: [B, C, A]
2081        player.process_command(PlayerCommand::Undo);
2082        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2083
2084        // Undo move: [A, B, C]
2085        player.process_command(PlayerCommand::Undo);
2086        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2087    }
2088
2089    /// Regression test for GitHub #89: AudioEngine must be dropped synchronously
2090    /// in stop_engine() before the caller changes sample rates. If the engine is
2091    /// dropped on a background thread, CoreAudio's internal buffer list can be
2092    /// freed while AudioUnitUninitialize is still tearing it down → crash.
2093    #[test]
2094    fn stop_engine_drops_engine_synchronously() {
2095        use std::sync::atomic::AtomicBool;
2096
2097        struct MockEngine {
2098            dropped: Arc<AtomicBool>,
2099        }
2100        impl AudioEngineHandle for MockEngine {
2101            fn start(&self) -> Result<(), BackendError> {
2102                Ok(())
2103            }
2104            fn stop(&self) -> Result<(), BackendError> {
2105                Ok(())
2106            }
2107            fn is_running(&self) -> bool {
2108                false
2109            }
2110        }
2111        impl Drop for MockEngine {
2112            fn drop(&mut self) {
2113                self.dropped.store(true, Ordering::SeqCst);
2114            }
2115        }
2116
2117        let dropped = Arc::new(AtomicBool::new(false));
2118
2119        // Build a minimal decode handle that won't block.
2120        let stop_flag = Arc::new(AtomicBool::new(false));
2121        let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
2122
2123        let mut player = Player::new();
2124        player.active_playback = Some(ActivePlayback {
2125            engine: Box::new(MockEngine {
2126                dropped: dropped.clone(),
2127            }),
2128            decode_handle,
2129        });
2130
2131        player.stop_engine();
2132
2133        // The engine must already be dropped when stop_engine returns.
2134        // If this fails, the engine was moved to a background thread — the
2135        // exact race condition that causes the #89 crash.
2136        assert!(
2137            dropped.load(Ordering::SeqCst),
2138            "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
2139        );
2140    }
2141
2142    // --- Engine format matches the decoded PCM ---
2143
2144    /// Backend pinned to one sample rate that refuses every switch, recording
2145    /// the format the engine is asked for.
2146    struct StuckBackend {
2147        rate: f64,
2148        asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
2149    }
2150
2151    struct NullEngine;
2152    impl AudioEngineHandle for NullEngine {
2153        fn start(&self) -> Result<(), BackendError> {
2154            Ok(())
2155        }
2156        fn stop(&self) -> Result<(), BackendError> {
2157            Ok(())
2158        }
2159        fn is_running(&self) -> bool {
2160            false
2161        }
2162    }
2163
2164    impl AudioBackend for StuckBackend {
2165        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2166            Ok(vec![self.default_device()?])
2167        }
2168        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2169            Ok(backend::DeviceInfo {
2170                name: "Stuck DAC".into(),
2171                sample_rates: vec![self.rate],
2172                platform_id: 0,
2173            })
2174        }
2175        fn supported_sample_rates(
2176            &self,
2177            _device: &backend::DeviceInfo,
2178        ) -> Result<Vec<f64>, BackendError> {
2179            Ok(vec![self.rate])
2180        }
2181        fn get_device_sample_rate(
2182            &self,
2183            _device: &backend::DeviceInfo,
2184        ) -> Result<f64, BackendError> {
2185            Ok(self.rate)
2186        }
2187        fn set_device_sample_rate(
2188            &self,
2189            _device: &backend::DeviceInfo,
2190            rate: f64,
2191        ) -> Result<f64, BackendError> {
2192            Err(BackendError::UnsupportedSampleRate(rate))
2193        }
2194        fn create_engine(
2195            &self,
2196            _device: &backend::DeviceInfo,
2197            sample_rate: f64,
2198            channels: u32,
2199            _consumer: rtrb::Consumer<f32>,
2200            _samples_played: Arc<AtomicU64>,
2201        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2202            *self.asked.lock().unwrap() = Some((sample_rate, channels));
2203            Ok(Box::new(NullEngine))
2204        }
2205    }
2206
2207    fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
2208        let asked = Arc::new(std::sync::Mutex::new(None));
2209        let mut player = Player::new();
2210        player.backend = Box::new(StuckBackend {
2211            rate: device_rate,
2212            asked: asked.clone(),
2213        });
2214
2215        let info = buffer::StreamInfo {
2216            codec: "MP3".into(),
2217            sample_rate: source_rate,
2218            channels,
2219            bit_depth: Some(16),
2220            bitrate_kbps: None,
2221            duration_ms: 1000,
2222        };
2223        let (_producer, consumer) = rtrb::RingBuffer::new(16);
2224        player
2225            .create_engine_for(&info, consumer)
2226            .expect("engine creation should succeed");
2227        let asked = *asked.lock().unwrap();
2228        asked.expect("engine was never created")
2229    }
2230
2231    #[test]
2232    fn engine_uses_source_rate_when_device_refuses_switch() {
2233        // MPEG-2 MP3 rates are routinely rejected by output devices. The engine
2234        // must still be told the rate the PCM actually is.
2235        assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
2236        assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
2237    }
2238
2239    #[test]
2240    fn engine_uses_source_channel_count() {
2241        assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
2242    }
2243
2244    /// The rate the device settled at, as the front ends read it.
2245    fn settled_rate_for(source_rate: u32, device_rate: f64) -> Option<u32> {
2246        let mut player = Player::new();
2247        player.backend = Box::new(StuckBackend {
2248            rate: device_rate,
2249            asked: Arc::new(std::sync::Mutex::new(None)),
2250        });
2251        let state = player.shared_state.clone();
2252
2253        let info = buffer::StreamInfo {
2254            codec: "MP3".into(),
2255            sample_rate: source_rate,
2256            channels: 2,
2257            bit_depth: Some(16),
2258            bitrate_kbps: None,
2259            duration_ms: 1000,
2260        };
2261        let (_producer, consumer) = rtrb::RingBuffer::new(16);
2262        player
2263            .create_engine_for(&info, consumer)
2264            .expect("engine creation should succeed");
2265        state.output_sample_rate()
2266    }
2267
2268    #[test]
2269    fn settled_device_rate_reaches_the_shared_state() {
2270        // A device that refuses the switch is being fed resampled audio, and
2271        // that is the case the front ends have to be able to see. Before this
2272        // the comparison happened once, in a log line.
2273        assert_eq!(settled_rate_for(22050, 48000.0), Some(48000));
2274        // No switch needed, so nothing resampled: the two rates agree.
2275        assert_eq!(settled_rate_for(44100, 44100.0), Some(44100));
2276    }
2277}