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