Skip to main content

koan_core/player/
mod.rs

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