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