Skip to main content

koan_core/player/
mod.rs

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