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.
24const 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}
55
56/// Holds the resources for an active playback session.
57struct ActivePlayback {
58    engine: Box<dyn AudioEngineHandle>,
59    decode_handle: buffer::DecodeHandle,
60}
61
62impl Default for Player {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl Player {
69    pub fn new() -> Self {
70        let viz_buffer = VizBuffer::new();
71        let viz_snapshot = VizSnapshot::new();
72        let cfg = crate::config::Config::load_or_default();
73        let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
74            Arc::clone(&viz_buffer),
75            &cfg.visualizer,
76            Arc::clone(&viz_snapshot),
77        );
78
79        Self {
80            shared_state: SharedPlayerState::new(),
81            commands: CommandChannel::new(),
82            active_playback: None,
83            timeline: PlaybackTimeline::new(),
84            viz_buffer,
85            viz_snapshot,
86            _viz_analyzer: viz_analyzer,
87            undo_stack: UndoStack::new(),
88            batch_buffer: None,
89            output_device_name: cfg.playback.output_device.clone(),
90            backend: crate::audio::platform_backend(),
91            last_skip: std::time::Instant::now(),
92        }
93    }
94
95    /// Get a clone of the shared state for UI reads.
96    pub fn shared_state(&self) -> Arc<SharedPlayerState> {
97        self.shared_state.clone()
98    }
99
100    /// Get the playback timeline for UI reads.
101    pub fn timeline(&self) -> Arc<PlaybackTimeline> {
102        self.timeline.clone()
103    }
104
105    /// Get the visualization buffer for the TUI.
106    pub fn viz_buffer(&self) -> Arc<VizBuffer> {
107        self.viz_buffer.clone()
108    }
109
110    /// Get the shared analysis snapshot for the TUI.
111    /// The analysis thread writes here; the UI thread reads a clone each frame.
112    pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
113        self.viz_snapshot.clone()
114    }
115
116    /// Access undo stack (for tests and UI state queries).
117    pub fn undo_stack(&self) -> &UndoStack {
118        &self.undo_stack
119    }
120
121    /// Resolve the output device: use configured device name if set,
122    /// falling back to system default if not set or if the named device is unavailable.
123    fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
124        if let Some(ref name) = self.output_device_name {
125            match self.backend.list_devices() {
126                Ok(devices) => {
127                    if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
128                        return Ok(dev);
129                    }
130                    log::warn!(
131                        "configured output device '{}' not found, falling back to default",
132                        name,
133                    );
134                }
135                Err(e) => {
136                    log::warn!("failed to list devices while resolving '{}': {}", name, e);
137                }
138            }
139        }
140        Ok(self.backend.default_device()?)
141    }
142
143    /// Switch the output device. Persists to config and restarts the engine
144    /// on the current track if playing.
145    pub fn set_output_device(&mut self, name: String) {
146        log::info!("switching output device to: {}", name);
147        self.output_device_name = Some(name.clone());
148
149        // Persist to config.toml (not the merged config — avoids leaking secrets).
150        if let Err(e) = crate::config::Config::update_base(|cfg| {
151            cfg.playback.output_device = Some(name);
152        }) {
153            log::error!("failed to save output device config: {}", e);
154        }
155
156        self.restart_on_current_track();
157    }
158
159    /// Clear the configured output device, reverting to system default.
160    pub fn clear_output_device(&mut self) {
161        log::info!("reverting to system default output device");
162        self.output_device_name = None;
163
164        if let Err(e) = crate::config::Config::update_base(|cfg| {
165            cfg.playback.output_device = None;
166        }) {
167            log::error!("failed to save output device config: {}", e);
168        }
169
170        self.restart_on_current_track();
171    }
172
173    /// If a track is currently playing or paused, restart playback at the
174    /// current position (e.g. after switching output devices). Preserves pause state.
175    fn restart_on_current_track(&mut self) {
176        if let Some(info) = self.shared_state.track_info() {
177            let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
178            let position_ms = self.shared_state.position_ms();
179            if let Err(e) = self.start_playback(info.id, &info.path, position_ms) {
180                log::error!("failed to restart playback on device switch: {}", e);
181                return;
182            }
183            if was_paused {
184                self.pause();
185            }
186        }
187    }
188
189    /// Get the current output device name (if configured).
190    pub fn output_device_name(&self) -> Option<&str> {
191        self.output_device_name.as_deref()
192    }
193
194    /// Get a command sender for the UI layer.
195    pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
196        self.commands.tx.clone()
197    }
198
199    /// Play a specific item in the playlist by ID.
200    /// Sets cursor, starts playback if Ready or streaming-ready, otherwise waits for TrackReady.
201    pub fn play(&mut self, id: QueueItemId) {
202        self.shared_state.set_cursor(Some(id));
203
204        match self.shared_state.item_playback_source(id) {
205            Some(PlaybackSource::Ready(path)) => {
206                if let Err(e) = self.start_playback(id, &path, 0) {
207                    log::error!("play failed: {}", e);
208                }
209            }
210            Some(PlaybackSource::Streaming {
211                path,
212                bytes_written,
213                total,
214            }) => {
215                if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
216                    log::error!("streaming play failed, waiting for full download: {}", e);
217                    // Fall back to waiting for TrackReady.
218                    self.stop_engine();
219                    self.shared_state.set_playback_state(PlaybackState::Stopped);
220                }
221            }
222            None => {
223                // Item not ready — stop current playback, wait for TrackReady.
224                self.stop_engine();
225                self.shared_state.set_playback_state(PlaybackState::Stopped);
226                log::info!("play: item {:?} not ready, waiting for TrackReady", id);
227            }
228        }
229    }
230
231    /// Internal: start playback of a file.
232    fn start_playback(
233        &mut self,
234        id: QueueItemId,
235        path: &Path,
236        seek_ms: u64,
237    ) -> Result<(), PlayerError> {
238        self.stop_engine();
239
240        let info = buffer::probe_file(path)?;
241
242        // Set track_info + position immediately so the UI never sees a gap.
243        // For seeks, this keeps the bar at the target position instead of
244        // flashing to 0 while the new timeline spins up.
245        self.shared_state.set_track_info(Some(TrackInfo {
246            id,
247            path: path.to_path_buf(),
248            codec: info.codec.clone(),
249            sample_rate: info.sample_rate,
250            bit_depth: info.bit_depth,
251            channels: info.channels,
252            duration_ms: info.duration_ms,
253        }));
254        self.shared_state.set_position_ms(seek_ms);
255        log::info!(
256            "playing: {} ({:?}) — {} {}Hz/{}bit/{}ch, {}ms{}",
257            path.display(),
258            id,
259            info.codec,
260            info.sample_rate,
261            info.bit_depth,
262            info.channels,
263            info.duration_ms,
264            if seek_ms > 0 {
265                format!(" @{}ms", seek_ms)
266            } else {
267                String::new()
268            }
269        );
270
271        let device = self.resolve_device()?;
272        let device_rate = self.backend.get_device_sample_rate(&device)?;
273        let source_rate = info.sample_rate as f64;
274
275        let actual_rate = if (device_rate - source_rate).abs() > 0.1 {
276            log::info!(
277                "switching device sample rate: {}Hz → {}Hz",
278                device_rate,
279                source_rate
280            );
281            match self.backend.set_device_sample_rate(&device, source_rate) {
282                Ok(confirmed_rate) => {
283                    if (confirmed_rate - source_rate).abs() > 0.1 {
284                        log::warn!(
285                            "device stayed at {}Hz (wanted {}Hz) — playback will use device rate",
286                            confirmed_rate,
287                            source_rate
288                        );
289                    }
290                    confirmed_rate
291                }
292                Err(e) => {
293                    log::warn!(
294                        "failed to set sample rate (continuing at device rate): {}",
295                        e
296                    );
297                    device_rate
298                }
299            }
300        } else {
301            device_rate
302        };
303
304        let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
305
306        let _generation = self.shared_state.bump_generation();
307
308        // Reset timeline for new playback session and start decode.
309        self.timeline.reset();
310
311        // Gapless lookahead: the decode thread maintains its own cursor
312        // (separate from the UI cursor) so it can look ahead through the
313        // playlist without affecting what the UI shows as "now playing".
314        let advance_state = self.shared_state.clone();
315        let decode_cursor = std::sync::Mutex::new(Some(id));
316        let next_track = move || {
317            let current = decode_cursor.lock().ok()?.take()?;
318            let next = advance_state.peek_next_ready_after(current);
319            if let Some((next_id, _)) = &next
320                && let Ok(mut guard) = decode_cursor.lock()
321            {
322                *guard = Some(*next_id);
323            }
324            next
325        };
326
327        // Load ReplayGain config for this playback session.
328        let cfg = crate::config::Config::load_or_default();
329        let rg_mode = cfg.playback.replaygain;
330        let pre_amp_db = cfg.playback.pre_amp_db;
331
332        let finish_tx = self.commands.tx.clone();
333        let (_stream_info, decode_handle) = buffer::start_decode_file(
334            id,
335            path,
336            producer,
337            seek_ms,
338            next_track,
339            self.timeline.clone(),
340            Some(self.viz_buffer.clone()),
341            rg_mode,
342            pre_amp_db,
343            move || {
344                finish_tx.send(PlayerCommand::DecodeFinished).ok();
345            },
346        )?;
347
348        // Create and start audio engine with the confirmed device rate.
349        let engine = self.backend.create_engine(
350            &device,
351            actual_rate,
352            info.channels as u32,
353            consumer,
354            self.timeline.samples_played_counter(),
355        )?;
356        engine.start()?;
357
358        self.shared_state.set_playback_state(PlaybackState::Playing);
359
360        self.active_playback = Some(ActivePlayback {
361            engine,
362            decode_handle,
363        });
364
365        Ok(())
366    }
367
368    /// Internal: start streaming playback from a partially-downloaded file.
369    ///
370    /// Creates a StreamBuffer and a pump thread that reads from the on-disk partial
371    /// file as bytes become available (tracked via `bytes_written`). The decode thread
372    /// reads from a StreamingSource backed by that buffer, blocking briefly when it
373    /// catches up to the write head.
374    fn start_streaming_playback(
375        &mut self,
376        id: QueueItemId,
377        path: &Path,
378        bytes_written: Arc<AtomicU64>,
379        total: u64,
380    ) -> Result<(), PlayerError> {
381        self.stop_engine();
382
383        // Create a StreamBuffer with known total length.
384        let stream_buf = streaming::StreamBuffer::new(if total > 0 { Some(total) } else { None });
385
386        // Spawn a pump thread: reads bytes from the on-disk partial file as they
387        // become available (per bytes_written) and pushes them into StreamBuffer.
388        // This bridges the disk-based download with StreamingSource's in-memory design.
389        // The playlist item's path points to the .part file during download, so the
390        // pump opens the correct file. After download completes, the .part is renamed
391        // to the final path and the item path is updated — but the pump's open FD
392        // remains valid (Unix rename semantics).
393        let pump_path = path.to_path_buf();
394        let pump_buf = stream_buf.clone();
395        let pump_written = bytes_written.clone();
396        thread::Builder::new()
397            .name("koan-stream-pump".into())
398            .spawn(move || {
399                use std::fs::File;
400                use std::io::Read;
401                let mut file = match File::open(&pump_path) {
402                    Ok(f) => f,
403                    Err(e) => {
404                        log::error!("stream pump: failed to open {}: {}", pump_path.display(), e);
405                        pump_buf.finish();
406                        return;
407                    }
408                };
409                let mut buf = vec![0u8; 65536];
410                let mut offset: u64 = 0;
411                loop {
412                    let available = pump_written.load(Ordering::Acquire);
413                    if offset >= available {
414                        if total > 0 && available >= total {
415                            break; // Download complete.
416                        }
417                        thread::sleep(std::time::Duration::from_millis(10));
418                        continue;
419                    }
420                    let to_read = ((available - offset) as usize).min(buf.len());
421                    match file.read(&mut buf[..to_read]) {
422                        Ok(0) => {
423                            // File data may lag behind bytes_written (OS buffer flush timing).
424                            // Only treat as true EOF if we've pumped all expected data.
425                            if total > 0 && offset >= total {
426                                break;
427                            }
428                            let latest = pump_written.load(Ordering::Acquire);
429                            if total > 0 && latest >= total && offset >= latest {
430                                break;
431                            }
432                            // Data not yet visible on disk — back off and retry.
433                            thread::sleep(std::time::Duration::from_millis(1));
434                            continue;
435                        }
436                        Ok(n) => {
437                            pump_buf.push(&buf[..n]);
438                            offset += n as u64;
439                        }
440                        Err(e) => {
441                            log::warn!("stream pump read error: {}", e);
442                            break;
443                        }
444                    }
445                }
446                pump_buf.finish();
447            })
448            .map_err(|e| PlayerError::Decode(buffer::DecodeError::Io(e)))?;
449
450        // Probe via a streaming reader — blocks (via condvar) until enough header data arrives.
451        let probe_reader = stream_buf.reader();
452        let probe_hint = {
453            let mut h = symphonia::core::probe::Hint::new();
454            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
455                h.with_extension(ext);
456            }
457            h
458        };
459        let probe_mss =
460            symphonia::core::io::MediaSourceStream::new(Box::new(probe_reader), Default::default());
461        let info = buffer::probe_source(probe_mss, &probe_hint)?;
462
463        self.shared_state.set_track_info(Some(TrackInfo {
464            id,
465            path: path.to_path_buf(),
466            codec: info.codec.clone(),
467            sample_rate: info.sample_rate,
468            bit_depth: info.bit_depth,
469            channels: info.channels,
470            duration_ms: info.duration_ms,
471        }));
472        self.shared_state.set_position_ms(0);
473        log::info!(
474            "streaming: {} ({:?}) — {} {}Hz/{}bit/{}ch, {}ms",
475            path.display(),
476            id,
477            info.codec,
478            info.sample_rate,
479            info.bit_depth,
480            info.channels,
481            info.duration_ms,
482        );
483
484        let device = self.resolve_device()?;
485        let device_rate = self.backend.get_device_sample_rate(&device)?;
486        let source_rate = info.sample_rate as f64;
487
488        let actual_rate = if (device_rate - source_rate).abs() > 0.1 {
489            log::info!(
490                "switching device sample rate: {}Hz → {}Hz",
491                device_rate,
492                source_rate
493            );
494            match self.backend.set_device_sample_rate(&device, source_rate) {
495                Ok(confirmed_rate) => {
496                    if (confirmed_rate - source_rate).abs() > 0.1 {
497                        log::warn!(
498                            "device stayed at {}Hz (wanted {}Hz) — playback will use device rate",
499                            confirmed_rate,
500                            source_rate
501                        );
502                    }
503                    confirmed_rate
504                }
505                Err(e) => {
506                    log::warn!(
507                        "failed to set sample rate (continuing at device rate): {}",
508                        e
509                    );
510                    device_rate
511                }
512            }
513        } else {
514            device_rate
515        };
516
517        let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
518
519        let _generation = self.shared_state.bump_generation();
520        self.timeline.reset();
521
522        // Gapless lookahead after streaming: next track uses normal file path.
523        let advance_state = self.shared_state.clone();
524        let decode_cursor = std::sync::Mutex::new(Some(id));
525        let next_track = move || {
526            let current = decode_cursor.lock().ok()?.take()?;
527            let next = advance_state.peek_next_ready_after(current);
528            if let Some((next_id, _)) = &next
529                && let Ok(mut guard) = decode_cursor.lock()
530            {
531                *guard = Some(*next_id);
532            }
533            next
534        };
535
536        // Decode using a fresh StreamingSource reader — reads from the StreamBuffer
537        // that the pump thread feeds. The decode thread blocks when it catches up to
538        // the write head, resuming as more data arrives.
539        // Build a SourceEntry using a fresh StreamingSource reader for the decode thread.
540        let decode_reader = stream_buf.reader();
541        let path_buf = path.to_path_buf();
542        let ext = path
543            .extension()
544            .and_then(|e| e.to_str())
545            .unwrap_or("")
546            .to_string();
547        let mut decode_hint = symphonia::core::probe::Hint::new();
548        if !ext.is_empty() {
549            decode_hint.with_extension(&ext);
550        }
551        let first = buffer::SourceEntry {
552            id,
553            path: path_buf,
554            hint: decode_hint,
555            make_mss: Box::new(move || {
556                Ok(symphonia::core::io::MediaSourceStream::new(
557                    Box::new(decode_reader),
558                    Default::default(),
559                ))
560            }),
561        };
562
563        // Load ReplayGain config for this streaming session.
564        let cfg = crate::config::Config::load_or_default();
565        let rg_mode = cfg.playback.replaygain;
566        let pre_amp_db = cfg.playback.pre_amp_db;
567
568        let finish_tx = self.commands.tx.clone();
569        let (_stream_info, decode_handle) = buffer::start_decode(
570            first,
571            producer,
572            0,
573            move || {
574                let (next_id, next_path) = next_track()?;
575                Some(buffer::SourceEntry::from_file(next_id, next_path))
576            },
577            self.timeline.clone(),
578            Some(self.viz_buffer.clone()),
579            rg_mode,
580            pre_amp_db,
581            move || {
582                finish_tx.send(PlayerCommand::DecodeFinished).ok();
583            },
584        )?;
585
586        // Create and start audio engine with the confirmed device rate.
587        let engine = self.backend.create_engine(
588            &device,
589            actual_rate,
590            info.channels as u32,
591            consumer,
592            self.timeline.samples_played_counter(),
593        )?;
594        engine.start()?;
595
596        self.shared_state.set_playback_state(PlaybackState::Playing);
597
598        self.active_playback = Some(ActivePlayback {
599            engine,
600            decode_handle,
601        });
602
603        Ok(())
604    }
605
606    /// Seek within the current track. Clamps to just before the end to avoid
607    /// accidentally skipping. Preserves pause state.
608    pub fn seek(&mut self, position_ms: u64) {
609        let info = match self.shared_state.track_info() {
610            Some(info) => info,
611            None => return,
612        };
613        let id = info.id;
614        let path = info.path.clone();
615        let duration = info.duration_ms;
616
617        // Clamp to just before the end so we don't skip to the next track.
618        let mut clamped = if duration > 0 {
619            position_ms.min(duration.saturating_sub(500))
620        } else {
621            position_ms
622        };
623
624        // Clamp to downloaded portion if streaming to prevent seeking into
625        // data that hasn't arrived yet.
626        if let Some(dl_frac) = self.shared_state.current_download_fraction() {
627            let max_ms = (dl_frac * duration as f64) as u64;
628            if max_ms > 5_000 {
629                clamped = clamped.min(max_ms - 5_000);
630            }
631        }
632
633        let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
634
635        if let Err(e) = self.start_playback(id, &path, clamped) {
636            log::error!("seek failed: {}", e);
637            return;
638        }
639
640        if was_paused {
641            self.pause();
642        }
643    }
644
645    /// Skip to next track in playlist.
646    pub fn next_track(&mut self) {
647        // advance_cursor finds the next Ready item after cursor.
648        match self.shared_state.advance_cursor() {
649            Some((id, path)) => {
650                if let Err(e) = self.start_playback(id, &path, 0) {
651                    log::error!("next track failed: {}", e);
652                }
653            }
654            None => {
655                log::info!("no more tracks in playlist");
656                self.stop_playback_and_clear_state();
657            }
658        }
659    }
660
661    /// Go back to previous track.
662    pub fn prev_track(&mut self) {
663        match self.shared_state.retreat_cursor() {
664            Some((id, path)) => {
665                if matches!(path.try_exists(), Ok(true)) {
666                    if let Err(e) = self.start_playback(id, &path, 0) {
667                        log::error!("prev track failed: {}", e);
668                    }
669                } else {
670                    log::warn!("prev track path doesn't exist: {}", path.display());
671                }
672            }
673            None => {
674                // No previous track — restart current from the beginning.
675                if let Some(info) = self.shared_state.track_info()
676                    && let Err(e) = self.start_playback(info.id, &info.path, 0)
677                {
678                    log::error!("restart failed: {}", e);
679                }
680            }
681        }
682    }
683
684    /// Pause playback.
685    pub fn pause(&mut self) {
686        if let Some(ref playback) = self.active_playback {
687            let _ = playback.engine.stop();
688            self.shared_state.set_playback_state(PlaybackState::Paused);
689        }
690    }
691
692    /// Resume playback.
693    pub fn resume(&mut self) {
694        if let Some(ref playback) = self.active_playback {
695            let _ = playback.engine.start();
696            self.shared_state.set_playback_state(PlaybackState::Playing);
697        }
698    }
699
700    /// Stop playback and clear playlist.
701    pub fn stop(&mut self) {
702        self.shared_state.clear_playlist();
703        self.stop_playback_and_clear_state();
704    }
705
706    /// Stop the audio engine and decode thread without touching shared state.
707    ///
708    /// The engine is stopped synchronously (silence begins immediately), but
709    /// the heavy teardown (decode thread join + AudioUnit dispose) is moved to
710    /// a background thread so the player command loop never blocks — preventing
711    /// UI freezes when CoreAudio or the decode thread is slow to shut down.
712    fn stop_engine(&mut self) {
713        if let Some(playback) = self.active_playback.take() {
714            // Stop audio output immediately.
715            let _ = playback.engine.stop();
716            // Signal decode thread to exit (non-blocking).
717            playback.decode_handle.signal_stop();
718
719            // Drop the audio engine synchronously. AudioUnitUninitialize must
720            // complete before any device sample rate change, otherwise CoreAudio's
721            // internal ExtendedAudioBufferList can be freed mid-teardown (crash
722            // in caulk::alloc::tiered_allocator::deallocate — GitHub #89).
723            let ActivePlayback {
724                engine,
725                decode_handle,
726            } = playback;
727            drop(engine);
728
729            // The decode handle join is the heavy part (waits for the decode
730            // thread to exit) — run it on a background thread so we don't block
731            // the player command loop.
732            thread::Builder::new()
733                .name("koan-cleanup".into())
734                .spawn(move || drop(decode_handle))
735                .ok();
736        }
737    }
738
739    /// Full stop: tear down engine + clear all display state.
740    fn stop_playback_and_clear_state(&mut self) {
741        self.stop_engine();
742        self.timeline.reset();
743        self.shared_state.set_playback_state(PlaybackState::Stopped);
744        self.shared_state.set_position_ms(0);
745        self.shared_state.set_track_info(None);
746    }
747
748    /// Remove a track from the playlist. If it was the cursor, skip to next.
749    pub fn remove_from_playlist(&mut self, id: QueueItemId) {
750        let was_cursor = self.shared_state.is_cursor(id);
751        self.shared_state.remove_item(id);
752        if was_cursor {
753            self.next_track();
754        }
755    }
756
757    /// A download finished — if cursor is waiting on this item, start playback.
758    /// If already streaming this item, trigger progressive metadata enhancement.
759    pub fn track_ready(&mut self, id: QueueItemId) {
760        // Mark as Ready (download thread already did this, but be safe).
761        self.shared_state.update_load_state(id, LoadState::Ready);
762
763        if !self.shared_state.is_cursor(id) {
764            return;
765        }
766
767        let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
768        let current_track_id = self.shared_state.track_info().map(|t| t.id);
769
770        if is_playing && current_track_id == Some(id) {
771            // Already streaming this track — download just finished.
772            // Trigger progressive enhancement: re-read full lofty metadata and update state.
773            log::info!(
774                "track_ready: download complete while streaming {:?}, refreshing metadata",
775                id
776            );
777            self.refresh_track_metadata(id);
778            return;
779        }
780
781        // Cursor is on this item but not yet playing — start playback now.
782        if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
783            log::info!("track_ready: starting playback for {:?}", id);
784            if let Err(e) = self.start_playback(id, &path, 0) {
785                log::error!("track_ready playback failed: {}", e);
786            }
787        }
788    }
789
790    /// Called when enough data has been buffered for streaming playback.
791    /// If the cursor is waiting on this track and nothing is playing, start streaming.
792    pub fn track_stream_ready(&mut self, id: QueueItemId) {
793        if !self.shared_state.is_cursor(id) {
794            return;
795        }
796
797        let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
798        if is_playing {
799            return; // Already playing something — don't interrupt.
800        }
801
802        match self.shared_state.item_playback_source(id) {
803            Some(PlaybackSource::Streaming {
804                path,
805                bytes_written,
806                total,
807            }) => {
808                log::info!(
809                    "track_stream_ready: starting streaming playback for {:?}",
810                    id
811                );
812                if let Err(e) = self.start_streaming_playback(id, &path, bytes_written, total) {
813                    log::error!("track_stream_ready streaming failed: {}", e);
814                }
815            }
816            Some(PlaybackSource::Ready(path)) => {
817                // Download finished between threshold and now — just play normally.
818                log::info!(
819                    "track_stream_ready: track already ready, starting normal playback for {:?}",
820                    id
821                );
822                if let Err(e) = self.start_playback(id, &path, 0) {
823                    log::error!("track_stream_ready playback failed: {}", e);
824                }
825            }
826            None => {} // Not enough data yet — wait.
827        }
828    }
829
830    /// Re-read full lofty metadata for a track after its download completes.
831    /// Updates the playlist item's tags and track_info with complete metadata.
832    /// Called from track_ready() when a streaming track finishes downloading.
833    fn refresh_track_metadata(&mut self, id: QueueItemId) {
834        use crate::index::metadata;
835
836        let path = match self.shared_state.item_path_if_ready(id) {
837            Some(p) => p,
838            None => return,
839        };
840
841        match metadata::read_metadata(&path) {
842            Ok(meta) => {
843                // Update playlist item with full lofty tags (title, artist, album, duration).
844                self.shared_state.update_item_metadata(
845                    id,
846                    meta.title,
847                    meta.artist,
848                    meta.album_artist.unwrap_or_default(),
849                    meta.album,
850                    meta.duration_ms.map(|d| d as u64),
851                );
852
853                // Re-probe the complete file for accurate duration + stream info.
854                // The initial probe was done on partial streaming data and may have
855                // underestimated duration, causing premature seek clamping or wrong
856                // progress bar display.
857                if let Ok(stream_info) = buffer::probe_file(&path)
858                    && let Some(current) = self.shared_state.track_info()
859                    && current.id == id
860                    && stream_info.duration_ms > current.duration_ms
861                {
862                    log::info!(
863                        "track_ready: duration corrected {}ms → {}ms",
864                        current.duration_ms,
865                        stream_info.duration_ms
866                    );
867                    self.shared_state.set_track_info(Some(TrackInfo {
868                        duration_ms: stream_info.duration_ms,
869                        ..current
870                    }));
871                }
872
873                // Signal UI to re-read cover art and update souvlaki media controls.
874                self.shared_state.signal_metadata_refresh();
875                log::info!("track_ready: metadata refreshed for {:?}", id);
876            }
877            Err(e) => {
878                log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
879            }
880        }
881    }
882
883    /// Poll the timeline and update shared state with current track/position.
884    /// Called from the command loop on each tick.
885    pub fn update_playback_state(&self) {
886        if self.active_playback.is_none() {
887            return;
888        }
889
890        if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
891            self.shared_state.set_position_ms(position_ms);
892
893            // Update track_info + cursor if the timeline shows a different track
894            // (gapless transition happened).
895            let current_id = self.shared_state.track_info().map(|t| t.id);
896            if current_id != Some(id) {
897                log::info!("timeline: now playing {:?}", id);
898                self.shared_state.set_track_info(Some(TrackInfo {
899                    id,
900                    path,
901                    codec: info.codec,
902                    sample_rate: info.sample_rate,
903                    bit_depth: info.bit_depth,
904                    channels: info.channels,
905                    duration_ms: info.duration_ms,
906                }));
907                self.shared_state.set_cursor(Some(id));
908            }
909        }
910    }
911
912    /// Decode thread naturally finished (playlist exhausted or error).
913    /// Try to advance to the next Ready track; otherwise stop cleanly.
914    fn on_decode_finished(&mut self) {
915        log::info!("decode finished, checking for next track");
916        match self.shared_state.advance_cursor() {
917            Some((id, path)) => {
918                if let Err(e) = self.start_playback(id, &path, 0) {
919                    log::error!("auto-advance after decode finished failed: {}", e);
920                    self.stop_playback_and_clear_state();
921                }
922            }
923            None => {
924                log::info!("no more tracks — stopping");
925                self.stop_playback_and_clear_state();
926            }
927        }
928    }
929
930    /// Route an undo entry to the batch buffer (if batching) or the undo stack.
931    fn push_undo(&mut self, entry: UndoEntry) {
932        if let Some(ref mut batch) = self.batch_buffer {
933            batch.push(entry);
934        } else {
935            self.undo_stack.push(entry);
936        }
937    }
938
939    /// Process a single command.
940    pub fn process_command(&mut self, cmd: PlayerCommand) {
941        match cmd {
942            PlayerCommand::Play(id) => self.play(id),
943            PlayerCommand::Pause => self.pause(),
944            PlayerCommand::Resume => self.resume(),
945            PlayerCommand::Stop => self.stop(),
946            PlayerCommand::Seek(pos) => self.seek(pos),
947            PlayerCommand::NextTrack => {
948                // Debounce: suppress key repeat from terminal (150ms window).
949                let now = std::time::Instant::now();
950                if now.duration_since(self.last_skip).as_millis() >= 150 {
951                    self.last_skip = now;
952                    self.next_track();
953                }
954            }
955            PlayerCommand::PrevTrack => {
956                let now = std::time::Instant::now();
957                if now.duration_since(self.last_skip).as_millis() >= 150 {
958                    self.last_skip = now;
959                    self.prev_track();
960                }
961            }
962            PlayerCommand::AddToPlaylist(items) => {
963                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
964                self.shared_state.add_items(items);
965                self.push_undo(UndoEntry::Added { ids });
966            }
967            PlayerCommand::UpdatePaths(updates) => {
968                self.shared_state.update_paths(&updates);
969                if let Some(info) = self.shared_state.track_info()
970                    && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
971                {
972                    self.shared_state.set_track_info(Some(TrackInfo {
973                        path: new_path.clone(),
974                        ..info
975                    }));
976                }
977            }
978            PlayerCommand::InsertInPlaylist { items, after } => {
979                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
980                self.shared_state.insert_items_after(items, after);
981                self.push_undo(UndoEntry::Inserted { ids });
982            }
983            PlayerCommand::ClearPlaylist => {
984                // Stop engine + clear display state WITHOUT touching the playlist,
985                // then snapshot, then clear. This avoids the race where stop()
986                // would clear the playlist before we capture it for undo.
987                self.stop_playback_and_clear_state();
988                let (items, cursor) = self.shared_state.snapshot_playlist();
989                self.shared_state.clear_playlist();
990                self.push_undo(UndoEntry::Replaced { items, cursor });
991            }
992            PlayerCommand::RemoveFromPlaylist(id) => {
993                let item = self.shared_state.get_item(id);
994                let after = self.shared_state.item_before(id);
995                self.remove_from_playlist(id);
996                if let Some(item) = item {
997                    self.push_undo(UndoEntry::Removed {
998                        items: vec![(Box::new(item), after)],
999                    });
1000                }
1001            }
1002            PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1003                // Snapshot all items with their predecessors before removing any.
1004                let items_with_pos: Vec<_> = ids
1005                    .iter()
1006                    .filter_map(|&id| {
1007                        let item = self.shared_state.get_item(id)?;
1008                        let after = self.shared_state.item_before(id);
1009                        Some((Box::new(item), after))
1010                    })
1011                    .collect();
1012                for &id in &ids {
1013                    self.remove_from_playlist(id);
1014                }
1015                if !items_with_pos.is_empty() {
1016                    self.push_undo(UndoEntry::Removed {
1017                        items: items_with_pos,
1018                    });
1019                }
1020            }
1021            PlayerCommand::MoveInPlaylist { id, target, after } => {
1022                let was_after = self.shared_state.item_before(id);
1023                self.shared_state.move_item(id, target, after);
1024                self.push_undo(UndoEntry::Moved { id, was_after });
1025            }
1026            PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1027                let entries = self.shared_state.items_before(&ids);
1028                self.shared_state.move_items(&ids, target, after);
1029                self.push_undo(UndoEntry::MovedBatch { entries });
1030            }
1031            PlayerCommand::TrackReady(id) => self.track_ready(id),
1032            PlayerCommand::DecodeFinished => self.on_decode_finished(),
1033            PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1034            PlayerCommand::Undo => self.execute_undo(),
1035            PlayerCommand::Redo => self.execute_redo(),
1036            PlayerCommand::BeginUndoBatch => {
1037                self.batch_buffer = Some(Vec::new());
1038            }
1039            PlayerCommand::EndUndoBatch => {
1040                if let Some(entries) = self.batch_buffer.take() {
1041                    if entries.len() == 1 {
1042                        // Single entry — push directly, no wrapping.
1043                        self.undo_stack.push(entries.into_iter().next().unwrap());
1044                    } else if !entries.is_empty() {
1045                        self.undo_stack.push(UndoEntry::Batch(entries));
1046                    }
1047                }
1048            }
1049            PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1050            PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1051        }
1052    }
1053
1054    /// Apply an undo/redo entry: mutate the playlist and return the inverse entry.
1055    fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1056        match entry {
1057            UndoEntry::Added { ids } => {
1058                // Undo of "items were added": snapshot them with positions, then remove.
1059                let items_with_pos: Vec<_> = ids
1060                    .iter()
1061                    .filter_map(|&id| {
1062                        let item = self.shared_state.get_item(id)?;
1063                        let after = self.shared_state.item_before(id);
1064                        Some((Box::new(item), after))
1065                    })
1066                    .collect();
1067                self.shared_state.remove_items(&ids);
1068                Some(UndoEntry::Removed {
1069                    items: items_with_pos,
1070                })
1071            }
1072            UndoEntry::Removed { items } => {
1073                // Undo of "items were removed": re-insert each at its position.
1074                let mut ids = Vec::with_capacity(items.len());
1075                for (item, after) in items {
1076                    ids.push(item.id);
1077                    self.shared_state.insert_item_at(*item, after);
1078                }
1079                Some(UndoEntry::Added { ids })
1080            }
1081            UndoEntry::Inserted { ids } => {
1082                // Same as Added — snapshot positions, remove items.
1083                let items_with_pos: Vec<_> = ids
1084                    .iter()
1085                    .filter_map(|&id| {
1086                        let item = self.shared_state.get_item(id)?;
1087                        let after = self.shared_state.item_before(id);
1088                        Some((Box::new(item), after))
1089                    })
1090                    .collect();
1091                self.shared_state.remove_items(&ids);
1092                Some(UndoEntry::Removed {
1093                    items: items_with_pos,
1094                })
1095            }
1096            UndoEntry::Moved { id, was_after } => {
1097                let current_after = self.shared_state.item_before(id);
1098                self.shared_state.move_item_to(id, was_after);
1099                Some(UndoEntry::Moved {
1100                    id,
1101                    was_after: current_after,
1102                })
1103            }
1104            UndoEntry::MovedBatch { entries } => {
1105                let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1106                let current_positions = self.shared_state.items_before(&ids);
1107                self.shared_state.move_items_to(&entries);
1108                Some(UndoEntry::MovedBatch {
1109                    entries: current_positions,
1110                })
1111            }
1112            UndoEntry::Replaced { items, cursor } => {
1113                let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1114                self.shared_state.restore_playlist(items, cursor);
1115                Some(UndoEntry::Replaced {
1116                    items: current_items,
1117                    cursor: current_cursor,
1118                })
1119            }
1120            UndoEntry::Batch(entries) => {
1121                // Apply entries in reverse order, collect inverses.
1122                let mut inverses = Vec::with_capacity(entries.len());
1123                for entry in entries.into_iter().rev() {
1124                    if let Some(inverse) = self.apply_entry(entry) {
1125                        inverses.push(inverse);
1126                    }
1127                }
1128                inverses.reverse();
1129                Some(UndoEntry::Batch(inverses))
1130            }
1131        }
1132    }
1133
1134    /// Execute an undo operation, pushing the inverse onto the redo stack.
1135    fn execute_undo(&mut self) {
1136        let Some(entry) = self.undo_stack.pop_undo() else {
1137            return;
1138        };
1139        if let Some(inverse) = self.apply_entry(entry) {
1140            self.undo_stack.push_redo(inverse);
1141        }
1142    }
1143
1144    /// Execute a redo operation, pushing the inverse onto the undo stack.
1145    fn execute_redo(&mut self) {
1146        let Some(entry) = self.undo_stack.pop_redo() else {
1147            return;
1148        };
1149        if let Some(inverse) = self.apply_entry(entry) {
1150            self.undo_stack.push_undo_keep_redo(inverse);
1151        }
1152    }
1153
1154    /// Run the command loop. Blocks until the sender is dropped.
1155    pub fn run(&mut self) {
1156        use std::time::Duration;
1157
1158        let rx = self.commands.rx.clone();
1159        loop {
1160            // Poll with timeout so we update position even without commands.
1161            match rx.recv_timeout(Duration::from_millis(50)) {
1162                Ok(cmd) => self.process_command(cmd),
1163                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1164                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1165            }
1166            self.update_playback_state();
1167        }
1168        self.stop();
1169    }
1170
1171    /// Spawn the player on a background thread, returning the shared state,
1172    /// timeline, visualization snapshot, and command sender.
1173    pub fn spawn() -> (
1174        Arc<SharedPlayerState>,
1175        Arc<PlaybackTimeline>,
1176        Arc<VizSnapshot>,
1177        crossbeam_channel::Sender<PlayerCommand>,
1178    ) {
1179        let mut player = Self::new();
1180        let state = player.shared_state();
1181        let timeline = player.timeline();
1182        let viz_snapshot = player.viz_snapshot();
1183        let tx = player.command_sender();
1184
1185        thread::Builder::new()
1186            .name("koan-player".into())
1187            .spawn(move || player.run())
1188            .expect("failed to spawn player thread");
1189
1190        (state, timeline, viz_snapshot, tx)
1191    }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use state::PlaylistItem;
1198    use std::path::PathBuf;
1199
1200    fn make_item(title: &str) -> PlaylistItem {
1201        PlaylistItem {
1202            id: QueueItemId::new(),
1203            db_id: None,
1204            path: PathBuf::from(format!("/music/{title}.flac")),
1205            title: title.to_string(),
1206            artist: String::new(),
1207            album_artist: String::new(),
1208            album: String::new(),
1209            year: None,
1210            codec: None,
1211            track_number: None,
1212            disc: None,
1213            duration_ms: None,
1214            load_state: LoadState::Ready,
1215        }
1216    }
1217
1218    fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1219        let (items, _) = player.shared_state.snapshot_playlist();
1220        items.iter().map(|i| i.id).collect()
1221    }
1222
1223    fn playlist_titles(player: &Player) -> Vec<String> {
1224        let (items, _) = player.shared_state.snapshot_playlist();
1225        items.iter().map(|i| i.title.clone()).collect()
1226    }
1227
1228    // --- AddToPlaylist undo/redo ---
1229
1230    #[test]
1231    fn undo_add_removes_items() {
1232        let mut player = Player::new();
1233        let items = vec![make_item("A"), make_item("B")];
1234        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1235
1236        player.process_command(PlayerCommand::AddToPlaylist(items));
1237        assert_eq!(playlist_ids(&player), ids);
1238        assert!(player.undo_stack().can_undo());
1239
1240        player.process_command(PlayerCommand::Undo);
1241        assert!(playlist_ids(&player).is_empty());
1242        assert!(player.undo_stack().can_redo());
1243    }
1244
1245    #[test]
1246    fn redo_add_restores_items() {
1247        let mut player = Player::new();
1248        let items = vec![make_item("A"), make_item("B")];
1249
1250        player.process_command(PlayerCommand::AddToPlaylist(items));
1251        player.process_command(PlayerCommand::Undo);
1252        assert!(playlist_ids(&player).is_empty());
1253
1254        player.process_command(PlayerCommand::Redo);
1255        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1256    }
1257
1258    // --- RemoveFromPlaylist undo/redo ---
1259
1260    #[test]
1261    fn undo_remove_restores_item_at_position() {
1262        let mut player = Player::new();
1263        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1264        let b_id = items[1].id;
1265
1266        player.process_command(PlayerCommand::AddToPlaylist(items));
1267        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1268        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1269
1270        player.process_command(PlayerCommand::Undo);
1271        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1272    }
1273
1274    #[test]
1275    fn undo_remove_first_item() {
1276        let mut player = Player::new();
1277        let items = vec![make_item("A"), make_item("B")];
1278        let a_id = items[0].id;
1279
1280        player.process_command(PlayerCommand::AddToPlaylist(items));
1281        player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1282        assert_eq!(playlist_titles(&player), vec!["B"]);
1283
1284        player.process_command(PlayerCommand::Undo);
1285        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1286    }
1287
1288    #[test]
1289    fn undo_batch_remove_restores_all() {
1290        let mut player = Player::new();
1291        let items = vec![
1292            make_item("A"),
1293            make_item("B"),
1294            make_item("C"),
1295            make_item("D"),
1296        ];
1297        let b_id = items[1].id;
1298        let c_id = items[2].id;
1299
1300        player.process_command(PlayerCommand::AddToPlaylist(items));
1301        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
1302        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1303
1304        // Single undo restores both
1305        player.process_command(PlayerCommand::Undo);
1306        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1307    }
1308
1309    #[test]
1310    fn redo_batch_remove() {
1311        let mut player = Player::new();
1312        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1313        let a_id = items[0].id;
1314        let b_id = items[1].id;
1315
1316        player.process_command(PlayerCommand::AddToPlaylist(items));
1317        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
1318        player.process_command(PlayerCommand::Undo);
1319        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1320
1321        player.process_command(PlayerCommand::Redo);
1322        assert_eq!(playlist_titles(&player), vec!["C"]);
1323    }
1324
1325    #[test]
1326    fn redo_remove() {
1327        let mut player = Player::new();
1328        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1329        let b_id = items[1].id;
1330
1331        player.process_command(PlayerCommand::AddToPlaylist(items));
1332        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1333        player.process_command(PlayerCommand::Undo);
1334        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1335
1336        player.process_command(PlayerCommand::Redo);
1337        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1338    }
1339
1340    // --- InsertInPlaylist undo/redo ---
1341
1342    #[test]
1343    fn undo_insert_removes_inserted_items() {
1344        let mut player = Player::new();
1345        let items = vec![make_item("A"), make_item("C")];
1346        let a_id = items[0].id;
1347
1348        player.process_command(PlayerCommand::AddToPlaylist(items));
1349
1350        let inserted = vec![make_item("B")];
1351        player.process_command(PlayerCommand::InsertInPlaylist {
1352            items: inserted,
1353            after: a_id,
1354        });
1355        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1356
1357        player.process_command(PlayerCommand::Undo);
1358        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1359    }
1360
1361    // --- MoveInPlaylist undo/redo ---
1362
1363    #[test]
1364    fn undo_move_restores_position() {
1365        let mut player = Player::new();
1366        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1367        let a_id = items[0].id;
1368        let c_id = items[2].id;
1369
1370        player.process_command(PlayerCommand::AddToPlaylist(items));
1371
1372        // Move A after C: [B, C, A]
1373        player.process_command(PlayerCommand::MoveInPlaylist {
1374            id: a_id,
1375            target: c_id,
1376            after: true,
1377        });
1378        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1379
1380        player.process_command(PlayerCommand::Undo);
1381        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1382    }
1383
1384    #[test]
1385    fn redo_move() {
1386        let mut player = Player::new();
1387        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1388        let a_id = items[0].id;
1389        let c_id = items[2].id;
1390
1391        player.process_command(PlayerCommand::AddToPlaylist(items));
1392        player.process_command(PlayerCommand::MoveInPlaylist {
1393            id: a_id,
1394            target: c_id,
1395            after: true,
1396        });
1397        player.process_command(PlayerCommand::Undo);
1398        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1399
1400        player.process_command(PlayerCommand::Redo);
1401        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1402    }
1403
1404    // --- MoveItemsInPlaylist (batch) undo/redo ---
1405
1406    #[test]
1407    fn undo_batch_move() {
1408        let mut player = Player::new();
1409        let items = vec![
1410            make_item("A"),
1411            make_item("B"),
1412            make_item("C"),
1413            make_item("D"),
1414        ];
1415        let a_id = items[0].id;
1416        let b_id = items[1].id;
1417        let d_id = items[3].id;
1418
1419        player.process_command(PlayerCommand::AddToPlaylist(items));
1420
1421        // Move A,B after D: [C, D, A, B]
1422        player.process_command(PlayerCommand::MoveItemsInPlaylist {
1423            ids: vec![a_id, b_id],
1424            target: d_id,
1425            after: true,
1426        });
1427        assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
1428
1429        player.process_command(PlayerCommand::Undo);
1430        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1431    }
1432
1433    // --- ClearPlaylist undo/redo ---
1434
1435    #[test]
1436    fn undo_clear_restores_playlist() {
1437        let mut player = Player::new();
1438        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1439
1440        player.process_command(PlayerCommand::AddToPlaylist(items));
1441        player.process_command(PlayerCommand::ClearPlaylist);
1442        assert!(playlist_ids(&player).is_empty());
1443
1444        player.process_command(PlayerCommand::Undo);
1445        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1446    }
1447
1448    #[test]
1449    fn redo_clear() {
1450        let mut player = Player::new();
1451        let items = vec![make_item("A"), make_item("B")];
1452
1453        player.process_command(PlayerCommand::AddToPlaylist(items));
1454        player.process_command(PlayerCommand::ClearPlaylist);
1455        player.process_command(PlayerCommand::Undo);
1456        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1457
1458        player.process_command(PlayerCommand::Redo);
1459        assert!(playlist_ids(&player).is_empty());
1460    }
1461
1462    // --- Multi-step undo/redo ---
1463
1464    #[test]
1465    fn multiple_undos_in_sequence() {
1466        let mut player = Player::new();
1467
1468        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
1469        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1470        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
1471        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1472
1473        player.process_command(PlayerCommand::Undo);
1474        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1475
1476        player.process_command(PlayerCommand::Undo);
1477        assert_eq!(playlist_titles(&player), vec!["A"]);
1478
1479        player.process_command(PlayerCommand::Undo);
1480        assert!(playlist_ids(&player).is_empty());
1481    }
1482
1483    #[test]
1484    fn undo_redo_undo_cycle() {
1485        let mut player = Player::new();
1486        let items = vec![make_item("A"), make_item("B")];
1487
1488        player.process_command(PlayerCommand::AddToPlaylist(items));
1489        player.process_command(PlayerCommand::Undo);
1490        assert!(playlist_ids(&player).is_empty());
1491
1492        player.process_command(PlayerCommand::Redo);
1493        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1494
1495        player.process_command(PlayerCommand::Undo);
1496        assert!(playlist_ids(&player).is_empty());
1497    }
1498
1499    #[test]
1500    fn new_action_clears_redo_stack() {
1501        let mut player = Player::new();
1502        let items = vec![make_item("A")];
1503
1504        player.process_command(PlayerCommand::AddToPlaylist(items));
1505        player.process_command(PlayerCommand::Undo);
1506        assert!(player.undo_stack().can_redo());
1507
1508        // New action should clear redo
1509        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1510        assert!(!player.undo_stack().can_redo());
1511    }
1512
1513    #[test]
1514    fn undo_on_empty_stack_is_noop() {
1515        let mut player = Player::new();
1516        player.process_command(PlayerCommand::Undo);
1517        assert!(playlist_ids(&player).is_empty());
1518    }
1519
1520    #[test]
1521    fn redo_on_empty_stack_is_noop() {
1522        let mut player = Player::new();
1523        player.process_command(PlayerCommand::Redo);
1524        assert!(playlist_ids(&player).is_empty());
1525    }
1526
1527    // --- Non-undoable commands don't push entries ---
1528
1529    #[test]
1530    fn playback_commands_not_undoable() {
1531        let mut player = Player::new();
1532        player.process_command(PlayerCommand::Pause);
1533        player.process_command(PlayerCommand::Resume);
1534        player.process_command(PlayerCommand::NextTrack);
1535        player.process_command(PlayerCommand::PrevTrack);
1536        assert!(!player.undo_stack().can_undo());
1537    }
1538
1539    #[test]
1540    fn update_paths_not_undoable() {
1541        let mut player = Player::new();
1542        let items = vec![make_item("A")];
1543        let id = items[0].id;
1544        player.process_command(PlayerCommand::AddToPlaylist(items));
1545
1546        let undo_count = player.undo_stack().undo_len();
1547        player.process_command(PlayerCommand::UpdatePaths(vec![(
1548            id,
1549            PathBuf::from("/new/path.flac"),
1550        )]));
1551        assert_eq!(player.undo_stack().undo_len(), undo_count);
1552    }
1553
1554    // --- Complex scenarios ---
1555
1556    #[test]
1557    fn add_remove_undo_undo_produces_original() {
1558        let mut player = Player::new();
1559        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1560        let b_id = items[1].id;
1561        let original_titles = vec!["A", "B", "C"];
1562
1563        player.process_command(PlayerCommand::AddToPlaylist(items));
1564        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1565        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1566
1567        // Undo remove → back to A, B, C
1568        player.process_command(PlayerCommand::Undo);
1569        assert_eq!(playlist_titles(&player), original_titles);
1570
1571        // Undo add → empty
1572        player.process_command(PlayerCommand::Undo);
1573        assert!(playlist_ids(&player).is_empty());
1574    }
1575
1576    #[test]
1577    fn interleaved_adds_and_moves_undo() {
1578        let mut player = Player::new();
1579        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1580        let a_id = items[0].id;
1581        let c_id = items[2].id;
1582
1583        player.process_command(PlayerCommand::AddToPlaylist(items));
1584
1585        // Move A after C: [B, C, A]
1586        player.process_command(PlayerCommand::MoveInPlaylist {
1587            id: a_id,
1588            target: c_id,
1589            after: true,
1590        });
1591        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1592
1593        // Add D: [B, C, A, D]
1594        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
1595        assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
1596
1597        // Undo add D: [B, C, A]
1598        player.process_command(PlayerCommand::Undo);
1599        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1600
1601        // Undo move: [A, B, C]
1602        player.process_command(PlayerCommand::Undo);
1603        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1604    }
1605
1606    /// Regression test for GitHub #89: AudioEngine must be dropped synchronously
1607    /// in stop_engine() before the caller changes sample rates. If the engine is
1608    /// dropped on a background thread, CoreAudio's internal buffer list can be
1609    /// freed while AudioUnitUninitialize is still tearing it down → crash.
1610    #[test]
1611    fn stop_engine_drops_engine_synchronously() {
1612        use std::sync::atomic::AtomicBool;
1613
1614        struct MockEngine {
1615            dropped: Arc<AtomicBool>,
1616        }
1617        impl AudioEngineHandle for MockEngine {
1618            fn start(&self) -> Result<(), BackendError> {
1619                Ok(())
1620            }
1621            fn stop(&self) -> Result<(), BackendError> {
1622                Ok(())
1623            }
1624            fn is_running(&self) -> bool {
1625                false
1626            }
1627        }
1628        impl Drop for MockEngine {
1629            fn drop(&mut self) {
1630                self.dropped.store(true, Ordering::SeqCst);
1631            }
1632        }
1633
1634        let dropped = Arc::new(AtomicBool::new(false));
1635
1636        // Build a minimal decode handle that won't block.
1637        let stop_flag = Arc::new(AtomicBool::new(false));
1638        let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
1639
1640        let mut player = Player::new();
1641        player.active_playback = Some(ActivePlayback {
1642            engine: Box::new(MockEngine {
1643                dropped: dropped.clone(),
1644            }),
1645            decode_handle,
1646        });
1647
1648        player.stop_engine();
1649
1650        // The engine must already be dropped when stop_engine returns.
1651        // If this fails, the engine was moved to a background thread — the
1652        // exact race condition that causes the #89 crash.
1653        assert!(
1654            dropped.load(Ordering::SeqCst),
1655            "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
1656        );
1657    }
1658}