Skip to main content

koan_core/player/
mod.rs

1pub mod commands;
2pub mod history;
3pub mod state;
4pub mod undo;
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::sync::atomic::AtomicU64;
9use std::thread;
10
11use thiserror::Error;
12
13use crate::audio::{
14    analyzer::VizAnalyzer,
15    backend::{self, AudioBackend, AudioEngineHandle, BackendError, SampleRateWatch},
16    buffer, streaming,
17    viz::{VizBuffer, VizSnapshot},
18};
19use buffer::PlaybackTimeline;
20use commands::{CommandChannel, PlayerCommand};
21use history::{InFlight, PlayEvent, PlayRecorder};
22use state::{
23    ItemState, LoadState, PlaybackSource, PlaybackState, QueueItemId, SharedPlayerState, TrackInfo,
24};
25use undo::{UndoEntry, UndoStack};
26
27/// Ring buffer size in samples. ~1s at 192kHz stereo.
28pub(crate) const RING_BUFFER_SIZE: usize = 192_000 * 2;
29
30/// Kept back from the end of a track when seeking, so dragging the thumb all
31/// the way over lands in the last moment of it rather than in the next track.
32const SEEK_END_GUARD_MS: u64 = 500;
33
34#[derive(Debug, Error)]
35pub enum PlayerError {
36    #[error("backend error: {0}")]
37    Backend(#[from] BackendError),
38    #[error("decode error: {0}")]
39    Decode(#[from] buffer::DecodeError),
40}
41
42/// Everything needed to read a track that is still downloading: where it is,
43/// how far the transfer has got, and how the container has to be opened.
44#[derive(Clone)]
45struct StreamSource {
46    path: PathBuf,
47    bytes_written: Arc<AtomicU64>,
48    total: u64,
49    mode: streaming::ProbeMode,
50}
51
52/// Symphonia's format hint for a path — its extension, where it has one.
53fn hint_for(path: &Path) -> symphonia::core::formats::probe::Hint {
54    let mut hint = symphonia::core::formats::probe::Hint::new();
55    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
56        hint.with_extension(ext);
57    }
58    hint
59}
60
61/// The player controller. Owns the audio pipeline and processes commands.
62pub struct Player {
63    shared_state: Arc<SharedPlayerState>,
64    commands: CommandChannel,
65    active_playback: Option<ActivePlayback>,
66    timeline: Arc<PlaybackTimeline>,
67    viz_buffer: Arc<VizBuffer>,
68    viz_snapshot: Arc<VizSnapshot>,
69    /// Background FFT analysis thread. Held for its lifetime; dropped on Player drop.
70    _viz_analyzer: VizAnalyzer,
71    undo_stack: UndoStack,
72    /// When Some, undo entries are collected into this buffer instead of pushed
73    /// directly onto the undo stack. Flushed on EndUndoBatch.
74    batch_buffer: Option<Vec<UndoEntry>>,
75    /// Configured output device name. None = system default.
76    output_device_name: Option<String>,
77    /// Platform audio backend (CoreAudio on macOS, cpal on Linux).
78    backend: Box<dyn AudioBackend>,
79    /// Debounce: timestamp of last NextTrack/PrevTrack to suppress key repeat.
80    last_skip: std::time::Instant,
81    /// How the file currently streaming had to be opened. A seek reopens it and
82    /// must not undo what the probe settled on.
83    stream_mode: streaming::ProbeMode,
84    /// Writes plays away from this thread. None when there is no database to
85    /// write to, and in tests, which must not touch the real library.
86    history: Option<PlayRecorder>,
87    /// How much of the current track has been heard so far.
88    in_flight: Option<InFlight>,
89    /// Playback sessions started — lets tests assert how many engine restarts
90    /// an operation costs.
91    #[cfg(test)]
92    playback_starts: usize,
93}
94
95/// Holds the resources for an active playback session.
96struct ActivePlayback {
97    engine: Box<dyn AudioEngineHandle>,
98    decode_handle: buffer::DecodeHandle,
99    /// Keeps the device rate subscription alive for as long as this engine is
100    /// the one feeding the DAC. Dropped with it.
101    _rate_watch: Option<Box<dyn SampleRateWatch>>,
102}
103
104impl Default for Player {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl Player {
111    pub fn new() -> Self {
112        let viz_buffer = VizBuffer::new();
113        let viz_snapshot = VizSnapshot::new();
114        let timeline = PlaybackTimeline::new();
115        let cfg = crate::config::Config::load_or_default();
116        let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
117            Arc::clone(&viz_buffer),
118            &cfg.visualizer,
119            Arc::clone(&viz_snapshot),
120            timeline.samples_played_counter(),
121        );
122
123        Self {
124            shared_state: SharedPlayerState::new(),
125            commands: CommandChannel::new(),
126            active_playback: None,
127            timeline,
128            viz_buffer,
129            viz_snapshot,
130            _viz_analyzer: viz_analyzer,
131            undo_stack: UndoStack::new(),
132            batch_buffer: None,
133            output_device_name: cfg.playback.output_device.clone(),
134            backend: crate::audio::platform_backend(),
135            last_skip: std::time::Instant::now(),
136            stream_mode: streaming::ProbeMode::Full,
137            history: None,
138            in_flight: None,
139            #[cfg(test)]
140            playback_starts: 0,
141        }
142    }
143
144    /// Get a clone of the shared state for UI reads.
145    pub fn shared_state(&self) -> Arc<SharedPlayerState> {
146        self.shared_state.clone()
147    }
148
149    /// Get the playback timeline for UI reads.
150    pub fn timeline(&self) -> Arc<PlaybackTimeline> {
151        self.timeline.clone()
152    }
153
154    /// Get the visualization buffer for the TUI.
155    pub fn viz_buffer(&self) -> Arc<VizBuffer> {
156        self.viz_buffer.clone()
157    }
158
159    /// Get the shared analysis snapshot for the TUI.
160    /// The analysis thread writes here; the UI thread reads a clone each frame.
161    pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
162        self.viz_snapshot.clone()
163    }
164
165    /// Access undo stack (for tests and UI state queries).
166    pub fn undo_stack(&self) -> &UndoStack {
167        &self.undo_stack
168    }
169
170    /// Create an audio engine for a stream, switching the output device to the
171    /// source rate first so output is bit-perfect.
172    ///
173    /// The engine is always configured with the source's own rate and channel
174    /// count — the format the decode thread writes into the ring buffer. A
175    /// device that cannot take the requested rate (MPEG-2/2.5 MP3 rates are
176    /// commonly refused) resamples instead of playing at the wrong speed.
177    #[allow(clippy::type_complexity)]
178    fn create_engine_for(
179        &self,
180        info: &buffer::StreamInfo,
181        consumer: rtrb::Consumer<f32>,
182    ) -> Result<(Box<dyn AudioEngineHandle>, Option<Box<dyn SampleRateWatch>>), PlayerError> {
183        let device = self.resolve_device()?;
184        let device_rate = self.backend.get_device_sample_rate(&device)?;
185        let source_rate = info.sample_rate as f64;
186
187        // The track info is already published, so anything read between here
188        // and the switch landing would pair this track with the last one's
189        // output rate — and a rate switch is not instant. Say nothing instead.
190        self.shared_state.clear_output_sample_rate();
191
192        let settled = if (device_rate - source_rate).abs() > 0.1 {
193            log::info!(
194                "switching device sample rate: {}Hz → {}Hz",
195                device_rate,
196                source_rate
197            );
198            match self.backend.set_device_sample_rate(&device, source_rate) {
199                Ok(rate) => rate,
200                Err(e) => {
201                    log::warn!("failed to set device sample rate: {}", e);
202                    device_rate
203                }
204            }
205        } else {
206            device_rate
207        };
208
209        if (settled - source_rate).abs() > 0.1 {
210            log::warn!(
211                "device stayed at {}Hz (wanted {}Hz) — output is resampled, not bit-perfect",
212                settled,
213                source_rate
214            );
215        }
216
217        // The front ends compare this against the source rate to say whether
218        // anything had to resample. A log line was the only place it went.
219        self.shared_state
220            .set_output_sample_rate(settled.round() as u32);
221
222        // koan is not the only client of this device. Subscribe so the front
223        // ends learn about a rate someone else moved instead of trusting the
224        // reading above until the next track happens to build an engine.
225        let watch_state = self.shared_state.clone();
226        let watch_name = device.name.clone();
227        let rate_watch = self.backend.watch_device_sample_rate(
228            &device,
229            Box::new(move |rate| {
230                log::info!("device sample rate changed externally: {rate}Hz on '{watch_name}'");
231                watch_state.set_output_sample_rate(rate.round() as u32);
232            }),
233        );
234
235        let engine = self.backend.create_engine(
236            &device,
237            source_rate,
238            info.channels as u32,
239            consumer,
240            self.timeline.samples_played_counter(),
241        )?;
242
243        Ok((engine, rate_watch))
244    }
245
246    /// Resolve the output device: use configured device name if set,
247    /// falling back to system default if not set or if the named device is unavailable.
248    fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
249        if let Some(ref name) = self.output_device_name {
250            match self.backend.list_devices() {
251                Ok(devices) => {
252                    if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
253                        return Ok(dev);
254                    }
255                    log::warn!(
256                        "configured output device '{}' not found, falling back to default",
257                        name,
258                    );
259                }
260                Err(e) => {
261                    log::warn!("failed to list devices while resolving '{}': {}", name, e);
262                }
263            }
264        }
265        Ok(self.backend.default_device()?)
266    }
267
268    /// Switch the output device. Persists to config and restarts the engine
269    /// on the current track if playing.
270    pub fn set_output_device(&mut self, name: String) {
271        log::info!("switching output device to: {}", name);
272        self.output_device_name = Some(name.clone());
273
274        if let Err(e) = crate::config::Config::persist(|cfg| {
275            cfg.playback.output_device = Some(name);
276        }) {
277            log::error!("failed to save output device config: {}", e);
278        }
279
280        self.restart_on_current_track();
281    }
282
283    /// Clear the configured output device, reverting to system default.
284    pub fn clear_output_device(&mut self) {
285        log::info!("reverting to system default output device");
286        self.output_device_name = None;
287
288        if let Err(e) = crate::config::Config::persist(|cfg| {
289            cfg.playback.output_device = None;
290        }) {
291            log::error!("failed to save output device config: {}", e);
292        }
293
294        self.restart_on_current_track();
295    }
296
297    /// If a track is currently playing or paused, restart playback at the
298    /// current position (e.g. after switching output devices). Preserves pause state.
299    fn restart_on_current_track(&mut self) {
300        if let Some(info) = self.shared_state.track_info() {
301            let position_ms = self.shared_state.position_ms();
302            if let Err(e) = self.restart_current(&info, position_ms) {
303                log::error!("failed to restart playback on device switch: {}", e);
304            }
305        }
306    }
307
308    /// Get the current output device name (if configured).
309    pub fn output_device_name(&self) -> Option<&str> {
310        self.output_device_name.as_deref()
311    }
312
313    /// Get a command sender for the UI layer.
314    pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
315        self.commands.tx.clone()
316    }
317
318    /// Play a specific item in the playlist by ID.
319    /// Sets cursor, starts playback if Ready or streaming-ready, otherwise waits for TrackReady.
320    pub fn play(&mut self, id: QueueItemId) {
321        self.shared_state.set_cursor(Some(id));
322
323        match self.shared_state.item_playback_source(id) {
324            Some(PlaybackSource::Ready(path)) => {
325                if let Err(e) = self.start_playback(id, &path, 0) {
326                    log::error!("play failed: {}", e);
327                }
328            }
329            Some(PlaybackSource::Streaming {
330                path,
331                bytes_written,
332                total,
333            }) => {
334                // Stop what is playing and park here. The probe answers on its
335                // own thread; if it cannot, TrackReady starts the track once
336                // the whole file has landed.
337                self.stop_engine();
338                self.shared_state.set_playback_state(PlaybackState::Stopped);
339                self.probe_stream_for_playback(id, &path, bytes_written, total);
340            }
341            None => {
342                // Item not ready — stop current playback, wait for TrackReady.
343                self.stop_engine();
344                self.shared_state.set_playback_state(PlaybackState::Stopped);
345                log::info!("play: item {:?} not ready, waiting for TrackReady", id);
346            }
347        }
348    }
349
350    /// Internal: start playback of a file.
351    ///
352    /// A failure leaves the player cleanly stopped. Displaying a track that no
353    /// engine is playing freezes the position and makes the transport lie.
354    fn start_playback(
355        &mut self,
356        id: QueueItemId,
357        path: &Path,
358        seek_ms: u64,
359    ) -> Result<(), PlayerError> {
360        #[cfg(test)]
361        {
362            self.playback_starts += 1;
363        }
364        let result = self.open_playback(id, path, seek_ms);
365        if result.is_err() {
366            self.stop_playback_and_clear_state();
367        }
368        result
369    }
370
371    fn open_playback(
372        &mut self,
373        id: QueueItemId,
374        path: &Path,
375        seek_ms: u64,
376    ) -> Result<(), PlayerError> {
377        self.stop_engine();
378
379        let info = buffer::probe_file(path)?;
380
381        // Set track_info + position immediately so the UI never sees a gap.
382        // For seeks, this keeps the bar at the target position instead of
383        // flashing to 0 while the new timeline spins up.
384        self.shared_state.set_track_info(Some(TrackInfo {
385            id,
386            path: path.to_path_buf(),
387            codec: info.codec.clone(),
388            sample_rate: info.sample_rate,
389            bit_depth: info.bit_depth,
390            bitrate_kbps: info.bitrate_kbps,
391            channels: info.channels,
392            duration_ms: info.duration_ms,
393        }));
394        self.shared_state.set_position_ms(seek_ms);
395        self.on_track_changed(id);
396        log::info!(
397            "playing: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
398            path.display(),
399            id,
400            info.codec,
401            info.sample_rate,
402            info.channels,
403            info.duration_ms,
404            if seek_ms > 0 {
405                format!(" @{}ms", seek_ms)
406            } else {
407                String::new()
408            }
409        );
410
411        let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
412
413        // Reset timeline for new playback session and start decode.
414        self.timeline.reset();
415
416        // Gapless lookahead: the decode thread maintains its own cursor
417        // (separate from the UI cursor) so it can look ahead through the
418        // playlist without affecting what the UI shows as "now playing".
419        let advance_state = self.shared_state.clone();
420        let decode_cursor = parking_lot::Mutex::new(Some(id));
421        let next_track = move || {
422            let current = decode_cursor.lock().take()?;
423            let next = advance_state.peek_next_ready_after(current);
424            if let Some((next_id, _)) = &next {
425                let mut guard = decode_cursor.lock();
426                *guard = Some(*next_id);
427            }
428            next
429        };
430
431        // Load ReplayGain config for this playback session.
432        let cfg = crate::config::Config::load_or_default();
433        let rg_mode = cfg.playback.replaygain;
434        let pre_amp_db = cfg.playback.pre_amp_db;
435
436        let finish_tx = self.commands.tx.clone();
437        let (_stream_info, decode_handle) = buffer::start_decode_file(
438            id,
439            path,
440            producer,
441            seek_ms,
442            next_track,
443            self.timeline.clone(),
444            Some(self.viz_buffer.clone()),
445            rg_mode,
446            pre_amp_db,
447            move || {
448                finish_tx.send(PlayerCommand::DecodeFinished).ok();
449            },
450        )?;
451
452        let (engine, rate_watch) = self.create_engine_for(&info, consumer)?;
453        engine.start()?;
454
455        self.shared_state.set_playback_state(PlaybackState::Playing);
456
457        self.active_playback = Some(ActivePlayback {
458            engine,
459            decode_handle,
460            _rate_watch: rate_watch,
461        });
462
463        Ok(())
464    }
465
466    /// Probe a partially-downloaded file on its own thread, and start it when
467    /// the answer comes back.
468    ///
469    /// Nothing here waits. Probing reads as much of the container as it takes
470    /// to describe itself — for Ogg, its last page, which means the whole
471    /// remaining download — and this is the thread that answers play, pause and
472    /// seek. So the probe goes elsewhere and its result returns as a command.
473    ///
474    /// A format that describes itself up front (FLAC, MP3) comes back in
475    /// milliseconds and starts early, which is the point of streaming. One that
476    /// does not comes back whenever it comes back, by which time the download
477    /// has usually landed and `TrackReady` has started the track from disk —
478    /// and the late answer is simply dropped. Either way the player kept
479    /// answering commands throughout.
480    fn probe_stream_for_playback(
481        &self,
482        id: QueueItemId,
483        path: &Path,
484        bytes_written: Arc<AtomicU64>,
485        total: u64,
486    ) {
487        let path = path.to_path_buf();
488        let tx = self.commands.tx.clone();
489        let hint = hint_for(&path);
490
491        // Abandon the moment the track stops being the one wanted. A probe of
492        // a container that needs its tail otherwise reads to the end of a
493        // download nobody is waiting for any more, and skipping through a
494        // queue that is still caching would leave one doing so per skip.
495        let status = {
496            let downloading = self.stream_status_fn(id);
497            let state = self.shared_state.clone();
498            Arc::new(move || {
499                if state.is_cursor(id) {
500                    downloading()
501                } else {
502                    streaming::StreamStatus::Failed
503                }
504            }) as Arc<dyn Fn() -> streaming::StreamStatus + Send + Sync>
505        };
506
507        let spawned = thread::Builder::new()
508            .name("koan-stream-probe".into())
509            .spawn(move || {
510                // `wait` says whether a read may sit at the write head for
511                // more of the download. The first attempt must not: a
512                // container that goes looking for its tail would wait for the
513                // whole transfer, and failing at once is how that is detected.
514                // The second has no length to go looking with, so whatever it
515                // still wants is in front of it and worth waiting for.
516                let attempt = |mode, wait: bool| {
517                    let open = if wait {
518                        streaming::PartialFileSource::open(
519                            &path,
520                            bytes_written.clone(),
521                            total,
522                            status.clone(),
523                            mode,
524                        )
525                    } else {
526                        streaming::PartialFileSource::open_for_probe(
527                            &path,
528                            bytes_written.clone(),
529                            total,
530                            status.clone(),
531                            mode,
532                        )
533                    };
534                    open.map_err(buffer::DecodeError::Io).and_then(|source| {
535                        let mss = symphonia::core::io::MediaSourceStream::new(
536                            Box::new(source),
537                            Default::default(),
538                        );
539                        buffer::probe_source(mss, &hint)
540                    })
541                };
542
543                // Ask for the whole description first. Neither attempt waits at
544                // the write head, so a container that needs bytes which have
545                // not arrived fails here rather than reading the transfer out.
546                let info = match attempt(streaming::ProbeMode::Full, false) {
547                    Ok(info) => Some((info, streaming::ProbeMode::Full)),
548                    Err(e) => {
549                        // Try again claiming no length. Ogg goes looking for its
550                        // last page only when told there is one to find; without
551                        // it the track opens now and plays, at the price of
552                        // seeking and of the duration that page carries. Both
553                        // come back when the download lands.
554                        log::info!(
555                            "stream probe: {} needs more than has arrived ({}), opening without a length",
556                            path.display(),
557                            e
558                        );
559                        attempt(streaming::ProbeMode::Lengthless, true)
560                            .ok()
561                            .map(|info| (info, streaming::ProbeMode::Lengthless))
562                    }
563                };
564
565                match info {
566                    Some((info, mode)) => {
567                        tx.send(PlayerCommand::StreamProbed {
568                            id,
569                            info: Box::new(info),
570                            mode,
571                        })
572                        .ok();
573                    }
574                    // Not a failure of the track: it plays from disk once the
575                    // download lands, and the cursor is still parked on it.
576                    None => log::info!(
577                        "stream probe: {} cannot start early, waiting for the download",
578                        path.display()
579                    ),
580                }
581            });
582
583        if let Err(e) = spawned {
584            log::warn!("stream probe: could not spawn for {:?}: {}", id, e);
585        }
586    }
587
588    /// A probe finished. Start the track if it is still the one wanted and
589    /// nothing has started it in the meantime.
590    fn stream_probed(
591        &mut self,
592        id: QueueItemId,
593        info: buffer::StreamInfo,
594        mode: streaming::ProbeMode,
595    ) {
596        if !self.shared_state.is_cursor(id) {
597            return; // Moved on.
598        }
599        if self.shared_state.playback_state() != PlaybackState::Stopped {
600            return; // Already playing — the download landed first, or the user did.
601        }
602
603        match self.shared_state.item_playback_source(id) {
604            // The download landed while probing: play it as an ordinary file.
605            Some(PlaybackSource::Ready(path)) => {
606                if let Err(e) = self.start_playback(id, &path, 0) {
607                    log::error!("stream probe: playback failed: {}", e);
608                }
609            }
610            Some(PlaybackSource::Streaming {
611                path,
612                bytes_written,
613                total,
614            }) => {
615                let source = StreamSource {
616                    path,
617                    bytes_written,
618                    total,
619                    mode,
620                };
621                if let Err(e) = self.start_streaming_playback(id, source, 0, info) {
622                    log::error!("stream probe: streaming playback failed: {}", e);
623                }
624            }
625            None => {}
626        }
627    }
628
629    /// What the streaming source asks per read to know whether the download is
630    /// still going. Asked each time rather than passed once: a transfer can
631    /// land, or die, at any point during playback.
632    fn stream_status_fn(
633        &self,
634        id: QueueItemId,
635    ) -> Arc<dyn Fn() -> streaming::StreamStatus + Send + Sync> {
636        let state = self.shared_state.clone();
637        Arc::new(move || match state.item_load_state(id) {
638            Some(LoadState::Ready) => streaming::StreamStatus::Complete,
639            Some(LoadState::Failed(_)) => streaming::StreamStatus::Failed,
640            _ => streaming::StreamStatus::Downloading,
641        })
642    }
643
644    /// Internal: start streaming playback from a partially-downloaded file.
645    ///
646    /// The decoder reads the `.part` file straight off disk through a
647    /// `PartialFileSource`, which blocks when it reaches the write head. The
648    /// download's final rename does not disturb an already-open descriptor, so
649    /// a transfer landing mid-track needs no handover.
650    ///
651    /// `info` is already known — from the off-thread probe when starting, or
652    /// from what is playing when seeking. Nothing here probes.
653    fn start_streaming_playback(
654        &mut self,
655        id: QueueItemId,
656        source: StreamSource,
657        seek_ms: u64,
658        info: buffer::StreamInfo,
659    ) -> Result<(), PlayerError> {
660        let result = self.open_streaming_playback(id, source, seek_ms, info);
661        if result.is_err() {
662            self.stop_playback_and_clear_state();
663        }
664        result
665    }
666
667    fn open_streaming_playback(
668        &mut self,
669        id: QueueItemId,
670        source: StreamSource,
671        seek_ms: u64,
672        info: buffer::StreamInfo,
673    ) -> Result<(), PlayerError> {
674        self.stop_engine();
675        // Held so a seek can reopen the same way without probing again.
676        self.stream_mode = source.mode;
677        let path = source.path.as_path();
678
679        let status = self.stream_status_fn(id);
680        let open_source = {
681            let StreamSource {
682                path,
683                bytes_written,
684                total,
685                mode,
686            } = source.clone();
687            let status = status.clone();
688            move || {
689                streaming::PartialFileSource::open(
690                    &path,
691                    bytes_written.clone(),
692                    total,
693                    status.clone(),
694                    mode,
695                )
696            }
697        };
698
699        self.shared_state.set_track_info(Some(TrackInfo {
700            id,
701            path: path.to_path_buf(),
702            codec: info.codec.clone(),
703            sample_rate: info.sample_rate,
704            bit_depth: info.bit_depth,
705            bitrate_kbps: info.bitrate_kbps,
706            channels: info.channels,
707            duration_ms: info.duration_ms,
708        }));
709        self.shared_state.set_position_ms(seek_ms);
710        self.on_track_changed(id);
711        log::info!(
712            "streaming: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
713            path.display(),
714            id,
715            info.codec,
716            info.sample_rate,
717            info.channels,
718            info.duration_ms,
719            if seek_ms > 0 {
720                format!(" @{}ms", seek_ms)
721            } else {
722                String::new()
723            },
724        );
725
726        let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
727
728        self.timeline.reset();
729
730        // Gapless lookahead after streaming: next track uses normal file path.
731        let advance_state = self.shared_state.clone();
732        let decode_cursor = parking_lot::Mutex::new(Some(id));
733        let next_track = move || {
734            let current = decode_cursor.lock().take()?;
735            let next = advance_state.peek_next_ready_after(current);
736            if let Some((next_id, _)) = &next {
737                let mut guard = decode_cursor.lock();
738                *guard = Some(*next_id);
739            }
740            next
741        };
742
743        let first = buffer::SourceEntry {
744            id,
745            path: path.to_path_buf(),
746            hint: hint_for(path),
747            make_mss: Box::new(move || {
748                Ok(symphonia::core::io::MediaSourceStream::new(
749                    Box::new(open_source()?),
750                    Default::default(),
751                ))
752            }),
753        };
754
755        // Load ReplayGain config for this streaming session.
756        let cfg = crate::config::Config::load_or_default();
757        let rg_mode = cfg.playback.replaygain;
758        let pre_amp_db = cfg.playback.pre_amp_db;
759
760        let finish_tx = self.commands.tx.clone();
761        let (_stream_info, decode_handle) = buffer::start_decode(
762            first,
763            producer,
764            seek_ms,
765            move || {
766                let (next_id, next_path) = next_track()?;
767                Some(buffer::SourceEntry::from_file(next_id, next_path))
768            },
769            self.timeline.clone(),
770            Some(self.viz_buffer.clone()),
771            rg_mode,
772            pre_amp_db,
773            move || {
774                finish_tx.send(PlayerCommand::DecodeFinished).ok();
775            },
776        )?;
777
778        let (engine, rate_watch) = self.create_engine_for(&info, consumer)?;
779        engine.start()?;
780
781        self.shared_state.set_playback_state(PlaybackState::Playing);
782
783        self.active_playback = Some(ActivePlayback {
784            engine,
785            decode_handle,
786            _rate_watch: rate_watch,
787        });
788
789        Ok(())
790    }
791
792    /// Seek within the current track, preserving pause state.
793    ///
794    /// A track still downloading is seekable only as far as its bytes reach, so
795    /// the target is clamped to `seekable_ms` and the restart goes back through
796    /// the streaming path — reopening a partial file as a plain file would
797    /// decode whatever happens to be on disk and end the track early.
798    pub fn seek(&mut self, position_ms: u64) {
799        let Some(info) = self.shared_state.track_info() else {
800            return;
801        };
802        // Stop just short of the end rather than falling into the next track.
803        let seekable = self.shared_state.seekable_ms();
804        if seekable == 0 {
805            // Nothing of this track can be reached yet — a partial container
806            // that has not said what it is. Restarting it at zero is not what
807            // anyone asked for, so the seek is simply declined.
808            log::debug!("seek declined: {:?} is not seekable yet", info.id);
809            return;
810        }
811        let ceiling = seekable.min(
812            self.shared_state
813                .duration_ms()
814                .saturating_sub(SEEK_END_GUARD_MS),
815        );
816        let clamped = position_ms.min(ceiling);
817
818        if let Err(e) = self.restart_current(&info, clamped) {
819            log::error!("seek failed: {}", e);
820        }
821    }
822
823    /// Restart what is playing at `position_ms`, preserving pause state.
824    ///
825    /// What a seek does, and what switching output device does, and what going
826    /// back from the first track does. All three restart the same track, so all
827    /// three resolve the source the same way: from the queue item, never from
828    /// `info.path`, which names the `.part` file for a track that was still
829    /// downloading when it started and is not renamed when the download lands.
830    fn restart_current(&mut self, info: &TrackInfo, position_ms: u64) -> Result<(), PlayerError> {
831        let was_paused = self.shared_state.playback_state() == PlaybackState::Paused;
832
833        match self.shared_state.item_playback_source(info.id) {
834            Some(PlaybackSource::Streaming {
835                path,
836                bytes_written,
837                total,
838            }) => {
839                // No probe: what is playing already said what this is, and
840                // reading an Ogg's last page to learn it again would mean
841                // waiting for the rest of the download.
842                let known = buffer::StreamInfo {
843                    codec: info.codec.clone(),
844                    sample_rate: info.sample_rate,
845                    channels: info.channels,
846                    bit_depth: info.bit_depth,
847                    bitrate_kbps: info.bitrate_kbps,
848                    duration_ms: info.duration_ms,
849                };
850                let source = StreamSource {
851                    path,
852                    bytes_written,
853                    total,
854                    mode: self.stream_mode,
855                };
856                self.start_streaming_playback(info.id, source, position_ms, known)?;
857            }
858            Some(PlaybackSource::Ready(path)) => {
859                self.start_playback(info.id, &path, position_ms)?;
860            }
861            None => return Ok(()),
862        }
863
864        if was_paused {
865            self.pause();
866        }
867        Ok(())
868    }
869
870    /// Skip to next track in playlist.
871    pub fn next_track(&mut self) {
872        match self.shared_state.advance_cursor_loadable() {
873            Some(id) => self.play(id),
874            None => {
875                log::info!("no more tracks in playlist");
876                self.stop_playback_and_clear_state();
877            }
878        }
879    }
880
881    /// Go back to previous track.
882    pub fn prev_track(&mut self) {
883        match self.shared_state.retreat_cursor() {
884            Some((id, path)) => {
885                if matches!(path.try_exists(), Ok(true)) {
886                    if let Err(e) = self.start_playback(id, &path, 0) {
887                        log::error!("prev track failed: {}", e);
888                    }
889                } else {
890                    log::warn!("prev track path doesn't exist: {}", path.display());
891                }
892            }
893            None => {
894                // No previous track — restart current from the beginning.
895                if let Some(info) = self.shared_state.track_info()
896                    && let Err(e) = self.restart_current(&info, 0)
897                {
898                    log::error!("restart failed: {}", e);
899                }
900            }
901        }
902    }
903
904    /// Pause playback.
905    pub fn pause(&mut self) {
906        if let Some(ref playback) = self.active_playback {
907            if let Err(e) = playback.engine.stop() {
908                log::error!("pause failed: {}", e);
909                return;
910            }
911            self.shared_state.set_playback_state(PlaybackState::Paused);
912        }
913    }
914
915    /// Resume playback.
916    pub fn resume(&mut self) {
917        if let Some(ref playback) = self.active_playback {
918            if let Err(e) = playback.engine.start() {
919                log::error!("resume failed: {}", e);
920                return;
921            }
922            self.shared_state.set_playback_state(PlaybackState::Playing);
923        }
924    }
925
926    /// Stop playback and clear playlist.
927    pub fn stop(&mut self) {
928        self.shared_state.clear_playlist();
929        self.stop_playback_and_clear_state();
930    }
931
932    /// Stop the audio engine and decode thread without touching shared state.
933    ///
934    /// The engine is stopped synchronously (silence begins immediately), but
935    /// the heavy teardown (decode thread join + AudioUnit dispose) is moved to
936    /// a background thread so the player command loop never blocks — preventing
937    /// UI freezes when CoreAudio or the decode thread is slow to shut down.
938    fn stop_engine(&mut self) {
939        let Some(playback) = self.active_playback.take() else {
940            return;
941        };
942        let ActivePlayback {
943            engine,
944            mut decode_handle,
945            _rate_watch,
946        } = playback;
947
948        // Stop audio output first, then get the decode thread gone *before* the
949        // engine is dropped.
950        //
951        // The old order signalled the decode thread and dropped the engine
952        // immediately, joining the thread afterwards on a background thread —
953        // so the engine's teardown ran while the decode thread was still alive
954        // and still writing into the ring buffer that the render callback
955        // reads. Tearing CoreAudio down underneath a live producer is exactly
956        // the shape of the end-of-queue crash (#89), and the overlap buys
957        // nothing: `stop()` has already silenced the output.
958        let _ = engine.stop();
959        decode_handle.stop();
960        drop(engine);
961    }
962
963    /// Full stop: tear down engine + clear all display state.
964    fn stop_playback_and_clear_state(&mut self) {
965        self.finish_play();
966        self.stop_engine();
967        self.timeline.reset();
968        self.shared_state.set_playback_state(PlaybackState::Stopped);
969        self.shared_state.set_position_ms(0);
970        self.shared_state.set_track_info(None);
971    }
972
973    /// Remove a track from the playlist. If it was the cursor, resume at the
974    /// track that followed it.
975    ///
976    /// `remove_item` clears the cursor, and an unset cursor means "start from the
977    /// top" — so the successor is pinned down by parking the cursor on the removed
978    /// track's predecessor first. `None` is correct only when it was the first item.
979    pub fn remove_from_playlist(&mut self, id: QueueItemId) {
980        let was_cursor = self.shared_state.is_cursor(id);
981        let resume_after = was_cursor
982            .then(|| self.shared_state.item_before(id))
983            .flatten();
984        self.shared_state.remove_item(id);
985        if was_cursor {
986            self.shared_state.set_cursor(resume_after);
987            self.next_track();
988        }
989    }
990
991    /// A download finished — if cursor is waiting on this item, start playback.
992    /// If already streaming this item, trigger progressive metadata enhancement.
993    pub fn track_ready(&mut self, id: QueueItemId) {
994        // Mark as Ready (download thread already did this, but be safe).
995        self.shared_state.update_item_state(id, ItemState::Ready);
996
997        if !self.shared_state.is_cursor(id) {
998            return;
999        }
1000
1001        let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
1002        let current_track_id = self.shared_state.track_info().map(|t| t.id);
1003
1004        if is_playing && current_track_id == Some(id) {
1005            // Already streaming this track — download just finished.
1006            // Trigger progressive enhancement: re-read full lofty metadata and update state.
1007            log::info!(
1008                "track_ready: download complete while streaming {:?}, refreshing metadata",
1009                id
1010            );
1011            self.refresh_track_metadata(id);
1012            return;
1013        }
1014
1015        // Cursor is on this item but not yet playing — start playback now.
1016        if !is_playing && let Some(path) = self.shared_state.item_path_if_ready(id) {
1017            log::info!("track_ready: starting playback for {:?}", id);
1018            if let Err(e) = self.start_playback(id, &path, 0) {
1019                log::error!("track_ready playback failed: {}", e);
1020            }
1021        }
1022    }
1023
1024    /// Called when enough data has been buffered for streaming playback.
1025    /// If the cursor is waiting on this track and nothing is playing, start streaming.
1026    pub fn track_stream_ready(&mut self, id: QueueItemId) {
1027        if !self.shared_state.is_cursor(id) {
1028            return;
1029        }
1030
1031        let is_playing = self.shared_state.playback_state() == PlaybackState::Playing;
1032        if is_playing {
1033            return; // Already playing something — don't interrupt.
1034        }
1035
1036        match self.shared_state.item_playback_source(id) {
1037            Some(PlaybackSource::Streaming {
1038                path,
1039                bytes_written,
1040                total,
1041            }) => {
1042                log::info!("track_stream_ready: probing partial file for {:?}", id);
1043                self.probe_stream_for_playback(id, &path, bytes_written, total);
1044            }
1045            Some(PlaybackSource::Ready(path)) => {
1046                // Download finished between threshold and now — just play normally.
1047                log::info!(
1048                    "track_stream_ready: track already ready, starting normal playback for {:?}",
1049                    id
1050                );
1051                if let Err(e) = self.start_playback(id, &path, 0) {
1052                    log::error!("track_stream_ready playback failed: {}", e);
1053                }
1054            }
1055            None => {} // Not enough data yet — wait.
1056        }
1057    }
1058
1059    /// Re-read full lofty metadata for a track after its download completes.
1060    /// Updates the playlist item's tags and track_info with complete metadata.
1061    /// Called from track_ready() when a streaming track finishes downloading.
1062    fn refresh_track_metadata(&mut self, id: QueueItemId) {
1063        use crate::index::metadata;
1064
1065        let path = match self.shared_state.item_path_if_ready(id) {
1066            Some(p) => p,
1067            None => return,
1068        };
1069
1070        match metadata::read_metadata(&path) {
1071            Ok(meta) => {
1072                // Update playlist item with full lofty tags (title, artist, album, duration).
1073                self.shared_state.update_item_metadata(
1074                    id,
1075                    meta.title,
1076                    meta.artist,
1077                    meta.album_artist.unwrap_or_default(),
1078                    meta.album,
1079                    meta.duration_ms.map(|d| d as u64),
1080                );
1081
1082                // Re-probe the complete file for accurate duration + stream info.
1083                // The initial probe was done on partial streaming data and may have
1084                // underestimated duration, causing premature seek clamping or wrong
1085                // progress bar display.
1086                //
1087                // The path is taken over at the same time. Playback started
1088                // against the `.part` file and the download's last act is to
1089                // rename it, so what `track_info` holds now names nothing.
1090                if let Some(current) = self.shared_state.track_info()
1091                    && current.id == id
1092                {
1093                    let probed = buffer::probe_file(&path).ok();
1094                    let duration_ms = probed
1095                        .as_ref()
1096                        .map(|s| s.duration_ms)
1097                        .filter(|d| *d > current.duration_ms)
1098                        .unwrap_or(current.duration_ms);
1099                    if duration_ms != current.duration_ms {
1100                        log::info!(
1101                            "track_ready: duration corrected {}ms → {}ms",
1102                            current.duration_ms,
1103                            duration_ms
1104                        );
1105                    }
1106                    self.shared_state.set_track_info(Some(TrackInfo {
1107                        duration_ms,
1108                        path: path.clone(),
1109                        ..current
1110                    }));
1111                }
1112
1113                // Signal UI to re-read cover art and update souvlaki media controls.
1114                self.shared_state.signal_metadata_refresh();
1115                log::info!("track_ready: metadata refreshed for {:?}", id);
1116            }
1117            Err(e) => {
1118                log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
1119            }
1120        }
1121    }
1122
1123    /// Poll the timeline and update shared state with current track/position.
1124    /// Called from the command loop on each tick.
1125    /// The needle has moved to `id`. Close out the outgoing track and write
1126    /// the new one to history straight away, so history reads in play order
1127    /// even for a track that is skipped a moment later.
1128    ///
1129    /// A seek restarts playback of the same track, so identity is checked
1130    /// rather than closing unconditionally — otherwise scrubbing around a
1131    /// track would enter it into history once per seek.
1132    fn on_track_changed(&mut self, id: QueueItemId) {
1133        if self.in_flight.as_ref().is_some_and(|f| f.item == id) {
1134            return;
1135        }
1136        self.finish_play();
1137        let track_id = self.shared_state.item_db_id(id);
1138        self.in_flight = Some(InFlight::new(id, track_id));
1139        if let (Some(track_id), Some(recorder)) = (track_id, self.history.as_ref()) {
1140            recorder.record(PlayEvent::Started { track_id });
1141        }
1142    }
1143
1144    /// Tell history how long the current track was heard for. Returns what was
1145    /// reported, which is how the tests see it.
1146    fn finish_play(&mut self) -> Option<PlayEvent> {
1147        let flight = self.in_flight.take()?;
1148        let event = PlayEvent::Finished {
1149            track_id: flight.track_id()?,
1150            listened_ms: flight.listened_ms(),
1151        };
1152        if let Some(recorder) = self.history.as_ref() {
1153            recorder.record(event);
1154        }
1155        Some(event)
1156    }
1157
1158    pub fn update_playback_state(&mut self) {
1159        if self.active_playback.is_none() {
1160            return;
1161        }
1162
1163        if let Some((id, path, info, position_ms)) = self.timeline.current_playback() {
1164            self.shared_state.set_position_ms(position_ms);
1165
1166            // A gapless transition moves the needle without anything on this
1167            // thread having asked it to, so the play is banked from here.
1168            self.on_track_changed(id);
1169            if let Some(f) = self.in_flight.as_mut() {
1170                f.advance(position_ms);
1171            }
1172
1173            // Update track_info + cursor if the timeline shows a different track
1174            // (gapless transition happened).
1175            let current_id = self.shared_state.track_info().map(|t| t.id);
1176            if current_id != Some(id) {
1177                log::info!("timeline: now playing {:?}", id);
1178                self.shared_state.set_track_info(Some(TrackInfo {
1179                    id,
1180                    path,
1181                    codec: info.codec,
1182                    sample_rate: info.sample_rate,
1183                    bit_depth: info.bit_depth,
1184                    bitrate_kbps: info.bitrate_kbps,
1185                    channels: info.channels,
1186                    duration_ms: info.duration_ms,
1187                }));
1188                self.shared_state.set_cursor(Some(id));
1189            }
1190        }
1191    }
1192
1193    /// A download the cursor is parked on will never land.
1194    ///
1195    /// `play()` leaves the cursor on an item that is not yet Ready and stops,
1196    /// waiting for `TrackReady`. When the download fails instead, that wait has
1197    /// no end — so walk on to the next item that can still load, or stop
1198    /// cleanly if there is none.
1199    pub fn track_failed(&mut self, id: QueueItemId) {
1200        if !self.shared_state.is_cursor(id) {
1201            return;
1202        }
1203        // Only a parked cursor is waiting on this. Playing means it is being
1204        // streamed from the partial file — the pump sees the failure and ends
1205        // the decode, which advances the queue — and paused is the user's.
1206        if self.shared_state.playback_state() != PlaybackState::Stopped {
1207            return;
1208        }
1209        log::info!("track {:?} cannot load, moving on", id);
1210        self.next_track();
1211    }
1212
1213    /// Decode thread naturally finished (playlist exhausted or error).
1214    /// Advance to the next playable track; otherwise stop cleanly.
1215    ///
1216    /// A track that has not finished downloading parks the cursor on it, so its
1217    /// `TrackReady`/`TrackStreamReady` resumes the queue instead of being
1218    /// discarded as "not the cursor".
1219    fn on_decode_finished(&mut self) {
1220        log::info!("decode finished, checking for next track");
1221        match self.shared_state.advance_cursor_loadable() {
1222            Some(id) => self.play(id),
1223            None => {
1224                log::info!("no more tracks — stopping");
1225                self.stop_playback_and_clear_state();
1226            }
1227        }
1228    }
1229
1230    /// Snapshot items with their predecessors for an undo of "these were removed".
1231    /// In playlist order, so undo re-inserts each item after a predecessor that
1232    /// is already back in place.
1233    fn snapshot_for_undo(
1234        &self,
1235        ids: &[QueueItemId],
1236    ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
1237        self.shared_state
1238            .items_before(ids)
1239            .into_iter()
1240            .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
1241            .collect()
1242    }
1243
1244    /// Route an undo entry to the batch buffer (if batching) or the undo stack.
1245    fn push_undo(&mut self, entry: UndoEntry) {
1246        if let Some(ref mut batch) = self.batch_buffer {
1247            batch.push(entry);
1248        } else {
1249            self.undo_stack.push(entry);
1250        }
1251    }
1252
1253    /// Process a single command.
1254    pub fn process_command(&mut self, cmd: PlayerCommand) {
1255        match cmd {
1256            PlayerCommand::Play(id) => self.play(id),
1257            PlayerCommand::Pause => self.pause(),
1258            PlayerCommand::Resume => self.resume(),
1259            PlayerCommand::Stop => self.stop(),
1260            PlayerCommand::Seek(pos) => self.seek(pos),
1261            PlayerCommand::NextTrack => {
1262                // Debounce: suppress key repeat from terminal (150ms window).
1263                let now = std::time::Instant::now();
1264                if now.duration_since(self.last_skip).as_millis() >= 150 {
1265                    self.last_skip = now;
1266                    self.next_track();
1267                }
1268            }
1269            PlayerCommand::PrevTrack => {
1270                let now = std::time::Instant::now();
1271                if now.duration_since(self.last_skip).as_millis() >= 150 {
1272                    self.last_skip = now;
1273                    self.prev_track();
1274                }
1275            }
1276            PlayerCommand::AddToPlaylist(items) => {
1277                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1278                self.shared_state.add_items(items);
1279                self.push_undo(UndoEntry::Added { ids });
1280            }
1281            PlayerCommand::UpdatePaths(updates) => {
1282                self.shared_state.update_paths(&updates);
1283                if let Some(info) = self.shared_state.track_info()
1284                    && let Some((_, new_path)) = updates.iter().find(|(id, _)| *id == info.id)
1285                {
1286                    self.shared_state.set_track_info(Some(TrackInfo {
1287                        path: new_path.clone(),
1288                        ..info
1289                    }));
1290                }
1291            }
1292            PlayerCommand::InsertInPlaylist { items, after } => {
1293                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1294                self.shared_state.insert_items_after(items, after);
1295                self.push_undo(UndoEntry::Inserted { ids });
1296            }
1297            PlayerCommand::ClearPlaylist => {
1298                // Stop engine + clear display state WITHOUT touching the playlist,
1299                // then snapshot, then clear. This avoids the race where stop()
1300                // would clear the playlist before we capture it for undo.
1301                self.stop_playback_and_clear_state();
1302                let (items, cursor) = self.shared_state.snapshot_playlist();
1303                self.shared_state.clear_playlist();
1304                self.push_undo(UndoEntry::Replaced { items, cursor });
1305            }
1306            PlayerCommand::ReplacePlaylist { items, start } => {
1307                // Same order as ClearPlaylist: stop and clear display state
1308                // before snapshotting, or the snapshot captures an already
1309                // emptied playlist and undo restores nothing.
1310                self.stop_playback_and_clear_state();
1311                let (old_items, cursor) = self.shared_state.snapshot_playlist();
1312                self.shared_state.clear_playlist();
1313                self.push_undo(UndoEntry::Replaced {
1314                    items: old_items,
1315                    cursor,
1316                });
1317
1318                if items.is_empty() {
1319                    return;
1320                }
1321                let start_id = items.get(start).unwrap_or(&items[0]).id;
1322                self.shared_state.add_items(items);
1323                self.play(start_id);
1324            }
1325            PlayerCommand::RemoveFromPlaylist(id) => {
1326                let item = self.shared_state.get_item(id);
1327                let after = self.shared_state.item_before(id);
1328                self.remove_from_playlist(id);
1329                if let Some(item) = item {
1330                    self.push_undo(UndoEntry::Removed {
1331                        items: vec![(Box::new(item), after)],
1332                    });
1333                }
1334            }
1335            PlayerCommand::RemoveFromPlaylistBatch(ids) => {
1336                // Snapshot before removing anything, and resolve the resume point
1337                // once: removing one at a time would restart the engine for every
1338                // deleted track that the cursor lands on along the way.
1339                let items_with_pos = self.snapshot_for_undo(&ids);
1340                let resume_after = match self.shared_state.cursor() {
1341                    Some(cursor) if ids.contains(&cursor) => {
1342                        Some(self.shared_state.surviving_item_before(cursor, &ids))
1343                    }
1344                    _ => None,
1345                };
1346
1347                self.shared_state.remove_items(&ids);
1348
1349                if let Some(resume_after) = resume_after {
1350                    self.shared_state.set_cursor(resume_after);
1351                    self.next_track();
1352                }
1353
1354                if !items_with_pos.is_empty() {
1355                    self.push_undo(UndoEntry::Removed {
1356                        items: items_with_pos,
1357                    });
1358                }
1359            }
1360            PlayerCommand::MoveInPlaylist { id, target, after } => {
1361                let was_after = self.shared_state.item_before(id);
1362                self.shared_state.move_item(id, target, after);
1363                self.push_undo(UndoEntry::Moved { id, was_after });
1364            }
1365            PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
1366                let entries = self.shared_state.items_before(&ids);
1367                self.shared_state.move_items(&ids, target, after);
1368                self.push_undo(UndoEntry::MovedBatch { entries });
1369            }
1370            PlayerCommand::ReorderPlaylist(order) => {
1371                // Undoable like any other move: undoing it puts the queue back
1372                // and, by doing so, ends the lock — which is the honest result
1373                // of having rearranged the queue by hand.
1374                let entries = self.shared_state.items_before(&order);
1375                self.shared_state.reorder_to(&order);
1376                self.push_undo(UndoEntry::MovedBatch { entries });
1377            }
1378            PlayerCommand::TrackReady(id) => self.track_ready(id),
1379            PlayerCommand::DecodeFinished => self.on_decode_finished(),
1380            PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
1381            PlayerCommand::StreamProbed { id, info, mode } => self.stream_probed(id, *info, mode),
1382            PlayerCommand::TrackFailed(id) => self.track_failed(id),
1383            PlayerCommand::Undo => self.execute_undo(),
1384            PlayerCommand::Redo => self.execute_redo(),
1385            PlayerCommand::BeginUndoBatch => {
1386                self.batch_buffer = Some(Vec::new());
1387            }
1388            PlayerCommand::EndUndoBatch => {
1389                if let Some(entries) = self.batch_buffer.take() {
1390                    if entries.len() == 1 {
1391                        // Single entry — push directly, no wrapping.
1392                        self.undo_stack.push(entries.into_iter().next().unwrap());
1393                    } else if !entries.is_empty() {
1394                        self.undo_stack.push(UndoEntry::Batch(entries));
1395                    }
1396                }
1397            }
1398            PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
1399            PlayerCommand::ClearOutputDevice => self.clear_output_device(),
1400        }
1401    }
1402
1403    /// Apply an undo/redo entry: mutate the playlist and return the inverse entry.
1404    fn apply_entry(&mut self, entry: UndoEntry) -> Option<UndoEntry> {
1405        match entry {
1406            UndoEntry::Added { ids } => {
1407                // Undo of "items were added": snapshot them with positions, then remove.
1408                let items_with_pos = self.snapshot_for_undo(&ids);
1409                self.shared_state.remove_items(&ids);
1410                Some(UndoEntry::Removed {
1411                    items: items_with_pos,
1412                })
1413            }
1414            UndoEntry::Removed { items } => {
1415                // Undo of "items were removed": re-insert each at its position.
1416                let mut ids = Vec::with_capacity(items.len());
1417                for (item, after) in items {
1418                    ids.push(item.id);
1419                    self.shared_state.insert_item_at(*item, after);
1420                }
1421                Some(UndoEntry::Added { ids })
1422            }
1423            UndoEntry::Inserted { ids } => {
1424                // Same as Added — snapshot positions, remove items.
1425                let items_with_pos = self.snapshot_for_undo(&ids);
1426                self.shared_state.remove_items(&ids);
1427                Some(UndoEntry::Removed {
1428                    items: items_with_pos,
1429                })
1430            }
1431            UndoEntry::Moved { id, was_after } => {
1432                let current_after = self.shared_state.item_before(id);
1433                self.shared_state.move_item_to(id, was_after);
1434                Some(UndoEntry::Moved {
1435                    id,
1436                    was_after: current_after,
1437                })
1438            }
1439            UndoEntry::MovedBatch { entries } => {
1440                let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
1441                let current_positions = self.shared_state.items_before(&ids);
1442                self.shared_state.move_items_to(&entries);
1443                Some(UndoEntry::MovedBatch {
1444                    entries: current_positions,
1445                })
1446            }
1447            UndoEntry::Replaced { items, cursor } => {
1448                let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
1449                self.shared_state.restore_playlist(items, cursor);
1450                Some(UndoEntry::Replaced {
1451                    items: current_items,
1452                    cursor: current_cursor,
1453                })
1454            }
1455            UndoEntry::Batch(entries) => {
1456                // Apply entries in reverse order, collect inverses.
1457                let mut inverses = Vec::with_capacity(entries.len());
1458                for entry in entries.into_iter().rev() {
1459                    if let Some(inverse) = self.apply_entry(entry) {
1460                        inverses.push(inverse);
1461                    }
1462                }
1463                inverses.reverse();
1464                Some(UndoEntry::Batch(inverses))
1465            }
1466        }
1467    }
1468
1469    /// Put playback back in agreement with the playlist.
1470    ///
1471    /// The engine keeps decoding whatever it was on while the playlist changes
1472    /// underneath it, which an undo can turn into a lie: undoing a replace
1473    /// restores the queue but leaves the engine playing a track that queue does
1474    /// not contain. The transport then describes an item nothing can select,
1475    /// and the decode lookahead — which finds the next track by locating the
1476    /// current one — has nothing to follow, so the queue ends at the end of the
1477    /// track instead of carrying on.
1478    ///
1479    /// Done once, after the entry is applied, rather than inside each variant:
1480    /// any undo that takes items away can orphan the engine, not only
1481    /// `Replaced`.
1482    fn reconcile_playback(&mut self) {
1483        let Some(playing) = self.shared_state.track_info().map(|t| t.id) else {
1484            return;
1485        };
1486        if self.shared_state.get_item(playing).is_some() {
1487            return;
1488        }
1489        // Pick the restored queue back up where its cursor says it was, but
1490        // only if something was already playing — an undo is not a reason to
1491        // start the music, and the position is not part of what was snapshotted
1492        // so the track begins again.
1493        let resume = (self.shared_state.playback_state() == PlaybackState::Playing)
1494            .then(|| self.shared_state.cursor())
1495            .flatten();
1496        self.stop_playback_and_clear_state();
1497        if let Some(id) = resume {
1498            self.play(id);
1499        }
1500    }
1501
1502    /// Execute an undo operation, pushing the inverse onto the redo stack.
1503    fn execute_undo(&mut self) {
1504        let Some(entry) = self.undo_stack.pop_undo() else {
1505            return;
1506        };
1507        if let Some(inverse) = self.apply_entry(entry) {
1508            self.undo_stack.push_redo(inverse);
1509        }
1510        self.reconcile_playback();
1511    }
1512
1513    /// Execute a redo operation, pushing the inverse onto the undo stack.
1514    fn execute_redo(&mut self) {
1515        let Some(entry) = self.undo_stack.pop_redo() else {
1516            return;
1517        };
1518        if let Some(inverse) = self.apply_entry(entry) {
1519            self.undo_stack.push_undo_keep_redo(inverse);
1520        }
1521        self.reconcile_playback();
1522    }
1523
1524    /// Run the command loop. Blocks until the sender is dropped.
1525    pub fn run(&mut self) {
1526        use std::time::Duration;
1527
1528        let rx = self.commands.rx.clone();
1529        loop {
1530            // Poll with timeout so we update position even without commands.
1531            match rx.recv_timeout(Duration::from_millis(50)) {
1532                Ok(cmd) => self.process_command(cmd),
1533                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
1534                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
1535            }
1536            self.update_playback_state();
1537        }
1538        self.stop();
1539    }
1540
1541    /// Spawn the player on a background thread, returning the shared state,
1542    /// timeline, visualization snapshot, and command sender.
1543    pub fn spawn() -> (
1544        Arc<SharedPlayerState>,
1545        Arc<PlaybackTimeline>,
1546        Arc<VizSnapshot>,
1547        crossbeam_channel::Sender<PlayerCommand>,
1548    ) {
1549        let mut player = Self::new();
1550        player.history = PlayRecorder::spawn();
1551        let state = player.shared_state();
1552        let timeline = player.timeline();
1553        let viz_snapshot = player.viz_snapshot();
1554        let tx = player.command_sender();
1555
1556        thread::Builder::new()
1557            .name("koan-player".into())
1558            .spawn(move || player.run())
1559            .expect("failed to spawn player thread");
1560
1561        (state, timeline, viz_snapshot, tx)
1562    }
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567    use super::*;
1568    use state::PlaylistItem;
1569    use std::path::PathBuf;
1570
1571    fn make_item(title: &str) -> PlaylistItem {
1572        PlaylistItem {
1573            playlist_entry_id: None,
1574            id: QueueItemId::new(),
1575            db_id: None,
1576            path: PathBuf::from(format!("/music/{title}.flac")),
1577            title: title.to_string(),
1578            artist: String::new(),
1579            album_artist: String::new(),
1580            album: String::new(),
1581            year: None,
1582            codec: None,
1583            track_number: None,
1584            disc: None,
1585            duration_ms: None,
1586            state: ItemState::Ready,
1587        }
1588    }
1589
1590    fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
1591        let (items, _) = player.shared_state.snapshot_playlist();
1592        items.iter().map(|i| i.id).collect()
1593    }
1594
1595    fn playlist_titles(player: &Player) -> Vec<String> {
1596        let (items, _) = player.shared_state.snapshot_playlist();
1597        items.iter().map(|i| i.title.clone()).collect()
1598    }
1599
1600    fn pending_item(title: &str) -> PlaylistItem {
1601        PlaylistItem {
1602            playlist_entry_id: None,
1603            state: ItemState::Pending,
1604            ..make_item(title)
1605        }
1606    }
1607
1608    /// Stand in for an engine that is playing `id`. The test items have no
1609    /// files behind them, so `start_playback` can never get far enough to leave
1610    /// this state on its own.
1611    fn pretend_playing(player: &mut Player, id: QueueItemId) {
1612        let item = player
1613            .shared_state
1614            .get_item(id)
1615            .expect("item is in the queue");
1616        player.shared_state.set_track_info(Some(TrackInfo {
1617            id,
1618            path: item.path,
1619            codec: String::new(),
1620            sample_rate: 44_100,
1621            bit_depth: None,
1622            bitrate_kbps: None,
1623            channels: 2,
1624            duration_ms: 1_000,
1625        }));
1626        player
1627            .shared_state
1628            .set_playback_state(PlaybackState::Playing);
1629    }
1630
1631    fn playing_id(player: &Player) -> Option<QueueItemId> {
1632        player.shared_state.track_info().map(|t| t.id)
1633    }
1634
1635    /// Build `n` ready items, add them, and return their IDs.
1636    fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
1637        let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
1638        let ids = items.iter().map(|i| i.id).collect();
1639        player.process_command(PlayerCommand::AddToPlaylist(items));
1640        ids
1641    }
1642
1643    // --- cursor transitions ---
1644
1645    /// Feed the player a track's worth of playback ticks, as the 50ms poll would.
1646    fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
1647        let mut at = from_ms;
1648        if let Some(f) = player.in_flight.as_mut() {
1649            f.advance(at); // the position the needle landed on
1650        }
1651        while at < to_ms {
1652            at = (at + 50).min(to_ms);
1653            if let Some(f) = player.in_flight.as_mut() {
1654                f.advance(at);
1655            }
1656        }
1657    }
1658
1659    fn start(player: &mut Player, track_id: i64) -> QueueItemId {
1660        let id = QueueItemId::new();
1661        player.on_track_changed(id);
1662        // The item is not in a playlist here, so there is no db_id to find.
1663        player
1664            .in_flight
1665            .as_mut()
1666            .unwrap()
1667            .track_id_for_test(track_id);
1668        id
1669    }
1670
1671    #[test]
1672    fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
1673        let mut player = Player::new();
1674        start(&mut player, 11);
1675        listen(&mut player, 0, 200_000);
1676
1677        let b = QueueItemId::new();
1678        player.on_track_changed(b);
1679        let f = player
1680            .in_flight
1681            .as_ref()
1682            .expect("the next track is counting");
1683        assert_eq!(f.item, b);
1684        assert_eq!(f.listened_ms(), 0, "and starts from nothing");
1685    }
1686
1687    #[test]
1688    fn a_track_skipped_seconds_in_is_still_history() {
1689        let mut player = Player::new();
1690        start(&mut player, 7);
1691        listen(&mut player, 0, 2_000);
1692
1693        let event = player
1694            .finish_play()
1695            .expect("putting something on is a thing you did, however briefly");
1696        assert!(matches!(
1697            event,
1698            history::PlayEvent::Finished {
1699                track_id: 7,
1700                listened_ms: 2_000
1701            }
1702        ));
1703    }
1704
1705    #[test]
1706    fn a_track_is_closed_out_once() {
1707        let mut player = Player::new();
1708        start(&mut player, 7);
1709        listen(&mut player, 0, 200_000);
1710
1711        assert!(player.finish_play().is_some());
1712        assert!(player.finish_play().is_none());
1713    }
1714
1715    #[test]
1716    fn seeking_around_a_track_does_not_enter_it_twice() {
1717        let mut player = Player::new();
1718        let id = start(&mut player, 7);
1719        listen(&mut player, 0, 120_000);
1720
1721        // A seek restarts playback of the same item.
1722        player.on_track_changed(id);
1723        assert_eq!(
1724            player.in_flight.as_ref().unwrap().listened_ms(),
1725            120_000,
1726            "the seek kept the count rather than restarting it"
1727        );
1728        listen(&mut player, 30_000, 40_000);
1729
1730        let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
1731            panic!("still one play");
1732        };
1733        assert_eq!(listened_ms, 130_000);
1734        assert!(player.finish_play().is_none());
1735    }
1736
1737    #[test]
1738    fn a_track_that_is_not_in_the_library_is_not_recorded() {
1739        let mut player = Player::new();
1740        let id = QueueItemId::new();
1741        player.on_track_changed(id);
1742        listen(&mut player, 0, 200_000);
1743        assert!(player.finish_play().is_none());
1744    }
1745
1746    #[test]
1747    fn stopping_closes_out_what_was_heard() {
1748        let mut player = Player::new();
1749        start(&mut player, 7);
1750        listen(&mut player, 0, 150_000);
1751
1752        player.stop_playback_and_clear_state();
1753        assert!(player.in_flight.is_none(), "the stop consumed it");
1754    }
1755
1756    #[test]
1757    fn removing_the_playing_track_resumes_at_its_successor() {
1758        let mut player = Player::new();
1759        let ids = seed(&mut player, 5);
1760        player.shared_state.set_cursor(Some(ids[2]));
1761
1762        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
1763
1764        assert_eq!(
1765            player.shared_state.cursor(),
1766            Some(ids[3]),
1767            "playback must continue at the next track, not restart the queue"
1768        );
1769        assert_eq!(player.playback_starts, 1);
1770    }
1771
1772    #[test]
1773    fn removing_the_first_playing_track_resumes_at_the_new_first() {
1774        let mut player = Player::new();
1775        let ids = seed(&mut player, 3);
1776        player.shared_state.set_cursor(Some(ids[0]));
1777
1778        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
1779
1780        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
1781    }
1782
1783    #[test]
1784    fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
1785        let mut player = Player::new();
1786        let playing = make_item("playing");
1787        let waiting = pending_item("waiting");
1788        let later = make_item("later");
1789        let (playing_id, waiting_id) = (playing.id, waiting.id);
1790        player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
1791        player.shared_state.set_cursor(Some(playing_id));
1792
1793        player.process_command(PlayerCommand::DecodeFinished);
1794
1795        assert_eq!(
1796            player.shared_state.cursor(),
1797            Some(waiting_id),
1798            "the cursor parks on the track being fetched"
1799        );
1800        assert_eq!(
1801            player.playback_starts, 0,
1802            "nothing to play until its bytes land"
1803        );
1804
1805        // The download completes. Because the cursor is parked here, the
1806        // TrackReady actually reaches the player and the queue resumes.
1807        player
1808            .shared_state
1809            .update_item_state(waiting_id, ItemState::Ready);
1810        player.process_command(PlayerCommand::TrackReady(waiting_id));
1811
1812        assert_eq!(player.playback_starts, 1);
1813        assert_eq!(player.shared_state.cursor(), Some(waiting_id));
1814    }
1815
1816    #[test]
1817    fn a_download_that_cannot_land_moves_the_cursor_on() {
1818        let mut player = Player::new();
1819        let waiting = pending_item("waiting");
1820        let later = make_item("later");
1821        let (waiting_id, later_id) = (waiting.id, later.id);
1822        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, later]));
1823
1824        player.process_command(PlayerCommand::Play(waiting_id));
1825        assert_eq!(player.playback_starts, 0, "nothing to play yet");
1826
1827        // The download gives up. Ready will never come.
1828        player
1829            .shared_state
1830            .update_item_state(waiting_id, ItemState::Failed("remote unavailable".into()));
1831        player.process_command(PlayerCommand::TrackFailed(waiting_id));
1832
1833        assert_eq!(
1834            player.shared_state.cursor(),
1835            Some(later_id),
1836            "the queue moves past a track that can never load"
1837        );
1838        assert_eq!(player.playback_starts, 1);
1839    }
1840
1841    #[test]
1842    fn a_queue_that_can_never_load_stops_rather_than_waiting() {
1843        let mut player = Player::new();
1844        let first = pending_item("first");
1845        let second = pending_item("second");
1846        let (first_id, second_id) = (first.id, second.id);
1847        player.process_command(PlayerCommand::AddToPlaylist(vec![first, second]));
1848
1849        player.process_command(PlayerCommand::Play(first_id));
1850        for id in [first_id, second_id] {
1851            player
1852                .shared_state
1853                .update_item_state(id, ItemState::Failed("remote unavailable".into()));
1854            player.process_command(PlayerCommand::TrackFailed(id));
1855        }
1856
1857        assert_eq!(player.playback_starts, 0);
1858        assert_eq!(
1859            player.shared_state.playback_state(),
1860            PlaybackState::Stopped,
1861            "a stop the UI can see, not an indefinite wait for TrackReady"
1862        );
1863    }
1864
1865    #[test]
1866    fn a_failure_elsewhere_in_the_queue_leaves_the_cursor_alone() {
1867        let mut player = Player::new();
1868        let waiting = pending_item("waiting");
1869        let other = pending_item("other");
1870        let (waiting_id, other_id) = (waiting.id, other.id);
1871        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, other]));
1872        player.process_command(PlayerCommand::Play(waiting_id));
1873
1874        player
1875            .shared_state
1876            .update_item_state(other_id, ItemState::Failed("remote unavailable".into()));
1877        player.process_command(PlayerCommand::TrackFailed(other_id));
1878
1879        assert_eq!(
1880            player.shared_state.cursor(),
1881            Some(waiting_id),
1882            "a track still downloading keeps the cursor"
1883        );
1884    }
1885
1886    #[test]
1887    fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
1888        let mut player = Player::new();
1889        let ids = seed(&mut player, 5);
1890        player.shared_state.set_cursor(Some(ids[2]));
1891
1892        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
1893            ids[1], ids[2], ids[3],
1894        ]));
1895
1896        assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
1897        assert_eq!(player.shared_state.cursor(), Some(ids[4]));
1898        assert_eq!(
1899            player.playback_starts, 1,
1900            "one resume for the whole selection, not one per deleted track"
1901        );
1902    }
1903
1904    #[test]
1905    fn batch_delete_below_the_cursor_leaves_playback_alone() {
1906        let mut player = Player::new();
1907        let ids = seed(&mut player, 4);
1908        player.shared_state.set_cursor(Some(ids[0]));
1909
1910        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
1911
1912        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
1913        assert_eq!(player.playback_starts, 0);
1914    }
1915
1916    #[test]
1917    fn undo_of_a_batch_delete_restores_the_original_order() {
1918        // The TUI collects a selection from a HashSet, so the IDs arrive in
1919        // arbitrary order — scrambled here so a snapshot that trusts that order
1920        // re-inserts C before B and lands it at the end of the playlist.
1921        let mut player = Player::new();
1922        let items = vec![
1923            make_item("A"),
1924            make_item("B"),
1925            make_item("C"),
1926            make_item("D"),
1927        ];
1928        let (b_id, c_id) = (items[1].id, items[2].id);
1929        player.process_command(PlayerCommand::AddToPlaylist(items));
1930
1931        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
1932        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
1933
1934        player.process_command(PlayerCommand::Undo);
1935        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
1936    }
1937
1938    // --- AddToPlaylist undo/redo ---
1939
1940    #[test]
1941    fn undo_add_removes_items() {
1942        let mut player = Player::new();
1943        let items = vec![make_item("A"), make_item("B")];
1944        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1945
1946        player.process_command(PlayerCommand::AddToPlaylist(items));
1947        assert_eq!(playlist_ids(&player), ids);
1948        assert!(player.undo_stack().can_undo());
1949
1950        player.process_command(PlayerCommand::Undo);
1951        assert!(playlist_ids(&player).is_empty());
1952        assert!(player.undo_stack().can_redo());
1953    }
1954
1955    #[test]
1956    fn redo_add_restores_items() {
1957        let mut player = Player::new();
1958        let items = vec![make_item("A"), make_item("B")];
1959
1960        player.process_command(PlayerCommand::AddToPlaylist(items));
1961        player.process_command(PlayerCommand::Undo);
1962        assert!(playlist_ids(&player).is_empty());
1963
1964        player.process_command(PlayerCommand::Redo);
1965        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1966    }
1967
1968    // --- RemoveFromPlaylist undo/redo ---
1969
1970    #[test]
1971    fn undo_remove_restores_item_at_position() {
1972        let mut player = Player::new();
1973        let items = vec![make_item("A"), make_item("B"), make_item("C")];
1974        let b_id = items[1].id;
1975
1976        player.process_command(PlayerCommand::AddToPlaylist(items));
1977        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
1978        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
1979
1980        player.process_command(PlayerCommand::Undo);
1981        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
1982    }
1983
1984    #[test]
1985    fn undo_remove_first_item() {
1986        let mut player = Player::new();
1987        let items = vec![make_item("A"), make_item("B")];
1988        let a_id = items[0].id;
1989
1990        player.process_command(PlayerCommand::AddToPlaylist(items));
1991        player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
1992        assert_eq!(playlist_titles(&player), vec!["B"]);
1993
1994        player.process_command(PlayerCommand::Undo);
1995        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
1996    }
1997
1998    #[test]
1999    fn undo_batch_remove_restores_all() {
2000        let mut player = Player::new();
2001        let items = vec![
2002            make_item("A"),
2003            make_item("B"),
2004            make_item("C"),
2005            make_item("D"),
2006        ];
2007        let b_id = items[1].id;
2008        let c_id = items[2].id;
2009
2010        player.process_command(PlayerCommand::AddToPlaylist(items));
2011        let version_before = player.shared_state.playlist_version();
2012        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
2013        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
2014        // One bump for the whole batch. Bumping per item is what made clearing
2015        // a large queue crawl, and every bump wakes every client watching.
2016        assert_eq!(
2017            player.shared_state.playlist_version(),
2018            version_before + 1,
2019            "batch removal must bump the playlist version exactly once"
2020        );
2021
2022        // Single undo restores both
2023        player.process_command(PlayerCommand::Undo);
2024        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
2025    }
2026
2027    #[test]
2028    fn redo_batch_remove() {
2029        let mut player = Player::new();
2030        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2031        let a_id = items[0].id;
2032        let b_id = items[1].id;
2033
2034        player.process_command(PlayerCommand::AddToPlaylist(items));
2035        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
2036        player.process_command(PlayerCommand::Undo);
2037        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2038
2039        player.process_command(PlayerCommand::Redo);
2040        assert_eq!(playlist_titles(&player), vec!["C"]);
2041    }
2042
2043    #[test]
2044    fn redo_remove() {
2045        let mut player = Player::new();
2046        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2047        let b_id = items[1].id;
2048
2049        player.process_command(PlayerCommand::AddToPlaylist(items));
2050        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2051        player.process_command(PlayerCommand::Undo);
2052        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2053
2054        player.process_command(PlayerCommand::Redo);
2055        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2056    }
2057
2058    // --- InsertInPlaylist undo/redo ---
2059
2060    #[test]
2061    fn undo_insert_removes_inserted_items() {
2062        let mut player = Player::new();
2063        let items = vec![make_item("A"), make_item("C")];
2064        let a_id = items[0].id;
2065
2066        player.process_command(PlayerCommand::AddToPlaylist(items));
2067
2068        let inserted = vec![make_item("B")];
2069        player.process_command(PlayerCommand::InsertInPlaylist {
2070            items: inserted,
2071            after: a_id,
2072        });
2073        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2074
2075        player.process_command(PlayerCommand::Undo);
2076        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2077    }
2078
2079    // --- MoveInPlaylist undo/redo ---
2080
2081    #[test]
2082    fn undo_move_restores_position() {
2083        let mut player = Player::new();
2084        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2085        let a_id = items[0].id;
2086        let c_id = items[2].id;
2087
2088        player.process_command(PlayerCommand::AddToPlaylist(items));
2089
2090        // Move A after C: [B, C, A]
2091        player.process_command(PlayerCommand::MoveInPlaylist {
2092            id: a_id,
2093            target: c_id,
2094            after: true,
2095        });
2096        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2097
2098        player.process_command(PlayerCommand::Undo);
2099        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2100    }
2101
2102    #[test]
2103    fn redo_move() {
2104        let mut player = Player::new();
2105        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2106        let a_id = items[0].id;
2107        let c_id = items[2].id;
2108
2109        player.process_command(PlayerCommand::AddToPlaylist(items));
2110        player.process_command(PlayerCommand::MoveInPlaylist {
2111            id: a_id,
2112            target: c_id,
2113            after: true,
2114        });
2115        player.process_command(PlayerCommand::Undo);
2116        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2117
2118        player.process_command(PlayerCommand::Redo);
2119        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2120    }
2121
2122    // --- MoveItemsInPlaylist (batch) undo/redo ---
2123
2124    #[test]
2125    fn undo_batch_move() {
2126        let mut player = Player::new();
2127        let items = vec![
2128            make_item("A"),
2129            make_item("B"),
2130            make_item("C"),
2131            make_item("D"),
2132        ];
2133        let a_id = items[0].id;
2134        let b_id = items[1].id;
2135        let d_id = items[3].id;
2136
2137        player.process_command(PlayerCommand::AddToPlaylist(items));
2138
2139        // Move A,B after D: [C, D, A, B]
2140        player.process_command(PlayerCommand::MoveItemsInPlaylist {
2141            ids: vec![a_id, b_id],
2142            target: d_id,
2143            after: true,
2144        });
2145        assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
2146
2147        player.process_command(PlayerCommand::Undo);
2148        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
2149    }
2150
2151    // --- ClearPlaylist undo/redo ---
2152
2153    #[test]
2154    fn undo_clear_restores_playlist() {
2155        let mut player = Player::new();
2156        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2157
2158        player.process_command(PlayerCommand::AddToPlaylist(items));
2159        player.process_command(PlayerCommand::ClearPlaylist);
2160        assert!(playlist_ids(&player).is_empty());
2161
2162        player.process_command(PlayerCommand::Undo);
2163        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2164    }
2165
2166    /// The bug: replacing the queue starts the new track, and undoing restored
2167    /// the old queue while leaving the engine on a track that queue no longer
2168    /// contains — a transport describing a row nobody can see, and a decode
2169    /// lookahead with nothing to follow.
2170    #[test]
2171    fn undoing_a_replace_does_not_leave_the_engine_on_an_orphaned_track() {
2172        let mut player = Player::new();
2173        let original = seed(&mut player, 3);
2174        player.shared_state.set_cursor(Some(original[0]));
2175        pretend_playing(&mut player, original[0]);
2176
2177        let replacement = vec![make_item("something else")];
2178        let orphan = replacement[0].id;
2179        player.process_command(PlayerCommand::ReplacePlaylist {
2180            items: replacement,
2181            start: 0,
2182        });
2183        // What `play()` would have left behind if the file existed.
2184        pretend_playing(&mut player, orphan);
2185
2186        player.process_command(PlayerCommand::Undo);
2187
2188        assert_eq!(playlist_ids(&player), original, "the queue comes back");
2189        assert!(
2190            player.shared_state.get_item(orphan).is_none(),
2191            "and the replacement is gone from it"
2192        );
2193        assert!(
2194            playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
2195            "so nothing may still be playing out of it"
2196        );
2197    }
2198
2199    /// The same orphaning, reached by undoing an add rather than a replace.
2200    #[test]
2201    fn undoing_an_add_does_not_leave_the_engine_on_a_removed_track() {
2202        let mut player = Player::new();
2203        seed(&mut player, 2);
2204        let added = seed(&mut player, 1);
2205        pretend_playing(&mut player, added[0]);
2206
2207        player.process_command(PlayerCommand::Undo);
2208
2209        assert!(player.shared_state.get_item(added[0]).is_none());
2210        assert!(
2211            playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
2212            "the engine cannot be left on the item the undo removed"
2213        );
2214    }
2215
2216    /// An undo that leaves the playing item where it is must not restart it.
2217    #[test]
2218    fn undoing_a_move_leaves_playback_alone() {
2219        let mut player = Player::new();
2220        let ids = seed(&mut player, 3);
2221        player.shared_state.set_cursor(Some(ids[0]));
2222        pretend_playing(&mut player, ids[0]);
2223        let starts = player.playback_starts;
2224
2225        player.process_command(PlayerCommand::MoveInPlaylist {
2226            id: ids[2],
2227            target: ids[0],
2228            after: false,
2229        });
2230        player.process_command(PlayerCommand::Undo);
2231
2232        assert_eq!(playlist_ids(&player), ids);
2233        assert_eq!(playing_id(&player), Some(ids[0]), "still on the same track");
2234        assert_eq!(player.playback_starts, starts, "and not restarted");
2235    }
2236
2237    #[test]
2238    fn redo_clear() {
2239        let mut player = Player::new();
2240        let items = vec![make_item("A"), make_item("B")];
2241
2242        player.process_command(PlayerCommand::AddToPlaylist(items));
2243        player.process_command(PlayerCommand::ClearPlaylist);
2244        player.process_command(PlayerCommand::Undo);
2245        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2246
2247        player.process_command(PlayerCommand::Redo);
2248        assert!(playlist_ids(&player).is_empty());
2249    }
2250
2251    // --- Multi-step undo/redo ---
2252
2253    #[test]
2254    fn multiple_undos_in_sequence() {
2255        let mut player = Player::new();
2256
2257        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
2258        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
2259        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
2260        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2261
2262        player.process_command(PlayerCommand::Undo);
2263        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2264
2265        player.process_command(PlayerCommand::Undo);
2266        assert_eq!(playlist_titles(&player), vec!["A"]);
2267
2268        player.process_command(PlayerCommand::Undo);
2269        assert!(playlist_ids(&player).is_empty());
2270    }
2271
2272    #[test]
2273    fn undo_redo_undo_cycle() {
2274        let mut player = Player::new();
2275        let items = vec![make_item("A"), make_item("B")];
2276
2277        player.process_command(PlayerCommand::AddToPlaylist(items));
2278        player.process_command(PlayerCommand::Undo);
2279        assert!(playlist_ids(&player).is_empty());
2280
2281        player.process_command(PlayerCommand::Redo);
2282        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
2283
2284        player.process_command(PlayerCommand::Undo);
2285        assert!(playlist_ids(&player).is_empty());
2286    }
2287
2288    #[test]
2289    fn new_action_clears_redo_stack() {
2290        let mut player = Player::new();
2291        let items = vec![make_item("A")];
2292
2293        player.process_command(PlayerCommand::AddToPlaylist(items));
2294        player.process_command(PlayerCommand::Undo);
2295        assert!(player.undo_stack().can_redo());
2296
2297        // New action should clear redo
2298        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
2299        assert!(!player.undo_stack().can_redo());
2300    }
2301
2302    #[test]
2303    fn undo_on_empty_stack_is_noop() {
2304        let mut player = Player::new();
2305        player.process_command(PlayerCommand::Undo);
2306        assert!(playlist_ids(&player).is_empty());
2307    }
2308
2309    #[test]
2310    fn redo_on_empty_stack_is_noop() {
2311        let mut player = Player::new();
2312        player.process_command(PlayerCommand::Redo);
2313        assert!(playlist_ids(&player).is_empty());
2314    }
2315
2316    // --- Non-undoable commands don't push entries ---
2317
2318    #[test]
2319    fn playback_commands_not_undoable() {
2320        let mut player = Player::new();
2321        player.process_command(PlayerCommand::Pause);
2322        player.process_command(PlayerCommand::Resume);
2323        player.process_command(PlayerCommand::NextTrack);
2324        player.process_command(PlayerCommand::PrevTrack);
2325        assert!(!player.undo_stack().can_undo());
2326    }
2327
2328    #[test]
2329    fn update_paths_not_undoable() {
2330        let mut player = Player::new();
2331        let items = vec![make_item("A")];
2332        let id = items[0].id;
2333        player.process_command(PlayerCommand::AddToPlaylist(items));
2334
2335        let undo_count = player.undo_stack().undo_len();
2336        player.process_command(PlayerCommand::UpdatePaths(vec![(
2337            id,
2338            PathBuf::from("/new/path.flac"),
2339        )]));
2340        assert_eq!(player.undo_stack().undo_len(), undo_count);
2341    }
2342
2343    // --- Complex scenarios ---
2344
2345    #[test]
2346    fn add_remove_undo_undo_produces_original() {
2347        let mut player = Player::new();
2348        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2349        let b_id = items[1].id;
2350        let original_titles = vec!["A", "B", "C"];
2351
2352        player.process_command(PlayerCommand::AddToPlaylist(items));
2353        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
2354        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
2355
2356        // Undo remove → back to A, B, C
2357        player.process_command(PlayerCommand::Undo);
2358        assert_eq!(playlist_titles(&player), original_titles);
2359
2360        // Undo add → empty
2361        player.process_command(PlayerCommand::Undo);
2362        assert!(playlist_ids(&player).is_empty());
2363    }
2364
2365    #[test]
2366    fn interleaved_adds_and_moves_undo() {
2367        let mut player = Player::new();
2368        let items = vec![make_item("A"), make_item("B"), make_item("C")];
2369        let a_id = items[0].id;
2370        let c_id = items[2].id;
2371
2372        player.process_command(PlayerCommand::AddToPlaylist(items));
2373
2374        // Move A after C: [B, C, A]
2375        player.process_command(PlayerCommand::MoveInPlaylist {
2376            id: a_id,
2377            target: c_id,
2378            after: true,
2379        });
2380        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2381
2382        // Add D: [B, C, A, D]
2383        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
2384        assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
2385
2386        // Undo add D: [B, C, A]
2387        player.process_command(PlayerCommand::Undo);
2388        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
2389
2390        // Undo move: [A, B, C]
2391        player.process_command(PlayerCommand::Undo);
2392        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
2393    }
2394
2395    /// Regression test for GitHub #89: AudioEngine must be dropped synchronously
2396    /// in stop_engine() before the caller changes sample rates. If the engine is
2397    /// dropped on a background thread, CoreAudio's internal buffer list can be
2398    /// freed while AudioUnitUninitialize is still tearing it down → crash.
2399    #[test]
2400    fn stop_engine_drops_engine_synchronously() {
2401        use std::sync::atomic::{AtomicBool, Ordering};
2402
2403        struct MockEngine {
2404            dropped: Arc<AtomicBool>,
2405        }
2406        impl AudioEngineHandle for MockEngine {
2407            fn start(&self) -> Result<(), BackendError> {
2408                Ok(())
2409            }
2410            fn stop(&self) -> Result<(), BackendError> {
2411                Ok(())
2412            }
2413            fn is_running(&self) -> bool {
2414                false
2415            }
2416        }
2417        impl Drop for MockEngine {
2418            fn drop(&mut self) {
2419                self.dropped.store(true, Ordering::SeqCst);
2420            }
2421        }
2422
2423        let dropped = Arc::new(AtomicBool::new(false));
2424
2425        // Build a minimal decode handle that won't block.
2426        let stop_flag = Arc::new(AtomicBool::new(false));
2427        let decode_handle = buffer::DecodeHandle::new_for_test(stop_flag);
2428
2429        let mut player = Player::new();
2430        player.active_playback = Some(ActivePlayback {
2431            engine: Box::new(MockEngine {
2432                dropped: dropped.clone(),
2433            }),
2434            decode_handle,
2435            _rate_watch: None,
2436        });
2437
2438        player.stop_engine();
2439
2440        // The engine must already be dropped when stop_engine returns.
2441        // If this fails, the engine was moved to a background thread — the
2442        // exact race condition that causes the #89 crash.
2443        assert!(
2444            dropped.load(Ordering::SeqCst),
2445            "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
2446        );
2447    }
2448
2449    // --- Engine format matches the decoded PCM ---
2450
2451    /// Backend pinned to one sample rate that refuses every switch, recording
2452    /// the format the engine is asked for.
2453    struct StuckBackend {
2454        rate: f64,
2455        asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
2456    }
2457
2458    struct NullEngine;
2459    impl AudioEngineHandle for NullEngine {
2460        fn start(&self) -> Result<(), BackendError> {
2461            Ok(())
2462        }
2463        fn stop(&self) -> Result<(), BackendError> {
2464            Ok(())
2465        }
2466        fn is_running(&self) -> bool {
2467            false
2468        }
2469    }
2470
2471    impl AudioBackend for StuckBackend {
2472        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2473            Ok(vec![self.default_device()?])
2474        }
2475        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2476            Ok(backend::DeviceInfo {
2477                name: "Stuck DAC".into(),
2478                sample_rates: vec![self.rate],
2479                platform_id: 0,
2480            })
2481        }
2482        fn supported_sample_rates(
2483            &self,
2484            _device: &backend::DeviceInfo,
2485        ) -> Result<Vec<f64>, BackendError> {
2486            Ok(vec![self.rate])
2487        }
2488        fn get_device_sample_rate(
2489            &self,
2490            _device: &backend::DeviceInfo,
2491        ) -> Result<f64, BackendError> {
2492            Ok(self.rate)
2493        }
2494        fn set_device_sample_rate(
2495            &self,
2496            _device: &backend::DeviceInfo,
2497            rate: f64,
2498        ) -> Result<f64, BackendError> {
2499            Err(BackendError::UnsupportedSampleRate(rate))
2500        }
2501        fn create_engine(
2502            &self,
2503            _device: &backend::DeviceInfo,
2504            sample_rate: f64,
2505            channels: u32,
2506            _consumer: rtrb::Consumer<f32>,
2507            _samples_played: Arc<AtomicU64>,
2508        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2509            *self.asked.lock().unwrap() = Some((sample_rate, channels));
2510            Ok(Box::new(NullEngine))
2511        }
2512    }
2513
2514    fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
2515        let asked = Arc::new(std::sync::Mutex::new(None));
2516        let mut player = Player::new();
2517        player.backend = Box::new(StuckBackend {
2518            rate: device_rate,
2519            asked: asked.clone(),
2520        });
2521
2522        let info = buffer::StreamInfo {
2523            codec: "MP3".into(),
2524            sample_rate: source_rate,
2525            channels,
2526            bit_depth: Some(16),
2527            bitrate_kbps: None,
2528            duration_ms: 1000,
2529        };
2530        let (_producer, consumer) = rtrb::RingBuffer::new(16);
2531        player
2532            .create_engine_for(&info, consumer)
2533            .expect("engine creation should succeed");
2534        let asked = *asked.lock().unwrap();
2535        asked.expect("engine was never created")
2536    }
2537
2538    #[test]
2539    fn engine_uses_source_rate_when_device_refuses_switch() {
2540        // MPEG-2 MP3 rates are routinely rejected by output devices. The engine
2541        // must still be told the rate the PCM actually is.
2542        assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
2543        assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
2544    }
2545
2546    #[test]
2547    fn engine_uses_source_channel_count() {
2548        assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
2549    }
2550
2551    /// The rate the device settled at, as the front ends read it.
2552    fn settled_rate_for(source_rate: u32, device_rate: f64) -> Option<u32> {
2553        let mut player = Player::new();
2554        player.backend = Box::new(StuckBackend {
2555            rate: device_rate,
2556            asked: Arc::new(std::sync::Mutex::new(None)),
2557        });
2558        let state = player.shared_state.clone();
2559
2560        let info = buffer::StreamInfo {
2561            codec: "MP3".into(),
2562            sample_rate: source_rate,
2563            channels: 2,
2564            bit_depth: Some(16),
2565            bitrate_kbps: None,
2566            duration_ms: 1000,
2567        };
2568        let (_producer, consumer) = rtrb::RingBuffer::new(16);
2569        player
2570            .create_engine_for(&info, consumer)
2571            .expect("engine creation should succeed");
2572        state.output_sample_rate()
2573    }
2574
2575    #[test]
2576    fn settled_device_rate_reaches_the_shared_state() {
2577        // A device that refuses the switch is being fed resampled audio, and
2578        // that is the case the front ends have to be able to see. Before this
2579        // the comparison happened once, in a log line.
2580        assert_eq!(settled_rate_for(22050, 48000.0), Some(48000));
2581        // No switch needed, so nothing resampled: the two rates agree.
2582        assert_eq!(settled_rate_for(44100, 44100.0), Some(44100));
2583    }
2584
2585    /// A device that takes its time reclocking, as real hardware does.
2586    struct SlowBackend {
2587        observed: Arc<std::sync::Mutex<Vec<Option<u32>>>>,
2588        state: Arc<SharedPlayerState>,
2589    }
2590
2591    impl AudioBackend for SlowBackend {
2592        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2593            Ok(vec![self.default_device()?])
2594        }
2595        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2596            Ok(backend::DeviceInfo {
2597                name: "Slow DAC".into(),
2598                sample_rates: vec![44100.0, 48000.0],
2599                platform_id: 0,
2600            })
2601        }
2602        fn supported_sample_rates(
2603            &self,
2604            _device: &backend::DeviceInfo,
2605        ) -> Result<Vec<f64>, BackendError> {
2606            Ok(vec![44100.0, 48000.0])
2607        }
2608        fn get_device_sample_rate(
2609            &self,
2610            _device: &backend::DeviceInfo,
2611        ) -> Result<f64, BackendError> {
2612            Ok(48000.0)
2613        }
2614        fn set_device_sample_rate(
2615            &self,
2616            _device: &backend::DeviceInfo,
2617            rate: f64,
2618        ) -> Result<f64, BackendError> {
2619            // What a front end polling mid-switch would see.
2620            self.observed
2621                .lock()
2622                .unwrap()
2623                .push(self.state.output_sample_rate());
2624            Ok(rate)
2625        }
2626        fn create_engine(
2627            &self,
2628            _device: &backend::DeviceInfo,
2629            _sample_rate: f64,
2630            _channels: u32,
2631            _consumer: rtrb::Consumer<f32>,
2632            _samples_played: Arc<AtomicU64>,
2633        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2634            Ok(Box::new(NullEngine))
2635        }
2636    }
2637
2638    #[test]
2639    fn the_previous_rate_is_not_published_while_the_device_reclocks() {
2640        // A 48 kHz track followed by a 44.1 kHz one: for as long as the switch
2641        // takes — the better part of a second on USB — the new track's info is
2642        // published against the old track's output rate. A front end polling in
2643        // that window used to latch "44.1 → 48" and, since nothing about the
2644        // codec or the source rate changed afterwards, never let go of it.
2645        let mut player = Player::new();
2646        let state = player.shared_state.clone();
2647        state.set_output_sample_rate(48000);
2648
2649        let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
2650        player.backend = Box::new(SlowBackend {
2651            observed: observed.clone(),
2652            state: state.clone(),
2653        });
2654
2655        let info = buffer::StreamInfo {
2656            codec: "FLAC".into(),
2657            sample_rate: 44100,
2658            channels: 2,
2659            bit_depth: Some(16),
2660            bitrate_kbps: None,
2661            duration_ms: 1000,
2662        };
2663        let (_producer, consumer) = rtrb::RingBuffer::new(16);
2664        player
2665            .create_engine_for(&info, consumer)
2666            .expect("engine creation should succeed");
2667
2668        assert_eq!(
2669            *observed.lock().unwrap(),
2670            vec![None],
2671            "mid-switch the output rate must read as unknown, not as the last track's"
2672        );
2673        assert_eq!(state.output_sample_rate(), Some(44100));
2674    }
2675
2676    /// Backend that hands its rate-change callback back to the test.
2677    struct WatchedBackend {
2678        inner: StuckBackend,
2679        #[allow(clippy::type_complexity)]
2680        captured: Arc<std::sync::Mutex<Option<Box<dyn Fn(f64) + Send + Sync>>>>,
2681    }
2682
2683    struct NullWatch;
2684    impl backend::SampleRateWatch for NullWatch {}
2685
2686    impl AudioBackend for WatchedBackend {
2687        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
2688            self.inner.list_devices()
2689        }
2690        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
2691            self.inner.default_device()
2692        }
2693        fn supported_sample_rates(
2694            &self,
2695            device: &backend::DeviceInfo,
2696        ) -> Result<Vec<f64>, BackendError> {
2697            self.inner.supported_sample_rates(device)
2698        }
2699        fn get_device_sample_rate(
2700            &self,
2701            device: &backend::DeviceInfo,
2702        ) -> Result<f64, BackendError> {
2703            self.inner.get_device_sample_rate(device)
2704        }
2705        fn set_device_sample_rate(
2706            &self,
2707            device: &backend::DeviceInfo,
2708            rate: f64,
2709        ) -> Result<f64, BackendError> {
2710            self.inner.set_device_sample_rate(device, rate)
2711        }
2712        fn watch_device_sample_rate(
2713            &self,
2714            _device: &backend::DeviceInfo,
2715            on_change: Box<dyn Fn(f64) + Send + Sync>,
2716        ) -> Option<Box<dyn backend::SampleRateWatch>> {
2717            *self.captured.lock().unwrap() = Some(on_change);
2718            Some(Box::new(NullWatch))
2719        }
2720        fn create_engine(
2721            &self,
2722            device: &backend::DeviceInfo,
2723            sample_rate: f64,
2724            channels: u32,
2725            consumer: rtrb::Consumer<f32>,
2726            samples_played: Arc<AtomicU64>,
2727        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
2728            self.inner
2729                .create_engine(device, sample_rate, channels, consumer, samples_played)
2730        }
2731    }
2732
2733    #[test]
2734    fn external_rate_change_reaches_the_shared_state() {
2735        // The device is shared. Another client moving the rate mid-track used
2736        // to leave the front ends asserting bit-perfection while the HAL
2737        // resampled underneath them.
2738        let captured = Arc::new(std::sync::Mutex::new(None));
2739        let mut player = Player::new();
2740        player.backend = Box::new(WatchedBackend {
2741            inner: StuckBackend {
2742                rate: 44100.0,
2743                asked: Arc::new(std::sync::Mutex::new(None)),
2744            },
2745            captured: captured.clone(),
2746        });
2747        let state = player.shared_state.clone();
2748
2749        let info = buffer::StreamInfo {
2750            codec: "FLAC".into(),
2751            sample_rate: 44100,
2752            channels: 2,
2753            bit_depth: Some(16),
2754            bitrate_kbps: None,
2755            duration_ms: 1000,
2756        };
2757        let (_producer, consumer) = rtrb::RingBuffer::new(16);
2758        player
2759            .create_engine_for(&info, consumer)
2760            .expect("engine creation should succeed");
2761        assert_eq!(state.output_sample_rate(), Some(44100));
2762
2763        let on_change = captured.lock().unwrap().take().expect("watch registered");
2764        on_change(48000.0);
2765        assert_eq!(state.output_sample_rate(), Some(48000));
2766    }
2767}