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