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