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