Skip to main content

koan_core/player/
mod.rs

1pub mod commands;
2pub mod history;
3mod renderer;
4mod sleep;
5pub mod state;
6pub mod undo;
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::thread;
11
12use thiserror::Error;
13
14use crate::audio::{
15    analyzer::VizAnalyzer,
16    backend::{self, AudioBackend, AudioEngineHandle, BackendError, SampleRateWatch},
17    buffer, streaming,
18    viz::{VizBuffer, VizSnapshot},
19};
20use crate::remote::client::PlaybackReportState;
21use buffer::PlaybackTimeline;
22use commands::{CommandChannel, PlayerCommand};
23use history::{InFlight, PlayEvent, PlayRecorder, PlaybackReport};
24use state::{
25    ItemState, PlayMode, PlaybackSource, PlaybackState, QueueItemId, QueueMode, Repeat,
26    SharedPlayerState, Sleep, SleepTimer, TrackInfo,
27};
28use undo::{UndoEntry, UndoStack};
29
30/// Ring buffer size in samples. ~1s at 192kHz stereo.
31pub(crate) const RING_BUFFER_SIZE: usize = 192_000 * 2;
32
33/// Kept back from the end of a track when seeking, so dragging the thumb all
34/// the way over lands in the last moment of it rather than in the next track.
35const SEEK_END_GUARD_MS: u64 = 500;
36
37/// Past the moment the next track should start, so the wake finds the
38/// playhead already in it rather than a hair short.
39const BOUNDARY_SLACK: std::time::Duration = std::time::Duration::from_millis(5);
40
41/// How long after output starts to look whether it has: a stream that has not
42/// begun to play by then holds the playhead while clients count on from it,
43/// and saying so is what stops them. Looked at again, as long, until it moves.
44/// Longer than the state watcher's `PLAYHEAD_STALL` in koan-ffi.
45const OUTPUT_START_CHECK: std::time::Duration = std::time::Duration::from_millis(400);
46
47/// How often to look at a fading pause, for the few checks it takes to reach
48/// silence.
49const FADE_CHECK: std::time::Duration = std::time::Duration::from_millis(50);
50/// How long a DSP change waits for its fade before restarting regardless: an
51/// output that has stopped calling back never reports silence.
52const QUICK_FADE_LIMIT: std::time::Duration = std::time::Duration::from_millis(100);
53
54/// How long output stays stopped before the audio session is given back. Long
55/// enough that pausing to answer someone does not hand the speaker to another
56/// app; short enough that the app paused before koan came along resumes soon.
57const SESSION_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
58
59/// When to look next for a DSP change's fade, begun at `since`, to have
60/// reached silence, for callbacks `period` long. The fade ends a known time
61/// after it began: once the callback next runs, up to a period later, and for
62/// the fade's length. Woken then, again a period on for a callback that ran
63/// late, then at the limit should the output have stopped calling back.
64fn dsp_wake(
65    since: std::time::Instant,
66    period: std::time::Duration,
67    now: std::time::Instant,
68) -> std::time::Instant {
69    let faded = since + crate::audio::fade::QUICK_FADE + period + BOUNDARY_SLACK;
70    [faded, faded + period, since + QUICK_FADE_LIMIT]
71        .into_iter()
72        .find(|&t| t > now)
73        .unwrap_or(now)
74}
75
76#[derive(Debug, Error)]
77pub enum PlayerError {
78    #[error("backend error: {0}")]
79    Backend(#[from] BackendError),
80    #[error("decode error: {0}")]
81    Decode(#[from] buffer::DecodeError),
82    #[error("renderer: {0}")]
83    Renderer(String),
84    /// The renderer cannot play this track. It is marked as such in the
85    /// queue and skipped.
86    #[error("{0}")]
87    Unplayable(String),
88}
89
90/// Everything needed to read a track that is still downloading: where it is,
91/// how far the transfer has got, and how the container has to be opened.
92#[derive(Clone)]
93struct StreamSource {
94    path: PathBuf,
95    bytes_written: Arc<crate::remote::downloads::ByteFeed>,
96    total: u64,
97    mode: streaming::ProbeMode,
98}
99
100/// A file's own extension, lowercased. A download in progress is named
101/// `track.m4a.part`, and its extension is the track's, not `part`.
102fn media_extension(path: &Path) -> Option<String> {
103    crate::remote::download::strip_part_suffix(path)
104        .extension()
105        .and_then(|e| e.to_str())
106        .map(str::to_ascii_lowercase)
107}
108
109/// Symphonia's format hint for a path — its extension, where it has one.
110fn hint_for(path: &Path) -> symphonia::core::formats::probe::Hint {
111    let mut hint = symphonia::core::formats::probe::Hint::new();
112    if let Some(ext) = media_extension(path) {
113        hint.with_extension(&ext);
114    }
115    hint
116}
117
118/// How to open a partial file once the whole description has failed.
119///
120/// Most containers describe their frames from the front and need an end they
121/// can actually reach: FLAC bisects towards the end it is given. Two need the
122/// whole file's end instead. Ogg takes the end it is handed as the end of the
123/// stream, so a file ending at the write head is a track already over. MP4
124/// bounds its top-level boxes by the end, so a `moov` larger than what has
125/// arrived overruns it and the file will not open until it has all landed.
126/// See `ProbeMode`.
127fn lengthless_mode_for(path: &Path) -> streaming::ProbeMode {
128    match media_extension(path).as_deref() {
129        Some("ogg" | "oga" | "opus" | "spx" | "m4a" | "m4b" | "mp4" | "mov") => {
130            streaming::ProbeMode::LengthlessWholeEnd
131        }
132        _ => streaming::ProbeMode::Lengthless,
133    }
134}
135
136/// The player controller. Owns the audio pipeline and processes commands.
137pub struct Player {
138    shared_state: Arc<SharedPlayerState>,
139    commands: CommandChannel,
140    /// What the player is doing with the track under the cursor.
141    transport: Transport,
142    timeline: Arc<PlaybackTimeline>,
143    viz_buffer: Arc<VizBuffer>,
144    viz_snapshot: Arc<VizSnapshot>,
145    /// Background FFT analysis thread. Held for its lifetime; dropped on Player drop.
146    _viz_analyzer: VizAnalyzer,
147    undo_stack: UndoStack,
148    /// When Some, undo entries are collected into this buffer instead of pushed
149    /// directly onto the undo stack. Flushed on EndUndoBatch.
150    batch_buffer: Option<Vec<UndoEntry>>,
151    /// Configured output device name. None = system default.
152    output_device_name: Option<String>,
153    /// Platform audio backend (CoreAudio on macOS and iOS, cpal on Linux).
154    backend: Box<dyn AudioBackend>,
155    /// How the file currently streaming had to be opened. A seek reopens it and
156    /// must not undo what the probe settled on.
157    stream_mode: streaming::ProbeMode,
158    /// Writes plays away from this thread. None when there is no database to
159    /// write to, and in tests, which must not touch the real library.
160    history: Option<PlayRecorder>,
161    /// This player's download queue, which follows its playlist. Held here so
162    /// it lives as long as the player it fetches for. `None` for a player
163    /// made without `spawn`, which fetches nothing.
164    downloads: Option<crate::remote::queue::DownloadQueue>,
165    /// How much of the current track has been heard so far.
166    in_flight: Option<InFlight>,
167    /// When the silence after a rate switch runs out and the track is heard.
168    lead_in_ends: Option<std::time::Instant>,
169    /// When to look whether output that started has begun to play, and the
170    /// samples played when it started.
171    output_check: Option<(std::time::Instant, u64)>,
172    /// When the audio session goes back to the system, output having stopped:
173    /// see `audio::ios_backend::AudioSession`.
174    session_release_at: Option<std::time::Instant>,
175    /// Bumped by every session opened and every one torn down, so that what a
176    /// torn-down session reports after the fact is recognised as stale.
177    session: u64,
178    /// Waiting for a pause's fade to reach silence, to hear where it did.
179    silence_waiters: Vec<crossbeam_channel::Sender<u64>>,
180    /// The DSP setup last loaded, and the config and device it was loaded
181    /// for. Reading impulse responses off disk on every seek would be wasted.
182    dsp: Option<DspCache>,
183    /// A DSP change fading the old processing out, since when: the session
184    /// restarts with the new one once that reaches silence. Changes made
185    /// meanwhile ride the same restart, which loads whatever is current.
186    dsp_restart: Option<std::time::Instant>,
187    /// Open the next session's output with a quick fade in: it follows a DSP
188    /// change's quick fade out.
189    quick_start: bool,
190    /// The UPnP renderer chosen as the output, when one is: every session
191    /// opened while it is set plays there. Not a transport of its own.
192    renderer: Option<renderer::RendererLink>,
193    /// Shuffle and repeat. Published by `publish`, which the queue's own
194    /// reads follow.
195    mode: PlayMode,
196    /// The sleep timer, while one is set.
197    sleep: Option<SleepSet>,
198    /// Its fade, while one runs or full level is being brought back.
199    sleep_fade: Option<sleep::SleepFade>,
200    /// Paused by hand during a fade: resuming plays at full level.
201    sleep_snap_on_resume: bool,
202    /// Playback sessions started — lets tests assert how many engine restarts
203    /// an operation costs.
204    #[cfg(test)]
205    playback_starts: usize,
206    /// What `dsp_for` answers in tests, in place of reading the profiles
207    /// from config: config is the process's, and the suite runs in parallel.
208    /// The first is this device's, the second the renderer's.
209    #[cfg(test)]
210    dsp_override: Option<Arc<crate::audio::dsp::Setup>>,
211    #[cfg(test)]
212    renderer_dsp_override: Option<Arc<crate::audio::dsp::Setup>>,
213    /// The renderer used last time is being looked for, and is to be gone
214    /// back to if it turns up before anyone plays or picks an output.
215    resume_renderer: bool,
216    /// What the session restored at launch asked for while that renderer is
217    /// looked for: it waits paused, so nothing starts here that the renderer
218    /// might still take, and is played wherever it lands.
219    held_run: Option<Run>,
220}
221
222/// A sleep timer that is set: what is published, and for one set for a time,
223/// the instant the player wakes to end playback.
224#[derive(Clone, Copy)]
225struct SleepSet {
226    sleep: Sleep,
227    at: Option<std::time::Instant>,
228    /// How long the fade before `at` runs.
229    fade: Option<std::time::Duration>,
230}
231
232struct DspCache {
233    config: Arc<crate::config::Config>,
234    device: String,
235    setup: Option<Arc<crate::audio::dsp::Setup>>,
236}
237
238/// How a pause falls silent.
239#[derive(Clone, Copy)]
240enum Fade {
241    Cut,
242    Short,
243}
244
245/// Whether a session plays or sits paused, and how a track waited for opens.
246/// A fade out is `Paused` with the engine still running until it is silent.
247#[derive(Clone, Copy, Debug, PartialEq, Eq)]
248enum Run {
249    Playing,
250    Paused,
251}
252
253/// What the player is doing. Everything it publishes — the playback state,
254/// the wait, the track — is derived from this in `publish`, so none of them
255/// can disagree with it or with each other.
256enum Transport {
257    /// Nothing loaded and nothing asked for.
258    Idle,
259    /// A track asked for that cannot open yet. Nothing opens a track without
260    /// one of these or a command naming it.
261    Waiting(Waiting),
262    /// A session is open, decoding into the ring.
263    Loaded(Session),
264}
265
266/// A track that cannot open yet, and how it opens once it can: at the start
267/// as soon as enough of it has streamed, or at a later position once the whole
268/// file is on disk. Streaming it would start it before the position can be
269/// reached, and a seek once it can would let the part before it be heard.
270#[derive(Clone, Copy)]
271struct Waiting {
272    id: QueueItemId,
273    position_ms: u64,
274    start: Run,
275}
276
277impl Waiting {
278    fn may_stream(&self, id: QueueItemId) -> bool {
279        self.id == id && self.position_ms == 0
280    }
281}
282
283/// An open playback session: the track under the playhead, whether it plays,
284/// and the output it plays on.
285struct Session {
286    /// The track under the playhead. Opened with the first; moved on by a
287    /// gapless transition, corrected when a download it streams lands.
288    track: TrackInfo,
289    run: Run,
290    /// The steps the decoder has taken through the queue, in order. Always
291    /// empty for a renderer, which has no decoder here to look ahead.
292    lookahead: Arc<parking_lot::Mutex<Vec<state::Lookahead>>>,
293    output: Output,
294}
295
296/// Where a session's sound comes out, and so whether koan decodes it.
297enum Output {
298    /// Decoded here, into the ring, drained by an engine: this device's own
299    /// output.
300    Local(Local),
301    /// A renderer: handed the original file, which it decodes and plays
302    /// gaplessly itself, or a stream decoded and processed here. See
303    /// `renderer`.
304    Renderer(Box<renderer::Play>),
305}
306
307struct Local {
308    engine: Box<dyn AudioEngineHandle>,
309    decode_handle: buffer::DecodeHandle,
310    /// Set when the decoder is reading a download as it arrives.
311    stream: Option<LiveStream>,
312    /// Keeps the device rate subscription alive for as long as this engine is
313    /// the one feeding the DAC. Dropped with it.
314    _rate_watch: Option<Box<dyn SampleRateWatch>>,
315    /// What the output device's profile does to this session, for the badge.
316    dsp: Option<crate::audio::dsp::DspStatus>,
317}
318
319impl Session {
320    /// The engine, for a session on this device.
321    fn engine(&self) -> Option<&dyn AudioEngineHandle> {
322        match &self.output {
323            Output::Local(local) => Some(local.engine.as_ref()),
324            Output::Renderer(_) => None,
325        }
326    }
327}
328
329/// Where a session reads its first track from.
330enum Source {
331    File(PathBuf),
332    Stream(StreamSource),
333}
334
335impl Source {
336    fn path(&self) -> &Path {
337        match self {
338            Source::File(path) => path,
339            Source::Stream(source) => &source.path,
340        }
341    }
342}
343
344/// A download being decoded as it lands. The reader may be parked at the write
345/// head waiting for bytes, so stopping has to tell it to give up and wake it,
346/// or the join waits on the network.
347struct LiveStream {
348    feed: Arc<crate::remote::downloads::ByteFeed>,
349    abandoned: Arc<std::sync::atomic::AtomicBool>,
350}
351
352impl LiveStream {
353    fn abandon(&self) {
354        self.abandoned
355            .store(true, std::sync::atomic::Ordering::Release);
356        self.feed.done();
357    }
358}
359
360impl Default for Player {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366impl Player {
367    pub fn new() -> Self {
368        let viz_buffer = VizBuffer::new();
369        let viz_snapshot = VizSnapshot::new();
370        let timeline = PlaybackTimeline::new();
371        let cfg = crate::config::Config::cached();
372        let viz_analyzer = VizAnalyzer::spawn_with_snapshot(
373            Arc::clone(&viz_buffer),
374            &cfg.visualizer,
375            Arc::clone(&viz_snapshot),
376            timeline.samples_played_counter(),
377        );
378
379        let shared_state = SharedPlayerState::new();
380        shared_state.attach_timeline(timeline.clone());
381        let commands = CommandChannel::new();
382        let tx = commands.tx.clone();
383        timeline.on_queued(move || {
384            let _ = tx.try_send(PlayerCommand::TrackQueued);
385        });
386
387        Self {
388            shared_state,
389            commands,
390            transport: Transport::Idle,
391            lead_in_ends: None,
392            output_check: None,
393            session_release_at: None,
394            dsp: None,
395            dsp_restart: None,
396            quick_start: false,
397            session: 0,
398            silence_waiters: Vec::new(),
399            renderer: None,
400            mode: PlayMode::default(),
401            sleep: None,
402            sleep_fade: None,
403            sleep_snap_on_resume: false,
404            timeline,
405            viz_buffer,
406            viz_snapshot,
407            _viz_analyzer: viz_analyzer,
408            undo_stack: UndoStack::new(),
409            batch_buffer: None,
410            output_device_name: cfg.playback.output_device.clone(),
411            backend: crate::audio::platform_backend(),
412            stream_mode: streaming::ProbeMode::Full,
413            history: None,
414            downloads: None,
415            in_flight: None,
416            #[cfg(test)]
417            playback_starts: 0,
418            #[cfg(test)]
419            dsp_override: None,
420            #[cfg(test)]
421            renderer_dsp_override: None,
422            resume_renderer: false,
423            held_run: None,
424        }
425    }
426
427    /// Get a clone of the shared state for UI reads.
428    pub fn shared_state(&self) -> Arc<SharedPlayerState> {
429        self.shared_state.clone()
430    }
431
432    /// Get the playback timeline for UI reads.
433    pub fn timeline(&self) -> Arc<PlaybackTimeline> {
434        self.timeline.clone()
435    }
436
437    /// Get the visualization buffer for the TUI.
438    pub fn viz_buffer(&self) -> Arc<VizBuffer> {
439        self.viz_buffer.clone()
440    }
441
442    /// Get the shared analysis snapshot for the TUI.
443    /// The analysis thread writes here; the UI thread reads a clone each frame.
444    pub fn viz_snapshot(&self) -> Arc<VizSnapshot> {
445        self.viz_snapshot.clone()
446    }
447
448    /// Access undo stack (for tests and UI state queries).
449    pub fn undo_stack(&self) -> &UndoStack {
450        &self.undo_stack
451    }
452
453    /// Create an audio engine for a stream, switching the output device to the
454    /// source rate first so output is bit-perfect.
455    ///
456    /// The engine is always configured with the source's own rate and channel
457    /// count — the format the decode thread writes into the ring buffer. A
458    /// device that cannot take the requested rate (MPEG-2/2.5 MP3 rates are
459    /// commonly refused) resamples instead of playing at the wrong speed.
460    #[allow(clippy::type_complexity)]
461    fn create_engine_for(
462        &mut self,
463        info: &buffer::StreamInfo,
464        consumer: rtrb::Consumer<f32>,
465    ) -> Result<(Box<dyn AudioEngineHandle>, Option<Box<dyn SampleRateWatch>>), PlayerError> {
466        let device = self.resolve_device()?;
467        let device_rate = self.backend.get_device_sample_rate(&device)?;
468        // The setup the decode thread was just handed, not a fresh read: a
469        // config edited in between would put the engine at another rate.
470        let dsp = match &self.dsp {
471            Some(cache) if cache.device == device.name => cache.setup.clone(),
472            _ => self.dsp_for(&device.name),
473        };
474        // The rate the decode thread writes at: the source's, unless DSP
475        // resamples it to reach an impulse response.
476        let source_rate =
477            dsp.as_ref()
478                .map_or(info.sample_rate, |d| d.output_rate(info.sample_rate)) as f64;
479
480        // Anything read between here and the switch landing would pair this
481        // track with the last one's output rate, and a rate switch is not
482        // instant. Say nothing instead.
483        self.shared_state.clear_output_sample_rate();
484
485        // DSP and ReplayGain hand the output floats of their own making: no
486        // source bit depth survives them.
487        let gained = crate::config::Config::cached().playback.replaygain
488            != crate::config::ReplayGainMode::Off;
489        let bit_depth = if dsp.is_some() || gained {
490            None
491        } else {
492            info.bit_depth
493        };
494        if (device_rate - source_rate).abs() > 0.1 {
495            log::info!(
496                "switching device sample rate: {}Hz → {}Hz",
497                device_rate,
498                source_rate
499            );
500        }
501        let settled = match self.backend.prepare_output(
502            &device,
503            device_rate,
504            source_rate,
505            info.channels,
506            bit_depth,
507        ) {
508            Ok(rate) => rate,
509            Err(e) => {
510                log::warn!("failed to set device sample rate: {}", e);
511                device_rate
512            }
513        };
514        if (settled - source_rate).abs() > 0.1 {
515            log::warn!(
516                "device stayed at {}Hz (wanted {}Hz) — output is resampled, not bit-perfect",
517                settled,
518                source_rate
519            );
520        }
521
522        // The front ends compare this against the source rate to say whether
523        // anything had to resample.
524        self.shared_state
525            .set_output_sample_rate(settled.round() as u32);
526
527        // koan is not the only client of this device. Subscribe so the front
528        // ends learn about a rate someone else moved instead of trusting the
529        // reading above until the next track happens to build an engine.
530        let watch_state = self.shared_state.clone();
531        let watch_name = device.name.clone();
532        let rate_watch = self.backend.watch_device_sample_rate(
533            &device,
534            Box::new(move |rate| {
535                log::info!("device sample rate changed externally: {rate}Hz on '{watch_name}'");
536                watch_state.set_output_sample_rate(rate.round() as u32);
537            }),
538        );
539
540        let engine = self.backend.create_engine(
541            &device,
542            source_rate,
543            info.channels as u32,
544            consumer,
545            self.timeline.samples_played_counter(),
546        )?;
547        // Said of the stream as it opened, which may have fallen back to the
548        // system mixer after asking for more: then the rate is the mixer's.
549        let mixed = self.backend.output_mixed();
550        self.shared_state.set_output_mixed(mixed);
551        if mixed == Some(true)
552            && let Ok(rate) = self.backend.get_device_sample_rate(&device)
553        {
554            self.shared_state
555                .set_output_sample_rate(rate.round() as u32);
556        }
557        // iOS answers 0 until its session has first been activated, and no
558        // switch from an unknown rate is one to wait out. A new engine inside the
559        // silence, a seek, still has the rest of the relock ahead of it.
560        let now = std::time::Instant::now();
561        let lead_in = if device_rate > 0.0 && (settled - device_rate).abs() > 0.1 {
562            std::time::Duration::from_millis(
563                crate::config::Config::cached()
564                    .playback
565                    .rate_switch_lead_in_ms as u64,
566            )
567        } else {
568            self.lead_in_ends
569                .map(|end| end.saturating_duration_since(now))
570                .unwrap_or_default()
571        };
572        self.lead_in_ends = None;
573        if !lead_in.is_zero() {
574            engine.lead_in((settled * lead_in.as_secs_f64()) as u64);
575            self.lead_in_ends = Some(now + lead_in);
576        }
577
578        Ok((engine, rate_watch))
579    }
580
581    /// The DSP profile for `device`, loaded once per config and device.
582    fn dsp_for(&mut self, device: &str) -> Option<Arc<crate::audio::dsp::Setup>> {
583        let config = crate::config::Config::cached();
584        #[cfg(test)]
585        if let Some(setup) = if self
586            .renderer
587            .as_ref()
588            .is_some_and(|l| l.device_name() == device)
589        {
590            self.renderer_dsp_override.clone()
591        } else {
592            self.dsp_override.clone()
593        } {
594            self.dsp = Some(DspCache {
595                config,
596                device: device.to_string(),
597                setup: Some(setup.clone()),
598            });
599            return Some(setup);
600        }
601        if let Some(cache) = &self.dsp
602            && Arc::ptr_eq(&cache.config, &config)
603            && cache.device == device
604        {
605            return cache.setup.clone();
606        }
607        let setup = crate::audio::dsp::profiles::for_playing(|| {
608            let chain = crate::audio::dsp::output_chain(&config.dsp, device)?;
609            crate::audio::dsp::Setup::load(&chain.profile, &chain.all, &crate::config::config_dir())
610                .inspect_err(|e| {
611                    log::error!(
612                        "dsp: profile '{}' not loaded, playing without it: {e}",
613                        chain.name
614                    )
615                })
616                .ok()
617                .flatten()
618                .map(|mut setup| {
619                    setup.name = chain.name;
620                    Arc::new(setup)
621                })
622        });
623        self.dsp = Some(DspCache {
624            config,
625            device: device.to_string(),
626            setup: setup.clone(),
627        });
628        setup
629    }
630
631    /// Load the profiles again, and restart where playback is if what the
632    /// output in use plays through has changed. The setups are compared as
633    /// loaded, responses included, since the files can change without the
634    /// config doing so. A preset given to another device, from the Play on
635    /// menu, changes nothing here and restarts nothing.
636    fn reload_dsp(&mut self) {
637        let device = match &self.renderer {
638            Some(link) => Ok(link.device_name().to_string()),
639            None => self.resolve_device().map(|d| d.name),
640        };
641        let Ok(device) = device else {
642            self.dsp = None;
643            return;
644        };
645        let was = self
646            .dsp
647            .take()
648            .filter(|c| c.device == device)
649            .map(|c| c.setup);
650        let now = self.dsp_for(&device);
651        let changed = match was {
652            Some(was) => was != now,
653            None => now.is_some(),
654        };
655        if changed {
656            self.restart_for_dsp();
657        }
658    }
659
660    /// Restart where playback is, for a DSP change. Playing here, the old
661    /// processing fades out quickly first and the new one fades in, so the
662    /// change is a dip of a few milliseconds rather than a cut mid-waveform;
663    /// `update_playback_state` restarts once the fade is silent. A change made
664    /// while one is fading joins it. The restart opens at the same rate unless
665    /// the new setup plays at another, which only a convolution can ask.
666    fn restart_for_dsp(&mut self) {
667        if self.dsp_restart.is_some() {
668            return;
669        }
670        if let Some(session) = self.session()
671            && session.run == Run::Playing
672            && let Output::Local(local) = &session.output
673            && local.engine.is_running()
674        {
675            local.engine.fade_out_quickly();
676            self.dsp_restart = Some(std::time::Instant::now());
677            return;
678        }
679        self.restart_on_current_track();
680    }
681
682    /// ReplayGain and DSP for a session on the output device.
683    fn processing(&mut self) -> buffer::Processing {
684        let cfg = crate::config::Config::cached();
685        let dsp = match self.resolve_device() {
686            Ok(device) => self.dsp_for(&device.name),
687            Err(_) => None,
688        };
689        buffer::Processing {
690            rg_mode: cfg.playback.replaygain,
691            pre_amp_db: cfg.playback.pre_amp_db,
692            dsp,
693            depth_limit: Arc::default(),
694        }
695    }
696
697    /// Resolve the output device: use configured device name if set,
698    /// falling back to system default if not set or if the named device is unavailable.
699    fn resolve_device(&self) -> Result<backend::DeviceInfo, PlayerError> {
700        if let Some(ref name) = self.output_device_name {
701            match self.backend.list_devices() {
702                Ok(devices) => {
703                    if let Some(dev) = devices.into_iter().find(|d| d.name == *name) {
704                        return Ok(dev);
705                    }
706                    log::warn!(
707                        "configured output device '{}' not found, falling back to default",
708                        name,
709                    );
710                }
711                Err(e) => {
712                    log::warn!("failed to list devices while resolving '{}': {}", name, e);
713                }
714            }
715        }
716        Ok(self.backend.default_device()?)
717    }
718
719    /// Switch the output device. Persists to config and restarts the engine
720    /// on the current track if playing.
721    pub fn set_output_device(&mut self, name: String) {
722        log::info!("switching output device to: {}", name);
723        self.output_device_name = Some(name.clone());
724
725        if let Err(e) = crate::config::Config::persist(|cfg| {
726            cfg.playback.output_device = Some(name);
727            cfg.playback.renderer = None;
728            cfg.playback.renderer_name = None;
729        }) {
730            log::error!("failed to save output device config: {}", e);
731        }
732
733        if self.renderer.is_some() {
734            self.use_renderer(None);
735        } else {
736            self.restart_on_current_track();
737        }
738    }
739
740    /// Clear the configured output device, reverting to system default.
741    pub fn clear_output_device(&mut self) {
742        log::info!("reverting to system default output device");
743        self.output_device_name = None;
744
745        if let Err(e) = crate::config::Config::persist(|cfg| {
746            cfg.playback.output_device = None;
747            cfg.playback.renderer = None;
748            cfg.playback.renderer_name = None;
749        }) {
750            log::error!("failed to save output device config: {}", e);
751        }
752
753        if self.renderer.is_some() {
754            self.use_renderer(None);
755        } else {
756            self.restart_on_current_track();
757        }
758    }
759
760    /// If a track is currently playing or paused, restart playback at the
761    /// current position (e.g. after switching output devices). Preserves pause state.
762    fn restart_on_current_track(&mut self) {
763        let position_ms = self.shared_state.position_ms();
764        if let Err(e) = self.restart_current(position_ms) {
765            log::error!("failed to restart playback on device switch: {}", e);
766        }
767    }
768
769    /// Get the current output device name (if configured).
770    pub fn output_device_name(&self) -> Option<&str> {
771        self.output_device_name.as_deref()
772    }
773
774    /// Get a command sender for the UI layer.
775    pub fn command_sender(&self) -> crossbeam_channel::Sender<PlayerCommand> {
776        self.commands.tx.clone()
777    }
778
779    fn session(&self) -> Option<&Session> {
780        match &self.transport {
781            Transport::Loaded(session) => Some(session),
782            _ => None,
783        }
784    }
785
786    fn waiting(&self) -> Option<Waiting> {
787        match self.transport {
788            Transport::Waiting(waiting) => Some(waiting),
789            _ => None,
790        }
791    }
792
793    /// Whether the track waited for may open from its download as it lands:
794    /// at the start, and not to a renderer, which is handed whole files only.
795    fn may_stream(&self, waiting: &Waiting, id: QueueItemId) -> bool {
796        waiting.may_stream(id) && self.renderer.is_none()
797    }
798
799    fn forget_waiting(&mut self) {
800        if matches!(self.transport, Transport::Waiting(_)) {
801            self.transport = Transport::Idle;
802        }
803    }
804
805    /// Tell the front ends what the player is doing, after each command and
806    /// each tick. Derived wholly from the transport: nothing else writes the
807    /// playback state, the wait or the track.
808    fn publish(&self) {
809        let state = &self.shared_state;
810        let (playback, waiting, track) = match &self.transport {
811            Transport::Idle => (PlaybackState::Stopped, false, None),
812            Transport::Waiting(waiting) => (
813                match waiting.start {
814                    Run::Playing => PlaybackState::Stopped,
815                    Run::Paused => PlaybackState::Paused,
816                },
817                true,
818                None,
819            ),
820            Transport::Loaded(session) => (
821                match session.run {
822                    Run::Playing => PlaybackState::Playing,
823                    Run::Paused => PlaybackState::Paused,
824                },
825                false,
826                Some(&session.track),
827            ),
828        };
829        if state.track_info().as_ref() != track {
830            state.set_track_info(track.cloned());
831        }
832        state.set_transport(playback, waiting);
833        // Only a session decoded here can have been processed: a renderer
834        // handed the original file plays it as it is.
835        let dsp = match &self.transport {
836            Transport::Loaded(Session {
837                output: Output::Local(local),
838                ..
839            }) => local.dsp.clone(),
840            Transport::Loaded(Session {
841                output: Output::Renderer(play),
842                ..
843            }) => play.dsp(),
844            _ => None,
845        };
846        if state.dsp() != dsp {
847            state.set_dsp(dsp);
848        }
849        state.set_play_mode(self.mode);
850        state.set_sleep(self.sleep.map(|s| s.sleep));
851        state.set_sleep_fading(self.sleep_fading());
852    }
853
854    /// What the listener last asked for: to hear something, to have it
855    /// paused, or nothing at all. An implicit move — the playing track
856    /// removed, a download failed, an undo, the end of the queue's decoding —
857    /// carries it over to the track it moves to.
858    fn intent(&self) -> Option<Run> {
859        match &self.transport {
860            Transport::Idle => None,
861            Transport::Waiting(waiting) => Some(waiting.start),
862            Transport::Loaded(session) => Some(session.run),
863        }
864    }
865
866    /// Move to `to` as the listener's last request would have it: playing
867    /// on, paused, or, with nothing asked for, the cursor alone.
868    fn carry_on(&mut self, to: Option<QueueItemId>, intent: Option<Run>) {
869        match (to, intent) {
870            (Some(id), Some(start)) => self.cue(id, 0, start),
871            (Some(id), None) => {
872                self.stop_playback_and_clear_state();
873                self.shared_state.set_cursor(Some(id));
874            }
875            (None, _) => self.stop_playback_and_clear_state(),
876        }
877    }
878
879    /// Play a specific item in the playlist by ID.
880    /// Sets cursor, starts playback if Ready or streaming-ready, otherwise waits for TrackReady.
881    pub fn play(&mut self, id: QueueItemId) {
882        // Asked for by name, so a new play even of the item playing: from the
883        // top, not a seek within what was heard.
884        if self.in_flight.as_ref().is_some_and(|f| f.item == id) {
885            self.finish_play();
886        }
887        self.forget_waiting();
888        self.shared_state.set_cursor(Some(id));
889
890        match self.shared_state.item_playback_source(id) {
891            Some(PlaybackSource::Ready(path)) => {
892                if let Err(e) = self.start_playback(id, &path, 0, Run::Playing) {
893                    self.start_failed("play", &e);
894                }
895            }
896            Some(PlaybackSource::Streaming {
897                path,
898                bytes_written,
899                total,
900            }) => {
901                // Stop what is playing and park here. The probe answers on its
902                // own thread; if it cannot, TrackReady starts the track once
903                // the whole file has landed. A renderer takes whole files
904                // only, so for one there is no probe: TrackReady it is.
905                self.park(id, 0, Run::Playing);
906                if self.renderer.is_none() {
907                    self.probe_stream_for_playback(id, &path, bytes_written, total);
908                }
909            }
910            None => {
911                self.park(id, 0, Run::Playing);
912                log::info!("play: item {:?} not ready, waiting for TrackReady", id);
913            }
914        }
915    }
916
917    /// Load a track at `position_ms`, playing or paused — where a restored
918    /// session or a hand-off picks up.
919    ///
920    /// A track still downloading waits until it can open — see `Waiting`.
921    fn cue(&mut self, id: QueueItemId, position_ms: u64, start: Run) {
922        self.forget_waiting();
923        self.shared_state.set_cursor(Some(id));
924        let Some(PlaybackSource::Ready(path)) = self.shared_state.item_playback_source(id) else {
925            if position_ms == 0 && start == Run::Playing {
926                self.play(id);
927                return;
928            }
929            self.park(id, position_ms, start);
930            log::info!("cue: {id:?} not on disk yet, opening at {position_ms}ms once it is");
931            return;
932        };
933        if let Err(e) = self.start_playback(id, &path, position_ms, start) {
934            self.start_failed("cue", &e);
935            return;
936        }
937        self.report(match start {
938            Run::Playing => PlaybackReportState::Playing,
939            Run::Paused => PlaybackReportState::Paused,
940        });
941    }
942
943    /// Stop what is playing and wait for `id` to become playable. A wait that
944    /// is to open paused already reads as paused, so a client offers to play
945    /// rather than showing a track on its way.
946    fn park(&mut self, id: QueueItemId, position_ms: u64, start: Run) {
947        self.report(PlaybackReportState::Stopped);
948        self.finish_play();
949        self.stop_engine();
950        self.timeline.reset();
951        self.shared_state.set_position_ms(position_ms);
952        self.transport = Transport::Waiting(Waiting {
953            id,
954            position_ms,
955            start,
956        });
957    }
958
959    fn start_playback(
960        &mut self,
961        id: QueueItemId,
962        path: &Path,
963        seek_ms: u64,
964        start: Run,
965    ) -> Result<(), PlayerError> {
966        self.open_session(id, Source::File(path.to_path_buf()), None, seek_ms, start)
967    }
968
969    /// Open a session on `id` at `seek_ms`, playing or paused.
970    ///
971    /// `info` is what is already known of the stream: from the off-thread
972    /// probe for a download, or from what is playing for a restart. A file
973    /// opened without it is probed here. A failure leaves the player cleanly
974    /// stopped; displaying a track that no engine is playing freezes the
975    /// position and makes the transport lie.
976    fn open_session(
977        &mut self,
978        id: QueueItemId,
979        source: Source,
980        info: Option<buffer::StreamInfo>,
981        seek_ms: u64,
982        start: Run,
983    ) -> Result<(), PlayerError> {
984        #[cfg(test)]
985        {
986            self.playback_starts += 1;
987        }
988        self.forget_waiting();
989        let mut result = self.try_open_session(id, source, info, seek_ms, start);
990        // A track the renderer cannot play is marked so in the queue and
991        // walked past, opening the next the way this one would have opened.
992        // A loop rather than a call back through `play`: a long run of them,
993        // a DSD library on a renderer without DSD, would grow the stack per
994        // track.
995        let mut skipped = id;
996        while matches!(result, Err(PlayerError::Unplayable(_)))
997            && self.shared_state.is_cursor(skipped)
998        {
999            let Some(next) = self.shared_state.advance_cursor_loadable() else {
1000                log::info!("upnp: nothing further the renderer can play");
1001                self.stop_playback_and_clear_state();
1002                return Ok(());
1003            };
1004            match self.shared_state.item_playback_source(next) {
1005                Some(PlaybackSource::Ready(path)) => {
1006                    skipped = next;
1007                    result = self.try_open_session(next, Source::File(path), None, 0, start);
1008                }
1009                // Not on disk yet: waited for, like any track not yet loaded.
1010                _ => {
1011                    self.cue(next, 0, start);
1012                    return Ok(());
1013                }
1014            }
1015        }
1016        if result.is_err() {
1017            self.stop_playback_and_clear_state();
1018        } else {
1019            self.shared_state.set_start_failure(None);
1020        }
1021        self.wake_analyzer();
1022        result
1023    }
1024
1025    /// A track asked for did not start: logged, and kept for the app to say,
1026    /// since what the person otherwise meets is silence.
1027    fn start_failed(&self, during: &str, e: &PlayerError) {
1028        log::error!("{during}: playback failed: {e}");
1029        self.shared_state
1030            .set_start_failure(Some(format!("Couldn't play this track: {e}")));
1031    }
1032
1033    fn try_open_session(
1034        &mut self,
1035        id: QueueItemId,
1036        source: Source,
1037        info: Option<buffer::StreamInfo>,
1038        seek_ms: u64,
1039        start: Run,
1040    ) -> Result<(), PlayerError> {
1041        if self.renderer.is_some() {
1042            return self.try_open_on_renderer(id, source, info, seek_ms, start);
1043        }
1044        self.stop_engine();
1045        let mut info = match info {
1046            Some(info) => info,
1047            None => buffer::probe_file(source.path())?,
1048        };
1049        // A stream opened before its container says how long it is (an Ogg
1050        // whose last page has not arrived) runs on the library's duration.
1051        if info.duration_ms == 0
1052            && let Some(known) = self.shared_state.get_item(id).and_then(|i| i.duration_ms)
1053        {
1054            info.duration_ms = known;
1055        }
1056        let path = source.path().to_path_buf();
1057        let streaming = matches!(source, Source::Stream(_));
1058
1059        let (first, stream) = match source {
1060            Source::File(path) => (buffer::SourceEntry::from_file(id, path), None),
1061            Source::Stream(source) => {
1062                // Held so a seek can reopen the same way without probing again.
1063                self.stream_mode = source.mode;
1064                let live = LiveStream {
1065                    feed: source.bytes_written.clone(),
1066                    abandoned: Default::default(),
1067                };
1068                let status = {
1069                    let downloading = self.stream_status_fn(id);
1070                    let abandoned = live.abandoned.clone();
1071                    Arc::new(move || {
1072                        if abandoned.load(std::sync::atomic::Ordering::Acquire) {
1073                            streaming::StreamStatus::Failed
1074                        } else {
1075                            downloading()
1076                        }
1077                    }) as Arc<dyn Fn() -> streaming::StreamStatus + Send + Sync>
1078                };
1079                let entry = buffer::SourceEntry {
1080                    id,
1081                    path: source.path.clone(),
1082                    hint: hint_for(&source.path),
1083                    make_mss: Box::new(move || {
1084                        let partial = streaming::PartialFileSource::open(
1085                            &source.path,
1086                            source.bytes_written.clone(),
1087                            source.total,
1088                            status.clone(),
1089                            source.mode,
1090                        )?;
1091                        Ok(symphonia::core::io::MediaSourceStream::new(
1092                            Box::new(partial),
1093                            Default::default(),
1094                        ))
1095                    }),
1096                };
1097                (entry, Some(live))
1098            }
1099        };
1100
1101        let track = TrackInfo {
1102            id,
1103            path: path.clone(),
1104            codec: info.codec.clone(),
1105            sample_rate: info.sample_rate,
1106            bit_depth: info.bit_depth,
1107            bitrate_kbps: info.bitrate_kbps,
1108            channels: info.channels,
1109            duration_ms: info.duration_ms,
1110        };
1111        // For seeks, this keeps the bar at the target position instead of
1112        // flashing to 0 while the new timeline spins up.
1113        self.shared_state.set_position_ms(seek_ms);
1114        self.on_track_changed(id, seek_ms);
1115        log::info!(
1116            "{}: {} ({:?}) — {} {}Hz/{}ch, {}ms{}",
1117            if streaming { "streaming" } else { "playing" },
1118            path.display(),
1119            id,
1120            info.codec,
1121            info.sample_rate,
1122            info.channels,
1123            info.duration_ms,
1124            if seek_ms > 0 {
1125                format!(" @{}ms", seek_ms)
1126            } else {
1127                String::new()
1128            }
1129        );
1130
1131        let (producer, consumer) = rtrb::RingBuffer::new(RING_BUFFER_SIZE);
1132        self.timeline.reset();
1133        let lookahead = Arc::new(parking_lot::Mutex::new(Vec::new()));
1134        let next_track = self.decode_cursor(id, lookahead.clone());
1135        // Files and downloads in progress open here alike, so both get the
1136        // output's processing.
1137        let processing = self.processing();
1138        let depth_limit = processing.depth_limit.clone();
1139        // The decoder starts before the output opens and runs far ahead of
1140        // it, past a short track's end within the first moments. Until the
1141        // output says what it keeps, a deeper track than this one ends the run.
1142        depth_limit.store(
1143            self.assumed_depth(&info),
1144            std::sync::atomic::Ordering::Relaxed,
1145        );
1146        self.session += 1;
1147        let session = self.session;
1148        let finish_tx = self.commands.tx.clone();
1149        let decode_handle = buffer::start_decode(
1150            first,
1151            producer,
1152            seek_ms,
1153            move || {
1154                let (next_id, next_path) = next_track()?;
1155                Some(buffer::SourceEntry::from_file(next_id, next_path))
1156            },
1157            self.timeline.clone(),
1158            Some(self.viz_buffer.clone()),
1159            processing,
1160            move |stop| {
1161                commands::send_unless_stopped(
1162                    &finish_tx,
1163                    PlayerCommand::DecodeFinished(session),
1164                    stop,
1165                );
1166            },
1167        )?;
1168
1169        let (engine, rate_watch) = self.create_engine_for(&info, consumer)?;
1170        self.timeline.set_presentation(engine.presentation());
1171        depth_limit.store(
1172            self.backend.output_depth().unwrap_or(0),
1173            std::sync::atomic::Ordering::Relaxed,
1174        );
1175        // The setup `create_engine_for` chose the rate by.
1176        let dsp = self
1177            .dsp
1178            .as_ref()
1179            .and_then(|c| c.setup.as_ref())
1180            .map(|s| s.status(info.sample_rate));
1181        // A session opened paused leaves the unit stopped: starting it and
1182        // stopping it again lets a moment of the track out.
1183        if start == Run::Playing {
1184            if std::mem::take(&mut self.quick_start) {
1185                engine.fade_in_quickly()?;
1186            } else {
1187                engine.start()?;
1188            }
1189            self.check_output_started();
1190        }
1191        self.transport = Transport::Loaded(Session {
1192            track,
1193            run: start,
1194            lookahead,
1195            output: Output::Local(Local {
1196                engine,
1197                decode_handle,
1198                stream,
1199                _rate_watch: rate_watch,
1200                dsp,
1201            }),
1202        });
1203        Ok(())
1204    }
1205
1206    /// Gapless lookahead: the decode thread keeps its own cursor, separate
1207    /// from the UI cursor, so it can look ahead through the playlist without
1208    /// moving what the UI shows as now playing.
1209    ///
1210    /// Each step is kept in `steps`, so a queue edit is checked against what
1211    /// the decoder decided rather than against what it would decide now —
1212    /// which differs once a download has landed or a file failed to open. The
1213    /// lock is held across the read and the record: an edit either lands
1214    /// before the read or finds the step recorded.
1215    fn decode_cursor(
1216        &self,
1217        id: QueueItemId,
1218        steps: Arc<parking_lot::Mutex<Vec<state::Lookahead>>>,
1219    ) -> impl Fn() -> Option<(QueueItemId, PathBuf)> + Send + 'static {
1220        let state = self.shared_state.clone();
1221        let timeline = self.timeline.clone();
1222        move || {
1223            let mut steps = steps.lock();
1224            let mut step = match steps.last() {
1225                None => state.lookahead_after(id)?,
1226                Some(step) => state.lookahead_from(step)?,
1227            };
1228            step.boundary = timeline.boundary_count();
1229            let next = step.chosen.clone();
1230            steps.push(step);
1231            next
1232        }
1233    }
1234
1235    /// Probe a partially-downloaded file on its own thread, and start it when
1236    /// the answer comes back.
1237    ///
1238    /// Nothing here waits. Probing reads as much of the container as it takes
1239    /// to describe itself — for Ogg, its last page, which means the whole
1240    /// remaining download — and this is the thread that answers play, pause and
1241    /// seek. So the probe goes elsewhere and its result returns as a command.
1242    ///
1243    /// A format that describes itself up front (FLAC, MP3) comes back in
1244    /// milliseconds and starts early, which is the point of streaming. One that
1245    /// does not comes back whenever it comes back, by which time the download
1246    /// has usually landed and `TrackReady` has started the track from disk —
1247    /// and the late answer is simply dropped. Either way the player kept
1248    /// answering commands throughout.
1249    fn probe_stream_for_playback(
1250        &self,
1251        id: QueueItemId,
1252        path: &Path,
1253        bytes_written: Arc<crate::remote::downloads::ByteFeed>,
1254        total: u64,
1255    ) {
1256        let path = path.to_path_buf();
1257        let tx = self.commands.tx.clone();
1258        let hint = hint_for(&path);
1259
1260        // Abandon the moment the track stops being the one wanted. A probe of
1261        // a container that needs its tail otherwise reads to the end of a
1262        // download nobody is waiting for any more, and skipping through a
1263        // queue that is still caching would leave one doing so per skip.
1264        let status = {
1265            let downloading = self.stream_status_fn(id);
1266            let state = self.shared_state.clone();
1267            Arc::new(move || {
1268                if state.is_cursor(id) {
1269                    downloading()
1270                } else {
1271                    streaming::StreamStatus::Failed
1272                }
1273            }) as Arc<dyn Fn() -> streaming::StreamStatus + Send + Sync>
1274        };
1275
1276        let spawned = thread::Builder::new()
1277            .name("koan-stream-probe".into())
1278            .spawn(move || {
1279                // `wait` says whether a read may sit at the write head for
1280                // more of the download. The first attempt must not: a
1281                // container that goes looking for its tail would wait for the
1282                // whole transfer, and failing at once is how that is detected.
1283                // The second has no length to go looking with, so whatever it
1284                // still wants is in front of it and worth waiting for.
1285                let attempt = |mode, wait: bool| {
1286                    let open = if wait {
1287                        streaming::PartialFileSource::open(
1288                            &path,
1289                            bytes_written.clone(),
1290                            total,
1291                            status.clone(),
1292                            mode,
1293                        )
1294                    } else {
1295                        streaming::PartialFileSource::open_for_probe(
1296                            &path,
1297                            bytes_written.clone(),
1298                            total,
1299                            status.clone(),
1300                            mode,
1301                        )
1302                    };
1303                    open.map_err(buffer::DecodeError::Io).and_then(|source| {
1304                        let mss = symphonia::core::io::MediaSourceStream::new(
1305                            Box::new(source),
1306                            Default::default(),
1307                        );
1308                        buffer::probe_source(mss, &hint)
1309                    })
1310                };
1311
1312                // Ask for the whole description first. Neither attempt waits at
1313                // the write head, so a container that needs bytes which have
1314                // not arrived fails here rather than reading the transfer out.
1315                let info = match attempt(streaming::ProbeMode::Full, false) {
1316                    Ok(info) => Some((info, streaming::ProbeMode::Full)),
1317                    Err(e) => {
1318                        // Try again claiming no length. Ogg goes looking for its
1319                        // last page only when told there is one to find; without
1320                        // it the track opens now and plays, at the price of
1321                        // seeking and of the duration that page carries. Both
1322                        // come back when the download lands.
1323                        log::info!(
1324                            "stream probe: {} needs more than has arrived ({}), opening without a length",
1325                            path.display(),
1326                            e
1327                        );
1328                        let lengthless = lengthless_mode_for(&path);
1329                        attempt(lengthless, true)
1330                            .ok()
1331                            .map(|info| (info, lengthless))
1332                    }
1333                };
1334
1335                match info {
1336                    Some((info, mode)) => {
1337                        tx.send(PlayerCommand::StreamProbed {
1338                            id,
1339                            info: Box::new(info),
1340                            mode,
1341                        })
1342                        .ok();
1343                    }
1344                    // Not a failure of the track: it plays from disk once the
1345                    // download lands, and the cursor is still parked on it.
1346                    None => log::info!(
1347                        "stream probe: {} cannot start early, waiting for the download",
1348                        path.display()
1349                    ),
1350                }
1351            });
1352
1353        if let Err(e) = spawned {
1354            log::warn!("stream probe: could not spawn for {:?}: {}", id, e);
1355        }
1356    }
1357
1358    /// A probe finished. Start the track if it is still the one wanted and
1359    /// nothing has started it in the meantime.
1360    fn stream_probed(
1361        &mut self,
1362        id: QueueItemId,
1363        info: buffer::StreamInfo,
1364        mode: streaming::ProbeMode,
1365    ) {
1366        // Moved on, or already open — the download landed first, or the user
1367        // asked for something else.
1368        let Some(waiting) = self.waiting().filter(|w| self.may_stream(w, id)) else {
1369            return;
1370        };
1371        if !self.shared_state.is_cursor(id) {
1372            return;
1373        }
1374
1375        match self.shared_state.item_playback_source(id) {
1376            // The download landed while probing: play it as an ordinary file.
1377            Some(PlaybackSource::Ready(path)) => {
1378                if let Err(e) = self.start_playback(id, &path, 0, waiting.start) {
1379                    self.start_failed("stream probe", &e);
1380                }
1381            }
1382            Some(PlaybackSource::Streaming {
1383                path,
1384                bytes_written,
1385                total,
1386            }) => {
1387                let source = StreamSource {
1388                    path,
1389                    bytes_written,
1390                    total,
1391                    mode,
1392                };
1393                if let Err(e) =
1394                    self.open_session(id, Source::Stream(source), Some(info), 0, waiting.start)
1395                {
1396                    log::error!("stream probe: streaming playback failed: {}", e);
1397                }
1398            }
1399            None => {}
1400        }
1401    }
1402
1403    /// What the streaming source asks per read to know whether the download is
1404    /// still going. Asked each time rather than passed once: a transfer can
1405    /// land, or die, at any point during playback.
1406    fn stream_status_fn(
1407        &self,
1408        id: QueueItemId,
1409    ) -> Arc<dyn Fn() -> streaming::StreamStatus + Send + Sync> {
1410        // The item's own state, which a transfer's end writes before it wakes
1411        // anyone: asked twice per read of a file still arriving, so nothing
1412        // else is looked up.
1413        let state = self.shared_state.clone();
1414        Arc::new(move || match state.item_state(id) {
1415            Some(ItemState::Ready) => streaming::StreamStatus::Complete,
1416            Some(ItemState::Failed(_)) => streaming::StreamStatus::Failed,
1417            _ => streaming::StreamStatus::Downloading,
1418        })
1419    }
1420
1421    /// Seek within the current track, preserving pause state.
1422    ///
1423    /// A track still downloading is seekable only as far as its bytes reach, so
1424    /// the target is clamped to `seekable_ms` and the restart goes back through
1425    /// the streaming path — reopening a partial file as a plain file would
1426    /// decode whatever happens to be on disk and end the track early.
1427    pub fn seek(&mut self, position_ms: u64) {
1428        let Some(id) = self.session().map(|s| s.track.id) else {
1429            return;
1430        };
1431        // Stop just short of the end rather than falling into the next track.
1432        let seekable = self.shared_state.seekable_ms();
1433        if seekable == 0 {
1434            // Nothing of this track can be reached yet — a partial container
1435            // that has not said what it is. Restarting it at zero is not what
1436            // anyone asked for, so the seek is simply declined.
1437            log::debug!("seek declined: {:?} is not seekable yet", id);
1438            return;
1439        }
1440        let ceiling = seekable.min(
1441            self.shared_state
1442                .duration_ms()
1443                .saturating_sub(SEEK_END_GUARD_MS),
1444        );
1445        let clamped = position_ms.min(ceiling);
1446
1447        // A renderer seeks the track it holds; reloading it would let the
1448        // top of the track be heard.
1449        if self.seek_on_renderer(clamped) {
1450            return;
1451        }
1452        if let Err(e) = self.restart_current(clamped) {
1453            log::error!("seek failed: {}", e);
1454        }
1455    }
1456
1457    /// Restart what is playing at `position_ms`, preserving pause state.
1458    ///
1459    /// What a seek does, and what switching output device does, and what going
1460    /// back from the first track does. All three restart the same track, so all
1461    /// three resolve the source the same way: from the queue item, never from
1462    /// the session's path, which names the `.part` file for a track that was still
1463    /// downloading when it started and is not renamed when the download lands.
1464    fn restart_current(&mut self, position_ms: u64) -> Result<(), PlayerError> {
1465        let Some(session) = self.session() else {
1466            return Ok(());
1467        };
1468        let (info, start) = (session.track.clone(), session.run);
1469
1470        match self.shared_state.item_playback_source(info.id) {
1471            // A renderer takes whole files only: wait for this one to land,
1472            // keeping its place and whether it was playing.
1473            Some(PlaybackSource::Streaming { .. }) if self.renderer.is_some() => {
1474                self.park(info.id, position_ms, start);
1475                return Ok(());
1476            }
1477            Some(PlaybackSource::Streaming {
1478                path,
1479                bytes_written,
1480                total,
1481            }) => {
1482                // No probe: what is playing already said what this is, and
1483                // reading an Ogg's last page to learn it again would mean
1484                // waiting for the rest of the download.
1485                let known = buffer::StreamInfo {
1486                    codec: info.codec.clone(),
1487                    sample_rate: info.sample_rate,
1488                    channels: info.channels,
1489                    bit_depth: info.bit_depth,
1490                    bitrate_kbps: info.bitrate_kbps,
1491                    duration_ms: info.duration_ms,
1492                };
1493                let source = StreamSource {
1494                    path,
1495                    bytes_written,
1496                    total,
1497                    mode: self.stream_mode,
1498                };
1499                self.open_session(
1500                    info.id,
1501                    Source::Stream(source),
1502                    Some(known),
1503                    position_ms,
1504                    start,
1505                )?;
1506            }
1507            Some(PlaybackSource::Ready(path)) => {
1508                self.start_playback(info.id, &path, position_ms, start)?;
1509            }
1510            None => return Ok(()),
1511        }
1512
1513        self.report(match start {
1514            Run::Playing => PlaybackReportState::Playing,
1515            Run::Paused => PlaybackReportState::Paused,
1516        });
1517        Ok(())
1518    }
1519
1520    /// Skip to next track in playlist.
1521    pub fn next_track(&mut self) {
1522        match self.shared_state.advance_cursor_loadable() {
1523            Some(id) => self.play(id),
1524            None => {
1525                log::info!("no more tracks in playlist");
1526                self.stop_playback_and_clear_state();
1527            }
1528        }
1529    }
1530
1531    /// Go back to previous track.
1532    /// Back to the start of the track once it has played for more than a
1533    /// second, as every player does; before that, to the track before.
1534    pub fn prev_track(&mut self) {
1535        if self.session().is_some() && self.shared_state.position_ms() > 1000 {
1536            if let Err(e) = self.restart_current(0) {
1537                log::error!("restart failed: {}", e);
1538            }
1539            return;
1540        }
1541        match self.shared_state.retreat_cursor() {
1542            Some((id, _)) => self.play(id),
1543            None => {
1544                // No previous track — restart current from the beginning.
1545                if let Err(e) = self.restart_current(0) {
1546                    log::error!("restart failed: {}", e);
1547                }
1548            }
1549        }
1550    }
1551
1552    /// Pause playback, fading out if the config asks for it.
1553    ///
1554    /// A fade leaves the unit running until it reaches silence;
1555    /// `update_playback_state` stops it from there. A track still on its way
1556    /// opens paused when it arrives.
1557    pub fn pause(&mut self) {
1558        let fade = crate::config::Config::cached().playback.fade_on_pause;
1559        self.pause_with(if fade { Fade::Short } else { Fade::Cut });
1560    }
1561
1562    fn pause_with(&mut self, fade: Fade) {
1563        match &mut self.transport {
1564            Transport::Idle => return,
1565            Transport::Waiting(waiting) => {
1566                waiting.start = Run::Paused;
1567                return;
1568            }
1569            Transport::Loaded(session) => {
1570                if let Output::Local(local) = &session.output {
1571                    match fade {
1572                        // A fade is the render callback's to finish, and a unit
1573                        // the system has stopped (an interruption on iOS) never
1574                        // calls back: there is nothing to fade, and a fade begun
1575                        // would leave the output muted for the resume.
1576                        Fade::Short if local.engine.is_running() => local.engine.fade_out(),
1577                        _ => {
1578                            if let Err(e) = local.engine.stop() {
1579                                log::error!("pause failed: {}", e);
1580                                return;
1581                            }
1582                        }
1583                    }
1584                }
1585                session.run = Run::Paused;
1586            }
1587        }
1588        self.pause_renderer();
1589        self.report(PlaybackReportState::Paused);
1590    }
1591
1592    /// Resume playback. Fades back in if the pause faded out.
1593    ///
1594    /// With nothing loaded — a session restored stopped, or a start that
1595    /// failed — there is nothing to resume, and play starts the track under
1596    /// the cursor instead of doing nothing. A track still on its way keeps the
1597    /// position it was waiting to open at.
1598    pub fn resume(&mut self) {
1599        self.answer_silence();
1600        if std::mem::take(&mut self.sleep_snap_on_resume) {
1601            self.set_sleep_gain(1.0, true);
1602        }
1603        let session = match &mut self.transport {
1604            Transport::Idle => {
1605                if let Some(id) = self.shared_state.cursor() {
1606                    self.play(id);
1607                }
1608                return;
1609            }
1610            Transport::Waiting(waiting) => {
1611                let waiting = *waiting;
1612                self.cue(waiting.id, waiting.position_ms, Run::Playing);
1613                return;
1614            }
1615            Transport::Loaded(session) => session,
1616        };
1617        let Output::Local(local) = &session.output else {
1618            self.resume_renderer();
1619            return;
1620        };
1621        let engine = &local.engine;
1622        // A fade out the system cut short by stopping the unit never reached
1623        // silence, and still has to be undone.
1624        let resumed = if engine.is_running() || engine.is_faded_out() {
1625            self.lead_in_ends = None;
1626            engine.fade_in()
1627        } else {
1628            engine.start()
1629        };
1630        if let Err(e) = resumed {
1631            log::error!("resume failed: {}", e);
1632            return;
1633        }
1634        session.run = Run::Playing;
1635        self.check_output_started();
1636        self.wake_analyzer();
1637        self.report(PlaybackReportState::Playing);
1638    }
1639
1640    /// Tell the analyser there is about to be something to hear.
1641    ///
1642    /// It parks when nothing is playing and nothing is reading, and the one
1643    /// thing it cannot be signalled from is the play head — that counter is
1644    /// written by the audio render callback, which may never take a lock. So
1645    /// the player says so instead, on the two edges where silence ends.
1646    fn wake_analyzer(&self) {
1647        self.viz_snapshot.wake();
1648    }
1649
1650    /// Stop playback and clear playlist.
1651    pub fn stop(&mut self) {
1652        self.shared_state.clear_playlist();
1653        self.stop_playback_and_clear_state();
1654    }
1655
1656    /// Stop the audio engine and decode thread without touching shared state.
1657    ///
1658    /// Output stops first, then the decode thread is joined, then the engine
1659    /// drops: tearing CoreAudio down under a live producer is the end-of-queue
1660    /// crash (#89).
1661    /// The depth limit a session starts with, before its output is open: the
1662    /// first track's own where the output may narrow, none otherwise.
1663    fn assumed_depth(&self, info: &buffer::StreamInfo) -> u16 {
1664        if self.backend.narrows_depth() {
1665            info.bit_depth.unwrap_or(24)
1666        } else {
1667            0
1668        }
1669    }
1670
1671    /// Look, shortly, whether the output just started has begun to play.
1672    fn check_output_started(&mut self) {
1673        self.output_check = Some((
1674            std::time::Instant::now() + OUTPUT_START_CHECK,
1675            self.timeline
1676                .samples_played_counter()
1677                .load(std::sync::atomic::Ordering::Relaxed),
1678        ));
1679    }
1680
1681    fn stop_engine(&mut self) {
1682        self.output_check = None;
1683        let playback = match std::mem::replace(&mut self.transport, Transport::Idle) {
1684            Transport::Loaded(playback) => playback,
1685            other => {
1686                self.transport = other;
1687                return;
1688            }
1689        };
1690        self.session += 1;
1691        // A session ending some other way ends the DSP change's wait with it.
1692        self.dsp_restart = None;
1693        self.bank_listening();
1694        let local = match playback.output {
1695            Output::Local(local) => local,
1696            Output::Renderer(play) => {
1697                self.halt_renderer(*play);
1698                self.answer_silence();
1699                return;
1700            }
1701        };
1702        let Local {
1703            engine,
1704            mut decode_handle,
1705            stream,
1706            ..
1707        } = local;
1708
1709        let _ = engine.stop();
1710        // Stop first, so the failed read the abandon causes reads as a stop
1711        // rather than a bad source to skip past.
1712        decode_handle.signal_stop();
1713        if let Some(stream) = stream {
1714            stream.abandon();
1715        }
1716        decode_handle.stop();
1717        self.timeline.set_presentation(None);
1718        drop(engine);
1719        self.answer_silence();
1720    }
1721
1722    /// Tell whoever waited for the pause where the playhead came to rest.
1723    fn answer_silence(&mut self) {
1724        let position_ms = self.shared_state.position_ms();
1725        for reply in self.silence_waiters.drain(..) {
1726            let _ = reply.send(position_ms);
1727        }
1728    }
1729
1730    /// Full stop: tear down engine + clear all display state.
1731    fn stop_playback_and_clear_state(&mut self) {
1732        self.forget_waiting();
1733        self.report(PlaybackReportState::Stopped);
1734        self.finish_play();
1735        self.stop_engine();
1736        self.timeline.reset();
1737        self.shared_state.set_position_ms(0);
1738    }
1739
1740    /// Remove a track from the playlist. If it was the cursor, move to the
1741    /// track that followed it, as the listener's last request would have it.
1742    ///
1743    /// `remove_item` clears the cursor, and an unset cursor means "start from the
1744    /// top" — so the successor is pinned down by parking the cursor on the removed
1745    /// track's predecessor first. `None` is correct only when it was the first item.
1746    pub fn remove_from_playlist(&mut self, id: QueueItemId) {
1747        let was_cursor = self.shared_state.is_cursor(id);
1748        let resume_after = was_cursor
1749            .then(|| self.shared_state.item_before(id))
1750            .flatten();
1751        self.shared_state.remove_item(id);
1752        if was_cursor {
1753            self.resume_from(resume_after);
1754            let next = self.shared_state.advance_cursor_loadable();
1755            self.carry_on(next, self.intent());
1756        }
1757    }
1758
1759    /// Where an advance goes on from once the cursor's item is removed: the
1760    /// item before it in the queue, or the top. Shuffled, from the play order
1761    /// with no cursor at all — putting it on the item before would count that
1762    /// one as played again.
1763    fn resume_from(&self, before: Option<QueueItemId>) {
1764        if !self.shared_state.is_shuffled() {
1765            self.shared_state.set_cursor(before);
1766        }
1767    }
1768
1769    /// A download finished. A track waiting on it opens; one already
1770    /// streaming from it, playing or paused, re-reads its metadata from the
1771    /// complete file.
1772    ///
1773    /// The item's state is the downloader's to set, before it sends this.
1774    /// Setting it here as well would turn a duplicate entry's `Failed` back to
1775    /// `Ready`, pointing at a `.part` file that was deleted.
1776    pub fn track_ready(&mut self, id: QueueItemId) {
1777        if !self.shared_state.is_cursor(id) {
1778            return;
1779        }
1780
1781        if let Some(waiting) = self.waiting().filter(|w| w.id == id) {
1782            log::info!("track_ready: opening {:?}", id);
1783            self.cue(id, waiting.position_ms, waiting.start);
1784            return;
1785        }
1786
1787        if self.session().is_some_and(|s| s.track.id == id) {
1788            log::info!(
1789                "track_ready: download complete while streaming {:?}, refreshing metadata",
1790                id
1791            );
1792            self.refresh_track_metadata(id);
1793        }
1794    }
1795
1796    /// Enough of a download has landed to stream it. Opens the track if it is
1797    /// waiting to start from the top.
1798    pub fn track_stream_ready(&mut self, id: QueueItemId) {
1799        let Some(waiting) = self.waiting().filter(|w| self.may_stream(w, id)) else {
1800            return;
1801        };
1802        if !self.shared_state.is_cursor(id) {
1803            return;
1804        }
1805
1806        match self.shared_state.item_playback_source(id) {
1807            Some(PlaybackSource::Streaming {
1808                path,
1809                bytes_written,
1810                total,
1811            }) => {
1812                log::info!("track_stream_ready: probing partial file for {:?}", id);
1813                self.probe_stream_for_playback(id, &path, bytes_written, total);
1814            }
1815            Some(PlaybackSource::Ready(path)) => {
1816                // Download finished between threshold and now — just play normally.
1817                log::info!(
1818                    "track_stream_ready: track already ready, starting normal playback for {:?}",
1819                    id
1820                );
1821                if let Err(e) = self.start_playback(id, &path, 0, waiting.start) {
1822                    self.start_failed("stream ready", &e);
1823                }
1824            }
1825            None => {} // Not enough data yet — wait.
1826        }
1827    }
1828
1829    /// Re-read full lofty metadata for a track after its download completes.
1830    /// Called from track_ready() when a streaming track finishes downloading.
1831    /// What the item takes from it is `update_item_metadata`'s call.
1832    fn refresh_track_metadata(&mut self, id: QueueItemId) {
1833        use crate::index::metadata;
1834
1835        let path = match self.shared_state.item_path_if_ready(id) {
1836            Some(p) => p,
1837            None => return,
1838        };
1839
1840        match metadata::read_metadata(&path) {
1841            Ok(meta) => {
1842                self.shared_state.update_item_metadata(
1843                    id,
1844                    meta.title,
1845                    meta.artist,
1846                    meta.album_artist.unwrap_or_default(),
1847                    meta.album,
1848                    meta.duration_ms.map(|d| d as u64),
1849                );
1850
1851                // Re-probe the complete file for accurate duration + stream info.
1852                // The initial probe was done on partial streaming data and may have
1853                // underestimated duration, causing premature seek clamping or wrong
1854                // progress bar display.
1855                //
1856                // The path is taken over at the same time. Playback started
1857                // against the `.part` file and the download's last act is to
1858                // rename it, so what `track_info` holds now names nothing.
1859                if let Transport::Loaded(session) = &mut self.transport
1860                    && session.track.id == id
1861                {
1862                    let current = &mut session.track;
1863                    let probed = buffer::probe_file(&path).ok();
1864                    let duration_ms = probed
1865                        .as_ref()
1866                        .map(|s| s.duration_ms)
1867                        .filter(|d| *d > current.duration_ms)
1868                        .unwrap_or(current.duration_ms);
1869                    if duration_ms != current.duration_ms {
1870                        log::info!(
1871                            "track_ready: duration corrected {}ms → {}ms",
1872                            current.duration_ms,
1873                            duration_ms
1874                        );
1875                    }
1876                    current.duration_ms = duration_ms;
1877                    current.path = path.clone();
1878                }
1879
1880                // Signal UI to re-read cover art and update souvlaki media controls.
1881                self.shared_state.signal_metadata_refresh();
1882                log::info!("track_ready: metadata refreshed for {:?}", id);
1883            }
1884            Err(e) => {
1885                log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
1886            }
1887        }
1888    }
1889
1890    /// A session has opened on `id`. A seek restarts playback of the same
1891    /// track, so the play in flight carries on when it is the same item —
1892    /// otherwise scrubbing around a track would enter it into history once
1893    /// per seek. Everything that ends a play and opens the same item again,
1894    /// as a new one, closes the old play first: `play`, a repeat at the end
1895    /// of a session.
1896    fn on_track_changed(&mut self, id: QueueItemId, position_ms: u64) {
1897        if let Some(f) = self.in_flight.as_mut().filter(|f| f.item == id) {
1898            f.jump(position_ms);
1899            // The session is new, so the play is its first boundary.
1900            f.boundary = 0;
1901            return;
1902        }
1903        self.begin_play(id, position_ms, 0);
1904    }
1905
1906    /// The needle has moved to a new play of `id`, at `boundary` of the
1907    /// session. Close out the outgoing play and write the new one to history
1908    /// straight away, so history reads in play order even for a track that
1909    /// is skipped a moment later.
1910    fn begin_play(&mut self, id: QueueItemId, position_ms: u64, boundary: usize) {
1911        self.finish_play();
1912        self.shared_state.mark_played(id);
1913        let track_id = self.shared_state.item_db_id(id);
1914        let mut flight = InFlight::new(id, track_id, position_ms);
1915        flight.boundary = boundary;
1916        self.in_flight = Some(flight);
1917        if let (Some(track_id), Some(recorder)) = (track_id, self.history.as_ref()) {
1918            recorder.record(PlayEvent::Started {
1919                track_id,
1920                position_ms,
1921            });
1922        }
1923    }
1924
1925    /// Count what has played of the track in flight since it was last
1926    /// counted. Before anything that resets the timeline, which is where the
1927    /// playhead is read from, and before a track is closed out.
1928    fn bank_listening(&mut self) {
1929        // A renderer's playhead is its clock: set only while one plays.
1930        let renderer_at = self
1931            .shared_state
1932            .renderer_clock()
1933            .map(|_| self.shared_state.position_ms());
1934        if let Some(f) = self.in_flight.as_mut()
1935            && let Some(at) = self.timeline.position_in(f.boundary).or(renderer_at)
1936        {
1937            f.advance(at);
1938        }
1939    }
1940
1941    /// Tell the remote server where the track playing now stands. A track
1942    /// starting is reported by `on_track_changed`; this covers what happens
1943    /// to it afterwards.
1944    fn report(&self, state: PlaybackReportState) {
1945        let Some(track_id) = self.in_flight.as_ref().and_then(InFlight::track_id) else {
1946            return;
1947        };
1948        if let Some(recorder) = self.history.as_ref() {
1949            recorder.record(PlayEvent::Playback(PlaybackReport {
1950                track_id,
1951                state,
1952                position_ms: self.shared_state.position_ms(),
1953            }));
1954        }
1955    }
1956
1957    /// Tell history how long the current track was heard for. Returns what was
1958    /// reported, which is how the tests see it.
1959    fn finish_play(&mut self) -> Option<PlayEvent> {
1960        self.bank_listening();
1961        let flight = self.in_flight.take()?;
1962        let event = PlayEvent::Finished {
1963            track_id: flight.track_id()?,
1964            listened_ms: flight.listened_ms(),
1965        };
1966        if let Some(recorder) = self.history.as_ref() {
1967            recorder.record(event);
1968        }
1969        Some(event)
1970    }
1971
1972    /// What happens without a command: the silence after a rate switch
1973    /// running out, a pause's fade reaching silence, and the playhead crossing
1974    /// into the next queued track. Called from the command loop on each wake.
1975    pub fn update_playback_state(&mut self) {
1976        self.sleep_tick();
1977        if let Some(SleepSet { at: Some(at), .. }) = self.sleep
1978            && std::time::Instant::now() >= at
1979        {
1980            self.sleep = None;
1981            log::info!("sleep timer: time's up");
1982            // Faded to silence by now: what is cut is not heard.
1983            if self.intent() == Some(Run::Playing) {
1984                self.pause_with(Fade::Cut);
1985            }
1986            self.end_sleep_fade();
1987        }
1988
1989        if self.session().is_some()
1990            && self
1991                .lead_in_ends
1992                .is_some_and(|end| std::time::Instant::now() >= end)
1993        {
1994            // The playhead held still through the silence while clients
1995            // counted on from where it was published; this is where they hear
1996            // it move.
1997            self.lead_in_ends = None;
1998            self.shared_state.changed();
1999        }
2000
2001        if let Some((at, from)) = self.output_check
2002            && std::time::Instant::now() >= at
2003        {
2004            // Wakes the state watcher, which tells clients where the playhead
2005            // is: held still if output has not begun, moving if it has.
2006            self.shared_state.changed();
2007            let moved = self
2008                .timeline
2009                .samples_played_counter()
2010                .load(std::sync::atomic::Ordering::Relaxed)
2011                > from;
2012            self.output_check = (!moved && self.intent() == Some(Run::Playing))
2013                .then(|| (std::time::Instant::now() + OUTPUT_START_CHECK, from));
2014        }
2015
2016        if let Some(session) = self.session()
2017            && session.run == Run::Paused
2018            && let Some(engine) = session.engine()
2019            && engine.is_running()
2020            && engine.is_silent()
2021        {
2022            if let Err(e) = engine.stop() {
2023                log::error!("stopping after fade failed: {}", e);
2024            }
2025            self.answer_silence();
2026        }
2027
2028        if let Some(since) = self.dsp_restart {
2029            let faded = self
2030                .session()
2031                .and_then(Session::engine)
2032                .is_none_or(|e| e.is_silent());
2033            if faded || since.elapsed() >= QUICK_FADE_LIMIT {
2034                self.dsp_restart = None;
2035                self.quick_start = true;
2036                self.restart_on_current_track();
2037                self.quick_start = false;
2038            }
2039        }
2040
2041        self.release_idle_session();
2042        self.follow_playhead();
2043        self.renderer_tick();
2044        self.publish();
2045    }
2046
2047    /// A gapless transition moves the playhead into the next track without
2048    /// anything on this thread asking, so the play is banked, the session's
2049    /// track moved on and the cursor brought along from here. A play is its
2050    /// boundary: an item repeated runs into itself, and that is a new play.
2051    ///
2052    /// A sleep timer for the end of the track or record stops playback the
2053    /// moment the next play is heard to start, gaplessly, so with no fade
2054    /// over a track only just begun.
2055    fn follow_playhead(&mut self) {
2056        let before = self.in_flight.as_ref().map(|f| (f.item, f.boundary));
2057        self.move_with_playhead();
2058        let after = self.in_flight.as_ref().map(|f| (f.item, f.boundary));
2059        if let (Some((ended, _)), Some((next, _))) = (before, after)
2060            && before != after
2061            && self.sleeps_between(Some(ended), Some(next))
2062        {
2063            self.pause_with(Fade::Cut);
2064            self.end_sleep_fade();
2065        }
2066    }
2067
2068    fn move_with_playhead(&mut self) {
2069        if self.session().is_none() {
2070            return;
2071        }
2072        let Some(playhead) = self.timeline.playhead() else {
2073            return;
2074        };
2075        let id = playhead.id;
2076        if self
2077            .in_flight
2078            .as_ref()
2079            .is_none_or(|f| f.item != id || f.boundary != playhead.boundary)
2080        {
2081            self.begin_play(id, playhead.position_ms, playhead.boundary);
2082        }
2083        let Transport::Loaded(session) = &mut self.transport else {
2084            return;
2085        };
2086        let pass = step_pass(&session.lookahead.lock(), playhead.boundary);
2087        if session.track.id == id {
2088            return;
2089        }
2090        let Some((id, path, info, _)) = self.timeline.current_playback() else {
2091            return;
2092        };
2093        log::info!("timeline: now playing {:?}", id);
2094        session.track = TrackInfo {
2095            id,
2096            path,
2097            codec: info.codec,
2098            sample_rate: info.sample_rate,
2099            bit_depth: info.bit_depth,
2100            bitrate_kbps: info.bitrate_kbps,
2101            channels: info.channels,
2102            duration_ms: info.duration_ms,
2103        };
2104        self.shared_state.move_on_to(id, pass);
2105    }
2106
2107    /// Whether a sleep timer set for the end of the track or record ends
2108    /// playback where `ended` gives way to `next`, `None` at the end of the
2109    /// queue. Spent if so.
2110    fn sleeps_between(&mut self, ended: Option<QueueItemId>, next: Option<QueueItemId>) -> bool {
2111        let record = |id: Option<QueueItemId>| {
2112            id.and_then(|id| self.shared_state.get_item(id))
2113                .map(|i| (i.album, i.album_artist))
2114        };
2115        let due = match self.sleep.map(|s| s.sleep) {
2116            Some(Sleep::EndOfTrack) => true,
2117            Some(Sleep::EndOfRecord) => next.is_none() || record(ended) != record(next),
2118            _ => false,
2119        };
2120        if due {
2121            log::info!(
2122                "sleep timer: stopping at the end of the {}",
2123                match self.sleep {
2124                    Some(SleepSet {
2125                        sleep: Sleep::EndOfTrack,
2126                        ..
2127                    }) => "track",
2128                    _ => "record",
2129                }
2130            );
2131            self.sleep = None;
2132        }
2133        due
2134    }
2135
2136    /// Set the sleep timer, or cancel it.
2137    fn set_sleep_timer(&mut self, timer: Option<SleepTimer>) {
2138        self.restore_sleep_fade();
2139        self.sleep = timer.map(|timer| match timer {
2140            SleepTimer::After { minutes } => {
2141                let after = std::time::Duration::from_secs(u64::from(minutes) * 60);
2142                let unix_ms = std::time::SystemTime::now()
2143                    .duration_since(std::time::UNIX_EPOCH)
2144                    .unwrap_or_default()
2145                    + after;
2146                SleepSet {
2147                    sleep: Sleep::At {
2148                        unix_ms: unix_ms.as_millis() as u64,
2149                    },
2150                    at: Some(std::time::Instant::now() + after),
2151                    fade: Some(sleep::fade_length(minutes)),
2152                }
2153            }
2154            SleepTimer::EndOfTrack => SleepSet {
2155                sleep: Sleep::EndOfTrack,
2156                at: None,
2157                fade: None,
2158            },
2159            SleepTimer::EndOfRecord => SleepSet {
2160                sleep: Sleep::EndOfRecord,
2161                at: None,
2162                fade: None,
2163            },
2164        });
2165        log::info!("sleep timer: {:?}", self.sleep.map(|s| s.sleep));
2166    }
2167
2168    /// A download a waiting track needs will never land.
2169    ///
2170    /// That wait has no end, so walk on to the next item that can still load,
2171    /// opening it the way the failed one would have opened, or stop cleanly if
2172    /// there is none. Only a waiting track is affected: one already streaming
2173    /// sees the failure in its reads and ends the decode, which advances the
2174    /// queue.
2175    pub fn track_failed(&mut self, id: QueueItemId) {
2176        let Some(waiting) = self.waiting().filter(|w| w.id == id) else {
2177            return;
2178        };
2179        if !self.shared_state.is_cursor(id) {
2180            return;
2181        }
2182        log::info!("track {:?} cannot load, moving on", id);
2183        let next = self.shared_state.advance_cursor_loadable();
2184        self.carry_on(next, Some(waiting.start));
2185    }
2186
2187    /// Decode thread naturally finished (playlist exhausted or error).
2188    /// Advance to the next playable track; otherwise stop cleanly.
2189    ///
2190    /// Ignored from a session already torn down: a play or seek handled after
2191    /// the message was sent has replaced what it describes.
2192    fn on_decode_finished(&mut self, session: u64) {
2193        if session != self.session || self.renderer_stream_finished() {
2194            return;
2195        }
2196        log::info!("decode finished, checking for next track");
2197        self.track_ended();
2198    }
2199
2200    /// The track under the playhead played out, here or on a renderer. Go on
2201    /// as the play mode says: the same item again under repeat one, the next
2202    /// otherwise, from the top again when the queue repeats.
2203    ///
2204    /// A track that has not finished downloading parks the cursor on it, so its
2205    /// `TrackReady`/`TrackStreamReady` resumes the queue instead of being
2206    /// discarded as "not the cursor".
2207    ///
2208    /// Opening the item that just ended again — repeating it, or a queue of
2209    /// one repeating — closes the play that ended first, so the session that
2210    /// opens is a new play rather than a seek within it. A repeat of something
2211    /// that played for no time at all — a file that opens but decodes nothing
2212    /// — would go round forever, so it stops instead.
2213    fn track_ended(&mut self) {
2214        self.bank_listening();
2215        let ended = self.in_flight.as_ref().map(|f| f.item);
2216        let heard = self.in_flight.as_ref().is_some_and(|f| f.listened_ms() > 0);
2217        // A track streamed while it downloads, ended with nothing heard and
2218        // its download still running, did not play: the stream gave out ahead
2219        // of the bytes. It is waited for and opened from disk when it lands,
2220        // as a track not yet downloaded is, rather than passed over for the
2221        // next. Only a track that has failed is moved past.
2222        if !heard
2223            && let Some(id) = self.session().map(|s| s.track.id)
2224            && self.shared_state.is_cursor(id)
2225            && matches!(self.shared_state.item_state(id), Some(ItemState::Pending))
2226        {
2227            log::info!("track {id:?} ended unheard before its download did; waiting for it");
2228            let start = self.intent().unwrap_or(Run::Playing);
2229            self.park(id, 0, start);
2230            return;
2231        }
2232        if self.mode.repeat != Repeat::Off && !heard {
2233            log::info!("track ended with nothing heard; not repeating it");
2234            self.stop_playback_and_clear_state();
2235            return;
2236        }
2237        let again = (self.mode.repeat == Repeat::One)
2238            .then(|| self.shared_state.cursor())
2239            .flatten()
2240            .filter(|id| {
2241                self.shared_state
2242                    .item_state(*id)
2243                    .is_some_and(|s| !matches!(s, ItemState::Failed(_)))
2244            });
2245        let next = again.or_else(|| self.shared_state.advance_cursor_loadable());
2246        if next.is_some() && next == ended {
2247            self.finish_play();
2248        }
2249        let sleeps = self.intent().is_some() && self.sleeps_between(ended, next);
2250        let intent = if sleeps {
2251            Some(Run::Paused)
2252        } else {
2253            self.intent()
2254        };
2255        self.carry_on(next, intent);
2256        if sleeps {
2257            self.end_sleep_fade();
2258        }
2259    }
2260
2261    /// Restart the session at the playhead if the queue no longer follows the
2262    /// playing track with what the decoder has already queued.
2263    ///
2264    /// The decoder looks ahead a few seconds before a track ends, and what it
2265    /// queued is in the ring. Without this, removing or moving the next track
2266    /// in that window has no effect, and a track inserted to play next is
2267    /// skipped. A lookahead not yet taken needs nothing: the decoder reads the
2268    /// queue when it gets there. One that found the end of the queue counts as
2269    /// taken, so a track added then follows gaplessly instead of after a stop.
2270    fn revoke_stale_lookahead(&mut self) {
2271        if self.session().is_none() {
2272            return;
2273        }
2274        self.update_playback_state();
2275        let Some(playhead) = self.timeline.playhead() else {
2276            return;
2277        };
2278        let position_ms = playhead.position_ms;
2279        let Some(session) = self.session().filter(|s| s.track.id == playhead.id) else {
2280            return;
2281        };
2282        let stale = {
2283            let steps = session.lookahead.lock();
2284            // The steps the playhead has yet to reach: those that open a
2285            // boundary past the one it is in. By boundary, not by the item
2286            // chosen — an item repeated is chosen by every step.
2287            steps
2288                .iter()
2289                .filter(|step| step.boundary > playhead.boundary)
2290                .any(|step| !self.shared_state.still_follows(step))
2291        };
2292        if !stale {
2293            return;
2294        }
2295        log::info!("queue changed under the lookahead, restarting at {position_ms}ms");
2296        if let Err(e) = self.restart_current(position_ms) {
2297            log::error!("restart after a queue edit failed: {}", e);
2298        }
2299    }
2300
2301    /// Snapshot items with their predecessors for an undo of "these were removed".
2302    /// In playlist order, so undo re-inserts each item after a predecessor that
2303    /// is already back in place.
2304    fn snapshot_for_undo(
2305        &self,
2306        ids: &[QueueItemId],
2307    ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
2308        self.shared_state
2309            .items_before(ids)
2310            .into_iter()
2311            .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
2312            .collect()
2313    }
2314
2315    /// Route an undo entry to the batch buffer (if batching) or the undo stack.
2316    fn push_undo(&mut self, entry: UndoEntry) {
2317        if let Some(ref mut batch) = self.batch_buffer {
2318            batch.push(entry);
2319        } else {
2320            self.undo_stack.push(entry);
2321        }
2322    }
2323
2324    /// Process a single command.
2325    pub fn process_command(&mut self, cmd: PlayerCommand) {
2326        // Someone wants to hear something, or has picked where: the renderer
2327        // remembered from last time is no longer what to go back to. A cue is
2328        // how a session is restored at launch, which is what the renderer is
2329        // to carry on with, so it is not one of them.
2330        // So, too, is one that pauses, stops or replaces the session: what was
2331        // held for the renderer is no longer wanted anywhere.
2332        if cmd.asks_to_play() && !matches!(cmd, PlayerCommand::Cue { .. })
2333            || matches!(
2334                cmd,
2335                PlayerCommand::UseRenderer(_)
2336                    | PlayerCommand::SetOutputDevice(_)
2337                    | PlayerCommand::ClearOutputDevice
2338                    | PlayerCommand::Pause
2339                    | PlayerCommand::PauseAndReport(_)
2340                    | PlayerCommand::Stop
2341                    | PlayerCommand::ClearPlaylist
2342                    | PlayerCommand::ReplacePlaylist { .. }
2343            )
2344        {
2345            self.resume_renderer = false;
2346            self.held_run = None;
2347        }
2348        // The restored session, while the renderer it played on is looked
2349        // for: opened paused, its run held for wherever it ends up.
2350        let cmd = match cmd {
2351            PlayerCommand::Cue {
2352                id,
2353                position_ms,
2354                play: true,
2355            } if self.resume_renderer => {
2356                self.held_run = Some(Run::Playing);
2357                PlayerCommand::Cue {
2358                    id,
2359                    position_ms,
2360                    play: false,
2361                }
2362            }
2363            cmd => cmd,
2364        };
2365        let edits_queue = matches!(
2366            cmd,
2367            PlayerCommand::AddToPlaylist(_)
2368                | PlayerCommand::InsertInPlaylist { .. }
2369                | PlayerCommand::RemoveFromPlaylist(_)
2370                | PlayerCommand::RemoveFromPlaylistBatch(_)
2371                | PlayerCommand::MoveInPlaylist { .. }
2372                | PlayerCommand::MoveItemsInPlaylist { .. }
2373                | PlayerCommand::ReorderPlaylist(_)
2374                | PlayerCommand::Undo
2375                | PlayerCommand::Redo
2376                | PlayerCommand::SetShuffle(_)
2377                | PlayerCommand::SetRepeat(_)
2378                | PlayerCommand::RestorePlayMode(_)
2379        );
2380        self.apply_command(cmd);
2381        if edits_queue {
2382            self.revoke_stale_lookahead();
2383        }
2384        self.publish();
2385    }
2386
2387    fn apply_command(&mut self, cmd: PlayerCommand) {
2388        match cmd {
2389            PlayerCommand::Play(id) => {
2390                self.shared_state.pick(id);
2391                self.play(id);
2392            }
2393            PlayerCommand::Cue {
2394                id,
2395                position_ms,
2396                play,
2397            } => self.cue(
2398                id,
2399                position_ms,
2400                if play { Run::Playing } else { Run::Paused },
2401            ),
2402            PlayerCommand::Pause => {
2403                self.paused_in_sleep_fade();
2404                self.pause();
2405            }
2406            // Every command before this one has been applied and published:
2407            // commands are taken one at a time, each published as it ends.
2408            PlayerCommand::Barrier(reply) => {
2409                let _ = reply.send(());
2410            }
2411            PlayerCommand::PauseAndReport(reply) => {
2412                self.pause();
2413                self.silence_waiters.push(reply);
2414                if self
2415                    .session()
2416                    .is_none_or(|p| p.engine().is_none_or(|e| !e.is_running()))
2417                {
2418                    self.answer_silence();
2419                }
2420            }
2421            PlayerCommand::Resume => self.resume(),
2422            PlayerCommand::Stop => self.stop(),
2423            PlayerCommand::Seek(pos) => self.seek(pos),
2424            PlayerCommand::NextTrack => self.next_track(),
2425            PlayerCommand::PrevTrack => self.prev_track(),
2426            PlayerCommand::AddToPlaylist(items) => {
2427                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
2428                self.shared_state.add_items(items);
2429                self.push_undo(UndoEntry::Added { ids });
2430            }
2431            PlayerCommand::AddAndPlay(items) => {
2432                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
2433                self.shared_state.add_items(items);
2434                self.push_undo(UndoEntry::Added { ids: ids.clone() });
2435                self.revoke_stale_lookahead();
2436                if let Some(&first) = ids.first() {
2437                    self.shared_state.pick(first);
2438                    self.play(first);
2439                }
2440            }
2441            PlayerCommand::UpdatePaths(updates) => {
2442                self.shared_state.update_paths(&updates);
2443                if let Transport::Loaded(session) = &mut self.transport
2444                    && let Some((_, new_path)) =
2445                        updates.iter().find(|(id, _)| *id == session.track.id)
2446                {
2447                    session.track.path = new_path.clone();
2448                }
2449            }
2450            PlayerCommand::InsertInPlaylist { items, after } => {
2451                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
2452                self.shared_state.insert_items_after(items, after);
2453                self.push_undo(UndoEntry::Inserted { ids });
2454            }
2455            PlayerCommand::ClearPlaylist => self.clear_playlist(),
2456            PlayerCommand::ReplacePlaylist {
2457                items,
2458                start,
2459                position_ms,
2460                play,
2461                mode,
2462            } => {
2463                if items.is_empty() {
2464                    self.clear_playlist();
2465                    return;
2466                }
2467                let start_id = items.get(start).unwrap_or(&items[0]).id;
2468                // Stopped before the swap, as `clear_playlist` does, and the
2469                // swap one change: see `SharedPlayerState::replace_playlist`.
2470                self.stop_playback_and_clear_state();
2471                if let Some(mode) = mode.play_mode() {
2472                    self.mode = mode;
2473                    // Published now, not at the end of the command: `cue`
2474                    // starts a decode thread, whose lookahead reads the
2475                    // published repeat. Left to `publish`, it could still
2476                    // read the old mode and queue a wrap that repeat-off
2477                    // forbids, which nothing revokes afterwards.
2478                    self.shared_state.set_play_mode(mode);
2479                    self.shared_state.set_shuffled(mode.shuffle);
2480                }
2481                let (old_items, cursor) = self.shared_state.replace_playlist(items);
2482                let start_id = match mode {
2483                    QueueMode::Shuffled => self
2484                        .shared_state
2485                        .advance_cursor_loadable()
2486                        .unwrap_or(start_id),
2487                    _ => start_id,
2488                };
2489                self.push_undo(UndoEntry::Replaced {
2490                    items: old_items,
2491                    cursor,
2492                });
2493                self.cue(
2494                    start_id,
2495                    position_ms,
2496                    if play { Run::Playing } else { Run::Paused },
2497                );
2498            }
2499            PlayerCommand::RemoveFromPlaylist(id) => {
2500                let item = self.shared_state.get_item(id);
2501                let after = self.shared_state.item_before(id);
2502                self.remove_from_playlist(id);
2503                if let Some(item) = item {
2504                    self.push_undo(UndoEntry::Removed {
2505                        items: vec![(Box::new(item), after)],
2506                    });
2507                }
2508            }
2509            PlayerCommand::RemoveFromPlaylistBatch(ids) => {
2510                // Snapshot before removing anything, and resolve the resume point
2511                // once: removing one at a time would restart the engine for every
2512                // deleted track that the cursor lands on along the way.
2513                let items_with_pos = self.snapshot_for_undo(&ids);
2514                let intent = self.intent();
2515                let resume_after = match self.shared_state.cursor() {
2516                    Some(cursor) if ids.contains(&cursor) => {
2517                        Some(self.shared_state.surviving_item_before(cursor, &ids))
2518                    }
2519                    _ => None,
2520                };
2521
2522                self.shared_state.remove_items(&ids);
2523
2524                if let Some(resume_after) = resume_after {
2525                    self.resume_from(resume_after);
2526                    let next = self.shared_state.advance_cursor_loadable();
2527                    self.carry_on(next, intent);
2528                }
2529
2530                if !items_with_pos.is_empty() {
2531                    self.push_undo(UndoEntry::Removed {
2532                        items: items_with_pos,
2533                    });
2534                }
2535            }
2536            PlayerCommand::MoveInPlaylist { id, target, after } => {
2537                let was_after = self.shared_state.item_before(id);
2538                self.shared_state.move_item(id, target, after);
2539                self.push_undo(UndoEntry::Moved { id, was_after });
2540            }
2541            PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
2542                let entries = self.shared_state.items_before(&ids);
2543                self.shared_state.move_items(&ids, target, after);
2544                self.push_undo(UndoEntry::MovedBatch { entries });
2545            }
2546            PlayerCommand::ReorderPlaylist(order) => {
2547                // Undoable like any other move: undoing it puts the queue back
2548                // and, by doing so, ends the lock — which is the honest result
2549                // of having rearranged the queue by hand.
2550                let entries = self.shared_state.items_before(&order);
2551                self.shared_state.reorder_to(&order);
2552                self.push_undo(UndoEntry::MovedBatch { entries });
2553            }
2554            PlayerCommand::TrackReady(id) => self.track_ready(id),
2555            PlayerCommand::DecodeFinished(session) => self.on_decode_finished(session),
2556            // Nothing to do but wake: the loop works out when the playhead
2557            // reaches the track just queued.
2558            PlayerCommand::TrackQueued => {}
2559            PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
2560            PlayerCommand::StreamProbed { id, info, mode } => self.stream_probed(id, *info, mode),
2561            PlayerCommand::TrackFailed(id) => self.track_failed(id),
2562            PlayerCommand::CacheTracks(ids) => {
2563                if let Some(downloads) = &self.downloads {
2564                    downloads.cache(ids);
2565                }
2566            }
2567            PlayerCommand::Undo => self.execute_undo(),
2568            PlayerCommand::Redo => self.execute_redo(),
2569            PlayerCommand::BeginUndoBatch => {
2570                self.batch_buffer = Some(Vec::new());
2571            }
2572            PlayerCommand::EndUndoBatch => {
2573                if let Some(entries) = self.batch_buffer.take() {
2574                    if entries.len() == 1 {
2575                        // Single entry — push directly, no wrapping.
2576                        self.undo_stack.push(entries.into_iter().next().unwrap());
2577                    } else if !entries.is_empty() {
2578                        self.undo_stack.push(UndoEntry::Batch(entries));
2579                    }
2580                }
2581            }
2582            PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
2583            PlayerCommand::RestartOutput => {
2584                log::info!("restarting audio output");
2585                self.restart_on_current_track();
2586            }
2587            PlayerCommand::ClearOutputDevice => self.clear_output_device(),
2588            PlayerCommand::ReloadDsp => self.reload_dsp(),
2589            PlayerCommand::RouteChanged => {
2590                crate::audio::follow_route();
2591                self.reload_dsp();
2592            }
2593            PlayerCommand::UseRenderer(connection) => {
2594                self.remember_renderer(connection.as_deref());
2595                self.use_renderer(connection);
2596            }
2597            PlayerCommand::ResumeRenderer(connection) => {
2598                if std::mem::take(&mut self.resume_renderer) && self.renderer.is_none() {
2599                    log::info!(
2600                        "upnp: back to {}, as last time",
2601                        connection.session.renderer().name
2602                    );
2603                    self.use_renderer(Some(connection));
2604                    if self.held_run.take() == Some(Run::Playing) {
2605                        self.resume();
2606                    }
2607                } else {
2608                    log::info!(
2609                        "upnp: not going back to {}: playback or the output moved first",
2610                        connection.session.renderer().name
2611                    );
2612                }
2613            }
2614            PlayerCommand::ResumeRendererMissed => {
2615                if std::mem::take(&mut self.resume_renderer)
2616                    && self.held_run.take() == Some(Run::Playing)
2617                {
2618                    log::info!("upnp: playing here what was held for the renderer");
2619                    self.resume();
2620                }
2621            }
2622            PlayerCommand::ReleaseRenderer(reply) => {
2623                self.release_renderer();
2624                let _ = reply.send(());
2625            }
2626            PlayerCommand::SetRendererVolume(volume) => self.set_renderer_volume(volume),
2627            PlayerCommand::Renderer { session, event } => self.on_renderer_event(session, event),
2628            PlayerCommand::SetShuffle(on) => self.set_shuffle(on),
2629            PlayerCommand::SetRepeat(repeat) => self.mode.repeat = repeat,
2630            PlayerCommand::RestorePlayMode(mode) => {
2631                self.mode = mode;
2632                self.shared_state.set_shuffled(mode.shuffle);
2633            }
2634            PlayerCommand::SetSleepTimer(timer) => self.set_sleep_timer(timer),
2635        }
2636    }
2637
2638    /// Turn shuffle on or off. Nothing in the queue moves, so it is not an
2639    /// edit to undo: on, the items yet to play this pass play in a random
2640    /// order kept beside the queue; off, the queue plays on in its own order
2641    /// from the cursor.
2642    fn set_shuffle(&mut self, on: bool) {
2643        self.mode.shuffle = on;
2644        self.shared_state.set_shuffled(on);
2645    }
2646
2647    /// Stop, then clear the playlist as one undoable step. Playback and display
2648    /// state go first, without touching the playlist, so the snapshot taken for
2649    /// undo is of the playlist as it was.
2650    fn clear_playlist(&mut self) {
2651        self.stop_playback_and_clear_state();
2652        let (items, cursor) = self.shared_state.snapshot_playlist();
2653        self.shared_state.clear_playlist();
2654        self.push_undo(UndoEntry::Replaced { items, cursor });
2655    }
2656
2657    /// Apply an undo/redo entry: mutate the playlist and return the inverse entry.
2658    fn apply_entry(&mut self, entry: UndoEntry) -> UndoEntry {
2659        match entry {
2660            UndoEntry::Added { ids } | UndoEntry::Inserted { ids } => {
2661                // Undo of "items were added": snapshot them with positions, then remove.
2662                let items_with_pos = self.snapshot_for_undo(&ids);
2663                self.shared_state.remove_items(&ids);
2664                UndoEntry::Removed {
2665                    items: items_with_pos,
2666                }
2667            }
2668            UndoEntry::Removed { items } => {
2669                // Undo of "items were removed": re-insert each at its position.
2670                let mut ids = Vec::with_capacity(items.len());
2671                for (item, after) in items {
2672                    ids.push(item.id);
2673                    self.shared_state.insert_item_at(*item, after);
2674                }
2675                UndoEntry::Added { ids }
2676            }
2677            UndoEntry::Moved { id, was_after } => {
2678                let current_after = self.shared_state.item_before(id);
2679                self.shared_state.move_item_to(id, was_after);
2680                UndoEntry::Moved {
2681                    id,
2682                    was_after: current_after,
2683                }
2684            }
2685            UndoEntry::MovedBatch { entries } => {
2686                let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
2687                let current_positions = self.shared_state.items_before(&ids);
2688                self.shared_state.move_items_to(&entries);
2689                UndoEntry::MovedBatch {
2690                    entries: current_positions,
2691                }
2692            }
2693            UndoEntry::Replaced { items, cursor } => {
2694                let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
2695                self.shared_state.restore_playlist(items, cursor);
2696                UndoEntry::Replaced {
2697                    items: current_items,
2698                    cursor: current_cursor,
2699                }
2700            }
2701            UndoEntry::Batch(entries) => {
2702                // Apply entries in reverse order, collect inverses.
2703                let mut inverses: Vec<_> = entries
2704                    .into_iter()
2705                    .rev()
2706                    .map(|e| self.apply_entry(e))
2707                    .collect();
2708                inverses.reverse();
2709                UndoEntry::Batch(inverses)
2710            }
2711        }
2712    }
2713
2714    /// Put playback back in agreement with the playlist.
2715    ///
2716    /// The engine keeps decoding whatever it was on while the playlist changes
2717    /// underneath it, which an undo can turn into a lie: undoing a replace
2718    /// restores the queue but leaves the engine playing a track that queue does
2719    /// not contain. The transport then describes an item nothing can select,
2720    /// and the decode lookahead — which finds the next track by locating the
2721    /// current one — has nothing to follow, so the queue ends at the end of the
2722    /// track instead of carrying on.
2723    ///
2724    /// Done once, after the entry is applied, rather than inside each variant:
2725    /// any undo that takes items away can orphan the engine, not only
2726    /// `Replaced`. A track waiting to open can be orphaned the same way, and
2727    /// would otherwise open when its download lands.
2728    ///
2729    /// Playing carries on playing and paused stays paused, the same as when
2730    /// the playing track is removed: an undo is not a reason to start the
2731    /// music, or to stop it.
2732    fn reconcile_playback(&mut self) {
2733        let orphaned = match &self.transport {
2734            Transport::Idle => false,
2735            Transport::Waiting(waiting) => self.shared_state.get_item(waiting.id).is_none(),
2736            Transport::Loaded(session) => self.shared_state.get_item(session.track.id).is_none(),
2737        };
2738        if !orphaned {
2739            return;
2740        }
2741        // Pick the restored queue up where its cursor says it was, as the
2742        // listener had it. The position is not part of what was snapshotted,
2743        // so the track begins again.
2744        let cursor = self.shared_state.cursor();
2745        self.carry_on(cursor, self.intent());
2746    }
2747
2748    /// Execute an undo operation, pushing the inverse onto the redo stack.
2749    fn execute_undo(&mut self) {
2750        let Some(entry) = self.undo_stack.pop_undo() else {
2751            return;
2752        };
2753        let inverse = self.apply_entry(entry);
2754        self.undo_stack.push_redo(inverse);
2755        self.reconcile_playback();
2756    }
2757
2758    /// Execute a redo operation, pushing the inverse onto the undo stack.
2759    fn execute_redo(&mut self) {
2760        let Some(entry) = self.undo_stack.pop_redo() else {
2761            return;
2762        };
2763        let inverse = self.apply_entry(entry);
2764        self.undo_stack.push_undo_keep_redo(inverse);
2765        self.reconcile_playback();
2766    }
2767
2768    /// Run the command loop. Blocks until the sender is dropped.
2769    ///
2770    /// Asleep until there is something to do: a command, or one of the two
2771    /// things that happen without one — see `next_wake`. A paused or stopped
2772    /// player does not wake at all.
2773    pub fn run(&mut self) {
2774        use crossbeam_channel::RecvTimeoutError;
2775
2776        let rx = self.commands.rx.clone();
2777        loop {
2778            let received = match self.next_wake() {
2779                Some(at) => rx.recv_deadline(at),
2780                None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
2781            };
2782            match received {
2783                Ok(cmd) => self.process_command(cmd),
2784                Err(RecvTimeoutError::Timeout) => {}
2785                Err(RecvTimeoutError::Disconnected) => break,
2786            }
2787            self.update_playback_state();
2788        }
2789        self.stop();
2790    }
2791
2792    /// Give the audio session back once output has been stopped for
2793    /// `SESSION_GRACE`, so another app can play. A resume inside it finds the
2794    /// session still active. Waiting for a track to arrive with play asked
2795    /// for counts as playing: the session is wanted again the moment it does.
2796    fn release_idle_session(&mut self) {
2797        if self.session_release_due(
2798            self.wants_output(),
2799            crate::audio::session_held(),
2800            std::time::Instant::now(),
2801        ) {
2802            log::info!("output idle: releasing the audio session");
2803            crate::audio::release_session();
2804        }
2805    }
2806
2807    /// Play asked for, or output still running (a pause fading out).
2808    fn wants_output(&self) -> bool {
2809        self.intent() == Some(Run::Playing)
2810            || self
2811                .session()
2812                .and_then(Session::engine)
2813                .is_some_and(|e| e.is_running())
2814    }
2815
2816    /// Whether the session is to be released now, starting the grace when
2817    /// output has just stopped.
2818    fn session_release_due(&mut self, playing: bool, held: bool, now: std::time::Instant) -> bool {
2819        if playing || !held {
2820            self.session_release_at = None;
2821            return false;
2822        }
2823        match self.session_release_at {
2824            None => {
2825                self.session_release_at = Some(now + SESSION_GRACE);
2826                false
2827            }
2828            Some(at) if now >= at => {
2829                self.session_release_at = None;
2830                true
2831            }
2832            Some(_) => false,
2833        }
2834    }
2835
2836    /// When something changes that no command announces: one of the
2837    /// session's events (`next_event`), a sleep timer's time coming, or the
2838    /// audio session's release.
2839    fn next_wake(&self) -> Option<std::time::Instant> {
2840        [
2841            self.next_event(),
2842            self.sleep.and_then(|s| s.at),
2843            self.sleep_wake(),
2844            self.session_release_at,
2845        ]
2846        .into_iter()
2847        .flatten()
2848        .min()
2849    }
2850
2851    /// The playhead reaching the next queued track, the silence after a rate
2852    /// switch running out, or a pause fading to silence.
2853    fn next_event(&self) -> Option<std::time::Instant> {
2854        let session = self.session()?;
2855        let now = std::time::Instant::now();
2856        let Output::Local(local) = &session.output else {
2857            // A stream's track changes are the timeline's, as they are here.
2858            let next_track = (session.run == Run::Playing && self.streaming_to_renderer())
2859                .then(|| self.timeline.until_next_track())
2860                .flatten()
2861                .map(|left| now + left + BOUNDARY_SLACK);
2862            return match (next_track, self.renderer_deadline()) {
2863                (Some(a), Some(b)) => Some(a.min(b)),
2864                (a, b) => a.or(b),
2865            };
2866        };
2867        if let Some(since) = self.dsp_restart {
2868            return Some(dsp_wake(since, local.engine.period(), now));
2869        }
2870        match session.run {
2871            Run::Playing => {
2872                let next_track = self
2873                    .timeline
2874                    .until_next_track()
2875                    .map(|left| now + left + BOUNDARY_SLACK);
2876                [
2877                    next_track,
2878                    self.lead_in_ends,
2879                    self.output_check.map(|(at, _)| at),
2880                ]
2881                .into_iter()
2882                .flatten()
2883                .min()
2884            }
2885            Run::Paused if local.engine.is_running() => Some(now + FADE_CHECK),
2886            Run::Paused => None,
2887        }
2888    }
2889
2890    /// Spawn the player on a background thread, returning the shared state,
2891    /// timeline, visualization snapshot, and command sender.
2892    /// Remember the renderer picked as the output, or that this device's own
2893    /// was, for the next launch. Only a choice is remembered: a renderer that
2894    /// drops off the network is gone back to next time.
2895    fn remember_renderer(&self, connection: Option<&crate::upnp::Connection>) {
2896        let renderer = connection.map(|c| c.session.renderer());
2897        if let Err(e) = crate::config::Config::persist(|cfg| {
2898            cfg.playback.renderer = renderer.map(|r| r.udn.clone());
2899            cfg.playback.renderer_name = renderer.map(|r| r.name.clone());
2900        }) {
2901            log::error!("failed to save the output: {e}");
2902        }
2903    }
2904
2905    /// Spawn a player for a process that does not own an output of its own:
2906    /// `koan serve`, `koan mcp`. It plays where it is told and nowhere else.
2907    pub fn spawn() -> (
2908        Arc<SharedPlayerState>,
2909        Arc<PlaybackTimeline>,
2910        Arc<VizSnapshot>,
2911        crossbeam_channel::Sender<PlayerCommand>,
2912    ) {
2913        Self::spawn_with(false)
2914    }
2915
2916    /// Spawn the player for an app someone listens through: the macOS and iOS
2917    /// apps and `koan play`. It goes back to the renderer used last time if
2918    /// that turns up at launch (`upnp::resume`). A headless process must not:
2919    /// the config is the machine's, and it would take the amplifier from the
2920    /// app.
2921    pub fn spawn_for_listening() -> (
2922        Arc<SharedPlayerState>,
2923        Arc<PlaybackTimeline>,
2924        Arc<VizSnapshot>,
2925        crossbeam_channel::Sender<PlayerCommand>,
2926    ) {
2927        Self::spawn_with(true)
2928    }
2929
2930    /// The renderer to go back to at launch: the one used last time, for a
2931    /// player someone listens through.
2932    fn renderer_to_resume(listening: bool) -> Option<String> {
2933        listening
2934            .then(|| crate::config::Config::cached().playback.renderer.clone())
2935            .flatten()
2936    }
2937
2938    fn spawn_with(
2939        listening: bool,
2940    ) -> (
2941        Arc<SharedPlayerState>,
2942        Arc<PlaybackTimeline>,
2943        Arc<VizSnapshot>,
2944        crossbeam_channel::Sender<PlayerCommand>,
2945    ) {
2946        // EQ from before presets and tunings of several EQs, brought up to
2947        // them before anything plays through it.
2948        if let Err(e) = crate::audio::dsp::profiles::migrate() {
2949            log::warn!("dsp: not brought up to date: {e}");
2950        }
2951        let mut player = Self::new();
2952        player.history = PlayRecorder::spawn();
2953        let state = player.shared_state();
2954        let timeline = player.timeline();
2955        let viz_snapshot = player.viz_snapshot();
2956        let tx = player.command_sender();
2957        {
2958            let tx = tx.clone();
2959            player.backend.on_disconnect(Arc::new(move || {
2960                let _ = tx.try_send(PlayerCommand::RestartOutput);
2961            }));
2962        }
2963        // Downloads follow the playlist, so they come with the player rather
2964        // than being something each front end has to remember to ask for.
2965        player.downloads = Some(crate::remote::queue::DownloadQueue::spawn(
2966            tx.clone(),
2967            state.clone(),
2968        ));
2969
2970        if let Some(udn) = Self::renderer_to_resume(listening) {
2971            player.resume_renderer = true;
2972            crate::upnp::resume(udn, &tx);
2973        }
2974
2975        thread::Builder::new()
2976            .name("koan-player".into())
2977            .spawn(move || player.run())
2978            .expect("failed to spawn player thread");
2979
2980        (state, timeline, viz_snapshot, tx)
2981    }
2982}
2983
2984/// The pass the decoder's step put the track at `boundary` in: by id alone, a
2985/// row in this pass and the next is ambiguous. The last step at the boundary,
2986/// since one whose file failed to open is followed by another taken for the
2987/// same boundary.
2988fn step_pass(steps: &[state::Lookahead], boundary: usize) -> Option<u64> {
2989    steps
2990        .iter()
2991        .rfind(|step| step.boundary == boundary)
2992        .map(|step| step.pass)
2993}
2994
2995#[cfg(test)]
2996mod tests {
2997    /// `koan serve` and `koan mcp` read the same config as the app, and must
2998    /// not go looking for the app's amplifier.
2999    #[test]
3000    fn a_headless_player_does_not_go_back_to_a_renderer() {
3001        let _guard = crate::config::tests::PERSIST_LOCK
3002            .lock()
3003            .unwrap_or_else(|e| e.into_inner());
3004        let dir = tempfile::tempdir().unwrap();
3005        crate::config::set_config_dir(dir.path());
3006        crate::config::Config::persist(|cfg| {
3007            cfg.playback.renderer = Some("uuid:headless-test".into());
3008        })
3009        .unwrap();
3010        assert_eq!(Player::renderer_to_resume(false), None);
3011    }
3012
3013    #[test]
3014    fn a_download_in_progress_is_known_by_its_own_extension() {
3015        use std::path::Path;
3016        // `.part` is the transfer's, not the track's.
3017        assert_eq!(
3018            lengthless_mode_for(Path::new("/c/t.m4a.part")),
3019            streaming::ProbeMode::LengthlessWholeEnd
3020        );
3021        assert_eq!(
3022            lengthless_mode_for(Path::new("/c/t.OPUS.part")),
3023            streaming::ProbeMode::LengthlessWholeEnd
3024        );
3025        assert_eq!(
3026            lengthless_mode_for(Path::new("/c/t.flac.part")),
3027            streaming::ProbeMode::Lengthless
3028        );
3029        assert_eq!(
3030            media_extension(Path::new("/c/t.m4a.part")).as_deref(),
3031            Some("m4a")
3032        );
3033        assert_eq!(
3034            media_extension(Path::new("/c/t.mp3")).as_deref(),
3035            Some("mp3")
3036        );
3037    }
3038
3039    use super::*;
3040    use state::PlaylistItem;
3041    use std::path::PathBuf;
3042    use std::sync::atomic::{AtomicU64, Ordering};
3043
3044    fn make_item(title: &str) -> PlaylistItem {
3045        PlaylistItem {
3046            uid: None,
3047            playlist_entry_id: None,
3048            id: QueueItemId::new(),
3049            db_id: None,
3050            path: PathBuf::from(format!("/music/{title}.flac")),
3051            title: title.to_string(),
3052            artist: String::new(),
3053            album_artist: String::new(),
3054            album: String::new(),
3055            year: None,
3056            codec: None,
3057            track_number: None,
3058            disc: None,
3059            duration_ms: None,
3060            state: ItemState::Ready,
3061            played: false,
3062        }
3063    }
3064
3065    pub(super) fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
3066        let (items, _) = player.shared_state.snapshot_playlist();
3067        items.iter().map(|i| i.id).collect()
3068    }
3069
3070    fn playlist_titles(player: &Player) -> Vec<String> {
3071        let (items, _) = player.shared_state.snapshot_playlist();
3072        items.iter().map(|i| i.title.clone()).collect()
3073    }
3074
3075    fn pending_item(title: &str) -> PlaylistItem {
3076        PlaylistItem {
3077            playlist_entry_id: None,
3078            state: ItemState::Pending,
3079            ..make_item(title)
3080        }
3081    }
3082
3083    /// A session playing `id` on `engine`, with no decode thread behind it.
3084    fn test_session(id: QueueItemId, engine: Box<dyn AudioEngineHandle>) -> Session {
3085        Session {
3086            track: TrackInfo {
3087                id,
3088                path: PathBuf::from("/music/t.flac"),
3089                codec: String::new(),
3090                sample_rate: 44_100,
3091                bit_depth: None,
3092                bitrate_kbps: None,
3093                channels: 2,
3094                duration_ms: 1_000,
3095            },
3096            run: Run::Playing,
3097            lookahead: Default::default(),
3098            output: Output::Local(Local {
3099                engine,
3100                decode_handle: buffer::DecodeHandle::new_for_test(Default::default()),
3101                stream: None,
3102                _rate_watch: None,
3103                dsp: None,
3104            }),
3105        }
3106    }
3107
3108    /// Stand in for an engine that is playing `id`. The test items have no
3109    /// files behind them, so `start_playback` can never get far enough to leave
3110    /// this state on its own.
3111    fn pretend_playing(player: &mut Player, id: QueueItemId) {
3112        assert!(
3113            player.shared_state.get_item(id).is_some(),
3114            "item is in the queue"
3115        );
3116        player.stop_engine();
3117        player.transport = Transport::Loaded(test_session(
3118            id,
3119            Box::new(NullEngine {
3120                starts: Default::default(),
3121                running: Default::default(),
3122                lead_in: Default::default(),
3123            }),
3124        ));
3125        player.shared_state.set_cursor(Some(id));
3126        player.publish();
3127    }
3128
3129    fn playing_id(player: &Player) -> Option<QueueItemId> {
3130        player.shared_state.track_info().map(|t| t.id)
3131    }
3132
3133    /// Build `n` ready items, add them, and return their IDs.
3134    fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
3135        let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
3136        let ids = items.iter().map(|i| i.id).collect();
3137        player.process_command(PlayerCommand::AddToPlaylist(items));
3138        ids
3139    }
3140
3141    // --- cursor transitions ---
3142
3143    /// Feed the player a track's worth of playback ticks, as the 50ms poll would.
3144    fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
3145        let mut at = from_ms;
3146        if let Some(f) = player.in_flight.as_mut() {
3147            f.advance(at); // the position the needle landed on
3148        }
3149        while at < to_ms {
3150            at = (at + 50).min(to_ms);
3151            if let Some(f) = player.in_flight.as_mut() {
3152                f.advance(at);
3153            }
3154        }
3155    }
3156
3157    fn start(player: &mut Player, track_id: i64) -> QueueItemId {
3158        let id = QueueItemId::new();
3159        player.on_track_changed(id, 0);
3160        // The item is not in a playlist here, so there is no db_id to find.
3161        player
3162            .in_flight
3163            .as_mut()
3164            .unwrap()
3165            .track_id_for_test(track_id);
3166        id
3167    }
3168
3169    #[test]
3170    fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
3171        let mut player = Player::new();
3172        start(&mut player, 11);
3173        listen(&mut player, 0, 200_000);
3174
3175        let b = QueueItemId::new();
3176        player.on_track_changed(b, 0);
3177        let f = player
3178            .in_flight
3179            .as_ref()
3180            .expect("the next track is counting");
3181        assert_eq!(f.item, b);
3182        assert_eq!(f.listened_ms(), 0, "and starts from nothing");
3183    }
3184
3185    #[test]
3186    fn a_track_skipped_seconds_in_is_still_history() {
3187        let mut player = Player::new();
3188        start(&mut player, 7);
3189        listen(&mut player, 0, 2_000);
3190
3191        let event = player
3192            .finish_play()
3193            .expect("putting something on is a thing you did, however briefly");
3194        assert!(matches!(
3195            event,
3196            history::PlayEvent::Finished {
3197                track_id: 7,
3198                listened_ms: 2_000
3199            }
3200        ));
3201    }
3202
3203    #[test]
3204    fn a_track_is_closed_out_once() {
3205        let mut player = Player::new();
3206        start(&mut player, 7);
3207        listen(&mut player, 0, 200_000);
3208
3209        assert!(player.finish_play().is_some());
3210        assert!(player.finish_play().is_none());
3211    }
3212
3213    #[test]
3214    fn seeking_around_a_track_does_not_enter_it_twice() {
3215        let mut player = Player::new();
3216        let id = start(&mut player, 7);
3217        listen(&mut player, 0, 120_000);
3218
3219        // A seek restarts playback of the same item.
3220        player.on_track_changed(id, 30_000);
3221        assert_eq!(
3222            player.in_flight.as_ref().unwrap().listened_ms(),
3223            120_000,
3224            "the seek kept the count rather than restarting it"
3225        );
3226        listen(&mut player, 30_000, 40_000);
3227
3228        let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
3229            panic!("still one play");
3230        };
3231        assert_eq!(listened_ms, 130_000);
3232        assert!(player.finish_play().is_none());
3233    }
3234
3235    #[test]
3236    fn a_track_that_is_not_in_the_library_is_not_recorded() {
3237        let mut player = Player::new();
3238        let id = QueueItemId::new();
3239        player.on_track_changed(id, 0);
3240        listen(&mut player, 0, 200_000);
3241        assert!(player.finish_play().is_none());
3242    }
3243
3244    #[test]
3245    fn stopping_closes_out_what_was_heard() {
3246        let mut player = Player::new();
3247        start(&mut player, 7);
3248        listen(&mut player, 0, 150_000);
3249
3250        player.stop_playback_and_clear_state();
3251        assert!(player.in_flight.is_none(), "the stop consumed it");
3252    }
3253
3254    #[test]
3255    fn resume_with_nothing_loaded_plays_the_cursor() {
3256        let dir = tempfile::tempdir().unwrap();
3257        let path = dir.path().join("t.wav");
3258        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3259
3260        let mut player = Player::new();
3261        player.backend = Box::new(StuckBackend {
3262            rate: 8_000.0,
3263            asked: Default::default(),
3264            starts: Default::default(),
3265        });
3266        let item = PlaylistItem {
3267            db_id: Some(5),
3268            path,
3269            ..make_item("t")
3270        };
3271        let id = item.id;
3272        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3273        player.shared_state.set_cursor(Some(id));
3274        assert!(player.session().is_none());
3275
3276        player.process_command(PlayerCommand::Resume);
3277        assert!(player.session().is_some(), "the cursor's track started");
3278        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3279        player.process_command(PlayerCommand::Stop);
3280    }
3281
3282    #[test]
3283    fn the_audio_session_is_released_only_after_output_has_been_idle_a_while() {
3284        let mut player = Player::new();
3285        let now = std::time::Instant::now();
3286        assert!(
3287            !player.session_release_due(false, true, now),
3288            "the grace starts"
3289        );
3290        assert!(!player.session_release_due(false, true, now + SESSION_GRACE / 2));
3291        assert!(
3292            player.session_release_due(false, true, now + SESSION_GRACE),
3293            "released once it runs out"
3294        );
3295        assert_eq!(player.session_release_at, None);
3296
3297        assert!(!player.session_release_due(false, true, now));
3298        assert!(
3299            !player.session_release_due(true, true, now + SESSION_GRACE / 2),
3300            "playing again inside the grace keeps the session"
3301        );
3302        assert_eq!(player.session_release_at, None);
3303        assert!(
3304            !player.session_release_due(false, false, now),
3305            "nothing held"
3306        );
3307        assert_eq!(player.session_release_at, None);
3308    }
3309
3310    /// An output the system can stop underneath the player, as iOS does when
3311    /// an interruption begins. Muted from a fade out until a fade in, as the
3312    /// real fade is.
3313    #[derive(Clone, Default)]
3314    struct Interruptible {
3315        running: Arc<std::sync::atomic::AtomicBool>,
3316        muted: Arc<std::sync::atomic::AtomicBool>,
3317    }
3318    impl AudioEngineHandle for Interruptible {
3319        fn start(&self) -> Result<(), BackendError> {
3320            self.running.store(true, Ordering::Relaxed);
3321            Ok(())
3322        }
3323        fn stop(&self) -> Result<(), BackendError> {
3324            self.running.store(false, Ordering::Relaxed);
3325            Ok(())
3326        }
3327        fn is_running(&self) -> bool {
3328            self.running.load(Ordering::Relaxed)
3329        }
3330        fn fade_out(&self) {
3331            self.muted.store(true, Ordering::Relaxed);
3332        }
3333        fn fade_in(&self) -> Result<(), BackendError> {
3334            self.muted.store(false, Ordering::Relaxed);
3335            self.start()
3336        }
3337        fn is_silent(&self) -> bool {
3338            false
3339        }
3340        fn is_faded_out(&self) -> bool {
3341            self.muted.load(Ordering::Relaxed)
3342        }
3343    }
3344
3345    #[test]
3346    fn pausing_output_the_system_stopped_leaves_nothing_waiting_and_resumes_audibly() {
3347        let engine = Interruptible::default();
3348        engine.running.store(true, Ordering::Relaxed);
3349        let mut player = Player::new();
3350        player.transport =
3351            Transport::Loaded(test_session(QueueItemId::new(), Box::new(engine.clone())));
3352
3353        // The interruption stops the unit, then the app pauses.
3354        engine.running.store(false, Ordering::Relaxed);
3355        player.pause_with(Fade::Short);
3356        player.update_playback_state();
3357        assert!(!engine.muted.load(Ordering::Relaxed), "no fade begun");
3358        assert_eq!(player.next_event(), None, "nothing to wake for");
3359        assert!(!player.wants_output(), "so the audio session can go");
3360
3361        player.resume();
3362        assert!(engine.running.load(Ordering::Relaxed));
3363        assert!(!engine.muted.load(Ordering::Relaxed), "heard on resuming");
3364
3365        // Paused with a fade the interruption then cuts short.
3366        player.pause_with(Fade::Short);
3367        assert!(engine.muted.load(Ordering::Relaxed), "fading");
3368        engine.running.store(false, Ordering::Relaxed);
3369        player.update_playback_state();
3370        assert_eq!(player.next_event(), None);
3371        player.resume();
3372        assert!(!engine.muted.load(Ordering::Relaxed), "faded back in");
3373    }
3374
3375    #[test]
3376    fn waiting_to_play_a_track_keeps_the_audio_session() {
3377        let mut player = Player::new();
3378        let item = make_item("t");
3379        player.transport = Transport::Waiting(Waiting {
3380            id: item.id,
3381            position_ms: 0,
3382            start: Run::Playing,
3383        });
3384        assert!(player.wants_output(), "play was asked for");
3385        let playing = player.wants_output();
3386        let now = std::time::Instant::now();
3387        for later in [now, now + SESSION_GRACE * 2] {
3388            assert!(!player.session_release_due(playing, true, later));
3389        }
3390        assert_eq!(player.session_release_at, None);
3391    }
3392
3393    #[test]
3394    fn an_item_unlinked_from_its_row_plays_from_its_own_file() {
3395        let dir = tempfile::tempdir().unwrap();
3396        let path = dir.path().join("t.wav");
3397        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
3398
3399        let mut player = Player::new();
3400        player.backend = Box::new(StuckBackend {
3401            rate: 8_000.0,
3402            asked: Default::default(),
3403            starts: Default::default(),
3404        });
3405        let on_disk = PlaylistItem {
3406            path,
3407            db_id: Some(7),
3408            uid: Some("0190a000-0000-7000-8000-000000000001".into()),
3409            ..make_item("t")
3410        };
3411        let arriving = PlaylistItem {
3412            path: dir.path().join("not yet.flac"),
3413            db_id: Some(8),
3414            uid: Some("0190a000-0000-7000-8000-000000000002".into()),
3415            state: ItemState::Pending,
3416            ..make_item("u")
3417        };
3418        let (kept, stranded) = (on_disk.id, arriving.id);
3419        player.process_command(PlayerCommand::AddToPlaylist(vec![on_disk, arriving]));
3420        // Their tracks forgotten: the rows they named are gone.
3421        player
3422            .shared_state
3423            .relink(&[(kept, None), (stranded, None)]);
3424        assert_eq!(player.shared_state.item_db_id(kept), None);
3425        assert!(
3426            matches!(
3427                player.shared_state.item_state(stranded),
3428                Some(ItemState::Failed(_))
3429            ),
3430            "nothing would ever fetch it: failed, not waited on"
3431        );
3432
3433        player.process_command(PlayerCommand::Play(kept));
3434        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3435
3436        // Its track back: waiting for its download again.
3437        player.shared_state.relink(&[(stranded, Some(9))]);
3438        assert_eq!(
3439            player.shared_state.item_state(stranded),
3440            Some(ItemState::Pending)
3441        );
3442    }
3443
3444    #[test]
3445    fn a_player_with_nothing_coming_has_nothing_to_wake_for() {
3446        let dir = tempfile::tempdir().unwrap();
3447        let path = dir.path().join("t.wav");
3448        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3449
3450        let mut player = Player::new();
3451        player.backend = Box::new(StuckBackend {
3452            rate: 8_000.0,
3453            asked: Default::default(),
3454            starts: Default::default(),
3455        });
3456        let item = PlaylistItem {
3457            path,
3458            ..make_item("t")
3459        };
3460        let id = item.id;
3461        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3462        assert_eq!(player.next_wake(), None, "stopped");
3463
3464        player.process_command(PlayerCommand::Play(id));
3465        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3466        // Once, to see that output began to move: clients count from it.
3467        let check = player.next_wake().expect("a look at the output starting");
3468        assert!(check <= std::time::Instant::now() + OUTPUT_START_CHECK);
3469        player
3470            .timeline
3471            .samples_played_counter()
3472            .store(800, std::sync::atomic::Ordering::Relaxed);
3473        player.output_check = Some((std::time::Instant::now(), 0));
3474        player.update_playback_state();
3475        assert_eq!(
3476            player.next_wake(),
3477            None,
3478            "playing, with no track queued after it"
3479        );
3480
3481        if let Transport::Loaded(session) = &mut player.transport {
3482            session.engine().unwrap().stop().unwrap();
3483            session.run = Run::Paused;
3484        }
3485        assert_eq!(player.next_wake(), None, "paused");
3486        player.process_command(PlayerCommand::Stop);
3487    }
3488
3489    #[test]
3490    fn a_track_cued_paused_never_starts_the_output() {
3491        let dir = tempfile::tempdir().unwrap();
3492        let path = dir.path().join("t.wav");
3493        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3494
3495        let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3496        let mut player = Player::new();
3497        player.backend = Box::new(StuckBackend {
3498            rate: 8_000.0,
3499            asked: Default::default(),
3500            starts: starts.clone(),
3501        });
3502        let item = PlaylistItem {
3503            path,
3504            ..make_item("t")
3505        };
3506        let id = item.id;
3507        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3508
3509        player.process_command(PlayerCommand::Cue {
3510            id,
3511            position_ms: 2_000,
3512            play: false,
3513        });
3514        assert!(player.session().is_some(), "loaded");
3515        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3516        // The decoder says where the seek landed when it queues the track,
3517        // and the playhead reads that from then on: the start of the packet
3518        // holding 2s, which can be a little before it.
3519        loop {
3520            match player
3521                .commands
3522                .rx
3523                .recv_timeout(std::time::Duration::from_secs(5))
3524            {
3525                Ok(PlayerCommand::TrackQueued) => break,
3526                Ok(_) => {}
3527                Err(e) => panic!("the decoder never queued the track: {e}"),
3528            }
3529        }
3530        let at = player.shared_state.position_ms();
3531        assert!((1_750..=2_000).contains(&at), "cued at {at}ms");
3532        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3533
3534        // Seeking while paused reopens the track, and stays quiet too.
3535        player.process_command(PlayerCommand::Seek(4_000));
3536        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3537        assert_eq!(starts.load(Ordering::Relaxed), 0);
3538
3539        player.process_command(PlayerCommand::Resume);
3540        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3541        assert_eq!(starts.load(Ordering::Relaxed), 1);
3542        player.process_command(PlayerCommand::Stop);
3543    }
3544
3545    /// Load `t.wav` as an item whose download has not landed, under a player
3546    /// on the fake output.
3547    fn downloading_wav(dir: &Path) -> (Player, QueueItemId, Arc<std::sync::atomic::AtomicUsize>) {
3548        let path = dir.join("t.wav");
3549        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3550        let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3551        let mut player = Player::new();
3552        player.backend = Box::new(StuckBackend {
3553            rate: 8_000.0,
3554            asked: Default::default(),
3555            starts: starts.clone(),
3556        });
3557        let item = PlaylistItem {
3558            path,
3559            state: ItemState::Pending,
3560            ..make_item("t")
3561        };
3562        let id = item.id;
3563        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3564        (player, id, starts)
3565    }
3566
3567    /// The download lands, as the downloader says so: the item first, then
3568    /// the player.
3569    fn land(player: &mut Player, id: QueueItemId) {
3570        player.shared_state.update_item_state(id, ItemState::Ready);
3571        player.process_command(PlayerCommand::TrackReady(id));
3572    }
3573
3574    #[test]
3575    fn track_ready_never_resurrects_an_item_that_failed() {
3576        let mut player = Player::new();
3577        let item = PlaylistItem {
3578            state: ItemState::Failed("gone".into()),
3579            ..make_item("t")
3580        };
3581        let id = item.id;
3582        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3583
3584        player.process_command(PlayerCommand::TrackReady(id));
3585
3586        assert!(matches!(
3587            player.shared_state.get_item(id).map(|i| i.state),
3588            Some(ItemState::Failed(_))
3589        ));
3590    }
3591
3592    fn await_queued(player: &Player) {
3593        loop {
3594            match player
3595                .commands
3596                .rx
3597                .recv_timeout(std::time::Duration::from_secs(5))
3598            {
3599                Ok(PlayerCommand::TrackQueued) => return,
3600                Ok(_) => {}
3601                Err(e) => panic!("the decoder never queued the track: {e}"),
3602            }
3603        }
3604    }
3605
3606    #[test]
3607    fn a_cue_on_a_downloading_track_opens_at_its_position_once_it_lands() {
3608        let dir = tempfile::tempdir().unwrap();
3609        let (mut player, id, starts) = downloading_wav(dir.path());
3610
3611        player.process_command(PlayerCommand::Cue {
3612            id,
3613            position_ms: 6_000,
3614            play: true,
3615        });
3616        assert!(player.session().is_none(), "nothing opened early");
3617        assert_eq!(player.shared_state.playback_state(), PlaybackState::Stopped);
3618        assert_eq!(starts.load(Ordering::Relaxed), 0);
3619
3620        land(&mut player, id);
3621        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3622        assert_eq!(player.playback_starts, 1, "opened once, at the position");
3623        assert_eq!(starts.load(Ordering::Relaxed), 1);
3624        await_queued(&player);
3625        let at = player.shared_state.position_ms();
3626        assert!((5_750..=6_000).contains(&at), "opened at {at}ms");
3627        player.process_command(PlayerCommand::Stop);
3628    }
3629
3630    #[test]
3631    fn a_paused_cue_on_a_downloading_track_lands_paused() {
3632        let dir = tempfile::tempdir().unwrap();
3633        let (mut player, id, starts) = downloading_wav(dir.path());
3634
3635        player.process_command(PlayerCommand::Cue {
3636            id,
3637            position_ms: 3_000,
3638            play: false,
3639        });
3640        land(&mut player, id);
3641        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3642        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3643        await_queued(&player);
3644        let at = player.shared_state.position_ms();
3645        assert!((2_750..=3_000).contains(&at), "cued at {at}ms");
3646        player.process_command(PlayerCommand::Stop);
3647    }
3648
3649    #[test]
3650    fn playing_something_else_forgets_a_waiting_cue() {
3651        let dir = tempfile::tempdir().unwrap();
3652        let (mut player, id, _) = downloading_wav(dir.path());
3653        let other = seed(&mut player, 1)[0];
3654
3655        player.process_command(PlayerCommand::Cue {
3656            id,
3657            position_ms: 6_000,
3658            play: true,
3659        });
3660        player.process_command(PlayerCommand::Play(other));
3661        player.process_command(PlayerCommand::Play(id));
3662        assert!(player.waiting().is_some_and(|w| w.position_ms == 0));
3663        player.process_command(PlayerCommand::Stop);
3664    }
3665
3666    #[test]
3667    fn a_paused_track_whose_download_lands_stays_paused_where_it_was() {
3668        let dir = tempfile::tempdir().unwrap();
3669        let (mut player, id, starts) = downloading_wav(dir.path());
3670        // Loaded paused mid-track, as a stream is when its download lands.
3671        player.shared_state.update_item_state(id, ItemState::Ready);
3672        player.process_command(PlayerCommand::Cue {
3673            id,
3674            position_ms: 3_000,
3675            play: false,
3676        });
3677        await_queued(&player);
3678
3679        player.process_command(PlayerCommand::TrackReady(id));
3680        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3681        assert_eq!(player.playback_starts, 1, "not reopened");
3682        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3683        player.process_command(PlayerCommand::Stop);
3684    }
3685
3686    #[test]
3687    fn pausing_a_track_on_its_way_opens_it_paused() {
3688        let dir = tempfile::tempdir().unwrap();
3689        let (mut player, id, starts) = downloading_wav(dir.path());
3690
3691        player.process_command(PlayerCommand::Play(id));
3692        assert!(player.shared_state.is_waiting());
3693        assert!(player.shared_state.wants_to_play(), "a toggle pauses it");
3694        assert!(!player.shared_state.is_idle(), "adding tracks leaves it be");
3695        player.process_command(PlayerCommand::Pause);
3696        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3697        assert!(!player.shared_state.wants_to_play());
3698
3699        player.shared_state.update_item_state(id, ItemState::Ready);
3700        player.process_command(PlayerCommand::TrackReady(id));
3701        assert!(player.session().is_some(), "loaded");
3702        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3703        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3704        player.process_command(PlayerCommand::Stop);
3705    }
3706
3707    #[test]
3708    fn a_cue_paused_and_resumed_on_its_way_keeps_its_position() {
3709        let dir = tempfile::tempdir().unwrap();
3710        let (mut player, id, starts) = downloading_wav(dir.path());
3711
3712        player.process_command(PlayerCommand::Cue {
3713            id,
3714            position_ms: 6_000,
3715            play: true,
3716        });
3717        player.process_command(PlayerCommand::Pause);
3718        player.process_command(PlayerCommand::Resume);
3719        assert!(player.session().is_none(), "still on its way");
3720        assert_eq!(player.shared_state.playback_state(), PlaybackState::Stopped);
3721
3722        player.shared_state.update_item_state(id, ItemState::Ready);
3723        player.process_command(PlayerCommand::TrackReady(id));
3724        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3725        assert_eq!(starts.load(Ordering::Relaxed), 1);
3726        await_queued(&player);
3727        let at = player.shared_state.position_ms();
3728        assert!((5_750..=6_000).contains(&at), "opened at {at}ms");
3729        player.process_command(PlayerCommand::Stop);
3730    }
3731
3732    #[test]
3733    fn a_restored_cursor_does_not_start_when_its_download_lands() {
3734        let dir = tempfile::tempdir().unwrap();
3735        let (mut player, id, starts) = downloading_wav(dir.path());
3736        player.shared_state.set_cursor(Some(id));
3737
3738        player.shared_state.update_item_state(id, ItemState::Ready);
3739        player.process_command(PlayerCommand::TrackReady(id));
3740        assert!(player.session().is_none());
3741        assert_eq!(starts.load(Ordering::Relaxed), 0);
3742    }
3743
3744    #[test]
3745    fn replacing_the_queue_keeps_a_transfer_both_queues_want() {
3746        // The download queue syncs from its own thread on each playlist
3747        // change. Replaced as a clear then an add, the playlist was empty in
3748        // between, and a sync there let go of every waiter: the transfer for a
3749        // track in both queues was abandoned and started over. The replace
3750        // must be one change, and that change must still want the track.
3751        let pending = |title: &str| PlaylistItem {
3752            db_id: Some(7),
3753            state: ItemState::Pending,
3754            ..make_item(title)
3755        };
3756        let mut player = Player::new();
3757        let old = pending("old");
3758        let old_id = old.id;
3759        player.process_command(PlayerCommand::AddToPlaylist(vec![old]));
3760        let store = player.shared_state.downloads().clone();
3761        store.claim(7, Some(old_id));
3762
3763        let before = player.shared_state.pending_version();
3764        player.process_command(PlayerCommand::ReplacePlaylist {
3765            items: vec![pending("again")],
3766            start: 0,
3767            position_ms: 0,
3768            play: false,
3769            mode: QueueMode::Keep,
3770        });
3771        assert_eq!(
3772            player.shared_state.pending_version(),
3773            before + 1,
3774            "one change, so no reader can see the playlist between two"
3775        );
3776
3777        // A sync against the one state it published.
3778        store.resync(&player.shared_state.pending_downloads());
3779        assert!(!store.abandoned(7));
3780    }
3781
3782    #[test]
3783    fn a_hand_off_opens_paused_at_its_position_in_one_command() {
3784        let dir = tempfile::tempdir().unwrap();
3785        let path = dir.path().join("t.wav");
3786        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3787        let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3788        let mut player = Player::new();
3789        player.backend = Box::new(StuckBackend {
3790            rate: 8_000.0,
3791            asked: Default::default(),
3792            starts: starts.clone(),
3793        });
3794        seed(&mut player, 2);
3795        let item = PlaylistItem {
3796            path,
3797            ..make_item("t")
3798        };
3799        let id = item.id;
3800
3801        player.process_command(PlayerCommand::ReplacePlaylist {
3802            items: vec![make_item("before"), item],
3803            start: 1,
3804            position_ms: 4_000,
3805            play: false,
3806            mode: QueueMode::Keep,
3807        });
3808        assert_eq!(
3809            playlist_titles(&player),
3810            vec!["before", "t"],
3811            "replaced, not added to"
3812        );
3813        assert_eq!(player.shared_state.cursor(), Some(id));
3814        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3815        assert_eq!(player.playback_starts, 1, "opened once, at the position");
3816        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3817        await_queued(&player);
3818        let at = player.shared_state.position_ms();
3819        assert!((3_750..=4_000).contains(&at), "opened at {at}ms");
3820
3821        player.process_command(PlayerCommand::Undo);
3822        assert_eq!(playlist_titles(&player), vec!["t0", "t1"], "one undo step");
3823        player.process_command(PlayerCommand::Stop);
3824    }
3825
3826    #[test]
3827    fn a_decode_end_from_a_replaced_session_is_ignored() {
3828        let dir = tempfile::tempdir().unwrap();
3829        let mut player = Player::new();
3830        player.backend = Box::new(StuckBackend {
3831            rate: 8_000.0,
3832            asked: Default::default(),
3833            starts: Default::default(),
3834        });
3835        let items: Vec<_> = ["a", "b", "c"]
3836            .iter()
3837            .map(|name| {
3838                let path = dir.path().join(format!("{name}.wav"));
3839                crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3840                PlaylistItem {
3841                    path,
3842                    ..make_item(name)
3843                }
3844            })
3845            .collect();
3846        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
3847        player.process_command(PlayerCommand::AddToPlaylist(items));
3848
3849        player.process_command(PlayerCommand::Play(ids[0]));
3850        let stale = player.session;
3851        player.process_command(PlayerCommand::Play(ids[1]));
3852        player.process_command(PlayerCommand::DecodeFinished(stale));
3853
3854        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
3855        assert_eq!(player.playback_starts, 2);
3856        player.process_command(PlayerCommand::Stop);
3857    }
3858
3859    /// Short tracks of one format under an output that never plays: the
3860    /// decoder queues them all behind the first.
3861    fn queued_wavs(dir: &Path, names: &[&str]) -> (Player, Vec<QueueItemId>) {
3862        let (player, ids) = wavs_playing(dir, names, 1.0);
3863        for _ in &ids {
3864            await_queued(&player);
3865        }
3866        (player, ids)
3867    }
3868
3869    /// `names` as WAVs of `seconds` each, the first playing. Long enough ones
3870    /// fill the ring before the decoder reaches the end of the queue.
3871    fn wavs_playing(dir: &Path, names: &[&str], seconds: f32) -> (Player, Vec<QueueItemId>) {
3872        wavs_in(dir, names, seconds, Repeat::Off)
3873    }
3874
3875    /// `wavs_playing`, under `repeat` from the start. Each has a library id,
3876    /// its index plus one, so history records it.
3877    fn wavs_in(
3878        dir: &Path,
3879        names: &[&str],
3880        seconds: f32,
3881        repeat: Repeat,
3882    ) -> (Player, Vec<QueueItemId>) {
3883        let mut player = Player::new();
3884        player.backend = Box::new(StuckBackend {
3885            rate: 8_000.0,
3886            asked: Default::default(),
3887            starts: Default::default(),
3888        });
3889        let items: Vec<_> = names
3890            .iter()
3891            .zip(1..)
3892            .map(|(name, track)| {
3893                let path = dir.join(format!("{name}.wav"));
3894                crate::test_utils::generate_wav(&path, 8_000, 1, seconds, 16);
3895                PlaylistItem {
3896                    path,
3897                    db_id: Some(track),
3898                    ..make_item(name)
3899                }
3900            })
3901            .collect();
3902        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
3903        player.process_command(PlayerCommand::AddToPlaylist(items));
3904        player.process_command(PlayerCommand::SetRepeat(repeat));
3905        player.process_command(PlayerCommand::Play(ids[0]));
3906        (player, ids)
3907    }
3908
3909    /// What the decoder has queued after the playhead, once it is at least `n`.
3910    fn queued_at_least(player: &Player, n: usize) -> Vec<QueueItemId> {
3911        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3912        loop {
3913            let queued = player.timeline.queued_after_playhead();
3914            if queued.len() >= n {
3915                return queued;
3916            }
3917            assert!(
3918                std::time::Instant::now() < deadline,
3919                "the decoder queued {queued:?}, never {n}"
3920            );
3921            std::thread::yield_now();
3922        }
3923    }
3924
3925    // --- play modes ---
3926
3927    #[test]
3928    fn repeating_the_queue_runs_the_last_track_into_the_first() {
3929        let dir = tempfile::tempdir().unwrap();
3930        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::Queue);
3931
3932        let queued = queued_at_least(&player, 3);
3933        assert_eq!(
3934            queued[..3],
3935            [ids[1], ids[0], ids[1]],
3936            "gapless, round again"
3937        );
3938        let steps = player.session().unwrap().lookahead.lock().clone();
3939        assert!(!steps[0].wrapped);
3940        let wrap = steps.iter().find(|s| s.after == ids[1]).unwrap();
3941        assert_eq!(wrap.next, Some(ids[0]));
3942        assert!(wrap.wrapped);
3943        assert_eq!(player.playback_starts, 1);
3944        player.process_command(PlayerCommand::Stop);
3945    }
3946
3947    #[test]
3948    fn turning_repeat_off_takes_back_a_wrap_already_queued() {
3949        let dir = tempfile::tempdir().unwrap();
3950        let (mut player, ids) = wavs_in(dir.path(), &["a"], 1.0, Repeat::Queue);
3951        assert_eq!(queued_at_least(&player, 1)[0], ids[0]);
3952
3953        player.process_command(PlayerCommand::SetRepeat(Repeat::Off));
3954        assert_eq!(player.playback_starts, 2, "restarted at the playhead");
3955        wait_for_lookahead(&player);
3956        assert!(player.timeline.queued_after_playhead().is_empty());
3957        player.process_command(PlayerCommand::Stop);
3958    }
3959
3960    #[test]
3961    fn repeating_one_track_queues_it_again_and_each_pass_is_a_play() {
3962        let dir = tempfile::tempdir().unwrap();
3963        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::One);
3964        let (recorder, events) = history::PlayRecorder::capture();
3965        player.history = Some(recorder);
3966        // The first pass as a play would have it, now there is somewhere to
3967        // record it.
3968        player.in_flight = None;
3969        player.on_track_changed(ids[0], 0);
3970
3971        assert_eq!(queued_at_least(&player, 2)[..2], [ids[0], ids[0]]);
3972        // Into the second pass: one second of 8 kHz mono and a little more.
3973        player
3974            .timeline
3975            .samples_played
3976            .store(8_400, Ordering::Relaxed);
3977        player.update_playback_state();
3978
3979        let flight = player.in_flight.as_ref().unwrap();
3980        assert_eq!((flight.item, flight.boundary), (ids[0], 1));
3981        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
3982        let events: Vec<_> = events.try_iter().collect();
3983        let started = events
3984            .iter()
3985            .filter(|e| matches!(e, PlayEvent::Started { track_id: 1, .. }))
3986            .count();
3987        assert_eq!(started, 2, "two plays: {events:?}");
3988        assert!(
3989            events.contains(&PlayEvent::Finished {
3990                track_id: 1,
3991                listened_ms: 1_000
3992            }),
3993            "the first pass banked whole: {events:?}"
3994        );
3995        player.process_command(PlayerCommand::Stop);
3996    }
3997
3998    #[test]
3999    fn next_moves_on_from_a_track_repeating() {
4000        let dir = tempfile::tempdir().unwrap();
4001        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::One);
4002
4003        player.process_command(PlayerCommand::NextTrack);
4004        assert_eq!(playing_id(&player), Some(ids[1]));
4005        player.process_command(PlayerCommand::NextTrack);
4006        assert_eq!(
4007            playing_id(&player),
4008            Some(ids[0]),
4009            "round from the last, as repeating does"
4010        );
4011        player.process_command(PlayerCommand::Stop);
4012    }
4013
4014    #[test]
4015    fn a_repeat_of_nothing_heard_stops_rather_than_going_round() {
4016        let dir = tempfile::tempdir().unwrap();
4017        let (mut player, _) = wavs_in(dir.path(), &["a"], 1.0, Repeat::One);
4018
4019        // Nothing plays through the stuck output, so the session that ends
4020        // has nothing heard of it: a file that decodes to nothing.
4021        player.process_command(PlayerCommand::DecodeFinished(player.session));
4022        assert!(matches!(player.transport, Transport::Idle));
4023    }
4024
4025    // --- sleep timer ---
4026
4027    fn session_run(player: &Player) -> Option<Run> {
4028        player.session().map(|s| s.run)
4029    }
4030
4031    #[test]
4032    fn a_sleep_timer_fades_out_at_its_time_and_keeps_the_queue() {
4033        let dir = tempfile::tempdir().unwrap();
4034        let (mut player, ids) = wavs_playing(dir.path(), &["a", "b"], 1.0);
4035
4036        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
4037            minutes: 30,
4038        })));
4039        let Some(Sleep::At { unix_ms }) = player.shared_state.sleep() else {
4040            panic!("published as a time");
4041        };
4042        let now = std::time::SystemTime::now()
4043            .duration_since(std::time::UNIX_EPOCH)
4044            .unwrap()
4045            .as_millis() as u64;
4046        assert!(unix_ms.abs_diff(now + 30 * 60_000) < 5_000);
4047        let at = player.sleep.and_then(|s| s.at).unwrap();
4048        assert!(player.next_wake().is_some_and(|w| w <= at), "woken for it");
4049
4050        // Its time comes, the fade having run down to silence.
4051        player.sleep.as_mut().unwrap().at = Some(std::time::Instant::now());
4052        player.update_playback_state();
4053        assert_eq!(session_run(&player), Some(Run::Paused));
4054        assert!(
4055            !player.session().unwrap().engine().unwrap().is_running(),
4056            "stopped at silence: the fade did the fading"
4057        );
4058        assert!(
4059            player.sleep_fade.is_none(),
4060            "full level for what plays next"
4061        );
4062        assert_eq!(player.shared_state.sleep(), None, "spent");
4063        assert_eq!(playlist_ids(&player), ids, "the queue as it was");
4064        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4065        player.process_command(PlayerCommand::Stop);
4066    }
4067
4068    fn fading_gain(player: &Player) -> Option<f32> {
4069        match player.sleep_fade {
4070            Some(sleep::SleepFade::Falling { gain, .. }) => Some(gain),
4071            _ => None,
4072        }
4073    }
4074
4075    fn db(gain: f32) -> f32 {
4076        20.0 * gain.log10()
4077    }
4078
4079    /// A 15-minute timer fades for 90 s: nothing before the deadline less
4080    /// that, then down evenly in decibels to silence at the deadline.
4081    #[test]
4082    fn a_sleep_timer_fades_over_the_run_up_to_its_deadline() {
4083        let dir = tempfile::tempdir().unwrap();
4084        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
4085        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
4086            minutes: 15,
4087        })));
4088        let now = std::time::Instant::now();
4089        let secs = std::time::Duration::from_secs;
4090
4091        player.sleep.as_mut().unwrap().at = Some(now + secs(91));
4092        player.update_playback_state();
4093        assert_eq!(fading_gain(&player), None, "not yet: 91 s out");
4094        assert!(!player.shared_state.sleep_fading());
4095        let wake = player.next_wake().unwrap();
4096        assert!(
4097            wake <= now + secs(2),
4098            "woken for the fade's start, a second away"
4099        );
4100
4101        player.sleep.as_mut().unwrap().at = Some(now + secs(45));
4102        player.update_playback_state();
4103        let half = fading_gain(&player).expect("half way through the fade");
4104        assert!((db(half) + 30.0).abs() < 1.0, "{} dB", db(half));
4105        assert!(player.shared_state.sleep_fading(), "published as fading");
4106        assert!(player.next_wake().unwrap() <= std::time::Instant::now() + secs(1));
4107
4108        player.sleep.as_mut().unwrap().at = Some(now + std::time::Duration::from_millis(900));
4109        player.update_playback_state();
4110        let late = fading_gain(&player).unwrap();
4111        assert!(
4112            db(late) < -59.0,
4113            "all but silent at the end: {} dB",
4114            db(late)
4115        );
4116        player.process_command(PlayerCommand::Stop);
4117    }
4118
4119    /// Cancelled half way down, the level comes back up over a second.
4120    #[test]
4121    fn cancelling_a_sleep_timer_mid_fade_brings_the_level_back() {
4122        let dir = tempfile::tempdir().unwrap();
4123        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
4124        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
4125            minutes: 15,
4126        })));
4127        player.sleep.as_mut().unwrap().at =
4128            Some(std::time::Instant::now() + std::time::Duration::from_secs(45));
4129        player.update_playback_state();
4130        assert!(fading_gain(&player).is_some());
4131
4132        player.process_command(PlayerCommand::SetSleepTimer(None));
4133        let Some(sleep::SleepFade::Restoring { from, .. }) = player.sleep_fade else {
4134            panic!("coming back up: {:?}", player.sleep_fade);
4135        };
4136        assert!((db(from) + 30.0).abs() < 1.0, "from where it had got to");
4137        assert!(!player.shared_state.sleep_fading());
4138        // A second on.
4139        if let Some(sleep::SleepFade::Restoring { since, .. }) = player.sleep_fade.as_mut() {
4140            *since -= std::time::Duration::from_secs(1);
4141        }
4142        player.update_playback_state();
4143        assert!(player.sleep_fade.is_none(), "at full level, and left alone");
4144        assert_eq!(session_run(&player), Some(Run::Playing));
4145        player.process_command(PlayerCommand::Stop);
4146    }
4147
4148    /// A pause by hand during the fade is someone awake: the timer is off,
4149    /// and playing again is at full level.
4150    #[test]
4151    fn pausing_during_the_fade_cancels_the_timer() {
4152        let dir = tempfile::tempdir().unwrap();
4153        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
4154        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
4155            minutes: 15,
4156        })));
4157        player.sleep.as_mut().unwrap().at =
4158            Some(std::time::Instant::now() + std::time::Duration::from_secs(45));
4159        player.update_playback_state();
4160        assert!(fading_gain(&player).is_some());
4161
4162        player.process_command(PlayerCommand::Pause);
4163        assert_eq!(player.shared_state.sleep(), None, "cancelled");
4164        assert!(player.sleep_fade.is_none());
4165        assert!(player.sleep_snap_on_resume);
4166        player.process_command(PlayerCommand::Resume);
4167        assert!(!player.sleep_snap_on_resume, "full level taken on resuming");
4168        player.process_command(PlayerCommand::Stop);
4169    }
4170
4171    /// The end of a track fades over its last minute, or all of it if it is
4172    /// shorter, as these one-second tracks are; the end of a record only on
4173    /// its last track.
4174    #[test]
4175    fn the_end_of_a_track_or_record_fades_into_the_boundary() {
4176        let dir = tempfile::tempdir().unwrap();
4177        let (mut player, _) = queued_wavs(dir.path(), &["a", "b"]);
4178        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
4179        player.update_playback_state();
4180        assert!(
4181            fading_gain(&player).is_some(),
4182            "a one-second track fades whole"
4183        );
4184
4185        // Both tracks are on one record (none named), so the first is not
4186        // its end.
4187        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfRecord)));
4188        if let Some(sleep::SleepFade::Restoring { since, .. }) = player.sleep_fade.as_mut() {
4189            *since -= std::time::Duration::from_secs(1);
4190        }
4191        player.update_playback_state();
4192        assert_eq!(fading_gain(&player), None, "the record goes on");
4193        player.process_command(PlayerCommand::Stop);
4194    }
4195
4196    #[test]
4197    fn a_sleep_timer_going_off_while_paused_only_ends() {
4198        let dir = tempfile::tempdir().unwrap();
4199        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
4200        player.process_command(PlayerCommand::Pause);
4201        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
4202            minutes: 1,
4203        })));
4204        player.sleep.as_mut().unwrap().at = Some(std::time::Instant::now());
4205        player.update_playback_state();
4206        assert_eq!(player.shared_state.sleep(), None);
4207        player.process_command(PlayerCommand::Resume);
4208        assert_eq!(
4209            session_run(&player),
4210            Some(Run::Playing),
4211            "nothing left to go off"
4212        );
4213        player.process_command(PlayerCommand::Stop);
4214    }
4215
4216    #[test]
4217    fn cancelling_a_sleep_timer_leaves_playback_alone() {
4218        let dir = tempfile::tempdir().unwrap();
4219        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
4220        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
4221            minutes: 1,
4222        })));
4223        player.process_command(PlayerCommand::SetSleepTimer(None));
4224        assert_eq!(player.shared_state.sleep(), None);
4225        assert!(player.sleep.is_none());
4226        player.update_playback_state();
4227        assert_eq!(session_run(&player), Some(Run::Playing));
4228        player.process_command(PlayerCommand::Stop);
4229    }
4230
4231    /// Gapless: the next track is already playing when the playhead crosses,
4232    /// and it is stopped there, not part way through the one before.
4233    #[test]
4234    fn a_sleep_timer_for_the_end_of_the_track_stops_at_the_boundary() {
4235        let dir = tempfile::tempdir().unwrap();
4236        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b"]);
4237        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
4238        assert_eq!(player.shared_state.sleep(), Some(Sleep::EndOfTrack));
4239
4240        // Half way through the first: playing on.
4241        player
4242            .timeline
4243            .samples_played
4244            .store(4_000, Ordering::Relaxed);
4245        player.update_playback_state();
4246        assert_eq!(session_run(&player), Some(Run::Playing));
4247        assert_eq!(player.shared_state.sleep(), Some(Sleep::EndOfTrack));
4248
4249        // Into the second.
4250        player
4251            .timeline
4252            .samples_played
4253            .store(8_400, Ordering::Relaxed);
4254        player.update_playback_state();
4255        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4256        assert_eq!(session_run(&player), Some(Run::Paused));
4257        assert!(
4258            !player.session().unwrap().engine().unwrap().is_running(),
4259            "stopped at once, nothing of the next track faded through"
4260        );
4261        assert_eq!(player.shared_state.sleep(), None);
4262        assert_eq!(playlist_ids(&player), ids);
4263        player.process_command(PlayerCommand::Stop);
4264    }
4265
4266    /// A track repeating runs into itself, and that is the end of the track
4267    /// too.
4268    #[test]
4269    fn a_sleep_timer_for_the_end_of_the_track_stops_a_track_repeating() {
4270        let dir = tempfile::tempdir().unwrap();
4271        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::One);
4272        assert_eq!(queued_at_least(&player, 1)[0], ids[0]);
4273        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
4274        player
4275            .timeline
4276            .samples_played
4277            .store(8_400, Ordering::Relaxed);
4278        player.update_playback_state();
4279        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4280        assert_eq!(session_run(&player), Some(Run::Paused));
4281        assert_eq!(player.shared_state.sleep(), None);
4282        player.process_command(PlayerCommand::Stop);
4283    }
4284
4285    /// A track that cannot follow gaplessly ends the decode instead, and the
4286    /// next opens paused.
4287    #[test]
4288    fn a_sleep_timer_for_the_end_of_the_track_opens_the_next_paused() {
4289        let dir = tempfile::tempdir().unwrap();
4290        let (mut player, ids) = wavs_playing(dir.path(), &["a", "b"], 1.0);
4291        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
4292        player.process_command(PlayerCommand::DecodeFinished(player.session));
4293        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4294        assert_eq!(player.intent(), Some(Run::Paused));
4295        assert_eq!(player.shared_state.sleep(), None);
4296        player.process_command(PlayerCommand::Stop);
4297    }
4298
4299    #[test]
4300    fn a_sleep_timer_for_the_end_of_the_record_plays_the_record_out() {
4301        let dir = tempfile::tempdir().unwrap();
4302        let mut player = Player::new();
4303        player.backend = Box::new(StuckBackend {
4304            rate: 8_000.0,
4305            asked: Default::default(),
4306            starts: Default::default(),
4307        });
4308        let items: Vec<_> = [("a", "Low"), ("b", "Low"), ("c", "Heroes")]
4309            .iter()
4310            .map(|(name, album)| {
4311                let path = dir.path().join(format!("{name}.wav"));
4312                crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4313                PlaylistItem {
4314                    path,
4315                    album: album.to_string(),
4316                    album_artist: "David Bowie".into(),
4317                    ..make_item(name)
4318                }
4319            })
4320            .collect();
4321        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
4322        player.process_command(PlayerCommand::AddToPlaylist(items));
4323        player.process_command(PlayerCommand::Play(ids[0]));
4324        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfRecord)));
4325
4326        player.process_command(PlayerCommand::DecodeFinished(player.session));
4327        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4328        assert_eq!(player.intent(), Some(Run::Playing), "the same record");
4329        assert_eq!(player.shared_state.sleep(), Some(Sleep::EndOfRecord));
4330
4331        player.process_command(PlayerCommand::DecodeFinished(player.session));
4332        assert_eq!(player.shared_state.cursor(), Some(ids[2]));
4333        assert_eq!(player.intent(), Some(Run::Paused), "the next record waits");
4334        assert_eq!(player.shared_state.sleep(), None);
4335        player.process_command(PlayerCommand::Stop);
4336    }
4337
4338    #[test]
4339    fn shuffle_is_not_an_undo_step() {
4340        let mut player = Player::new();
4341        let ids = seed(&mut player, 10);
4342        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[4]));
4343        player.process_command(PlayerCommand::SetShuffle(true));
4344        assert_eq!(playlist_ids(&player).len(), 9, "the queue is as it was");
4345
4346        player.process_command(PlayerCommand::Undo);
4347        assert_eq!(playlist_ids(&player), ids, "the removal undone");
4348        assert!(player.shared_state.play_mode().shuffle, "shuffle left on");
4349    }
4350
4351    #[test]
4352    fn a_queue_replaced_while_shuffled_starts_where_asked_and_shuffles_the_rest() {
4353        let mut player = Player::new();
4354        seed(&mut player, 3);
4355        player.process_command(PlayerCommand::SetShuffle(true));
4356
4357        let items: Vec<_> = (0..20).map(|i| make_item(&format!("n{i}"))).collect();
4358        let given: Vec<_> = items.iter().map(|i| i.id).collect();
4359        player.process_command(PlayerCommand::ReplacePlaylist {
4360            items,
4361            start: 5,
4362            position_ms: 0,
4363            play: false,
4364            mode: QueueMode::Keep,
4365        });
4366        assert_eq!(playlist_ids(&player), given, "in the order given");
4367        assert_eq!(player.shared_state.cursor(), Some(given[5]));
4368
4369        let mut heard = vec![given[5]];
4370        while let Some(id) = player.shared_state.advance_cursor_loadable() {
4371            heard.push(id);
4372        }
4373        assert_ne!(heard, given);
4374        heard.sort_by_key(|id| given.iter().position(|i| i == id));
4375        assert_eq!(heard, given, "each once");
4376    }
4377
4378    #[test]
4379    fn the_pass_of_a_track_is_the_step_that_opened_it() {
4380        let step = |boundary, pass| state::Lookahead {
4381            after: QueueItemId::new(),
4382            next: None,
4383            chosen: None,
4384            wrapped: false,
4385            boundary,
4386            after_pass: 0,
4387            pass,
4388        };
4389        // The first pick at boundary 1 failed to open; the decoder stepped
4390        // again for the same boundary, into the next pass.
4391        let steps = [step(0, 0), step(1, 0), step(1, 1), step(2, 1)];
4392        assert_eq!(step_pass(&steps, 1), Some(1));
4393        assert_eq!(step_pass(&steps, 3), None);
4394    }
4395
4396    /// Every row of a shuffled queue played, the last under the cursor, as a
4397    /// session saved at the end of a pass is.
4398    fn played_out(player: &mut Player) -> Vec<QueueItemId> {
4399        let ids = seed(player, 4);
4400        player.process_command(PlayerCommand::SetShuffle(true));
4401        let state = &player.shared_state;
4402        state.advance_cursor_loadable();
4403        loop {
4404            state.mark_played(state.cursor().unwrap());
4405            if state.advance_cursor_loadable().is_none() {
4406                break;
4407            }
4408        }
4409        ids
4410    }
4411
4412    fn played_count(player: &Player) -> usize {
4413        player.shared_state.derive_visible_queue().finished_count
4414    }
4415
4416    #[test]
4417    fn a_session_restored_at_the_end_of_a_pass_does_not_start_another() {
4418        let mut player = Player::new();
4419        let ids = played_out(&mut player);
4420        let last = player.shared_state.cursor().unwrap();
4421
4422        player.process_command(PlayerCommand::Cue {
4423            id: last,
4424            position_ms: 0,
4425            play: false,
4426        });
4427        assert_eq!(played_count(&player), ids.len() - 1, "the marks kept");
4428        assert_eq!(
4429            player.shared_state.advance_cursor_loadable(),
4430            None,
4431            "repeat off: the queue ends"
4432        );
4433    }
4434
4435    #[test]
4436    fn a_track_repeating_keeps_the_marks() {
4437        let mut player = Player::new();
4438        let ids = played_out(&mut player);
4439        player.process_command(PlayerCommand::SetRepeat(Repeat::One));
4440        let last = player.shared_state.cursor().unwrap();
4441        // Repeat one without gapless: the track is opened again by a cue.
4442        player.cue(last, 0, Run::Paused);
4443        assert_eq!(played_count(&player), ids.len() - 1);
4444        player.process_command(PlayerCommand::Stop);
4445    }
4446
4447    #[test]
4448    fn picking_a_row_of_a_played_out_queue_starts_a_new_pass() {
4449        let mut player = Player::new();
4450        let ids = played_out(&mut player);
4451        player.process_command(PlayerCommand::Play(ids[1]));
4452        assert_eq!(played_count(&player), 0);
4453        assert_eq!(player.shared_state.upcoming().len(), ids.len() - 1);
4454        player.process_command(PlayerCommand::Stop);
4455    }
4456
4457    fn replace(player: &mut Player, n: usize, mode: QueueMode) -> Vec<QueueItemId> {
4458        let items: Vec<_> = (0..n).map(|i| make_item(&format!("r{i}"))).collect();
4459        let ids = items.iter().map(|i| i.id).collect();
4460        player.process_command(PlayerCommand::ReplacePlaylist {
4461            items,
4462            start: 0,
4463            position_ms: 0,
4464            play: false,
4465            mode,
4466        });
4467        ids
4468    }
4469
4470    fn shuffled_and_repeating() -> Player {
4471        let mut player = Player::new();
4472        seed(&mut player, 3);
4473        player.process_command(PlayerCommand::SetShuffle(true));
4474        player.process_command(PlayerCommand::SetRepeat(Repeat::Queue));
4475        player
4476    }
4477
4478    #[test]
4479    fn playing_from_a_play_button_turns_shuffle_and_repeat_off() {
4480        let mut player = shuffled_and_repeating();
4481        let ids = replace(&mut player, 8, QueueMode::InOrder);
4482        assert_eq!(player.shared_state.play_mode(), PlayMode::default());
4483        assert!(!player.shared_state.is_shuffled());
4484        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4485    }
4486
4487    #[test]
4488    fn shuffling_from_a_play_button_turns_shuffle_on_and_repeat_off() {
4489        let mut player = Player::new();
4490        player.process_command(PlayerCommand::SetRepeat(Repeat::One));
4491        let ids = replace(&mut player, 12, QueueMode::Shuffled);
4492        assert_eq!(
4493            player.shared_state.play_mode(),
4494            PlayMode {
4495                shuffle: true,
4496                repeat: Repeat::Off
4497            }
4498        );
4499        assert_eq!(playlist_ids(&player), ids, "the queue in the order given");
4500
4501        let mut heard = vec![player.shared_state.cursor().unwrap()];
4502        while let Some(id) = player.shared_state.advance_cursor_loadable() {
4503            heard.push(id);
4504        }
4505        assert_ne!(heard, ids, "started and played in a shuffled order");
4506        heard.sort_by_key(|id| ids.iter().position(|i| i == id));
4507        assert_eq!(heard, ids);
4508    }
4509
4510    #[test]
4511    fn a_queue_restored_or_handed_over_keeps_the_modes() {
4512        let mut player = shuffled_and_repeating();
4513        let mode = player.shared_state.play_mode();
4514        replace(&mut player, 5, QueueMode::Keep);
4515        assert_eq!(player.shared_state.play_mode(), mode);
4516        assert!(player.shared_state.is_shuffled());
4517    }
4518
4519    #[test]
4520    fn adding_to_the_queue_keeps_the_modes() {
4521        let mut player = shuffled_and_repeating();
4522        let mode = player.shared_state.play_mode();
4523        seed(&mut player, 4);
4524        let ids = playlist_ids(&player);
4525        player.process_command(PlayerCommand::InsertInPlaylist {
4526            items: vec![make_item("next")],
4527            after: ids[0],
4528        });
4529        assert_eq!(player.shared_state.play_mode(), mode);
4530    }
4531
4532    #[test]
4533    fn removing_the_playing_track_while_shuffled_moves_on_in_the_play_order() {
4534        let mut player = Player::new();
4535        seed(&mut player, 10);
4536        player.process_command(PlayerCommand::SetShuffle(true));
4537        let playing = player.shared_state.advance_cursor_loadable().unwrap();
4538        pretend_playing(&mut player, playing);
4539        let next = player.shared_state.lookahead_after(playing).unwrap().next;
4540
4541        player.process_command(PlayerCommand::RemoveFromPlaylist(playing));
4542        assert_eq!(player.shared_state.cursor(), next);
4543        player.process_command(PlayerCommand::Stop);
4544    }
4545
4546    #[test]
4547    fn shuffled_the_decoder_queues_the_play_order() {
4548        let dir = tempfile::tempdir().unwrap();
4549        let names = ["a", "b", "c", "d", "e", "f"];
4550        let (mut player, ids) = wavs_playing(dir.path(), &names, 1.0);
4551        queued_at_least(&player, names.len() - 1);
4552
4553        player.process_command(PlayerCommand::SetShuffle(true));
4554        let order = player.shared_state.upcoming();
4555        assert_eq!(order.len(), names.len() - 1);
4556        assert_eq!(queued_at_least(&player, order.len()), order);
4557        assert_eq!(playlist_ids(&player), ids, "the queue never moved");
4558        player.process_command(PlayerCommand::Stop);
4559    }
4560
4561    #[test]
4562    fn removing_the_last_track_playing_carries_on_from_the_top_when_repeating() {
4563        let mut player = Player::new();
4564        let ids = seed(&mut player, 3);
4565        player.process_command(PlayerCommand::SetRepeat(Repeat::Queue));
4566        player.shared_state.set_cursor(Some(ids[2]));
4567
4568        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
4569        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4570    }
4571
4572    #[test]
4573    fn removing_a_track_the_decoder_queued_takes_it_back() {
4574        let dir = tempfile::tempdir().unwrap();
4575        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b", "c"]);
4576        assert_eq!(player.timeline.queued_after_playhead(), ids[1..]);
4577
4578        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[1]));
4579        assert_eq!(player.playback_starts, 2, "restarted at the playhead");
4580        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4581        await_queued(&player);
4582        await_queued(&player);
4583        assert_eq!(player.timeline.queued_after_playhead(), vec![ids[2]]);
4584        player.process_command(PlayerCommand::Stop);
4585    }
4586
4587    #[test]
4588    fn a_track_inserted_to_play_next_is_not_skipped() {
4589        let dir = tempfile::tempdir().unwrap();
4590        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b"]);
4591        let path = dir.path().join("next.wav");
4592        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4593        let next = PlaylistItem {
4594            path,
4595            ..make_item("next")
4596        };
4597        let next_id = next.id;
4598
4599        player.process_command(PlayerCommand::InsertInPlaylist {
4600            items: vec![next],
4601            after: ids[0],
4602        });
4603        assert_eq!(player.playback_starts, 2);
4604        for _ in 0..3 {
4605            await_queued(&player);
4606        }
4607        assert_eq!(
4608            player.timeline.queued_after_playhead(),
4609            vec![next_id, ids[1]]
4610        );
4611        player.process_command(PlayerCommand::Stop);
4612    }
4613
4614    #[test]
4615    fn a_track_the_decoder_could_not_open_does_not_make_every_edit_restart() {
4616        let dir = tempfile::tempdir().unwrap();
4617        let mut player = Player::new();
4618        player.backend = Box::new(StuckBackend {
4619            rate: 8_000.0,
4620            asked: Default::default(),
4621            starts: Default::default(),
4622        });
4623        let wav = |name: &str| {
4624            let path = dir.path().join(format!("{name}.wav"));
4625            crate::test_utils::generate_wav(&path, 8_000, 1, 30.0, 16);
4626            PlaylistItem {
4627                path,
4628                ..make_item(name)
4629            }
4630        };
4631        // Ready, but with no file behind it: the decoder skips it.
4632        let items = vec![wav("a"), make_item("missing"), wav("c")];
4633        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
4634        player.process_command(PlayerCommand::AddToPlaylist(items));
4635        player.process_command(PlayerCommand::Play(ids[0]));
4636        await_queued(&player);
4637        await_queued(&player);
4638
4639        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("later")]));
4640        assert_eq!(
4641            player.playback_starts, 1,
4642            "nothing the decoder decided changed"
4643        );
4644        assert_eq!(player.timeline.queued_after_playhead(), vec![ids[2]]);
4645        player.process_command(PlayerCommand::Stop);
4646    }
4647
4648    #[test]
4649    fn a_track_added_after_the_decoder_reached_the_end_follows_gaplessly() {
4650        let dir = tempfile::tempdir().unwrap();
4651        let (mut player, ids) = queued_wavs(dir.path(), &["a"]);
4652        wait_for_lookahead(&player);
4653        let path = dir.path().join("b.wav");
4654        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4655        let b = PlaylistItem {
4656            path,
4657            ..make_item("b")
4658        };
4659        let b_id = b.id;
4660
4661        player.process_command(PlayerCommand::AddToPlaylist(vec![b]));
4662        assert_eq!(player.playback_starts, 2, "restarted to queue it");
4663        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4664        await_queued(&player);
4665        await_queued(&player);
4666        assert_eq!(player.timeline.queued_after_playhead(), vec![b_id]);
4667        player.process_command(PlayerCommand::Stop);
4668    }
4669
4670    #[test]
4671    fn playing_next_a_track_still_downloading_takes_back_the_lookahead() {
4672        let dir = tempfile::tempdir().unwrap();
4673        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b"]);
4674        let remote = pending_item("remote");
4675        let remote_id = remote.id;
4676
4677        player.process_command(PlayerCommand::InsertInPlaylist {
4678            items: vec![remote],
4679            after: ids[0],
4680        });
4681        assert_eq!(player.playback_starts, 2, "b is taken back out of the ring");
4682        await_queued(&player);
4683        wait_for_lookahead(&player);
4684        let session = player.session().unwrap();
4685        let step = session.lookahead.lock().last().cloned().unwrap();
4686        assert_eq!(step.next, Some(remote_id), "the decoder waits for it");
4687        assert!(player.timeline.queued_after_playhead().is_empty());
4688        player.process_command(PlayerCommand::Stop);
4689    }
4690
4691    #[test]
4692    fn gapless_playback_waits_for_a_track_still_downloading() {
4693        let dir = tempfile::tempdir().unwrap();
4694        let mut player = Player::new();
4695        player.backend = Box::new(StuckBackend {
4696            rate: 8_000.0,
4697            asked: Default::default(),
4698            starts: Default::default(),
4699        });
4700        let wav = |name: &str| {
4701            let path = dir.path().join(format!("{name}.wav"));
4702            crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4703            PlaylistItem {
4704                path,
4705                ..make_item(name)
4706            }
4707        };
4708        let items = vec![wav("a"), pending_item("arriving"), wav("c")];
4709        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
4710        player.process_command(PlayerCommand::AddToPlaylist(items));
4711        player.process_command(PlayerCommand::Play(ids[0]));
4712        await_queued(&player);
4713        wait_for_lookahead(&player);
4714
4715        assert!(
4716            player.timeline.queued_after_playhead().is_empty(),
4717            "c is not queued over the track before it"
4718        );
4719        // The session drains and ends; the advance parks on the track.
4720        player.process_command(PlayerCommand::DecodeFinished(player.session));
4721        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4722        assert!(player.waiting().is_some());
4723        player.process_command(PlayerCommand::Stop);
4724    }
4725
4726    /// Until the decoder has taken a step past the playing track.
4727    fn wait_for_lookahead(player: &Player) {
4728        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
4729        while player
4730            .session()
4731            .is_some_and(|s| s.lookahead.lock().is_empty())
4732        {
4733            assert!(
4734                std::time::Instant::now() < deadline,
4735                "the decoder never looked ahead"
4736            );
4737            std::thread::yield_now();
4738        }
4739    }
4740
4741    #[test]
4742    fn an_edit_after_what_the_decoder_queued_leaves_playback_alone() {
4743        let dir = tempfile::tempdir().unwrap();
4744        let (mut player, ids) = wavs_playing(dir.path(), &["a", "b"], 30.0);
4745        await_queued(&player);
4746        await_queued(&player);
4747        let path = dir.path().join("later.wav");
4748        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4749
4750        player.process_command(PlayerCommand::AddToPlaylist(vec![PlaylistItem {
4751            path,
4752            ..make_item("later")
4753        }]));
4754        assert_eq!(player.playback_starts, 1);
4755        assert_eq!(player.timeline.queued_after_playhead(), vec![ids[1]]);
4756        player.process_command(PlayerCommand::Stop);
4757    }
4758
4759    #[test]
4760    fn undoing_the_add_of_a_track_on_its_way_forgets_it() {
4761        let dir = tempfile::tempdir().unwrap();
4762        let (mut player, _, starts) = downloading_wav(dir.path());
4763        let path = dir.path().join("added.wav");
4764        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4765        let added = PlaylistItem {
4766            path,
4767            state: ItemState::Pending,
4768            ..make_item("added")
4769        };
4770        let added_id = added.id;
4771        player.process_command(PlayerCommand::AddToPlaylist(vec![added]));
4772        player.process_command(PlayerCommand::Play(added_id));
4773
4774        player.process_command(PlayerCommand::Undo);
4775        assert!(player.waiting().is_none());
4776        assert_eq!(player.shared_state.playback_state(), PlaybackState::Stopped);
4777
4778        // Its download lands anyway; nothing asked for it any more.
4779        player.process_command(PlayerCommand::Redo);
4780        player
4781            .shared_state
4782            .update_item_state(added_id, ItemState::Ready);
4783        player.process_command(PlayerCommand::TrackReady(added_id));
4784        assert!(player.session().is_none());
4785        assert_eq!(starts.load(Ordering::Relaxed), 0);
4786    }
4787
4788    /// xorshift64: enough randomness to drive the player, reproducible from
4789    /// its seed, and no dependency for it.
4790    pub(super) struct Rng(pub(super) u64);
4791
4792    impl Rng {
4793        pub(super) fn next(&mut self) -> u64 {
4794            self.0 ^= self.0 << 13;
4795            self.0 ^= self.0 >> 7;
4796            self.0 ^= self.0 << 17;
4797            self.0
4798        }
4799
4800        pub(super) fn below(&mut self, n: usize) -> usize {
4801            (self.next() % n.max(1) as u64) as usize
4802        }
4803
4804        pub(super) fn coin(&mut self) -> bool {
4805            self.next() & 1 == 1
4806        }
4807    }
4808
4809    /// Commands a listener can send that ask for sound. Anything else may only
4810    /// keep playing what was already playing or on its way to.
4811    pub(super) fn asks_to_play(cmd: &PlayerCommand) -> bool {
4812        cmd.asks_to_play()
4813    }
4814
4815    /// Check the invariants #679 sets out, as far as they can be seen from
4816    /// outside the audio thread.
4817    pub(super) fn check_invariants(
4818        player: &Player,
4819        wanted_before: bool,
4820        asked: bool,
4821    ) -> Result<(), String> {
4822        let state = &player.shared_state;
4823        let ids = playlist_ids(player);
4824        let listed = |id: QueueItemId| ids.contains(&id);
4825        let mut seen = std::collections::HashSet::new();
4826        if !ids.iter().all(|id| seen.insert(*id)) {
4827            return Err("an item is in the playlist twice".into());
4828        }
4829        let cursor = state.cursor();
4830        if cursor.is_some_and(|c| !listed(c)) {
4831            return Err("the cursor is on an item not in the playlist".into());
4832        }
4833        let info = state.track_info();
4834        match (&player.transport, &info) {
4835            (Transport::Loaded(_), None) => return Err("loaded, but no track published".into()),
4836            (Transport::Loaded(_), Some(info)) => {
4837                if !listed(info.id) {
4838                    return Err("playing an item not in the playlist".into());
4839                }
4840                if cursor != Some(info.id) {
4841                    return Err("the cursor is not on what is playing".into());
4842                }
4843                if !matches!(
4844                    state.playback_state(),
4845                    PlaybackState::Playing | PlaybackState::Paused
4846                ) {
4847                    return Err("loaded, but published as stopped".into());
4848                }
4849            }
4850            (_, Some(_)) => return Err("a track is published with nothing loaded".into()),
4851            (Transport::Waiting(w), None) => {
4852                if cursor != Some(w.id) || !listed(w.id) {
4853                    return Err("waiting on an item that is not the cursor's".into());
4854                }
4855                let expected = match w.start {
4856                    Run::Playing => PlaybackState::Stopped,
4857                    Run::Paused => PlaybackState::Paused,
4858                };
4859                if state.playback_state() != expected {
4860                    return Err("a wait published in the wrong state".into());
4861                }
4862            }
4863            (Transport::Idle, None) => {
4864                if state.playback_state() != PlaybackState::Stopped {
4865                    return Err("idle, but not published as stopped".into());
4866                }
4867            }
4868        }
4869        if state.is_waiting() != player.waiting().is_some() {
4870            return Err("the published wait disagrees with the player".into());
4871        }
4872        if player
4873            .timeline
4874            .queued_after_playhead()
4875            .into_iter()
4876            .any(|id| !listed(id))
4877        {
4878            return Err("the decoder has queued an item no longer in the playlist".into());
4879        }
4880        if !asked && !wanted_before && state.wants_to_play() {
4881            return Err("started without being asked".into());
4882        }
4883        if state.play_mode() != player.mode {
4884            return Err("the published mode disagrees with the player".into());
4885        }
4886        if state.is_shuffled() != player.mode.shuffle {
4887            return Err("a play order kept, or not, against the mode".into());
4888        }
4889        if player.mode.repeat == Repeat::Off
4890            && let Some(session) = player.session()
4891            && let Some(playhead) = player.timeline.playhead()
4892            && session.track.id == playhead.id
4893            && session
4894                .lookahead
4895                .lock()
4896                .iter()
4897                .filter(|step| step.boundary > playhead.boundary)
4898                .any(|step| step.wrapped || step.next == Some(step.after))
4899        {
4900            return Err("repeat is off, but the decoder has queued a wrap".into());
4901        }
4902        Ok(())
4903    }
4904
4905    #[test]
4906    fn random_use_keeps_the_player_honest() {
4907        let dir = tempfile::tempdir().unwrap();
4908        let path = dir.path().join("t.wav");
4909        crate::test_utils::generate_wav(&path, 8_000, 1, 0.5, 16);
4910
4911        for seed in 1..=40u64 {
4912            let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
4913            let mut player = Player::new();
4914            player.backend = Box::new(StuckBackend {
4915                rate: 8_000.0,
4916                asked: Default::default(),
4917                starts: Default::default(),
4918            });
4919            let fresh = |rng: &mut Rng| {
4920                let state = match rng.below(3) {
4921                    0 => ItemState::Pending,
4922                    _ => ItemState::Ready,
4923                };
4924                PlaylistItem {
4925                    path: path.clone(),
4926                    state,
4927                    ..make_item("t")
4928                }
4929            };
4930            let start: Vec<_> = (0..5).map(|_| fresh(&mut rng)).collect();
4931            player.process_command(PlayerCommand::AddToPlaylist(start));
4932
4933            let mut history = Vec::new();
4934            for step in 0..150 {
4935                let ids = playlist_ids(&player);
4936                let pick = |rng: &mut Rng| ids.get(rng.below(ids.len())).copied();
4937                let cmd = match rng.below(23) {
4938                    0 => pick(&mut rng).map(PlayerCommand::Play),
4939                    1 => pick(&mut rng).map(|id| PlayerCommand::Cue {
4940                        id,
4941                        position_ms: if rng.coin() { 0 } else { 200 },
4942                        play: rng.coin(),
4943                    }),
4944                    2 => Some(PlayerCommand::Pause),
4945                    3 => Some(PlayerCommand::Resume),
4946                    4 => Some(PlayerCommand::NextTrack),
4947                    5 => Some(PlayerCommand::PrevTrack),
4948                    6 => Some(PlayerCommand::Seek(100)),
4949                    7 => pick(&mut rng).map(PlayerCommand::RemoveFromPlaylist),
4950                    8 => Some(PlayerCommand::RemoveFromPlaylistBatch(
4951                        (0..2).filter_map(|_| pick(&mut rng)).collect(),
4952                    )),
4953                    9 => pick(&mut rng).zip(pick(&mut rng)).map(|(id, target)| {
4954                        PlayerCommand::MoveInPlaylist {
4955                            id,
4956                            target,
4957                            after: rng.coin(),
4958                        }
4959                    }),
4960                    10 => pick(&mut rng).map(|after| PlayerCommand::InsertInPlaylist {
4961                        items: vec![fresh(&mut rng)],
4962                        after,
4963                    }),
4964                    11 => Some(PlayerCommand::AddToPlaylist(vec![fresh(&mut rng)])),
4965                    12 => Some(PlayerCommand::Undo),
4966                    13 => Some(PlayerCommand::Redo),
4967                    14 | 15 => {
4968                        let (items, _) = player.shared_state.snapshot_playlist();
4969                        let pending: Vec<_> = items
4970                            .iter()
4971                            .filter(|i| matches!(i.state, ItemState::Pending))
4972                            .map(|i| i.id)
4973                            .collect();
4974                        pending.get(rng.below(pending.len())).map(|&id| {
4975                            if rng.below(4) == 0 {
4976                                player
4977                                    .shared_state
4978                                    .update_item_state(id, ItemState::Failed("gone".into()));
4979                                PlayerCommand::TrackFailed(id)
4980                            } else {
4981                                player.shared_state.update_item_state(id, ItemState::Ready);
4982                                PlayerCommand::TrackReady(id)
4983                            }
4984                        })
4985                    }
4986                    16 => Some(PlayerCommand::DecodeFinished(player.session)),
4987                    17 => Some(PlayerCommand::DecodeFinished(
4988                        player.session.wrapping_sub(1),
4989                    )),
4990                    18 => Some(PlayerCommand::ClearPlaylist),
4991                    20 => Some(PlayerCommand::SetShuffle(rng.coin())),
4992                    21 => Some(PlayerCommand::SetRepeat(
4993                        [Repeat::Off, Repeat::Queue, Repeat::One][rng.below(3)],
4994                    )),
4995                    _ => Some(PlayerCommand::ReplacePlaylist {
4996                        items: (0..3).map(|_| fresh(&mut rng)).collect(),
4997                        start: rng.below(4),
4998                        position_ms: if rng.coin() { 0 } else { 200 },
4999                        play: rng.coin(),
5000                        mode: [QueueMode::Keep, QueueMode::InOrder, QueueMode::Shuffled]
5001                            [rng.below(3)],
5002                    }),
5003                };
5004                let Some(cmd) = cmd else { continue };
5005                let label = format!("{cmd:?}");
5006                let asked = asks_to_play(&cmd);
5007                let wanted_before = player.shared_state.wants_to_play();
5008                player.process_command(cmd);
5009                // What the decode threads sent meanwhile, as the loop would
5010                // see it. A full channel would stall a decode thread on its
5011                // way out.
5012                while let Ok(sent) = player.commands.rx.try_recv() {
5013                    player.process_command(sent);
5014                }
5015                player.update_playback_state();
5016                history.push(label);
5017                if let Err(broken) = check_invariants(&player, wanted_before, asked) {
5018                    let tail = history[history.len().saturating_sub(8)..].join("\n  ");
5019                    panic!("seed {seed}, step {step}: {broken}\nlast commands:\n  {tail}");
5020                }
5021            }
5022            player.process_command(PlayerCommand::Stop);
5023        }
5024    }
5025
5026    struct QuickEngine {
5027        silent: Arc<std::sync::atomic::AtomicBool>,
5028        quick_outs: Arc<std::sync::atomic::AtomicUsize>,
5029        outs: Arc<std::sync::atomic::AtomicUsize>,
5030        period: std::time::Duration,
5031    }
5032    impl AudioEngineHandle for QuickEngine {
5033        fn start(&self) -> Result<(), BackendError> {
5034            Ok(())
5035        }
5036        fn stop(&self) -> Result<(), BackendError> {
5037            Ok(())
5038        }
5039        fn is_running(&self) -> bool {
5040            true
5041        }
5042        fn fade_out(&self) {
5043            self.outs.fetch_add(1, Ordering::Relaxed);
5044        }
5045        fn fade_out_quickly(&self) {
5046            self.quick_outs.fetch_add(1, Ordering::Relaxed);
5047        }
5048        fn fade_in(&self) -> Result<(), BackendError> {
5049            Ok(())
5050        }
5051        fn is_silent(&self) -> bool {
5052            self.silent.load(Ordering::Relaxed)
5053        }
5054        fn period(&self) -> std::time::Duration {
5055            self.period
5056        }
5057    }
5058
5059    /// A player playing through a `QuickEngine` with callbacks of `frames` at
5060    /// `rate`, and the engine's silence and fade counts.
5061    fn quick_player(
5062        frames: u32,
5063        rate: u32,
5064    ) -> (
5065        Player,
5066        Arc<std::sync::atomic::AtomicBool>,
5067        Arc<std::sync::atomic::AtomicUsize>,
5068        Arc<std::sync::atomic::AtomicUsize>,
5069    ) {
5070        let silent = Arc::new(std::sync::atomic::AtomicBool::new(false));
5071        let quick_outs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5072        let outs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5073        let mut player = Player::new();
5074        player.transport = Transport::Loaded(test_session(
5075            QueueItemId::new(),
5076            Box::new(QuickEngine {
5077                silent: silent.clone(),
5078                quick_outs: quick_outs.clone(),
5079                outs: outs.clone(),
5080                period: std::time::Duration::from_secs_f64(frames as f64 / rate as f64),
5081            }),
5082        ));
5083        (player, silent, quick_outs, outs)
5084    }
5085
5086    /// A DSP change while playing fades the output out quickly and restarts
5087    /// only once that is silent; changes made meanwhile join it, so a run of
5088    /// them is one dip and one restart.
5089    #[test]
5090    fn a_dsp_change_restarts_after_a_quick_fade_and_coalesces() {
5091        let (mut player, silent, quick_outs, outs) = quick_player(512, 44100);
5092
5093        player.restart_for_dsp();
5094        assert_eq!(quick_outs.load(Ordering::Relaxed), 1);
5095        assert_eq!(outs.load(Ordering::Relaxed), 0, "not the pause's fade");
5096
5097        // Another change mid-fade: the same dip.
5098        player.restart_for_dsp();
5099        assert_eq!(quick_outs.load(Ordering::Relaxed), 1);
5100        player.update_playback_state();
5101        assert!(player.dsp_restart.is_some(), "not restarted while audible");
5102
5103        silent.store(true, Ordering::Relaxed);
5104        player.update_playback_state();
5105        assert!(player.dsp_restart.is_none(), "restarted once silent");
5106        assert!(
5107            !player.quick_start,
5108            "the quick start is the restart's alone"
5109        );
5110    }
5111
5112    /// The fade can begin up to one callback after it is asked for, so the
5113    /// first wake allows a period for that; a callback later still is waited
5114    /// for one period more, well inside the limit, for the buffers a Mac and
5115    /// PipeWire use by default.
5116    #[test]
5117    fn a_dsp_change_wakes_a_buffer_period_after_its_fade() {
5118        use crate::audio::fade::QUICK_FADE;
5119        for (frames, rate) in [(512, 44100), (1024, 48000)] {
5120            let period = std::time::Duration::from_secs_f64(frames as f64 / rate as f64);
5121            let (mut player, silent, _, _) = quick_player(frames, rate);
5122            player.restart_for_dsp();
5123            let since = player.dsp_restart.expect("waiting on the fade");
5124
5125            let first = player.next_event().expect("a wake when the fade ends");
5126            assert_eq!(first, since + QUICK_FADE + period + BOUNDARY_SLACK);
5127            assert_eq!(dsp_wake(since, period, since), first);
5128
5129            // Woken then, still sounding: once more, a period on, inside the
5130            // limit; past that, the limit.
5131            player.update_playback_state();
5132            assert!(player.dsp_restart.is_some());
5133            let second = dsp_wake(since, period, first);
5134            assert_eq!(second, first + period);
5135            assert!(
5136                second < since + QUICK_FADE_LIMIT,
5137                "{frames} frames: inside the limit"
5138            );
5139            assert_eq!(dsp_wake(since, period, second), since + QUICK_FADE_LIMIT);
5140
5141            silent.store(true, Ordering::Relaxed);
5142            player.update_playback_state();
5143            assert!(
5144                player.dsp_restart.is_none(),
5145                "{frames} frames: restarted at silence"
5146            );
5147        }
5148    }
5149
5150    #[test]
5151    fn a_pause_reports_where_the_fade_went_silent() {
5152        use std::sync::atomic::AtomicBool;
5153
5154        struct FadingEngine {
5155            running: Arc<AtomicBool>,
5156            silent: Arc<AtomicBool>,
5157        }
5158        impl AudioEngineHandle for FadingEngine {
5159            fn start(&self) -> Result<(), BackendError> {
5160                self.running.store(true, Ordering::Relaxed);
5161                Ok(())
5162            }
5163            fn stop(&self) -> Result<(), BackendError> {
5164                self.running.store(false, Ordering::Relaxed);
5165                Ok(())
5166            }
5167            fn is_running(&self) -> bool {
5168                self.running.load(Ordering::Relaxed)
5169            }
5170            fn fade_out(&self) {}
5171            fn fade_in(&self) -> Result<(), BackendError> {
5172                Ok(())
5173            }
5174            fn is_silent(&self) -> bool {
5175                self.silent.load(Ordering::Relaxed)
5176            }
5177        }
5178
5179        let running = Arc::new(AtomicBool::new(true));
5180        let silent = Arc::new(AtomicBool::new(false));
5181        let mut player = Player::new();
5182        player.transport = Transport::Loaded(test_session(
5183            QueueItemId::new(),
5184            Box::new(FadingEngine {
5185                running: running.clone(),
5186                silent: silent.clone(),
5187            }),
5188        ));
5189        player
5190            .shared_state
5191            .set_playback_state(PlaybackState::Playing);
5192        player.shared_state.set_position_ms(5_000);
5193
5194        let (reply, answer) = crossbeam_channel::bounded(1);
5195        player.process_command(PlayerCommand::PauseAndReport(reply));
5196
5197        if crate::config::Config::cached().playback.fade_on_pause {
5198            assert!(answer.try_recv().is_err(), "not while the fade is audible");
5199            // The fade plays on, and the playhead with it.
5200            player.shared_state.set_position_ms(5_150);
5201            player.update_playback_state();
5202            assert!(answer.try_recv().is_err());
5203            silent.store(true, Ordering::Relaxed);
5204            player.update_playback_state();
5205            assert!(!running.load(Ordering::Relaxed));
5206            assert_eq!(answer.try_recv().unwrap(), 5_150);
5207        } else {
5208            assert_eq!(answer.try_recv().unwrap(), 5_000);
5209        }
5210    }
5211
5212    #[test]
5213    fn the_server_hears_each_turn_playback_takes() {
5214        use PlaybackReportState::{Paused, Playing, Stopped};
5215
5216        let dir = tempfile::tempdir().unwrap();
5217        let path = dir.path().join("t.wav");
5218        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
5219
5220        let mut player = Player::new();
5221        player.backend = Box::new(StuckBackend {
5222            rate: 8_000.0,
5223            asked: Default::default(),
5224            starts: Default::default(),
5225        });
5226        let (recorder, events) = PlayRecorder::capture();
5227        player.history = Some(recorder);
5228
5229        let item = PlaylistItem {
5230            db_id: Some(5),
5231            path,
5232            ..make_item("t")
5233        };
5234        let id = item.id;
5235        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
5236        player.process_command(PlayerCommand::Play(id));
5237        player.process_command(PlayerCommand::Pause);
5238        player.process_command(PlayerCommand::Seek(4_000));
5239        // Once the decoder has queued the track, the playhead reads the start
5240        // of the packet holding 4 s, which can be a little before it; until
5241        // then it reads the 4 s asked for. Waiting makes the read the same on
5242        // every run.
5243        await_queued(&player);
5244        player.process_command(PlayerCommand::Resume);
5245        player.process_command(PlayerCommand::Stop);
5246
5247        let events: Vec<_> = events.try_iter().collect();
5248        let near_seek = |position_ms: u64| (3_750..=4_000).contains(&position_ms);
5249        let reports: Vec<_> = events
5250            .iter()
5251            .filter_map(|e| match e {
5252                PlayEvent::Playback(r) => Some((r.track_id, r.state, r.position_ms)),
5253                _ => None,
5254            })
5255            .collect();
5256        assert_eq!(
5257            events.first(),
5258            Some(&PlayEvent::Started {
5259                track_id: 5,
5260                position_ms: 0
5261            })
5262        );
5263        assert_eq!(reports.len(), 4, "{events:?}");
5264        assert_eq!(reports[0], (5, Paused, 0));
5265        for (report, state) in reports[1..].iter().zip([Paused, Playing, Stopped]) {
5266            assert_eq!((report.0, report.1), (5, state), "{events:?}");
5267            assert!(near_seek(report.2), "{events:?}");
5268        }
5269        assert_eq!(
5270            events.last(),
5271            Some(&PlayEvent::Finished {
5272                track_id: 5,
5273                listened_ms: 0
5274            })
5275        );
5276    }
5277
5278    #[test]
5279    fn removing_the_playing_track_resumes_at_its_successor() {
5280        let mut player = Player::new();
5281        let ids = seed(&mut player, 5);
5282        pretend_playing(&mut player, ids[2]);
5283
5284        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
5285
5286        assert_eq!(
5287            player.shared_state.cursor(),
5288            Some(ids[3]),
5289            "playback must continue at the next track, not restart the queue"
5290        );
5291        assert_eq!(player.playback_starts, 1);
5292    }
5293
5294    #[test]
5295    fn removing_the_paused_track_moves_on_paused() {
5296        let mut player = Player::new();
5297        let ids = seed(&mut player, 3);
5298        pretend_playing(&mut player, ids[1]);
5299        if let Transport::Loaded(session) = &mut player.transport {
5300            session.run = Run::Paused;
5301        }
5302
5303        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[1]));
5304        assert_eq!(player.shared_state.cursor(), Some(ids[2]));
5305        assert!(!player.shared_state.wants_to_play(), "still paused");
5306    }
5307
5308    #[test]
5309    fn removing_the_cursor_with_nothing_loaded_starts_nothing() {
5310        let mut player = Player::new();
5311        let ids = seed(&mut player, 3);
5312        player.shared_state.set_cursor(Some(ids[1]));
5313
5314        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[1]));
5315        assert_eq!(player.shared_state.cursor(), Some(ids[2]));
5316        assert_eq!(player.playback_starts, 0);
5317    }
5318
5319    #[test]
5320    fn removing_the_first_playing_track_resumes_at_the_new_first() {
5321        let mut player = Player::new();
5322        let ids = seed(&mut player, 3);
5323        pretend_playing(&mut player, ids[0]);
5324
5325        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
5326
5327        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
5328    }
5329
5330    #[test]
5331    fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
5332        let mut player = Player::new();
5333        let playing = make_item("playing");
5334        let waiting = pending_item("waiting");
5335        let later = make_item("later");
5336        let (playing_id, waiting_id) = (playing.id, waiting.id);
5337        player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
5338        pretend_playing(&mut player, playing_id);
5339
5340        player.process_command(PlayerCommand::DecodeFinished(player.session));
5341
5342        assert_eq!(
5343            player.shared_state.cursor(),
5344            Some(waiting_id),
5345            "the cursor parks on the track being fetched"
5346        );
5347        assert_eq!(
5348            player.playback_starts, 0,
5349            "nothing to play until its bytes land"
5350        );
5351
5352        // The download completes. Because the cursor is parked here, the
5353        // TrackReady actually reaches the player and the queue resumes.
5354        player
5355            .shared_state
5356            .update_item_state(waiting_id, ItemState::Ready);
5357        player.process_command(PlayerCommand::TrackReady(waiting_id));
5358
5359        assert_eq!(player.playback_starts, 1);
5360        assert_eq!(player.shared_state.cursor(), Some(waiting_id));
5361    }
5362
5363    /// Play pressed on an album whose first track is still on the server and
5364    /// whose next two are cached: the first is waited for and played first,
5365    /// not passed over for the first that happens to be on disk.
5366    #[test]
5367    fn an_album_starts_at_its_first_track_however_much_is_cached() {
5368        let mut player = Player::new();
5369        let remote = pending_item("remote");
5370        let cached = [make_item("cached 2"), make_item("cached 3")];
5371        let first = remote.id;
5372        player.process_command(PlayerCommand::ReplacePlaylist {
5373            items: [vec![remote], cached.to_vec()].concat(),
5374            start: 0,
5375            position_ms: 0,
5376            play: true,
5377            mode: QueueMode::Keep,
5378        });
5379
5380        assert_eq!(player.shared_state.cursor(), Some(first));
5381        assert_eq!(player.playback_starts, 0, "nothing decodes before it lands");
5382
5383        player
5384            .shared_state
5385            .update_item_state(first, ItemState::Ready);
5386        player.process_command(PlayerCommand::TrackReady(first));
5387        assert_eq!(player.playback_starts, 1);
5388        assert_eq!(
5389            player.shared_state.cursor(),
5390            Some(first),
5391            "the first track decodes first"
5392        );
5393    }
5394
5395    /// A stream opened ahead of its download that gives out with nothing
5396    /// heard has not played: the track is waited for, not skipped.
5397    #[test]
5398    fn a_stream_that_ends_unheard_mid_download_waits_for_its_track() {
5399        let mut player = Player::new();
5400        let streaming = pending_item("streaming");
5401        let next = make_item("next");
5402        let id = streaming.id;
5403        player.process_command(PlayerCommand::AddToPlaylist(vec![streaming, next]));
5404        pretend_playing(&mut player, id);
5405
5406        player.process_command(PlayerCommand::DecodeFinished(player.session));
5407        assert_eq!(player.shared_state.cursor(), Some(id), "not passed over");
5408        assert_eq!(player.playback_starts, 0);
5409
5410        player.shared_state.update_item_state(id, ItemState::Ready);
5411        player.process_command(PlayerCommand::TrackReady(id));
5412        assert_eq!(player.playback_starts, 1);
5413        assert_eq!(player.shared_state.cursor(), Some(id));
5414    }
5415
5416    #[test]
5417    fn a_download_that_cannot_land_moves_the_cursor_on() {
5418        let mut player = Player::new();
5419        let waiting = pending_item("waiting");
5420        let later = make_item("later");
5421        let (waiting_id, later_id) = (waiting.id, later.id);
5422        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, later]));
5423
5424        player.process_command(PlayerCommand::Play(waiting_id));
5425        assert_eq!(player.playback_starts, 0, "nothing to play yet");
5426
5427        // The download gives up. Ready will never come.
5428        player
5429            .shared_state
5430            .update_item_state(waiting_id, ItemState::Failed("remote unavailable".into()));
5431        player.process_command(PlayerCommand::TrackFailed(waiting_id));
5432
5433        assert_eq!(
5434            player.shared_state.cursor(),
5435            Some(later_id),
5436            "the queue moves past a track that can never load"
5437        );
5438        assert_eq!(player.playback_starts, 1);
5439    }
5440
5441    #[test]
5442    fn a_queue_that_can_never_load_stops_rather_than_waiting() {
5443        let mut player = Player::new();
5444        let first = pending_item("first");
5445        let second = pending_item("second");
5446        let (first_id, second_id) = (first.id, second.id);
5447        player.process_command(PlayerCommand::AddToPlaylist(vec![first, second]));
5448
5449        player.process_command(PlayerCommand::Play(first_id));
5450        for id in [first_id, second_id] {
5451            player
5452                .shared_state
5453                .update_item_state(id, ItemState::Failed("remote unavailable".into()));
5454            player.process_command(PlayerCommand::TrackFailed(id));
5455        }
5456
5457        assert_eq!(player.playback_starts, 0);
5458        assert_eq!(
5459            player.shared_state.playback_state(),
5460            PlaybackState::Stopped,
5461            "a stop the UI can see, not an indefinite wait for TrackReady"
5462        );
5463    }
5464
5465    #[test]
5466    fn a_failure_elsewhere_in_the_queue_leaves_the_cursor_alone() {
5467        let mut player = Player::new();
5468        let waiting = pending_item("waiting");
5469        let other = pending_item("other");
5470        let (waiting_id, other_id) = (waiting.id, other.id);
5471        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, other]));
5472        player.process_command(PlayerCommand::Play(waiting_id));
5473
5474        player
5475            .shared_state
5476            .update_item_state(other_id, ItemState::Failed("remote unavailable".into()));
5477        player.process_command(PlayerCommand::TrackFailed(other_id));
5478
5479        assert_eq!(
5480            player.shared_state.cursor(),
5481            Some(waiting_id),
5482            "a track still downloading keeps the cursor"
5483        );
5484    }
5485
5486    #[test]
5487    fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
5488        let mut player = Player::new();
5489        let ids = seed(&mut player, 5);
5490        pretend_playing(&mut player, ids[2]);
5491
5492        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
5493            ids[1], ids[2], ids[3],
5494        ]));
5495
5496        assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
5497        assert_eq!(player.shared_state.cursor(), Some(ids[4]));
5498        assert_eq!(
5499            player.playback_starts, 1,
5500            "one resume for the whole selection, not one per deleted track"
5501        );
5502    }
5503
5504    #[test]
5505    fn batch_delete_below_the_cursor_leaves_playback_alone() {
5506        let mut player = Player::new();
5507        let ids = seed(&mut player, 4);
5508        player.shared_state.set_cursor(Some(ids[0]));
5509
5510        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
5511
5512        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
5513        assert_eq!(player.playback_starts, 0);
5514    }
5515
5516    #[test]
5517    fn undo_of_a_batch_delete_restores_the_original_order() {
5518        // The TUI collects a selection from a HashSet, so the IDs arrive in
5519        // arbitrary order — scrambled here so a snapshot that trusts that order
5520        // re-inserts C before B and lands it at the end of the playlist.
5521        let mut player = Player::new();
5522        let items = vec![
5523            make_item("A"),
5524            make_item("B"),
5525            make_item("C"),
5526            make_item("D"),
5527        ];
5528        let (b_id, c_id) = (items[1].id, items[2].id);
5529        player.process_command(PlayerCommand::AddToPlaylist(items));
5530
5531        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
5532        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
5533
5534        player.process_command(PlayerCommand::Undo);
5535        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
5536    }
5537
5538    // --- AddToPlaylist undo/redo ---
5539
5540    #[test]
5541    fn undo_add_removes_items() {
5542        let mut player = Player::new();
5543        let items = vec![make_item("A"), make_item("B")];
5544        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
5545
5546        player.process_command(PlayerCommand::AddToPlaylist(items));
5547        assert_eq!(playlist_ids(&player), ids);
5548        assert!(player.undo_stack().can_undo());
5549
5550        player.process_command(PlayerCommand::Undo);
5551        assert!(playlist_ids(&player).is_empty());
5552        assert!(player.undo_stack().can_redo());
5553    }
5554
5555    #[test]
5556    fn redo_add_restores_items() {
5557        let mut player = Player::new();
5558        let items = vec![make_item("A"), make_item("B")];
5559
5560        player.process_command(PlayerCommand::AddToPlaylist(items));
5561        player.process_command(PlayerCommand::Undo);
5562        assert!(playlist_ids(&player).is_empty());
5563
5564        player.process_command(PlayerCommand::Redo);
5565        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5566    }
5567
5568    // --- RemoveFromPlaylist undo/redo ---
5569
5570    #[test]
5571    fn undo_remove_restores_item_at_position() {
5572        let mut player = Player::new();
5573        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5574        let b_id = items[1].id;
5575
5576        player.process_command(PlayerCommand::AddToPlaylist(items));
5577        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
5578        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5579
5580        player.process_command(PlayerCommand::Undo);
5581        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5582    }
5583
5584    #[test]
5585    fn undo_remove_first_item() {
5586        let mut player = Player::new();
5587        let items = vec![make_item("A"), make_item("B")];
5588        let a_id = items[0].id;
5589
5590        player.process_command(PlayerCommand::AddToPlaylist(items));
5591        player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
5592        assert_eq!(playlist_titles(&player), vec!["B"]);
5593
5594        player.process_command(PlayerCommand::Undo);
5595        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5596    }
5597
5598    #[test]
5599    fn undo_batch_remove_restores_all() {
5600        let mut player = Player::new();
5601        let items = vec![
5602            make_item("A"),
5603            make_item("B"),
5604            make_item("C"),
5605            make_item("D"),
5606        ];
5607        let b_id = items[1].id;
5608        let c_id = items[2].id;
5609
5610        player.process_command(PlayerCommand::AddToPlaylist(items));
5611        let version_before = player.shared_state.playlist_version();
5612        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
5613        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
5614        // One bump for the whole batch. Bumping per item is what made clearing
5615        // a large queue crawl, and every bump wakes every client watching.
5616        assert_eq!(
5617            player.shared_state.playlist_version(),
5618            version_before + 1,
5619            "batch removal must bump the playlist version exactly once"
5620        );
5621
5622        // Single undo restores both
5623        player.process_command(PlayerCommand::Undo);
5624        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
5625    }
5626
5627    #[test]
5628    fn redo_batch_remove() {
5629        let mut player = Player::new();
5630        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5631        let a_id = items[0].id;
5632        let b_id = items[1].id;
5633
5634        player.process_command(PlayerCommand::AddToPlaylist(items));
5635        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
5636        player.process_command(PlayerCommand::Undo);
5637        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5638
5639        player.process_command(PlayerCommand::Redo);
5640        assert_eq!(playlist_titles(&player), vec!["C"]);
5641    }
5642
5643    #[test]
5644    fn redo_remove() {
5645        let mut player = Player::new();
5646        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5647        let b_id = items[1].id;
5648
5649        player.process_command(PlayerCommand::AddToPlaylist(items));
5650        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
5651        player.process_command(PlayerCommand::Undo);
5652        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5653
5654        player.process_command(PlayerCommand::Redo);
5655        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5656    }
5657
5658    // --- InsertInPlaylist undo/redo ---
5659
5660    #[test]
5661    fn undo_insert_removes_inserted_items() {
5662        let mut player = Player::new();
5663        let items = vec![make_item("A"), make_item("C")];
5664        let a_id = items[0].id;
5665
5666        player.process_command(PlayerCommand::AddToPlaylist(items));
5667
5668        let inserted = vec![make_item("B")];
5669        player.process_command(PlayerCommand::InsertInPlaylist {
5670            items: inserted,
5671            after: a_id,
5672        });
5673        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5674
5675        player.process_command(PlayerCommand::Undo);
5676        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5677    }
5678
5679    // --- MoveInPlaylist undo/redo ---
5680
5681    #[test]
5682    fn undo_move_restores_position() {
5683        let mut player = Player::new();
5684        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5685        let a_id = items[0].id;
5686        let c_id = items[2].id;
5687
5688        player.process_command(PlayerCommand::AddToPlaylist(items));
5689
5690        // Move A after C: [B, C, A]
5691        player.process_command(PlayerCommand::MoveInPlaylist {
5692            id: a_id,
5693            target: c_id,
5694            after: true,
5695        });
5696        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5697
5698        player.process_command(PlayerCommand::Undo);
5699        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5700    }
5701
5702    #[test]
5703    fn redo_move() {
5704        let mut player = Player::new();
5705        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5706        let a_id = items[0].id;
5707        let c_id = items[2].id;
5708
5709        player.process_command(PlayerCommand::AddToPlaylist(items));
5710        player.process_command(PlayerCommand::MoveInPlaylist {
5711            id: a_id,
5712            target: c_id,
5713            after: true,
5714        });
5715        player.process_command(PlayerCommand::Undo);
5716        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5717
5718        player.process_command(PlayerCommand::Redo);
5719        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5720    }
5721
5722    // --- MoveItemsInPlaylist (batch) undo/redo ---
5723
5724    #[test]
5725    fn undo_batch_move() {
5726        let mut player = Player::new();
5727        let items = vec![
5728            make_item("A"),
5729            make_item("B"),
5730            make_item("C"),
5731            make_item("D"),
5732        ];
5733        let a_id = items[0].id;
5734        let b_id = items[1].id;
5735        let d_id = items[3].id;
5736
5737        player.process_command(PlayerCommand::AddToPlaylist(items));
5738
5739        // Move A,B after D: [C, D, A, B]
5740        player.process_command(PlayerCommand::MoveItemsInPlaylist {
5741            ids: vec![a_id, b_id],
5742            target: d_id,
5743            after: true,
5744        });
5745        assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
5746
5747        player.process_command(PlayerCommand::Undo);
5748        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
5749    }
5750
5751    // --- ClearPlaylist undo/redo ---
5752
5753    #[test]
5754    fn undo_clear_restores_playlist() {
5755        let mut player = Player::new();
5756        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5757
5758        player.process_command(PlayerCommand::AddToPlaylist(items));
5759        player.process_command(PlayerCommand::ClearPlaylist);
5760        assert!(playlist_ids(&player).is_empty());
5761
5762        player.process_command(PlayerCommand::Undo);
5763        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5764    }
5765
5766    /// The bug: replacing the queue starts the new track, and undoing restored
5767    /// the old queue while leaving the engine on a track that queue no longer
5768    /// contains — a transport describing a row nobody can see, and a decode
5769    /// lookahead with nothing to follow.
5770    #[test]
5771    fn undoing_a_replace_does_not_leave_the_engine_on_an_orphaned_track() {
5772        let mut player = Player::new();
5773        let original = seed(&mut player, 3);
5774        player.shared_state.set_cursor(Some(original[0]));
5775        pretend_playing(&mut player, original[0]);
5776
5777        let replacement = vec![make_item("something else")];
5778        let orphan = replacement[0].id;
5779        player.process_command(PlayerCommand::ReplacePlaylist {
5780            items: replacement,
5781            start: 0,
5782            position_ms: 0,
5783            play: true,
5784            mode: QueueMode::Keep,
5785        });
5786        // What `play()` would have left behind if the file existed.
5787        pretend_playing(&mut player, orphan);
5788
5789        player.process_command(PlayerCommand::Undo);
5790
5791        assert_eq!(playlist_ids(&player), original, "the queue comes back");
5792        assert!(
5793            player.shared_state.get_item(orphan).is_none(),
5794            "and the replacement is gone from it"
5795        );
5796        assert!(
5797            playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
5798            "so nothing may still be playing out of it"
5799        );
5800    }
5801
5802    /// The same orphaning, reached by undoing an add rather than a replace.
5803    #[test]
5804    fn undoing_an_add_does_not_leave_the_engine_on_a_removed_track() {
5805        let mut player = Player::new();
5806        seed(&mut player, 2);
5807        let added = seed(&mut player, 1);
5808        pretend_playing(&mut player, added[0]);
5809
5810        player.process_command(PlayerCommand::Undo);
5811
5812        assert!(player.shared_state.get_item(added[0]).is_none());
5813        assert!(
5814            playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
5815            "the engine cannot be left on the item the undo removed"
5816        );
5817    }
5818
5819    /// An undo that leaves the playing item where it is must not restart it.
5820    #[test]
5821    fn undoing_a_move_leaves_playback_alone() {
5822        let mut player = Player::new();
5823        let ids = seed(&mut player, 3);
5824        player.shared_state.set_cursor(Some(ids[0]));
5825        pretend_playing(&mut player, ids[0]);
5826        let starts = player.playback_starts;
5827
5828        player.process_command(PlayerCommand::MoveInPlaylist {
5829            id: ids[2],
5830            target: ids[0],
5831            after: false,
5832        });
5833        player.process_command(PlayerCommand::Undo);
5834
5835        assert_eq!(playlist_ids(&player), ids);
5836        assert_eq!(playing_id(&player), Some(ids[0]), "still on the same track");
5837        assert_eq!(player.playback_starts, starts, "and not restarted");
5838    }
5839
5840    #[test]
5841    fn redo_clear() {
5842        let mut player = Player::new();
5843        let items = vec![make_item("A"), make_item("B")];
5844
5845        player.process_command(PlayerCommand::AddToPlaylist(items));
5846        player.process_command(PlayerCommand::ClearPlaylist);
5847        player.process_command(PlayerCommand::Undo);
5848        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5849
5850        player.process_command(PlayerCommand::Redo);
5851        assert!(playlist_ids(&player).is_empty());
5852    }
5853
5854    // --- Multi-step undo/redo ---
5855
5856    #[test]
5857    fn multiple_undos_in_sequence() {
5858        let mut player = Player::new();
5859
5860        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
5861        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
5862        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
5863        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5864
5865        player.process_command(PlayerCommand::Undo);
5866        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5867
5868        player.process_command(PlayerCommand::Undo);
5869        assert_eq!(playlist_titles(&player), vec!["A"]);
5870
5871        player.process_command(PlayerCommand::Undo);
5872        assert!(playlist_ids(&player).is_empty());
5873    }
5874
5875    #[test]
5876    fn undo_redo_undo_cycle() {
5877        let mut player = Player::new();
5878        let items = vec![make_item("A"), make_item("B")];
5879
5880        player.process_command(PlayerCommand::AddToPlaylist(items));
5881        player.process_command(PlayerCommand::Undo);
5882        assert!(playlist_ids(&player).is_empty());
5883
5884        player.process_command(PlayerCommand::Redo);
5885        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5886
5887        player.process_command(PlayerCommand::Undo);
5888        assert!(playlist_ids(&player).is_empty());
5889    }
5890
5891    #[test]
5892    fn new_action_clears_redo_stack() {
5893        let mut player = Player::new();
5894        let items = vec![make_item("A")];
5895
5896        player.process_command(PlayerCommand::AddToPlaylist(items));
5897        player.process_command(PlayerCommand::Undo);
5898        assert!(player.undo_stack().can_redo());
5899
5900        // New action should clear redo
5901        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
5902        assert!(!player.undo_stack().can_redo());
5903    }
5904
5905    #[test]
5906    fn undo_on_empty_stack_is_noop() {
5907        let mut player = Player::new();
5908        player.process_command(PlayerCommand::Undo);
5909        assert!(playlist_ids(&player).is_empty());
5910    }
5911
5912    #[test]
5913    fn redo_on_empty_stack_is_noop() {
5914        let mut player = Player::new();
5915        player.process_command(PlayerCommand::Redo);
5916        assert!(playlist_ids(&player).is_empty());
5917    }
5918
5919    // --- Non-undoable commands don't push entries ---
5920
5921    #[test]
5922    fn playback_commands_not_undoable() {
5923        let mut player = Player::new();
5924        player.process_command(PlayerCommand::Pause);
5925        player.process_command(PlayerCommand::Resume);
5926        player.process_command(PlayerCommand::NextTrack);
5927        player.process_command(PlayerCommand::PrevTrack);
5928        assert!(!player.undo_stack().can_undo());
5929    }
5930
5931    #[test]
5932    fn update_paths_not_undoable() {
5933        let mut player = Player::new();
5934        let items = vec![make_item("A")];
5935        let id = items[0].id;
5936        player.process_command(PlayerCommand::AddToPlaylist(items));
5937
5938        let undo_count = player.undo_stack().undo_len();
5939        player.process_command(PlayerCommand::UpdatePaths(vec![(
5940            id,
5941            PathBuf::from("/new/path.flac"),
5942        )]));
5943        assert_eq!(player.undo_stack().undo_len(), undo_count);
5944    }
5945
5946    // --- Complex scenarios ---
5947
5948    #[test]
5949    fn add_remove_undo_undo_produces_original() {
5950        let mut player = Player::new();
5951        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5952        let b_id = items[1].id;
5953        let original_titles = vec!["A", "B", "C"];
5954
5955        player.process_command(PlayerCommand::AddToPlaylist(items));
5956        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
5957        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5958
5959        // Undo remove → back to A, B, C
5960        player.process_command(PlayerCommand::Undo);
5961        assert_eq!(playlist_titles(&player), original_titles);
5962
5963        // Undo add → empty
5964        player.process_command(PlayerCommand::Undo);
5965        assert!(playlist_ids(&player).is_empty());
5966    }
5967
5968    #[test]
5969    fn interleaved_adds_and_moves_undo() {
5970        let mut player = Player::new();
5971        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5972        let a_id = items[0].id;
5973        let c_id = items[2].id;
5974
5975        player.process_command(PlayerCommand::AddToPlaylist(items));
5976
5977        // Move A after C: [B, C, A]
5978        player.process_command(PlayerCommand::MoveInPlaylist {
5979            id: a_id,
5980            target: c_id,
5981            after: true,
5982        });
5983        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5984
5985        // Add D: [B, C, A, D]
5986        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
5987        assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
5988
5989        // Undo add D: [B, C, A]
5990        player.process_command(PlayerCommand::Undo);
5991        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5992
5993        // Undo move: [A, B, C]
5994        player.process_command(PlayerCommand::Undo);
5995        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5996    }
5997
5998    /// Regression test for GitHub #89: AudioEngine must be dropped synchronously
5999    /// in stop_engine() before the caller changes sample rates. If the engine is
6000    /// dropped on a background thread, CoreAudio's internal buffer list can be
6001    /// freed while AudioUnitUninitialize is still tearing it down → crash.
6002    #[test]
6003    fn stop_engine_drops_engine_synchronously() {
6004        use std::sync::atomic::{AtomicBool, Ordering};
6005
6006        struct MockEngine {
6007            dropped: Arc<AtomicBool>,
6008        }
6009        impl AudioEngineHandle for MockEngine {
6010            fn start(&self) -> Result<(), BackendError> {
6011                Ok(())
6012            }
6013            fn stop(&self) -> Result<(), BackendError> {
6014                Ok(())
6015            }
6016            fn is_running(&self) -> bool {
6017                false
6018            }
6019            fn fade_out(&self) {}
6020            fn fade_in(&self) -> Result<(), BackendError> {
6021                Ok(())
6022            }
6023            fn is_silent(&self) -> bool {
6024                false
6025            }
6026        }
6027        impl Drop for MockEngine {
6028            fn drop(&mut self) {
6029                self.dropped.store(true, Ordering::SeqCst);
6030            }
6031        }
6032
6033        let dropped = Arc::new(AtomicBool::new(false));
6034
6035        let mut player = Player::new();
6036        player.transport = Transport::Loaded(test_session(
6037            QueueItemId::new(),
6038            Box::new(MockEngine {
6039                dropped: dropped.clone(),
6040            }),
6041        ));
6042
6043        player.stop_engine();
6044
6045        // The engine must already be dropped when stop_engine returns.
6046        // If this fails, the engine was moved to a background thread — the
6047        // exact race condition that causes the #89 crash.
6048        assert!(
6049            dropped.load(Ordering::SeqCst),
6050            "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
6051        );
6052    }
6053
6054    #[test]
6055    fn abandoning_a_stream_wakes_a_reader_parked_at_the_write_head() {
6056        let live = LiveStream {
6057            feed: crate::remote::downloads::ByteFeed::new(),
6058            abandoned: Default::default(),
6059        };
6060        let feed = live.feed.clone();
6061        let started = std::time::Instant::now();
6062        let reader = thread::spawn(move || {
6063            feed.wait_past(
6064                0,
6065                std::time::Instant::now() + std::time::Duration::from_secs(30),
6066            )
6067        });
6068        thread::sleep(std::time::Duration::from_millis(50));
6069        live.abandon();
6070        reader.join().unwrap();
6071
6072        assert!(live.abandoned.load(std::sync::atomic::Ordering::Acquire));
6073        assert!(started.elapsed() < std::time::Duration::from_secs(5));
6074    }
6075
6076    // --- Engine format matches the decoded PCM ---
6077
6078    /// Backend pinned to one sample rate that refuses every switch, recording
6079    /// the format the engine is asked for.
6080    pub(super) struct StuckBackend {
6081        pub(super) rate: f64,
6082        pub(super) asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
6083        /// How many times an engine it made was started.
6084        pub(super) starts: Arc<std::sync::atomic::AtomicUsize>,
6085    }
6086
6087    struct NullEngine {
6088        starts: Arc<std::sync::atomic::AtomicUsize>,
6089        running: std::sync::atomic::AtomicBool,
6090        lead_in: Arc<AtomicU64>,
6091    }
6092    impl AudioEngineHandle for NullEngine {
6093        fn start(&self) -> Result<(), BackendError> {
6094            self.starts.fetch_add(1, Ordering::Relaxed);
6095            self.running.store(true, Ordering::Relaxed);
6096            Ok(())
6097        }
6098        fn stop(&self) -> Result<(), BackendError> {
6099            self.running.store(false, Ordering::Relaxed);
6100            Ok(())
6101        }
6102        fn is_running(&self) -> bool {
6103            self.running.load(Ordering::Relaxed)
6104        }
6105        fn fade_out(&self) {}
6106        fn fade_in(&self) -> Result<(), BackendError> {
6107            Ok(())
6108        }
6109        fn is_silent(&self) -> bool {
6110            false
6111        }
6112        fn lead_in(&self, frames: u64) {
6113            self.lead_in.store(frames, Ordering::Relaxed);
6114        }
6115    }
6116
6117    impl AudioBackend for StuckBackend {
6118        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6119            Ok(vec![self.default_device()?])
6120        }
6121        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6122            Ok(backend::DeviceInfo {
6123                name: "Stuck DAC".into(),
6124                sample_rates: vec![self.rate],
6125                platform_id: 0,
6126                kind: Default::default(),
6127            })
6128        }
6129        fn supported_sample_rates(
6130            &self,
6131            _device: &backend::DeviceInfo,
6132        ) -> Result<Vec<f64>, BackendError> {
6133            Ok(vec![self.rate])
6134        }
6135        fn get_device_sample_rate(
6136            &self,
6137            _device: &backend::DeviceInfo,
6138        ) -> Result<f64, BackendError> {
6139            Ok(self.rate)
6140        }
6141        fn set_device_sample_rate(
6142            &self,
6143            _device: &backend::DeviceInfo,
6144            rate: f64,
6145        ) -> Result<f64, BackendError> {
6146            Err(BackendError::UnsupportedSampleRate(rate))
6147        }
6148        fn create_engine(
6149            &self,
6150            _device: &backend::DeviceInfo,
6151            sample_rate: f64,
6152            channels: u32,
6153            _consumer: rtrb::Consumer<f32>,
6154            _samples_played: Arc<AtomicU64>,
6155        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6156            *self.asked.lock().unwrap() = Some((sample_rate, channels));
6157            Ok(Box::new(NullEngine {
6158                starts: self.starts.clone(),
6159                running: Default::default(),
6160                lead_in: Default::default(),
6161            }))
6162        }
6163    }
6164
6165    /// Android's shape of output: the device takes a format bit-perfect when
6166    /// asked, but the stream opens through the mixer, at the mixer's rate.
6167    struct FallbackBackend {
6168        asked_depth: Arc<std::sync::Mutex<Option<Option<u16>>>>,
6169        mixed: std::sync::atomic::AtomicBool,
6170    }
6171
6172    impl AudioBackend for FallbackBackend {
6173        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6174            Ok(vec![self.default_device()?])
6175        }
6176        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6177            Ok(backend::DeviceInfo {
6178                name: "Android".into(),
6179                sample_rates: vec![],
6180                platform_id: 0,
6181                kind: Default::default(),
6182            })
6183        }
6184        fn supported_sample_rates(
6185            &self,
6186            _device: &backend::DeviceInfo,
6187        ) -> Result<Vec<f64>, BackendError> {
6188            Ok(vec![])
6189        }
6190        fn get_device_sample_rate(
6191            &self,
6192            _device: &backend::DeviceInfo,
6193        ) -> Result<f64, BackendError> {
6194            Ok(if self.mixed.load(std::sync::atomic::Ordering::Relaxed) {
6195                48000.0
6196            } else {
6197                44100.0
6198            })
6199        }
6200        fn set_device_sample_rate(
6201            &self,
6202            _device: &backend::DeviceInfo,
6203            rate: f64,
6204        ) -> Result<f64, BackendError> {
6205            Ok(rate)
6206        }
6207        fn prepare_output(
6208            &self,
6209            _device: &backend::DeviceInfo,
6210            _current: f64,
6211            rate: f64,
6212            _channels: u16,
6213            bit_depth: Option<u16>,
6214        ) -> Result<f64, BackendError> {
6215            *self.asked_depth.lock().unwrap() = Some(bit_depth);
6216            Ok(rate)
6217        }
6218        fn output_mixed(&self) -> Option<bool> {
6219            Some(self.mixed.load(std::sync::atomic::Ordering::Relaxed))
6220        }
6221        fn narrows_depth(&self) -> bool {
6222            true
6223        }
6224        fn create_engine(
6225            &self,
6226            _device: &backend::DeviceInfo,
6227            _sample_rate: f64,
6228            _channels: u32,
6229            _consumer: rtrb::Consumer<f32>,
6230            _samples_played: Arc<AtomicU64>,
6231        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6232            self.mixed.store(true, std::sync::atomic::Ordering::Relaxed);
6233            Ok(Box::new(NullEngine {
6234                starts: Default::default(),
6235                running: Default::default(),
6236                lead_in: Default::default(),
6237            }))
6238        }
6239    }
6240
6241    #[test]
6242    fn a_session_assumes_its_first_tracks_depth_until_a_narrowing_output_opens() {
6243        let info = |bit_depth| buffer::StreamInfo {
6244            codec: "FLAC".into(),
6245            sample_rate: 44100,
6246            channels: 2,
6247            bit_depth,
6248            bitrate_kbps: None,
6249            duration_ms: 1000,
6250        };
6251        let mut player = Player::new();
6252        player.backend = Box::new(FallbackBackend {
6253            asked_depth: Default::default(),
6254            mixed: Default::default(),
6255        });
6256        assert_eq!(player.assumed_depth(&info(Some(16))), 16);
6257        assert_eq!(player.assumed_depth(&info(None)), 24);
6258
6259        player.backend = Box::new(StuckBackend {
6260            rate: 44100.0,
6261            asked: Default::default(),
6262            starts: Default::default(),
6263        });
6264        assert_eq!(player.assumed_depth(&info(Some(16))), 0, "nothing narrows");
6265    }
6266
6267    #[test]
6268    fn a_stream_that_opens_through_the_mixer_is_reported_mixed_at_its_rate() {
6269        let mut player = Player::new();
6270        let asked_depth = Arc::new(std::sync::Mutex::new(None));
6271        player.backend = Box::new(FallbackBackend {
6272            asked_depth: asked_depth.clone(),
6273            mixed: Default::default(),
6274        });
6275        let state = player.shared_state.clone();
6276        let info = buffer::StreamInfo {
6277            codec: "FLAC".into(),
6278            sample_rate: 44100,
6279            channels: 2,
6280            bit_depth: Some(16),
6281            bitrate_kbps: None,
6282            duration_ms: 1000,
6283        };
6284        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6285        player.create_engine_for(&info, consumer).unwrap();
6286        assert_eq!(*asked_depth.lock().unwrap(), Some(Some(16)));
6287        assert_eq!(
6288            state.output_mixed(),
6289            Some(true),
6290            "said of the stream as it opened"
6291        );
6292        assert_eq!(state.output_sample_rate(), Some(48000));
6293    }
6294
6295    /// Hands the test every ring buffer an engine is made with, so what
6296    /// reaches the device can be read back.
6297    struct CaptureBackend {
6298        consumers: Arc<std::sync::Mutex<Vec<rtrb::Consumer<f32>>>>,
6299    }
6300
6301    impl AudioBackend for CaptureBackend {
6302        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6303            Ok(vec![self.default_device()?])
6304        }
6305        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6306            Ok(backend::DeviceInfo {
6307                name: "Capture DAC".into(),
6308                sample_rates: vec![44100.0],
6309                platform_id: 0,
6310                kind: Default::default(),
6311            })
6312        }
6313        fn supported_sample_rates(
6314            &self,
6315            _device: &backend::DeviceInfo,
6316        ) -> Result<Vec<f64>, BackendError> {
6317            Ok(vec![44100.0])
6318        }
6319        fn get_device_sample_rate(
6320            &self,
6321            _device: &backend::DeviceInfo,
6322        ) -> Result<f64, BackendError> {
6323            Ok(44100.0)
6324        }
6325        fn set_device_sample_rate(
6326            &self,
6327            _device: &backend::DeviceInfo,
6328            rate: f64,
6329        ) -> Result<f64, BackendError> {
6330            Ok(rate)
6331        }
6332        fn create_engine(
6333            &self,
6334            _device: &backend::DeviceInfo,
6335            _sample_rate: f64,
6336            _channels: u32,
6337            consumer: rtrb::Consumer<f32>,
6338            _samples_played: Arc<AtomicU64>,
6339        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6340            self.consumers.lock().unwrap().push(consumer);
6341            Ok(Box::new(NullEngine {
6342                starts: Default::default(),
6343                running: Default::default(),
6344                lead_in: Default::default(),
6345            }))
6346        }
6347    }
6348
6349    /// The loudest sample of a session's first `want` samples.
6350    fn peak_reaching_the_device(
6351        consumers: &std::sync::Mutex<Vec<rtrb::Consumer<f32>>>,
6352        want: usize,
6353    ) -> f32 {
6354        let mut consumer = consumers.lock().unwrap().pop().expect("an engine was made");
6355        let mut peak = 0.0f32;
6356        let mut got = 0;
6357        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
6358        while got < want && std::time::Instant::now() < deadline {
6359            let n = consumer.slots();
6360            if n == 0 {
6361                std::thread::sleep(std::time::Duration::from_millis(2));
6362                continue;
6363            }
6364            let chunk = consumer.read_chunk(n).unwrap();
6365            let (a, b) = chunk.as_slices();
6366            peak = a.iter().chain(b).fold(peak, |p, s| p.max(s.abs()));
6367            got += n;
6368            chunk.commit_all();
6369        }
6370        assert!(got >= want, "only {got} of {want} samples arrived");
6371        peak
6372    }
6373
6374    /// A file on disk and a download still landing open through the same
6375    /// session, so both are processed. The streaming path is the one that
6376    /// used to be its own function, and the easy one to leave behind.
6377    #[test]
6378    fn a_profile_processes_files_and_streams_alike() {
6379        let dir = tempfile::tempdir().unwrap();
6380        let tone = dir.path().join("tone.wav");
6381        crate::test_utils::generate_wav_tone(&tone, 44100, 440.0, 0.5);
6382        let want = 22050;
6383
6384        let consumers = Arc::new(std::sync::Mutex::new(Vec::new()));
6385        let mut player = Player::new();
6386        player.backend = Box::new(CaptureBackend {
6387            consumers: consumers.clone(),
6388        });
6389        let mut item = make_item("tone");
6390        item.path = tone.clone();
6391        let id = item.id;
6392        player.shared_state.add_items(vec![item]);
6393        player.shared_state.set_cursor(Some(id));
6394
6395        let stream = || {
6396            let feed = crate::remote::downloads::ByteFeed::new();
6397            feed.set(std::fs::metadata(&tone).unwrap().len());
6398            Source::Stream(StreamSource {
6399                path: tone.clone(),
6400                bytes_written: feed,
6401                total: 0,
6402                mode: streaming::ProbeMode::Full,
6403            })
6404        };
6405        let peaks = |player: &mut Player| {
6406            let mut out = Vec::new();
6407            for source in [Source::File(tone.clone()), stream()] {
6408                player
6409                    .try_open_session(id, source, None, 0, Run::Playing)
6410                    .unwrap();
6411                player.publish();
6412                out.push((
6413                    peak_reaching_the_device(&consumers, want),
6414                    player.shared_state.dsp().map(|d| d.profile),
6415                ));
6416                player.stop_engine();
6417            }
6418            out
6419        };
6420
6421        let untouched = peaks(&mut player);
6422
6423        player.dsp_override = Some(Arc::new(
6424            crate::audio::dsp::Setup::new(vec![], vec![]).with_preamp(-6.0206),
6425        ));
6426        let processed = peaks(&mut player);
6427
6428        for (kind, ((before, none), (after, half))) in ["file", "stream"]
6429            .iter()
6430            .zip(untouched.iter().zip(&processed))
6431        {
6432            assert_eq!(
6433                none, &None,
6434                "{kind}: nothing is published without a profile"
6435            );
6436            assert_eq!(half.as_deref(), Some("test"), "{kind}: the badge names it");
6437            assert!(*before > 0.1, "{kind}: the tone reached the device");
6438            assert!(
6439                (after / before - 0.5).abs() < 0.01,
6440                "{kind}: {before} → {after}, not halved"
6441            );
6442        }
6443    }
6444
6445    /// Replacing the queue while the track playing is paused, with a track
6446    /// that has still to arrive: nothing sounds until the new one can open,
6447    /// the old one is never reopened, and the new one is what is published
6448    /// from the start. The iOS report this answers: a playlist's play button,
6449    /// pressed over a paused track, was heard as that track carrying on.
6450    #[test]
6451    fn a_replaced_queue_never_sounds_the_track_it_replaced() {
6452        let dir = tempfile::tempdir().unwrap();
6453        let a = dir.path().join("a.wav");
6454        crate::test_utils::generate_wav_tone(&a, 44100, 440.0, 5.0);
6455        let consumers = Arc::new(std::sync::Mutex::new(Vec::new()));
6456        let mut player = Player::new();
6457        player.backend = Box::new(CaptureBackend {
6458            consumers: consumers.clone(),
6459        });
6460        let engines = || consumers.lock().unwrap().len();
6461
6462        let item_a = PlaylistItem {
6463            path: a,
6464            ..make_item("paused")
6465        };
6466        let a_id = item_a.id;
6467        player.process_command(PlayerCommand::AddToPlaylist(vec![item_a]));
6468        player.process_command(PlayerCommand::Play(a_id));
6469        player.process_command(PlayerCommand::Pause);
6470        assert_eq!(engines(), 1);
6471
6472        let landed = dir.path().join("long.opus");
6473        let item_b = PlaylistItem {
6474            db_id: Some(77),
6475            state: ItemState::Pending,
6476            duration_ms: Some(32_523_781),
6477            path: landed.clone(),
6478            ..make_item("long")
6479        };
6480        let b_id = item_b.id;
6481        player.process_command(PlayerCommand::ReplacePlaylist {
6482            items: vec![item_b],
6483            start: 0,
6484            position_ms: 0,
6485            play: true,
6486            mode: QueueMode::Keep,
6487        });
6488        player.publish();
6489        let state = player.shared_state.clone();
6490        assert_eq!(state.cursor(), Some(b_id), "the new track, at once");
6491        assert!(
6492            state.is_waiting(),
6493            "waiting for it, which a client shows as loading"
6494        );
6495        assert_eq!(state.playback_state(), PlaybackState::Stopped);
6496        assert_eq!(
6497            state.track_info(),
6498            None,
6499            "the paused track is no longer published"
6500        );
6501        assert_eq!(engines(), 1, "nothing opened while it is on its way");
6502
6503        // Its download lands.
6504        let store = state.downloads().clone();
6505        store.claim(77, Some(b_id));
6506        std::fs::write(&landed, include_bytes!("testdata/thirty-seconds.opus")).unwrap();
6507        // Told to the player below, as the download queue would.
6508        let _settled = crate::remote::downloads::settle(&state, 77, &Ok(landed));
6509        player.process_command(PlayerCommand::TrackReady(b_id));
6510        player.publish();
6511
6512        assert_eq!(
6513            engines(),
6514            2,
6515            "one engine for the new track, none for the old"
6516        );
6517        assert_eq!(state.track_info().map(|t| t.id), Some(b_id));
6518        assert_eq!(state.playback_state(), PlaybackState::Playing);
6519        player.process_command(PlayerCommand::Stop);
6520    }
6521
6522    /// A long Ogg is opened before its last page has arrived, which is where
6523    /// Ogg keeps its length, so the stream itself cannot say how long it is.
6524    /// The library can: a nine-hour remote track showed no duration until
6525    /// the whole file had downloaded.
6526    #[test]
6527    fn a_stream_opened_without_a_length_takes_the_librarys_duration() {
6528        let dir = tempfile::tempdir().unwrap();
6529        let bytes = include_bytes!("testdata/thirty-seconds.opus");
6530        let half = bytes.len() / 2;
6531        let part = dir.path().join("long.opus.part");
6532        std::fs::write(&part, &bytes[..half]).unwrap();
6533        let feed = crate::remote::downloads::ByteFeed::new();
6534        feed.set(half as u64);
6535
6536        let mut player = Player::new();
6537        player.backend = Box::new(CaptureBackend {
6538            consumers: Default::default(),
6539        });
6540        let item = PlaylistItem {
6541            db_id: Some(9),
6542            state: ItemState::Pending,
6543            duration_ms: Some(32_523_781),
6544            path: dir.path().join("long.opus"),
6545            ..make_item("long")
6546        };
6547        let id = item.id;
6548        player.shared_state.add_items(vec![item]);
6549        player.shared_state.set_cursor(Some(id));
6550
6551        let lengthless = buffer::StreamInfo {
6552            codec: "Opus".into(),
6553            sample_rate: 48_000,
6554            channels: 1,
6555            bit_depth: None,
6556            bitrate_kbps: None,
6557            duration_ms: 0,
6558        };
6559        player
6560            .try_open_session(
6561                id,
6562                Source::Stream(StreamSource {
6563                    path: part,
6564                    bytes_written: feed,
6565                    total: bytes.len() as u64,
6566                    mode: streaming::ProbeMode::Lengthless,
6567                }),
6568                Some(lengthless),
6569                0,
6570                Run::Playing,
6571            )
6572            .unwrap();
6573        player.publish();
6574
6575        assert_eq!(player.shared_state.duration_ms(), 32_523_781);
6576        assert_eq!(
6577            player.shared_state.track_info().map(|t| t.duration_ms),
6578            Some(32_523_781)
6579        );
6580        player.stop_engine();
6581    }
6582
6583    fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
6584        let asked = Arc::new(std::sync::Mutex::new(None));
6585        let mut player = Player::new();
6586        player.backend = Box::new(StuckBackend {
6587            rate: device_rate,
6588            asked: asked.clone(),
6589            starts: Default::default(),
6590        });
6591
6592        let info = buffer::StreamInfo {
6593            codec: "MP3".into(),
6594            sample_rate: source_rate,
6595            channels,
6596            bit_depth: Some(16),
6597            bitrate_kbps: None,
6598            duration_ms: 1000,
6599        };
6600        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6601        player
6602            .create_engine_for(&info, consumer)
6603            .expect("engine creation should succeed");
6604        let asked = *asked.lock().unwrap();
6605        asked.expect("engine was never created")
6606    }
6607
6608    #[test]
6609    fn engine_uses_source_rate_when_device_refuses_switch() {
6610        // MPEG-2 MP3 rates are routinely rejected by output devices. The engine
6611        // must still be told the rate the PCM actually is.
6612        assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
6613        assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
6614    }
6615
6616    #[test]
6617    fn engine_uses_source_channel_count() {
6618        assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
6619    }
6620
6621    /// The rate the device settled at, as the front ends read it.
6622    fn settled_rate_for(source_rate: u32, device_rate: f64) -> Option<u32> {
6623        let mut player = Player::new();
6624        player.backend = Box::new(StuckBackend {
6625            rate: device_rate,
6626            asked: Arc::new(std::sync::Mutex::new(None)),
6627            starts: Default::default(),
6628        });
6629        let state = player.shared_state.clone();
6630
6631        let info = buffer::StreamInfo {
6632            codec: "MP3".into(),
6633            sample_rate: source_rate,
6634            channels: 2,
6635            bit_depth: Some(16),
6636            bitrate_kbps: None,
6637            duration_ms: 1000,
6638        };
6639        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6640        player
6641            .create_engine_for(&info, consumer)
6642            .expect("engine creation should succeed");
6643        state.output_sample_rate()
6644    }
6645
6646    #[test]
6647    fn settled_device_rate_reaches_the_shared_state() {
6648        // A device that refuses the switch is being fed resampled audio, and
6649        // that is the case the front ends have to be able to see. Before this
6650        // the comparison happened once, in a log line.
6651        assert_eq!(settled_rate_for(22050, 48000.0), Some(48000));
6652        // No switch needed, so nothing resampled: the two rates agree.
6653        assert_eq!(settled_rate_for(44100, 44100.0), Some(44100));
6654    }
6655
6656    /// A device that takes its time reclocking, as real hardware does.
6657    struct SlowBackend {
6658        observed: Arc<std::sync::Mutex<Vec<Option<u32>>>>,
6659        state: Arc<SharedPlayerState>,
6660        lead_in: Arc<AtomicU64>,
6661    }
6662
6663    impl AudioBackend for SlowBackend {
6664        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6665            Ok(vec![self.default_device()?])
6666        }
6667        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6668            Ok(backend::DeviceInfo {
6669                name: "Slow DAC".into(),
6670                sample_rates: vec![44100.0, 48000.0],
6671                platform_id: 0,
6672                kind: Default::default(),
6673            })
6674        }
6675        fn supported_sample_rates(
6676            &self,
6677            _device: &backend::DeviceInfo,
6678        ) -> Result<Vec<f64>, BackendError> {
6679            Ok(vec![44100.0, 48000.0])
6680        }
6681        fn get_device_sample_rate(
6682            &self,
6683            _device: &backend::DeviceInfo,
6684        ) -> Result<f64, BackendError> {
6685            Ok(48000.0)
6686        }
6687        fn set_device_sample_rate(
6688            &self,
6689            _device: &backend::DeviceInfo,
6690            rate: f64,
6691        ) -> Result<f64, BackendError> {
6692            // What a front end polling mid-switch would see.
6693            self.observed
6694                .lock()
6695                .unwrap()
6696                .push(self.state.output_sample_rate());
6697            Ok(rate)
6698        }
6699        fn create_engine(
6700            &self,
6701            _device: &backend::DeviceInfo,
6702            _sample_rate: f64,
6703            _channels: u32,
6704            _consumer: rtrb::Consumer<f32>,
6705            _samples_played: Arc<AtomicU64>,
6706        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6707            Ok(Box::new(NullEngine {
6708                starts: Default::default(),
6709                running: Default::default(),
6710                lead_in: self.lead_in.clone(),
6711            }))
6712        }
6713    }
6714
6715    #[test]
6716    fn the_previous_rate_is_not_published_while_the_device_reclocks() {
6717        // A 48 kHz track followed by a 44.1 kHz one: for as long as the switch
6718        // takes — the better part of a second on USB — the new track's info is
6719        // published against the old track's output rate. A front end polling in
6720        // that window used to latch "44.1 → 48" and, since nothing about the
6721        // codec or the source rate changed afterwards, never let go of it.
6722        let mut player = Player::new();
6723        let state = player.shared_state.clone();
6724        state.set_output_sample_rate(48000);
6725
6726        let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
6727        player.backend = Box::new(SlowBackend {
6728            observed: observed.clone(),
6729            state: state.clone(),
6730            lead_in: Default::default(),
6731        });
6732
6733        let info = buffer::StreamInfo {
6734            codec: "FLAC".into(),
6735            sample_rate: 44100,
6736            channels: 2,
6737            bit_depth: Some(16),
6738            bitrate_kbps: None,
6739            duration_ms: 1000,
6740        };
6741        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6742        player
6743            .create_engine_for(&info, consumer)
6744            .expect("engine creation should succeed");
6745
6746        assert_eq!(
6747            *observed.lock().unwrap(),
6748            vec![None],
6749            "mid-switch the output rate must read as unknown, not as the last track's"
6750        );
6751        assert_eq!(state.output_sample_rate(), Some(44100));
6752    }
6753
6754    /// The lead-in an engine was given, for a track at `source_rate` on a
6755    /// device sitting at 48 kHz.
6756    fn lead_in_for(source_rate: u32) -> u64 {
6757        let mut player = Player::new();
6758        let lead_in = Arc::new(AtomicU64::new(0));
6759        player.backend = Box::new(SlowBackend {
6760            observed: Default::default(),
6761            state: player.shared_state.clone(),
6762            lead_in: lead_in.clone(),
6763        });
6764        let info = buffer::StreamInfo {
6765            codec: "FLAC".into(),
6766            sample_rate: source_rate,
6767            channels: 2,
6768            bit_depth: Some(16),
6769            bitrate_kbps: None,
6770            duration_ms: 1000,
6771        };
6772        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6773        player
6774            .create_engine_for(&info, consumer)
6775            .expect("engine creation should succeed");
6776        lead_in.load(Ordering::Relaxed)
6777    }
6778
6779    #[test]
6780    fn an_engine_made_inside_the_silence_keeps_the_rest_of_it() {
6781        // A seek straight after a rate switch: the device is still relocking,
6782        // though the new engine finds its rate already matching.
6783        let mut player = Player::new();
6784        let lead_in = Arc::new(AtomicU64::new(0));
6785        player.backend = Box::new(SlowBackend {
6786            observed: Default::default(),
6787            state: player.shared_state.clone(),
6788            lead_in: lead_in.clone(),
6789        });
6790        let info = |sample_rate| buffer::StreamInfo {
6791            codec: "FLAC".into(),
6792            sample_rate,
6793            channels: 2,
6794            bit_depth: Some(16),
6795            bitrate_kbps: None,
6796            duration_ms: 1000,
6797        };
6798        let (_p, consumer) = rtrb::RingBuffer::new(16);
6799        player.create_engine_for(&info(44100), consumer).unwrap();
6800        let first = lead_in.swap(0, Ordering::Relaxed);
6801        assert!(first > 0);
6802
6803        let (_p, consumer) = rtrb::RingBuffer::new(16);
6804        player.create_engine_for(&info(48000), consumer).unwrap();
6805        let carried = lead_in.load(Ordering::Relaxed);
6806        // Frames at the new engine's rate: what is left of the same second.
6807        assert!(
6808            carried > 0 && carried < first * 48000 / 44100,
6809            "carried {carried} of {first}"
6810        );
6811    }
6812
6813    #[test]
6814    fn silence_leads_in_only_after_the_device_changed_rate() {
6815        assert!(
6816            lead_in_for(44100) > 0,
6817            "the device is relocking, so the start of the track would be lost"
6818        );
6819        assert_eq!(lead_in_for(48000), 0, "no switch, nothing to wait for");
6820    }
6821
6822    /// iOS's rates on an engine that plays nothing.
6823    #[cfg(target_os = "macos")]
6824    struct IosRates {
6825        lead_in: Arc<AtomicU64>,
6826    }
6827
6828    #[cfg(target_os = "macos")]
6829    impl AudioBackend for IosRates {
6830        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6831            crate::audio::ios_backend::IosAudioBackend.list_devices()
6832        }
6833        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6834            crate::audio::ios_backend::IosAudioBackend.default_device()
6835        }
6836        fn supported_sample_rates(
6837            &self,
6838            device: &backend::DeviceInfo,
6839        ) -> Result<Vec<f64>, BackendError> {
6840            crate::audio::ios_backend::IosAudioBackend.supported_sample_rates(device)
6841        }
6842        fn get_device_sample_rate(
6843            &self,
6844            device: &backend::DeviceInfo,
6845        ) -> Result<f64, BackendError> {
6846            crate::audio::ios_backend::IosAudioBackend.get_device_sample_rate(device)
6847        }
6848        fn set_device_sample_rate(
6849            &self,
6850            device: &backend::DeviceInfo,
6851            rate: f64,
6852        ) -> Result<f64, BackendError> {
6853            crate::audio::ios_backend::IosAudioBackend.set_device_sample_rate(device, rate)
6854        }
6855        fn create_engine(
6856            &self,
6857            _device: &backend::DeviceInfo,
6858            _sample_rate: f64,
6859            _channels: u32,
6860            _consumer: rtrb::Consumer<f32>,
6861            _samples_played: Arc<AtomicU64>,
6862        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6863            Ok(Box::new(NullEngine {
6864                starts: Default::default(),
6865                running: Default::default(),
6866                lead_in: self.lead_in.clone(),
6867            }))
6868        }
6869    }
6870
6871    /// A DAC that runs at whichever of 44.1 and 48 kHz it is asked for.
6872    #[cfg(target_os = "macos")]
6873    struct SwitchingDac;
6874
6875    #[cfg(target_os = "macos")]
6876    impl crate::audio::ios_backend::AudioSession for SwitchingDac {
6877        fn activate(&self, sample_rate: f64) -> Option<f64> {
6878            Some(self.follow(sample_rate))
6879        }
6880        fn follow(&self, sample_rate: f64) -> f64 {
6881            if sample_rate == 44100.0 {
6882                44100.0
6883            } else {
6884                48000.0
6885            }
6886        }
6887        fn release(&self) {}
6888    }
6889
6890    #[test]
6891    #[cfg(target_os = "macos")]
6892    fn a_session_let_go_is_no_rate_switch_to_wait_out() {
6893        use crate::audio::ios_backend;
6894        let _session = ios_backend::TEST_SESSION.lock();
6895        ios_backend::set_session(Arc::new(SwitchingDac));
6896        let lead_in_at = |sample_rate| {
6897            let mut player = Player::new();
6898            let lead_in = Arc::new(AtomicU64::new(0));
6899            player.backend = Box::new(IosRates {
6900                lead_in: lead_in.clone(),
6901            });
6902            let info = buffer::StreamInfo {
6903                codec: "FLAC".into(),
6904                sample_rate,
6905                channels: 2,
6906                bit_depth: Some(16),
6907                bitrate_kbps: None,
6908                duration_ms: 1000,
6909            };
6910            let (_producer, consumer) = rtrb::RingBuffer::new(16);
6911            player.create_engine_for(&info, consumer).unwrap();
6912            lead_in.load(Ordering::Relaxed)
6913        };
6914
6915        // Held at 48 kHz, a 44.1 kHz track switches the DAC.
6916        assert!(ios_backend::activate_session(48000.0));
6917        assert!(lead_in_at(44100) > 0);
6918
6919        // Played at 48 kHz, then idle long enough to let the session go: the
6920        // rate it last ran at is no longer the hardware's, and the next
6921        // track, at 44.1 kHz, starts without a second of silence.
6922        assert!(ios_backend::activate_session(48000.0));
6923        ios_backend::release_session();
6924        assert_eq!(lead_in_at(44100), 0);
6925    }
6926
6927    /// Backend that hands its rate-change callback back to the test.
6928    struct WatchedBackend {
6929        inner: StuckBackend,
6930        #[allow(clippy::type_complexity)]
6931        captured: Arc<std::sync::Mutex<Option<Box<dyn Fn(f64) + Send + Sync>>>>,
6932    }
6933
6934    struct NullWatch;
6935    impl backend::SampleRateWatch for NullWatch {}
6936
6937    impl AudioBackend for WatchedBackend {
6938        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6939            self.inner.list_devices()
6940        }
6941        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6942            self.inner.default_device()
6943        }
6944        fn supported_sample_rates(
6945            &self,
6946            device: &backend::DeviceInfo,
6947        ) -> Result<Vec<f64>, BackendError> {
6948            self.inner.supported_sample_rates(device)
6949        }
6950        fn get_device_sample_rate(
6951            &self,
6952            device: &backend::DeviceInfo,
6953        ) -> Result<f64, BackendError> {
6954            self.inner.get_device_sample_rate(device)
6955        }
6956        fn set_device_sample_rate(
6957            &self,
6958            device: &backend::DeviceInfo,
6959            rate: f64,
6960        ) -> Result<f64, BackendError> {
6961            self.inner.set_device_sample_rate(device, rate)
6962        }
6963        fn watch_device_sample_rate(
6964            &self,
6965            _device: &backend::DeviceInfo,
6966            on_change: Box<dyn Fn(f64) + Send + Sync>,
6967        ) -> Option<Box<dyn backend::SampleRateWatch>> {
6968            *self.captured.lock().unwrap() = Some(on_change);
6969            Some(Box::new(NullWatch))
6970        }
6971        fn create_engine(
6972            &self,
6973            device: &backend::DeviceInfo,
6974            sample_rate: f64,
6975            channels: u32,
6976            consumer: rtrb::Consumer<f32>,
6977            samples_played: Arc<AtomicU64>,
6978        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6979            self.inner
6980                .create_engine(device, sample_rate, channels, consumer, samples_played)
6981        }
6982    }
6983
6984    #[test]
6985    fn external_rate_change_reaches_the_shared_state() {
6986        // The device is shared. Another client moving the rate mid-track used
6987        // to leave the front ends asserting bit-perfection while the HAL
6988        // resampled underneath them.
6989        let captured = Arc::new(std::sync::Mutex::new(None));
6990        let mut player = Player::new();
6991        player.backend = Box::new(WatchedBackend {
6992            inner: StuckBackend {
6993                rate: 44100.0,
6994                asked: Arc::new(std::sync::Mutex::new(None)),
6995                starts: Default::default(),
6996            },
6997            captured: captured.clone(),
6998        });
6999        let state = player.shared_state.clone();
7000
7001        let info = buffer::StreamInfo {
7002            codec: "FLAC".into(),
7003            sample_rate: 44100,
7004            channels: 2,
7005            bit_depth: Some(16),
7006            bitrate_kbps: None,
7007            duration_ms: 1000,
7008        };
7009        let (_producer, consumer) = rtrb::RingBuffer::new(16);
7010        player
7011            .create_engine_for(&info, consumer)
7012            .expect("engine creation should succeed");
7013        assert_eq!(state.output_sample_rate(), Some(44100));
7014
7015        let on_change = captured.lock().unwrap().take().expect("watch registered");
7016        on_change(48000.0);
7017        assert_eq!(state.output_sample_rate(), Some(48000));
7018    }
7019}