Skip to main content

koan_core/player/
mod.rs

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