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