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            bitrate_kbps: info.bitrate_kbps,
252            channels: info.channels,
253            duration_ms: info.duration_ms,
254        }));
255        self.shared_state.set_position_ms(seek_ms);
256        log::info!(
257            "playing: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
258            path.display(),
259            id,
260            info.codec,
261            info.sample_rate,
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            bitrate_kbps: info.bitrate_kbps,
470            channels: info.channels,
471            duration_ms: info.duration_ms,
472        }));
473        self.shared_state.set_position_ms(0);
474        log::info!(
475            "streaming: {} ({:?}) — {} {}Hz/{}ch, {}ms",
476            path.display(),
477            id,
478            info.codec,
479            info.sample_rate,
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                    bitrate_kbps: info.bitrate_kbps,
905                    channels: info.channels,
906                    duration_ms: info.duration_ms,
907                }));
908                self.shared_state.set_cursor(Some(id));
909            }
910        }
911    }
912
913    /// Decode thread naturally finished (playlist exhausted or error).
914    /// Try to advance to the next Ready track; otherwise stop cleanly.
915    fn on_decode_finished(&mut self) {
916        log::info!("decode finished, checking for next track");
917        match self.shared_state.advance_cursor() {
918            Some((id, path)) => {
919                if let Err(e) = self.start_playback(id, &path, 0) {
920                    log::error!("auto-advance after decode finished failed: {}", e);
921                    self.stop_playback_and_clear_state();
922                }
923            }
924            None => {
925                log::info!("no more tracks — stopping");
926                self.stop_playback_and_clear_state();
927            }
928        }
929    }
930
931    /// Route an undo entry to the batch buffer (if batching) or the undo stack.
932    fn push_undo(&mut self, entry: UndoEntry) {
933        if let Some(ref mut batch) = self.batch_buffer {
934            batch.push(entry);
935        } else {
936            self.undo_stack.push(entry);
937        }
938    }
939
940    /// Process a single command.
941    pub fn process_command(&mut self, cmd: PlayerCommand) {
942        match cmd {
943            PlayerCommand::Play(id) => self.play(id),
944            PlayerCommand::Pause => self.pause(),
945            PlayerCommand::Resume => self.resume(),
946            PlayerCommand::Stop => self.stop(),
947            PlayerCommand::Seek(pos) => self.seek(pos),
948            PlayerCommand::NextTrack => {
949                // Debounce: suppress key repeat from terminal (150ms window).
950                let now = std::time::Instant::now();
951                if now.duration_since(self.last_skip).as_millis() >= 150 {
952                    self.last_skip = now;
953                    self.next_track();
954                }
955            }
956            PlayerCommand::PrevTrack => {
957                let now = std::time::Instant::now();
958                if now.duration_since(self.last_skip).as_millis() >= 150 {
959                    self.last_skip = now;
960                    self.prev_track();
961                }
962            }
963            PlayerCommand::AddToPlaylist(items) => {
964                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
965                self.shared_state.add_items(items);
966                self.push_undo(UndoEntry::Added { ids });
967            }
968            PlayerCommand::UpdatePaths(updates) => {
969                self.shared_state.update_paths(&updates);
970                if let Some(info) = self.shared_state.track_info()
971                    && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
972                {
973                    self.shared_state.set_track_info(Some(TrackInfo {
974                        path: new_path.clone(),
975                        ..info
976                    }));
977                }
978            }
979            PlayerCommand::InsertInPlaylist { items, after } => {
980                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
981                self.shared_state.insert_items_after(items, after);
982                self.push_undo(UndoEntry::Inserted { ids });
983            }
984            PlayerCommand::ClearPlaylist => {
985                // Stop engine + clear display state WITHOUT touching the playlist,
986                // then snapshot, then clear. This avoids the race where stop()
987                // would clear the playlist before we capture it for undo.
988                self.stop_playback_and_clear_state();
989                let (items, cursor) = self.shared_state.snapshot_playlist();
990                self.shared_state.clear_playlist();
991                self.push_undo(UndoEntry::Replaced { items, cursor });
992            }
993            PlayerCommand::RemoveFromPlaylist(id) => {
994                let item = self.shared_state.get_item(id);
995                let after = self.shared_state.item_before(id);
996                self.remove_from_playlist(id);
997                if let Some(item) = item {
998                    self.push_undo(UndoEntry::Removed {
999                        items: vec![(Box::new(item), after)],
1000                    });
1001                }
1002            }
1003            PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1004                // Snapshot all items with their predecessors before removing any.
1005                let items_with_pos: Vec<_> = ids
1006                    .iter()
1007                    .filter_map(|&id| {
1008                        let item = self.shared_state.get_item(id)?;
1009                        let after = self.shared_state.item_before(id);
1010                        Some((Box::new(item), after))
1011                    })
1012                    .collect();
1013                for &id in &ids {
1014                    self.remove_from_playlist(id);
1015                }
1016                if !items_with_pos.is_empty() {
1017                    self.push_undo(UndoEntry::Removed {
1018                        items: items_with_pos,
1019                    });
1020                }
1021            }
1022            PlayerCommand::MoveInPlaylist { id, target, after } => {
1023                let was_after = self.shared_state.item_before(id);
1024                self.shared_state.move_item(id, target, after);
1025                self.push_undo(UndoEntry::Moved { id, was_after });
1026            }
1027            PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1028                let entries = self.shared_state.items_before(&ids);
1029                self.shared_state.move_items(&ids, target, after);
1030                self.push_undo(UndoEntry::MovedBatch { entries });
1031            }
1032            PlayerCommand::TrackReady(id) => self.track_ready(id),
1033            PlayerCommand::DecodeFinished => self.on_decode_finished(),
1034            PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1035            PlayerCommand::Undo => self.execute_undo(),
1036            PlayerCommand::Redo => self.execute_redo(),
1037            PlayerCommand::BeginUndoBatch => {
1038                self.batch_buffer = Some(Vec::new());
1039            }
1040            PlayerCommand::EndUndoBatch => {
1041                if let Some(entries) = self.batch_buffer.take() {
1042                    if entries.len() == 1 {
1043                        // Single entry — push directly, no wrapping.
1044                        self.undo_stack.push(entries.into_iter().next().unwrap());
1045                    } else if !entries.is_empty() {
1046                        self.undo_stack.push(UndoEntry::Batch(entries));
1047                    }
1048                }
1049            }
1050            PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1051            PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1052        }
1053    }
1054
1055    /// Apply an undo/redo entry: mutate the playlist and return the inverse entry.
1056    fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1057        match entry {
1058            UndoEntry::Added { ids } => {
1059                // Undo of "items were added": snapshot them with positions, then remove.
1060                let items_with_pos: Vec<_> = ids
1061                    .iter()
1062                    .filter_map(|&id| {
1063                        let item = self.shared_state.get_item(id)?;
1064                        let after = self.shared_state.item_before(id);
1065                        Some((Box::new(item), after))
1066                    })
1067                    .collect();
1068                self.shared_state.remove_items(&ids);
1069                Some(UndoEntry::Removed {
1070                    items: items_with_pos,
1071                })
1072            }
1073            UndoEntry::Removed { items } => {
1074                // Undo of "items were removed": re-insert each at its position.
1075                let mut ids = Vec::with_capacity(items.len());
1076                for (item, after) in items {
1077                    ids.push(item.id);
1078                    self.shared_state.insert_item_at(*item, after);
1079                }
1080                Some(UndoEntry::Added { ids })
1081            }
1082            UndoEntry::Inserted { ids } => {
1083                // Same as Added — snapshot positions, remove items.
1084                let items_with_pos: Vec<_> = ids
1085                    .iter()
1086                    .filter_map(|&id| {
1087                        let item = self.shared_state.get_item(id)?;
1088                        let after = self.shared_state.item_before(id);
1089                        Some((Box::new(item), after))
1090                    })
1091                    .collect();
1092                self.shared_state.remove_items(&ids);
1093                Some(UndoEntry::Removed {
1094                    items: items_with_pos,
1095                })
1096            }
1097            UndoEntry::Moved { id, was_after } => {
1098                let current_after = self.shared_state.item_before(id);
1099                self.shared_state.move_item_to(id, was_after);
1100                Some(UndoEntry::Moved {
1101                    id,
1102                    was_after: current_after,
1103                })
1104            }
1105            UndoEntry::MovedBatch { entries } => {
1106                let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1107                let current_positions = self.shared_state.items_before(&ids);
1108                self.shared_state.move_items_to(&entries);
1109                Some(UndoEntry::MovedBatch {
1110                    entries: current_positions,
1111                })
1112            }
1113            UndoEntry::Replaced { items, cursor } => {
1114                let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1115                self.shared_state.restore_playlist(items, cursor);
1116                Some(UndoEntry::Replaced {
1117                    items: current_items,
1118                    cursor: current_cursor,
1119                })
1120            }
1121            UndoEntry::Batch(entries) => {
1122                // Apply entries in reverse order, collect inverses.
1123                let mut inverses = Vec::with_capacity(entries.len());
1124                for entry in entries.into_iter().rev() {
1125                    if let Some(inverse) = self.apply_entry(entry) {
1126                        inverses.push(inverse);
1127                    }
1128                }
1129                inverses.reverse();
1130                Some(UndoEntry::Batch(inverses))
1131            }
1132        }
1133    }
1134
1135    /// Execute an undo operation, pushing the inverse onto the redo stack.
1136    fn execute_undo(&mut self) {
1137        let Some(entry) = self.undo_stack.pop_undo() else {
1138            return;
1139        };
1140        if let Some(inverse) = self.apply_entry(entry) {
1141            self.undo_stack.push_redo(inverse);
1142        }
1143    }
1144
1145    /// Execute a redo operation, pushing the inverse onto the undo stack.
1146    fn execute_redo(&mut self) {
1147        let Some(entry) = self.undo_stack.pop_redo() else {
1148            return;
1149        };
1150        if let Some(inverse) = self.apply_entry(entry) {
1151            self.undo_stack.push_undo_keep_redo(inverse);
1152        }
1153    }
1154
1155    /// Run the command loop. Blocks until the sender is dropped.
1156    pub fn run(&mut self) {
1157        use std::time::Duration;
1158
1159        let rx = self.commands.rx.clone();
1160        loop {
1161            // Poll with timeout so we update position even without commands.
1162            match rx.recv_timeout(Duration::from_millis(50)) {
1163                Ok(cmd) => self.process_command(cmd),
1164                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1165                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1166            }
1167            self.update_playback_state();
1168        }
1169        self.stop();
1170    }
1171
1172    /// Spawn the player on a background thread, returning the shared state,
1173    /// timeline, visualization snapshot, and command sender.
1174    pub fn spawn() -> (
1175        Arc<SharedPlayerState>,
1176        Arc<PlaybackTimeline>,
1177        Arc<VizSnapshot>,
1178        crossbeam_channel::Sender<PlayerCommand>,
1179    ) {
1180        let mut player = Self::new();
1181        let state = player.shared_state();
1182        let timeline = player.timeline();
1183        let viz_snapshot = player.viz_snapshot();
1184        let tx = player.command_sender();
1185
1186        thread::Builder::new()
1187            .name("koan-player".into())
1188            .spawn(move || player.run())
1189            .expect("failed to spawn player thread");
1190
1191        (state, timeline, viz_snapshot, tx)
1192    }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198    use state::PlaylistItem;
1199    use std::path::PathBuf;
1200
1201    fn make_item(title: &str) -> PlaylistItem {
1202        PlaylistItem {
1203            id: QueueItemId::new(),
1204            db_id: None,
1205            path: PathBuf::from(format!("/music/{title}.flac")),
1206            title: title.to_string(),
1207            artist: String::new(),
1208            album_artist: String::new(),
1209            album: String::new(),
1210            year: None,
1211            codec: None,
1212            track_number: None,
1213            disc: None,
1214            duration_ms: None,
1215            load_state: LoadState::Ready,
1216        }
1217    }
1218
1219    fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1220        let (items, _) = player.shared_state.snapshot_playlist();
1221        items.iter().map(|i| i.id).collect()
1222    }
1223
1224    fn playlist_titles(player: &Player) -> Vec<String> {
1225        let (items, _) = player.shared_state.snapshot_playlist();
1226        items.iter().map(|i| i.title.clone()).collect()
1227    }
1228
1229    // --- AddToPlaylist undo/redo ---
1230
1231    #[test]
1232    fn undo_add_removes_items() {
1233        let mut player = Player::new();
1234        let items = vec![make_item("A"), make_item("B")];
1235        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1236
1237        player.process_command(PlayerCommand::AddToPlaylist(items));
1238        assert_eq!(playlist_ids(&player), ids);
1239        assert!(player.undo_stack().can_undo());
1240
1241        player.process_command(PlayerCommand::Undo);
1242        assert!(playlist_ids(&player).is_empty());
1243        assert!(player.undo_stack().can_redo());
1244    }
1245
1246    #[test]
1247    fn redo_add_restores_items() {
1248        let mut player = Player::new();
1249        let items = vec![make_item("A"), make_item("B")];
1250
1251        player.process_command(PlayerCommand::AddToPlaylist(items));
1252        player.process_command(PlayerCommand::Undo);
1253        assert!(playlist_ids(&player).is_empty());
1254
1255        player.process_command(PlayerCommand::Redo);
1256        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1257    }
1258
1259    // --- RemoveFromPlaylist undo/redo ---
1260
1261    #[test]
1262    fn undo_remove_restores_item_at_position() {
1263        let mut player = Player::new();
1264        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1265        let b_id = items[1].id;
1266
1267        player.process_command(PlayerCommand::AddToPlaylist(items));
1268        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1269        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1270
1271        player.process_command(PlayerCommand::Undo);
1272        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1273    }
1274
1275    #[test]
1276    fn undo_remove_first_item() {
1277        let mut player = Player::new();
1278        let items = vec![make_item("A"), make_item("B")];
1279        let a_id = items[0].id;
1280
1281        player.process_command(PlayerCommand::AddToPlaylist(items));
1282        player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1283        assert_eq!(playlist_titles(&player), vec!["B"]);
1284
1285        player.process_command(PlayerCommand::Undo);
1286        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1287    }
1288
1289    #[test]
1290    fn undo_batch_remove_restores_all() {
1291        let mut player = Player::new();
1292        let items = vec![
1293            make_item("A"),
1294            make_item("B"),
1295            make_item("C"),
1296            make_item("D"),
1297        ];
1298        let b_id = items[1].id;
1299        let c_id = items[2].id;
1300
1301        player.process_command(PlayerCommand::AddToPlaylist(items));
1302        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
1303        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1304
1305        // Single undo restores both
1306        player.process_command(PlayerCommand::Undo);
1307        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1308    }
1309
1310    #[test]
1311    fn redo_batch_remove() {
1312        let mut player = Player::new();
1313        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1314        let a_id = items[0].id;
1315        let b_id = items[1].id;
1316
1317        player.process_command(PlayerCommand::AddToPlaylist(items));
1318        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
1319        player.process_command(PlayerCommand::Undo);
1320        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1321
1322        player.process_command(PlayerCommand::Redo);
1323        assert_eq!(playlist_titles(&player), vec!["C"]);
1324    }
1325
1326    #[test]
1327    fn redo_remove() {
1328        let mut player = Player::new();
1329        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1330        let b_id = items[1].id;
1331
1332        player.process_command(PlayerCommand::AddToPlaylist(items));
1333        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1334        player.process_command(PlayerCommand::Undo);
1335        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1336
1337        player.process_command(PlayerCommand::Redo);
1338        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1339    }
1340
1341    // --- InsertInPlaylist undo/redo ---
1342
1343    #[test]
1344    fn undo_insert_removes_inserted_items() {
1345        let mut player = Player::new();
1346        let items = vec![make_item("A"), make_item("C")];
1347        let a_id = items[0].id;
1348
1349        player.process_command(PlayerCommand::AddToPlaylist(items));
1350
1351        let inserted = vec![make_item("B")];
1352        player.process_command(PlayerCommand::InsertInPlaylist {
1353            items: inserted,
1354            after: a_id,
1355        });
1356        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1357
1358        player.process_command(PlayerCommand::Undo);
1359        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1360    }
1361
1362    // --- MoveInPlaylist undo/redo ---
1363
1364    #[test]
1365    fn undo_move_restores_position() {
1366        let mut player = Player::new();
1367        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1368        let a_id = items[0].id;
1369        let c_id = items[2].id;
1370
1371        player.process_command(PlayerCommand::AddToPlaylist(items));
1372
1373        // Move A after C: [B, C, A]
1374        player.process_command(PlayerCommand::MoveInPlaylist {
1375            id: a_id,
1376            target: c_id,
1377            after: true,
1378        });
1379        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1380
1381        player.process_command(PlayerCommand::Undo);
1382        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1383    }
1384
1385    #[test]
1386    fn redo_move() {
1387        let mut player = Player::new();
1388        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1389        let a_id = items[0].id;
1390        let c_id = items[2].id;
1391
1392        player.process_command(PlayerCommand::AddToPlaylist(items));
1393        player.process_command(PlayerCommand::MoveInPlaylist {
1394            id: a_id,
1395            target: c_id,
1396            after: true,
1397        });
1398        player.process_command(PlayerCommand::Undo);
1399        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1400
1401        player.process_command(PlayerCommand::Redo);
1402        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1403    }
1404
1405    // --- MoveItemsInPlaylist (batch) undo/redo ---
1406
1407    #[test]
1408    fn undo_batch_move() {
1409        let mut player = Player::new();
1410        let items = vec![
1411            make_item("A"),
1412            make_item("B"),
1413            make_item("C"),
1414            make_item("D"),
1415        ];
1416        let a_id = items[0].id;
1417        let b_id = items[1].id;
1418        let d_id = items[3].id;
1419
1420        player.process_command(PlayerCommand::AddToPlaylist(items));
1421
1422        // Move A,B after D: [C, D, A, B]
1423        player.process_command(PlayerCommand::MoveItemsInPlaylist {
1424            ids: vec![a_id, b_id],
1425            target: d_id,
1426            after: true,
1427        });
1428        assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
1429
1430        player.process_command(PlayerCommand::Undo);
1431        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1432    }
1433
1434    // --- ClearPlaylist undo/redo ---
1435
1436    #[test]
1437    fn undo_clear_restores_playlist() {
1438        let mut player = Player::new();
1439        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1440
1441        player.process_command(PlayerCommand::AddToPlaylist(items));
1442        player.process_command(PlayerCommand::ClearPlaylist);
1443        assert!(playlist_ids(&player).is_empty());
1444
1445        player.process_command(PlayerCommand::Undo);
1446        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1447    }
1448
1449    #[test]
1450    fn redo_clear() {
1451        let mut player = Player::new();
1452        let items = vec![make_item("A"), make_item("B")];
1453
1454        player.process_command(PlayerCommand::AddToPlaylist(items));
1455        player.process_command(PlayerCommand::ClearPlaylist);
1456        player.process_command(PlayerCommand::Undo);
1457        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1458
1459        player.process_command(PlayerCommand::Redo);
1460        assert!(playlist_ids(&player).is_empty());
1461    }
1462
1463    // --- Multi-step undo/redo ---
1464
1465    #[test]
1466    fn multiple_undos_in_sequence() {
1467        let mut player = Player::new();
1468
1469        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
1470        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1471        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
1472        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1473
1474        player.process_command(PlayerCommand::Undo);
1475        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1476
1477        player.process_command(PlayerCommand::Undo);
1478        assert_eq!(playlist_titles(&player), vec!["A"]);
1479
1480        player.process_command(PlayerCommand::Undo);
1481        assert!(playlist_ids(&player).is_empty());
1482    }
1483
1484    #[test]
1485    fn undo_redo_undo_cycle() {
1486        let mut player = Player::new();
1487        let items = vec![make_item("A"), make_item("B")];
1488
1489        player.process_command(PlayerCommand::AddToPlaylist(items));
1490        player.process_command(PlayerCommand::Undo);
1491        assert!(playlist_ids(&player).is_empty());
1492
1493        player.process_command(PlayerCommand::Redo);
1494        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1495
1496        player.process_command(PlayerCommand::Undo);
1497        assert!(playlist_ids(&player).is_empty());
1498    }
1499
1500    #[test]
1501    fn new_action_clears_redo_stack() {
1502        let mut player = Player::new();
1503        let items = vec![make_item("A")];
1504
1505        player.process_command(PlayerCommand::AddToPlaylist(items));
1506        player.process_command(PlayerCommand::Undo);
1507        assert!(player.undo_stack().can_redo());
1508
1509        // New action should clear redo
1510        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
1511        assert!(!player.undo_stack().can_redo());
1512    }
1513
1514    #[test]
1515    fn undo_on_empty_stack_is_noop() {
1516        let mut player = Player::new();
1517        player.process_command(PlayerCommand::Undo);
1518        assert!(playlist_ids(&player).is_empty());
1519    }
1520
1521    #[test]
1522    fn redo_on_empty_stack_is_noop() {
1523        let mut player = Player::new();
1524        player.process_command(PlayerCommand::Redo);
1525        assert!(playlist_ids(&player).is_empty());
1526    }
1527
1528    // --- Non-undoable commands don't push entries ---
1529
1530    #[test]
1531    fn playback_commands_not_undoable() {
1532        let mut player = Player::new();
1533        player.process_command(PlayerCommand::Pause);
1534        player.process_command(PlayerCommand::Resume);
1535        player.process_command(PlayerCommand::NextTrack);
1536        player.process_command(PlayerCommand::PrevTrack);
1537        assert!(!player.undo_stack().can_undo());
1538    }
1539
1540    #[test]
1541    fn update_paths_not_undoable() {
1542        let mut player = Player::new();
1543        let items = vec![make_item("A")];
1544        let id = items[0].id;
1545        player.process_command(PlayerCommand::AddToPlaylist(items));
1546
1547        let undo_count = player.undo_stack().undo_len();
1548        player.process_command(PlayerCommand::UpdatePaths(vec![(
1549            id,
1550            PathBuf::from("/new/path.flac"),
1551        )]));
1552        assert_eq!(player.undo_stack().undo_len(), undo_count);
1553    }
1554
1555    // --- Complex scenarios ---
1556
1557    #[test]
1558    fn add_remove_undo_undo_produces_original() {
1559        let mut player = Player::new();
1560        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1561        let b_id = items[1].id;
1562        let original_titles = vec!["A", "B", "C"];
1563
1564        player.process_command(PlayerCommand::AddToPlaylist(items));
1565        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1566        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1567
1568        // Undo remove → back to A, B, C
1569        player.process_command(PlayerCommand::Undo);
1570        assert_eq!(playlist_titles(&player), original_titles);
1571
1572        // Undo add → empty
1573        player.process_command(PlayerCommand::Undo);
1574        assert!(playlist_ids(&player).is_empty());
1575    }
1576
1577    #[test]
1578    fn interleaved_adds_and_moves_undo() {
1579        let mut player = Player::new();
1580        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1581        let a_id = items[0].id;
1582        let c_id = items[2].id;
1583
1584        player.process_command(PlayerCommand::AddToPlaylist(items));
1585
1586        // Move A after C: [B, C, A]
1587        player.process_command(PlayerCommand::MoveInPlaylist {
1588            id: a_id,
1589            target: c_id,
1590            after: true,
1591        });
1592        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1593
1594        // Add D: [B, C, A, D]
1595        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
1596        assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
1597
1598        // Undo add D: [B, C, A]
1599        player.process_command(PlayerCommand::Undo);
1600        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
1601
1602        // Undo move: [A, B, C]
1603        player.process_command(PlayerCommand::Undo);
1604        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1605    }
1606
1607    /// Regression test for GitHub #89: AudioEngine must be dropped synchronously
1608    /// in stop_engine() before the caller changes sample rates. If the engine is
1609    /// dropped on a background thread, CoreAudio's internal buffer list can be
1610    /// freed while AudioUnitUninitialize is still tearing it down → crash.
1611    #[test]
1612    fn stop_engine_drops_engine_synchronously() {
1613        use std::sync::atomic::AtomicBool;
1614
1615        struct MockEngine {
1616            dropped: Arc<AtomicBool>,
1617        }
1618        impl AudioEngineHandle for MockEngine {
1619            fn start(&self) -> Result<(), BackendError> {
1620                Ok(())
1621            }
1622            fn stop(&self) -> Result<(), BackendError> {
1623                Ok(())
1624            }
1625            fn is_running(&self) -> bool {
1626                false
1627            }
1628        }
1629        impl Drop for MockEngine {
1630            fn drop(&mut self) {
1631                self.dropped.store(true, Ordering::SeqCst);
1632            }
1633        }
1634
1635        let dropped = Arc::new(AtomicBool::new(false));
1636
1637        // Build a minimal decode handle that won't block.
1638        let stop_flag = Arc::new(AtomicBool::new(false));
1639        let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
1640
1641        let mut player = Player::new();
1642        player.active_playback = Some(ActivePlayback {
1643            engine: Box::new(MockEngine {
1644                dropped: dropped.clone(),
1645            }),
1646            decode_handle,
1647        });
1648
1649        player.stop_engine();
1650
1651        // The engine must already be dropped when stop_engine returns.
1652        // If this fails, the engine was moved to a background thread — the
1653        // exact race condition that causes the #89 crash.
1654        assert!(
1655            dropped.load(Ordering::SeqCst),
1656            "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
1657        );
1658    }
1659}