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