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