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    /// Back to the start of the track once it has played for more than a
1476    /// second, as every player does; before that, to the track before.
1477    pub fn prev_track(&mut self) {
1478        if self.session().is_some() && self.shared_state.position_ms() > 1000 {
1479            if let Err(e) = self.restart_current(0) {
1480                log::error!("restart failed: {}", e);
1481            }
1482            return;
1483        }
1484        match self.shared_state.retreat_cursor() {
1485            Some((id, _)) => self.play(id),
1486            None => {
1487                // No previous track — restart current from the beginning.
1488                if let Err(e) = self.restart_current(0) {
1489                    log::error!("restart failed: {}", e);
1490                }
1491            }
1492        }
1493    }
1494
1495    /// Pause playback, fading out if the config asks for it.
1496    ///
1497    /// A fade leaves the unit running until it reaches silence;
1498    /// `update_playback_state` stops it from there. A track still on its way
1499    /// opens paused when it arrives.
1500    pub fn pause(&mut self) {
1501        let fade = crate::config::Config::cached().playback.fade_on_pause;
1502        self.pause_with(if fade { Fade::Short } else { Fade::Cut });
1503    }
1504
1505    fn pause_with(&mut self, fade: Fade) {
1506        match &mut self.transport {
1507            Transport::Idle => return,
1508            Transport::Waiting(waiting) => {
1509                waiting.start = Run::Paused;
1510                return;
1511            }
1512            Transport::Loaded(session) => {
1513                if let Output::Local(local) = &session.output {
1514                    match fade {
1515                        Fade::Short => local.engine.fade_out(),
1516                        Fade::Cut => {
1517                            if let Err(e) = local.engine.stop() {
1518                                log::error!("pause failed: {}", e);
1519                                return;
1520                            }
1521                        }
1522                    }
1523                }
1524                session.run = Run::Paused;
1525            }
1526        }
1527        self.pause_renderer();
1528        self.report(PlaybackReportState::Paused);
1529    }
1530
1531    /// Resume playback. Fades back in if the pause faded out.
1532    ///
1533    /// With nothing loaded — a session restored stopped, or a start that
1534    /// failed — there is nothing to resume, and play starts the track under
1535    /// the cursor instead of doing nothing. A track still on its way keeps the
1536    /// position it was waiting to open at.
1537    pub fn resume(&mut self) {
1538        self.answer_silence();
1539        if std::mem::take(&mut self.sleep_snap_on_resume) {
1540            self.set_sleep_gain(1.0, true);
1541        }
1542        let session = match &mut self.transport {
1543            Transport::Idle => {
1544                if let Some(id) = self.shared_state.cursor() {
1545                    self.play(id);
1546                }
1547                return;
1548            }
1549            Transport::Waiting(waiting) => {
1550                let waiting = *waiting;
1551                self.cue(waiting.id, waiting.position_ms, Run::Playing);
1552                return;
1553            }
1554            Transport::Loaded(session) => session,
1555        };
1556        let Output::Local(local) = &session.output else {
1557            self.resume_renderer();
1558            return;
1559        };
1560        let engine = &local.engine;
1561        let resumed = if engine.is_running() || engine.is_silent() {
1562            self.lead_in_ends = None;
1563            engine.fade_in()
1564        } else {
1565            engine.start()
1566        };
1567        if let Err(e) = resumed {
1568            log::error!("resume failed: {}", e);
1569            return;
1570        }
1571        session.run = Run::Playing;
1572        self.wake_analyzer();
1573        self.report(PlaybackReportState::Playing);
1574    }
1575
1576    /// Tell the analyser there is about to be something to hear.
1577    ///
1578    /// It parks when nothing is playing and nothing is reading, and the one
1579    /// thing it cannot be signalled from is the play head — that counter is
1580    /// written by the audio render callback, which may never take a lock. So
1581    /// the player says so instead, on the two edges where silence ends.
1582    fn wake_analyzer(&self) {
1583        self.viz_snapshot.wake();
1584    }
1585
1586    /// Stop playback and clear playlist.
1587    pub fn stop(&mut self) {
1588        self.shared_state.clear_playlist();
1589        self.stop_playback_and_clear_state();
1590    }
1591
1592    /// Stop the audio engine and decode thread without touching shared state.
1593    ///
1594    /// Output stops first, then the decode thread is joined, then the engine
1595    /// drops: tearing CoreAudio down under a live producer is the end-of-queue
1596    /// crash (#89).
1597    fn stop_engine(&mut self) {
1598        let playback = match std::mem::replace(&mut self.transport, Transport::Idle) {
1599            Transport::Loaded(playback) => playback,
1600            other => {
1601                self.transport = other;
1602                return;
1603            }
1604        };
1605        self.session += 1;
1606        // A session ending some other way ends the DSP change's wait with it.
1607        self.dsp_restart = None;
1608        self.bank_listening();
1609        let local = match playback.output {
1610            Output::Local(local) => local,
1611            Output::Renderer(play) => {
1612                self.halt_renderer(*play);
1613                self.answer_silence();
1614                return;
1615            }
1616        };
1617        let Local {
1618            engine,
1619            mut decode_handle,
1620            stream,
1621            ..
1622        } = local;
1623
1624        let _ = engine.stop();
1625        // Stop first, so the failed read the abandon causes reads as a stop
1626        // rather than a bad source to skip past.
1627        decode_handle.signal_stop();
1628        if let Some(stream) = stream {
1629            stream.abandon();
1630        }
1631        decode_handle.stop();
1632        drop(engine);
1633        self.answer_silence();
1634    }
1635
1636    /// Tell whoever waited for the pause where the playhead came to rest.
1637    fn answer_silence(&mut self) {
1638        let position_ms = self.shared_state.position_ms();
1639        for reply in self.silence_waiters.drain(..) {
1640            let _ = reply.send(position_ms);
1641        }
1642    }
1643
1644    /// Full stop: tear down engine + clear all display state.
1645    fn stop_playback_and_clear_state(&mut self) {
1646        self.forget_waiting();
1647        self.report(PlaybackReportState::Stopped);
1648        self.finish_play();
1649        self.stop_engine();
1650        self.timeline.reset();
1651        self.shared_state.set_position_ms(0);
1652    }
1653
1654    /// Remove a track from the playlist. If it was the cursor, move to the
1655    /// track that followed it, as the listener's last request would have it.
1656    ///
1657    /// `remove_item` clears the cursor, and an unset cursor means "start from the
1658    /// top" — so the successor is pinned down by parking the cursor on the removed
1659    /// track's predecessor first. `None` is correct only when it was the first item.
1660    pub fn remove_from_playlist(&mut self, id: QueueItemId) {
1661        let was_cursor = self.shared_state.is_cursor(id);
1662        let resume_after = was_cursor
1663            .then(|| self.shared_state.item_before(id))
1664            .flatten();
1665        self.shared_state.remove_item(id);
1666        if was_cursor {
1667            self.resume_from(resume_after);
1668            let next = self.shared_state.advance_cursor_loadable();
1669            self.carry_on(next, self.intent());
1670        }
1671    }
1672
1673    /// Where an advance goes on from once the cursor's item is removed: the
1674    /// item before it in the queue, or the top. Shuffled, from the play order
1675    /// with no cursor at all — putting it on the item before would count that
1676    /// one as played again.
1677    fn resume_from(&self, before: Option<QueueItemId>) {
1678        if !self.shared_state.is_shuffled() {
1679            self.shared_state.set_cursor(before);
1680        }
1681    }
1682
1683    /// A download finished. A track waiting on it opens; one already
1684    /// streaming from it, playing or paused, re-reads its metadata from the
1685    /// complete file.
1686    ///
1687    /// The item's state is the downloader's to set, before it sends this.
1688    /// Setting it here as well would turn a duplicate entry's `Failed` back to
1689    /// `Ready`, pointing at a `.part` file that was deleted.
1690    pub fn track_ready(&mut self, id: QueueItemId) {
1691        if !self.shared_state.is_cursor(id) {
1692            return;
1693        }
1694
1695        if let Some(waiting) = self.waiting().filter(|w| w.id == id) {
1696            log::info!("track_ready: opening {:?}", id);
1697            self.cue(id, waiting.position_ms, waiting.start);
1698            return;
1699        }
1700
1701        if self.session().is_some_and(|s| s.track.id == id) {
1702            log::info!(
1703                "track_ready: download complete while streaming {:?}, refreshing metadata",
1704                id
1705            );
1706            self.refresh_track_metadata(id);
1707        }
1708    }
1709
1710    /// Enough of a download has landed to stream it. Opens the track if it is
1711    /// waiting to start from the top.
1712    pub fn track_stream_ready(&mut self, id: QueueItemId) {
1713        let Some(waiting) = self.waiting().filter(|w| self.may_stream(w, id)) else {
1714            return;
1715        };
1716        if !self.shared_state.is_cursor(id) {
1717            return;
1718        }
1719
1720        match self.shared_state.item_playback_source(id) {
1721            Some(PlaybackSource::Streaming {
1722                path,
1723                bytes_written,
1724                total,
1725            }) => {
1726                log::info!("track_stream_ready: probing partial file for {:?}", id);
1727                self.probe_stream_for_playback(id, &path, bytes_written, total);
1728            }
1729            Some(PlaybackSource::Ready(path)) => {
1730                // Download finished between threshold and now — just play normally.
1731                log::info!(
1732                    "track_stream_ready: track already ready, starting normal playback for {:?}",
1733                    id
1734                );
1735                if let Err(e) = self.start_playback(id, &path, 0, waiting.start) {
1736                    log::error!("track_stream_ready playback failed: {}", e);
1737                }
1738            }
1739            None => {} // Not enough data yet — wait.
1740        }
1741    }
1742
1743    /// Re-read full lofty metadata for a track after its download completes.
1744    /// Called from track_ready() when a streaming track finishes downloading.
1745    /// What the item takes from it is `update_item_metadata`'s call.
1746    fn refresh_track_metadata(&mut self, id: QueueItemId) {
1747        use crate::index::metadata;
1748
1749        let path = match self.shared_state.item_path_if_ready(id) {
1750            Some(p) => p,
1751            None => return,
1752        };
1753
1754        match metadata::read_metadata(&path) {
1755            Ok(meta) => {
1756                self.shared_state.update_item_metadata(
1757                    id,
1758                    meta.title,
1759                    meta.artist,
1760                    meta.album_artist.unwrap_or_default(),
1761                    meta.album,
1762                    meta.duration_ms.map(|d| d as u64),
1763                );
1764
1765                // Re-probe the complete file for accurate duration + stream info.
1766                // The initial probe was done on partial streaming data and may have
1767                // underestimated duration, causing premature seek clamping or wrong
1768                // progress bar display.
1769                //
1770                // The path is taken over at the same time. Playback started
1771                // against the `.part` file and the download's last act is to
1772                // rename it, so what `track_info` holds now names nothing.
1773                if let Transport::Loaded(session) = &mut self.transport
1774                    && session.track.id == id
1775                {
1776                    let current = &mut session.track;
1777                    let probed = buffer::probe_file(&path).ok();
1778                    let duration_ms = probed
1779                        .as_ref()
1780                        .map(|s| s.duration_ms)
1781                        .filter(|d| *d > current.duration_ms)
1782                        .unwrap_or(current.duration_ms);
1783                    if duration_ms != current.duration_ms {
1784                        log::info!(
1785                            "track_ready: duration corrected {}ms → {}ms",
1786                            current.duration_ms,
1787                            duration_ms
1788                        );
1789                    }
1790                    current.duration_ms = duration_ms;
1791                    current.path = path.clone();
1792                }
1793
1794                // Signal UI to re-read cover art and update souvlaki media controls.
1795                self.shared_state.signal_metadata_refresh();
1796                log::info!("track_ready: metadata refreshed for {:?}", id);
1797            }
1798            Err(e) => {
1799                log::warn!("track_ready: metadata refresh failed for {:?}: {}", id, e);
1800            }
1801        }
1802    }
1803
1804    /// A session has opened on `id`. A seek restarts playback of the same
1805    /// track, so the play in flight carries on when it is the same item —
1806    /// otherwise scrubbing around a track would enter it into history once
1807    /// per seek. Everything that ends a play and opens the same item again,
1808    /// as a new one, closes the old play first: `play`, a repeat at the end
1809    /// of a session.
1810    fn on_track_changed(&mut self, id: QueueItemId, position_ms: u64) {
1811        if let Some(f) = self.in_flight.as_mut().filter(|f| f.item == id) {
1812            f.jump(position_ms);
1813            // The session is new, so the play is its first boundary.
1814            f.boundary = 0;
1815            return;
1816        }
1817        self.begin_play(id, position_ms, 0);
1818    }
1819
1820    /// The needle has moved to a new play of `id`, at `boundary` of the
1821    /// session. Close out the outgoing play and write the new one to history
1822    /// straight away, so history reads in play order even for a track that
1823    /// is skipped a moment later.
1824    fn begin_play(&mut self, id: QueueItemId, position_ms: u64, boundary: usize) {
1825        self.finish_play();
1826        self.shared_state.mark_played(id);
1827        let track_id = self.shared_state.item_db_id(id);
1828        let mut flight = InFlight::new(id, track_id, position_ms);
1829        flight.boundary = boundary;
1830        self.in_flight = Some(flight);
1831        if let (Some(track_id), Some(recorder)) = (track_id, self.history.as_ref()) {
1832            recorder.record(PlayEvent::Started {
1833                track_id,
1834                position_ms,
1835            });
1836        }
1837    }
1838
1839    /// Count what has played of the track in flight since it was last
1840    /// counted. Before anything that resets the timeline, which is where the
1841    /// playhead is read from, and before a track is closed out.
1842    fn bank_listening(&mut self) {
1843        // A renderer's playhead is its clock: set only while one plays.
1844        let renderer_at = self
1845            .shared_state
1846            .renderer_clock()
1847            .map(|_| self.shared_state.position_ms());
1848        if let Some(f) = self.in_flight.as_mut()
1849            && let Some(at) = self.timeline.position_in(f.boundary).or(renderer_at)
1850        {
1851            f.advance(at);
1852        }
1853    }
1854
1855    /// Tell the remote server where the track playing now stands. A track
1856    /// starting is reported by `on_track_changed`; this covers what happens
1857    /// to it afterwards.
1858    fn report(&self, state: PlaybackReportState) {
1859        let Some(track_id) = self.in_flight.as_ref().and_then(InFlight::track_id) else {
1860            return;
1861        };
1862        if let Some(recorder) = self.history.as_ref() {
1863            recorder.record(PlayEvent::Playback(PlaybackReport {
1864                track_id,
1865                state,
1866                position_ms: self.shared_state.position_ms(),
1867            }));
1868        }
1869    }
1870
1871    /// Tell history how long the current track was heard for. Returns what was
1872    /// reported, which is how the tests see it.
1873    fn finish_play(&mut self) -> Option<PlayEvent> {
1874        self.bank_listening();
1875        let flight = self.in_flight.take()?;
1876        let event = PlayEvent::Finished {
1877            track_id: flight.track_id()?,
1878            listened_ms: flight.listened_ms(),
1879        };
1880        if let Some(recorder) = self.history.as_ref() {
1881            recorder.record(event);
1882        }
1883        Some(event)
1884    }
1885
1886    /// What happens without a command: the silence after a rate switch
1887    /// running out, a pause's fade reaching silence, and the playhead crossing
1888    /// into the next queued track. Called from the command loop on each wake.
1889    pub fn update_playback_state(&mut self) {
1890        self.sleep_tick();
1891        if let Some(SleepSet { at: Some(at), .. }) = self.sleep
1892            && std::time::Instant::now() >= at
1893        {
1894            self.sleep = None;
1895            log::info!("sleep timer: time's up");
1896            // Faded to silence by now: what is cut is not heard.
1897            if self.intent() == Some(Run::Playing) {
1898                self.pause_with(Fade::Cut);
1899            }
1900            self.end_sleep_fade();
1901        }
1902
1903        if self.session().is_some()
1904            && self
1905                .lead_in_ends
1906                .is_some_and(|end| std::time::Instant::now() >= end)
1907        {
1908            // The playhead held still through the silence while clients
1909            // counted on from where it was published; this is where they hear
1910            // it move.
1911            self.lead_in_ends = None;
1912            self.shared_state.changed();
1913        }
1914
1915        if let Some(session) = self.session()
1916            && session.run == Run::Paused
1917            && let Some(engine) = session.engine()
1918            && engine.is_running()
1919            && engine.is_silent()
1920        {
1921            if let Err(e) = engine.stop() {
1922                log::error!("stopping after fade failed: {}", e);
1923            }
1924            self.answer_silence();
1925        }
1926
1927        if let Some(since) = self.dsp_restart {
1928            let faded = self
1929                .session()
1930                .and_then(Session::engine)
1931                .is_none_or(|e| e.is_silent());
1932            if faded || since.elapsed() >= QUICK_FADE_LIMIT {
1933                self.dsp_restart = None;
1934                self.quick_start = true;
1935                self.restart_on_current_track();
1936                self.quick_start = false;
1937            }
1938        }
1939
1940        self.release_idle_session();
1941        self.follow_playhead();
1942        self.renderer_tick();
1943        self.publish();
1944    }
1945
1946    /// A gapless transition moves the playhead into the next track without
1947    /// anything on this thread asking, so the play is banked, the session's
1948    /// track moved on and the cursor brought along from here. A play is its
1949    /// boundary: an item repeated runs into itself, and that is a new play.
1950    ///
1951    /// A sleep timer for the end of the track or record stops playback the
1952    /// moment the next play is heard to start, gaplessly, so with no fade
1953    /// over a track only just begun.
1954    fn follow_playhead(&mut self) {
1955        let before = self.in_flight.as_ref().map(|f| (f.item, f.boundary));
1956        self.move_with_playhead();
1957        let after = self.in_flight.as_ref().map(|f| (f.item, f.boundary));
1958        if let (Some((ended, _)), Some((next, _))) = (before, after)
1959            && before != after
1960            && self.sleeps_between(Some(ended), Some(next))
1961        {
1962            self.pause_with(Fade::Cut);
1963            self.end_sleep_fade();
1964        }
1965    }
1966
1967    fn move_with_playhead(&mut self) {
1968        if self.session().is_none() {
1969            return;
1970        }
1971        let Some(playhead) = self.timeline.playhead() else {
1972            return;
1973        };
1974        let id = playhead.id;
1975        if self
1976            .in_flight
1977            .as_ref()
1978            .is_none_or(|f| f.item != id || f.boundary != playhead.boundary)
1979        {
1980            self.begin_play(id, playhead.position_ms, playhead.boundary);
1981        }
1982        let Transport::Loaded(session) = &mut self.transport else {
1983            return;
1984        };
1985        let pass = step_pass(&session.lookahead.lock(), playhead.boundary);
1986        if session.track.id == id {
1987            return;
1988        }
1989        let Some((id, path, info, _)) = self.timeline.current_playback() else {
1990            return;
1991        };
1992        log::info!("timeline: now playing {:?}", id);
1993        session.track = TrackInfo {
1994            id,
1995            path,
1996            codec: info.codec,
1997            sample_rate: info.sample_rate,
1998            bit_depth: info.bit_depth,
1999            bitrate_kbps: info.bitrate_kbps,
2000            channels: info.channels,
2001            duration_ms: info.duration_ms,
2002        };
2003        self.shared_state.move_on_to(id, pass);
2004    }
2005
2006    /// Whether a sleep timer set for the end of the track or record ends
2007    /// playback where `ended` gives way to `next`, `None` at the end of the
2008    /// queue. Spent if so.
2009    fn sleeps_between(&mut self, ended: Option<QueueItemId>, next: Option<QueueItemId>) -> bool {
2010        let record = |id: Option<QueueItemId>| {
2011            id.and_then(|id| self.shared_state.get_item(id))
2012                .map(|i| (i.album, i.album_artist))
2013        };
2014        let due = match self.sleep.map(|s| s.sleep) {
2015            Some(Sleep::EndOfTrack) => true,
2016            Some(Sleep::EndOfRecord) => next.is_none() || record(ended) != record(next),
2017            _ => false,
2018        };
2019        if due {
2020            log::info!(
2021                "sleep timer: stopping at the end of the {}",
2022                match self.sleep {
2023                    Some(SleepSet {
2024                        sleep: Sleep::EndOfTrack,
2025                        ..
2026                    }) => "track",
2027                    _ => "record",
2028                }
2029            );
2030            self.sleep = None;
2031        }
2032        due
2033    }
2034
2035    /// Set the sleep timer, or cancel it.
2036    fn set_sleep_timer(&mut self, timer: Option<SleepTimer>) {
2037        self.restore_sleep_fade();
2038        self.sleep = timer.map(|timer| match timer {
2039            SleepTimer::After { minutes } => {
2040                let after = std::time::Duration::from_secs(u64::from(minutes) * 60);
2041                let unix_ms = std::time::SystemTime::now()
2042                    .duration_since(std::time::UNIX_EPOCH)
2043                    .unwrap_or_default()
2044                    + after;
2045                SleepSet {
2046                    sleep: Sleep::At {
2047                        unix_ms: unix_ms.as_millis() as u64,
2048                    },
2049                    at: Some(std::time::Instant::now() + after),
2050                    fade: Some(sleep::fade_length(minutes)),
2051                }
2052            }
2053            SleepTimer::EndOfTrack => SleepSet {
2054                sleep: Sleep::EndOfTrack,
2055                at: None,
2056                fade: None,
2057            },
2058            SleepTimer::EndOfRecord => SleepSet {
2059                sleep: Sleep::EndOfRecord,
2060                at: None,
2061                fade: None,
2062            },
2063        });
2064        log::info!("sleep timer: {:?}", self.sleep.map(|s| s.sleep));
2065    }
2066
2067    /// A download a waiting track needs will never land.
2068    ///
2069    /// That wait has no end, so walk on to the next item that can still load,
2070    /// opening it the way the failed one would have opened, or stop cleanly if
2071    /// there is none. Only a waiting track is affected: one already streaming
2072    /// sees the failure in its reads and ends the decode, which advances the
2073    /// queue.
2074    pub fn track_failed(&mut self, id: QueueItemId) {
2075        let Some(waiting) = self.waiting().filter(|w| w.id == id) else {
2076            return;
2077        };
2078        if !self.shared_state.is_cursor(id) {
2079            return;
2080        }
2081        log::info!("track {:?} cannot load, moving on", id);
2082        let next = self.shared_state.advance_cursor_loadable();
2083        self.carry_on(next, Some(waiting.start));
2084    }
2085
2086    /// Decode thread naturally finished (playlist exhausted or error).
2087    /// Advance to the next playable track; otherwise stop cleanly.
2088    ///
2089    /// Ignored from a session already torn down: a play or seek handled after
2090    /// the message was sent has replaced what it describes.
2091    fn on_decode_finished(&mut self, session: u64) {
2092        if session != self.session || self.renderer_stream_finished() {
2093            return;
2094        }
2095        log::info!("decode finished, checking for next track");
2096        self.track_ended();
2097    }
2098
2099    /// The track under the playhead played out, here or on a renderer. Go on
2100    /// as the play mode says: the same item again under repeat one, the next
2101    /// otherwise, from the top again when the queue repeats.
2102    ///
2103    /// A track that has not finished downloading parks the cursor on it, so its
2104    /// `TrackReady`/`TrackStreamReady` resumes the queue instead of being
2105    /// discarded as "not the cursor".
2106    ///
2107    /// Opening the item that just ended again — repeating it, or a queue of
2108    /// one repeating — closes the play that ended first, so the session that
2109    /// opens is a new play rather than a seek within it. A repeat of something
2110    /// that played for no time at all — a file that opens but decodes nothing
2111    /// — would go round forever, so it stops instead.
2112    fn track_ended(&mut self) {
2113        self.bank_listening();
2114        let ended = self.in_flight.as_ref().map(|f| f.item);
2115        let heard = self.in_flight.as_ref().is_some_and(|f| f.listened_ms() > 0);
2116        // A track streamed while it downloads, ended with nothing heard and
2117        // its download still running, did not play: the stream gave out ahead
2118        // of the bytes. It is waited for and opened from disk when it lands,
2119        // as a track not yet downloaded is, rather than passed over for the
2120        // next. Only a track that has failed is moved past.
2121        if !heard
2122            && let Some(id) = self.session().map(|s| s.track.id)
2123            && self.shared_state.is_cursor(id)
2124            && matches!(self.shared_state.item_state(id), Some(ItemState::Pending))
2125        {
2126            log::info!("track {id:?} ended unheard before its download did; waiting for it");
2127            let start = self.intent().unwrap_or(Run::Playing);
2128            self.park(id, 0, start);
2129            return;
2130        }
2131        if self.mode.repeat != Repeat::Off && !heard {
2132            log::info!("track ended with nothing heard; not repeating it");
2133            self.stop_playback_and_clear_state();
2134            return;
2135        }
2136        let again = (self.mode.repeat == Repeat::One)
2137            .then(|| self.shared_state.cursor())
2138            .flatten()
2139            .filter(|id| {
2140                self.shared_state
2141                    .item_state(*id)
2142                    .is_some_and(|s| !matches!(s, ItemState::Failed(_)))
2143            });
2144        let next = again.or_else(|| self.shared_state.advance_cursor_loadable());
2145        if next.is_some() && next == ended {
2146            self.finish_play();
2147        }
2148        let sleeps = self.intent().is_some() && self.sleeps_between(ended, next);
2149        let intent = if sleeps {
2150            Some(Run::Paused)
2151        } else {
2152            self.intent()
2153        };
2154        self.carry_on(next, intent);
2155        if sleeps {
2156            self.end_sleep_fade();
2157        }
2158    }
2159
2160    /// Restart the session at the playhead if the queue no longer follows the
2161    /// playing track with what the decoder has already queued.
2162    ///
2163    /// The decoder looks ahead a few seconds before a track ends, and what it
2164    /// queued is in the ring. Without this, removing or moving the next track
2165    /// in that window has no effect, and a track inserted to play next is
2166    /// skipped. A lookahead not yet taken needs nothing: the decoder reads the
2167    /// queue when it gets there. One that found the end of the queue counts as
2168    /// taken, so a track added then follows gaplessly instead of after a stop.
2169    fn revoke_stale_lookahead(&mut self) {
2170        if self.session().is_none() {
2171            return;
2172        }
2173        self.update_playback_state();
2174        let Some(playhead) = self.timeline.playhead() else {
2175            return;
2176        };
2177        let position_ms = playhead.position_ms;
2178        let Some(session) = self.session().filter(|s| s.track.id == playhead.id) else {
2179            return;
2180        };
2181        let stale = {
2182            let steps = session.lookahead.lock();
2183            // The steps the playhead has yet to reach: those that open a
2184            // boundary past the one it is in. By boundary, not by the item
2185            // chosen — an item repeated is chosen by every step.
2186            steps
2187                .iter()
2188                .filter(|step| step.boundary > playhead.boundary)
2189                .any(|step| !self.shared_state.still_follows(step))
2190        };
2191        if !stale {
2192            return;
2193        }
2194        log::info!("queue changed under the lookahead, restarting at {position_ms}ms");
2195        if let Err(e) = self.restart_current(position_ms) {
2196            log::error!("restart after a queue edit failed: {}", e);
2197        }
2198    }
2199
2200    /// Snapshot items with their predecessors for an undo of "these were removed".
2201    /// In playlist order, so undo re-inserts each item after a predecessor that
2202    /// is already back in place.
2203    fn snapshot_for_undo(
2204        &self,
2205        ids: &[QueueItemId],
2206    ) -> Vec<(Box<state::PlaylistItem>, Option<QueueItemId>)> {
2207        self.shared_state
2208            .items_before(ids)
2209            .into_iter()
2210            .filter_map(|(id, after)| Some((Box::new(self.shared_state.get_item(id)?), after)))
2211            .collect()
2212    }
2213
2214    /// Route an undo entry to the batch buffer (if batching) or the undo stack.
2215    fn push_undo(&mut self, entry: UndoEntry) {
2216        if let Some(ref mut batch) = self.batch_buffer {
2217            batch.push(entry);
2218        } else {
2219            self.undo_stack.push(entry);
2220        }
2221    }
2222
2223    /// Process a single command.
2224    pub fn process_command(&mut self, cmd: PlayerCommand) {
2225        // Someone wants to hear something, or has picked where: the renderer
2226        // remembered from last time is no longer what to go back to. A cue is
2227        // how a session is restored at launch, which is what the renderer is
2228        // to carry on with, so it is not one of them.
2229        // So, too, is one that pauses, stops or replaces the session: what was
2230        // held for the renderer is no longer wanted anywhere.
2231        if cmd.asks_to_play() && !matches!(cmd, PlayerCommand::Cue { .. })
2232            || matches!(
2233                cmd,
2234                PlayerCommand::UseRenderer(_)
2235                    | PlayerCommand::SetOutputDevice(_)
2236                    | PlayerCommand::ClearOutputDevice
2237                    | PlayerCommand::Pause
2238                    | PlayerCommand::PauseAndReport(_)
2239                    | PlayerCommand::Stop
2240                    | PlayerCommand::ClearPlaylist
2241                    | PlayerCommand::ReplacePlaylist { .. }
2242            )
2243        {
2244            self.resume_renderer = false;
2245            self.held_run = None;
2246        }
2247        // The restored session, while the renderer it played on is looked
2248        // for: opened paused, its run held for wherever it ends up.
2249        let cmd = match cmd {
2250            PlayerCommand::Cue {
2251                id,
2252                position_ms,
2253                play: true,
2254            } if self.resume_renderer => {
2255                self.held_run = Some(Run::Playing);
2256                PlayerCommand::Cue {
2257                    id,
2258                    position_ms,
2259                    play: false,
2260                }
2261            }
2262            cmd => cmd,
2263        };
2264        let edits_queue = matches!(
2265            cmd,
2266            PlayerCommand::AddToPlaylist(_)
2267                | PlayerCommand::InsertInPlaylist { .. }
2268                | PlayerCommand::RemoveFromPlaylist(_)
2269                | PlayerCommand::RemoveFromPlaylistBatch(_)
2270                | PlayerCommand::MoveInPlaylist { .. }
2271                | PlayerCommand::MoveItemsInPlaylist { .. }
2272                | PlayerCommand::ReorderPlaylist(_)
2273                | PlayerCommand::Undo
2274                | PlayerCommand::Redo
2275                | PlayerCommand::SetShuffle(_)
2276                | PlayerCommand::SetRepeat(_)
2277                | PlayerCommand::RestorePlayMode(_)
2278        );
2279        self.apply_command(cmd);
2280        if edits_queue {
2281            self.revoke_stale_lookahead();
2282        }
2283        self.publish();
2284    }
2285
2286    fn apply_command(&mut self, cmd: PlayerCommand) {
2287        match cmd {
2288            PlayerCommand::Play(id) => {
2289                self.shared_state.pick(id);
2290                self.play(id);
2291            }
2292            PlayerCommand::Cue {
2293                id,
2294                position_ms,
2295                play,
2296            } => self.cue(
2297                id,
2298                position_ms,
2299                if play { Run::Playing } else { Run::Paused },
2300            ),
2301            PlayerCommand::Pause => {
2302                self.paused_in_sleep_fade();
2303                self.pause();
2304            }
2305            // Every command before this one has been applied and published:
2306            // commands are taken one at a time, each published as it ends.
2307            PlayerCommand::Barrier(reply) => {
2308                let _ = reply.send(());
2309            }
2310            PlayerCommand::PauseAndReport(reply) => {
2311                self.pause();
2312                self.silence_waiters.push(reply);
2313                if self
2314                    .session()
2315                    .is_none_or(|p| p.engine().is_none_or(|e| !e.is_running()))
2316                {
2317                    self.answer_silence();
2318                }
2319            }
2320            PlayerCommand::Resume => self.resume(),
2321            PlayerCommand::Stop => self.stop(),
2322            PlayerCommand::Seek(pos) => self.seek(pos),
2323            PlayerCommand::NextTrack => self.next_track(),
2324            PlayerCommand::PrevTrack => self.prev_track(),
2325            PlayerCommand::AddToPlaylist(items) => {
2326                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
2327                self.shared_state.add_items(items);
2328                self.push_undo(UndoEntry::Added { ids });
2329            }
2330            PlayerCommand::UpdatePaths(updates) => {
2331                self.shared_state.update_paths(&updates);
2332                if let Transport::Loaded(session) = &mut self.transport
2333                    && let Some((_, new_path)) =
2334                        updates.iter().find(|(id, _)| *id == session.track.id)
2335                {
2336                    session.track.path = new_path.clone();
2337                }
2338            }
2339            PlayerCommand::InsertInPlaylist { items, after } => {
2340                let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
2341                self.shared_state.insert_items_after(items, after);
2342                self.push_undo(UndoEntry::Inserted { ids });
2343            }
2344            PlayerCommand::ClearPlaylist => self.clear_playlist(),
2345            PlayerCommand::ReplacePlaylist {
2346                items,
2347                start,
2348                position_ms,
2349                play,
2350                mode,
2351            } => {
2352                if items.is_empty() {
2353                    self.clear_playlist();
2354                    return;
2355                }
2356                let start_id = items.get(start).unwrap_or(&items[0]).id;
2357                // Stopped before the swap, as `clear_playlist` does, and the
2358                // swap one change: see `SharedPlayerState::replace_playlist`.
2359                self.stop_playback_and_clear_state();
2360                if let Some(mode) = mode.play_mode() {
2361                    self.mode = mode;
2362                    self.shared_state.set_shuffled(mode.shuffle);
2363                }
2364                let (old_items, cursor) = self.shared_state.replace_playlist(items);
2365                let start_id = match mode {
2366                    QueueMode::Shuffled => self
2367                        .shared_state
2368                        .advance_cursor_loadable()
2369                        .unwrap_or(start_id),
2370                    _ => start_id,
2371                };
2372                self.push_undo(UndoEntry::Replaced {
2373                    items: old_items,
2374                    cursor,
2375                });
2376                self.cue(
2377                    start_id,
2378                    position_ms,
2379                    if play { Run::Playing } else { Run::Paused },
2380                );
2381            }
2382            PlayerCommand::RemoveFromPlaylist(id) => {
2383                let item = self.shared_state.get_item(id);
2384                let after = self.shared_state.item_before(id);
2385                self.remove_from_playlist(id);
2386                if let Some(item) = item {
2387                    self.push_undo(UndoEntry::Removed {
2388                        items: vec![(Box::new(item), after)],
2389                    });
2390                }
2391            }
2392            PlayerCommand::RemoveFromPlaylistBatch(ids) => {
2393                // Snapshot before removing anything, and resolve the resume point
2394                // once: removing one at a time would restart the engine for every
2395                // deleted track that the cursor lands on along the way.
2396                let items_with_pos = self.snapshot_for_undo(&ids);
2397                let intent = self.intent();
2398                let resume_after = match self.shared_state.cursor() {
2399                    Some(cursor) if ids.contains(&cursor) => {
2400                        Some(self.shared_state.surviving_item_before(cursor, &ids))
2401                    }
2402                    _ => None,
2403                };
2404
2405                self.shared_state.remove_items(&ids);
2406
2407                if let Some(resume_after) = resume_after {
2408                    self.resume_from(resume_after);
2409                    let next = self.shared_state.advance_cursor_loadable();
2410                    self.carry_on(next, intent);
2411                }
2412
2413                if !items_with_pos.is_empty() {
2414                    self.push_undo(UndoEntry::Removed {
2415                        items: items_with_pos,
2416                    });
2417                }
2418            }
2419            PlayerCommand::MoveInPlaylist { id, target, after } => {
2420                let was_after = self.shared_state.item_before(id);
2421                self.shared_state.move_item(id, target, after);
2422                self.push_undo(UndoEntry::Moved { id, was_after });
2423            }
2424            PlayerCommand::MoveItemsInPlaylist { ids, target, after } => {
2425                let entries = self.shared_state.items_before(&ids);
2426                self.shared_state.move_items(&ids, target, after);
2427                self.push_undo(UndoEntry::MovedBatch { entries });
2428            }
2429            PlayerCommand::ReorderPlaylist(order) => {
2430                // Undoable like any other move: undoing it puts the queue back
2431                // and, by doing so, ends the lock — which is the honest result
2432                // of having rearranged the queue by hand.
2433                let entries = self.shared_state.items_before(&order);
2434                self.shared_state.reorder_to(&order);
2435                self.push_undo(UndoEntry::MovedBatch { entries });
2436            }
2437            PlayerCommand::TrackReady(id) => self.track_ready(id),
2438            PlayerCommand::DecodeFinished(session) => self.on_decode_finished(session),
2439            // Nothing to do but wake: the loop works out when the playhead
2440            // reaches the track just queued.
2441            PlayerCommand::TrackQueued => {}
2442            PlayerCommand::TrackStreamReady(id) => self.track_stream_ready(id),
2443            PlayerCommand::StreamProbed { id, info, mode } => self.stream_probed(id, *info, mode),
2444            PlayerCommand::TrackFailed(id) => self.track_failed(id),
2445            PlayerCommand::CacheTracks(ids) => {
2446                if let Some(downloads) = &self.downloads {
2447                    downloads.cache(ids);
2448                }
2449            }
2450            PlayerCommand::Undo => self.execute_undo(),
2451            PlayerCommand::Redo => self.execute_redo(),
2452            PlayerCommand::BeginUndoBatch => {
2453                self.batch_buffer = Some(Vec::new());
2454            }
2455            PlayerCommand::EndUndoBatch => {
2456                if let Some(entries) = self.batch_buffer.take() {
2457                    if entries.len() == 1 {
2458                        // Single entry — push directly, no wrapping.
2459                        self.undo_stack.push(entries.into_iter().next().unwrap());
2460                    } else if !entries.is_empty() {
2461                        self.undo_stack.push(UndoEntry::Batch(entries));
2462                    }
2463                }
2464            }
2465            PlayerCommand::SetOutputDevice(name) => self.set_output_device(name),
2466            PlayerCommand::RestartOutput => {
2467                log::info!("restarting audio output");
2468                self.restart_on_current_track();
2469            }
2470            PlayerCommand::ClearOutputDevice => self.clear_output_device(),
2471            PlayerCommand::ReloadDsp => self.reload_dsp(),
2472            PlayerCommand::RouteChanged => {
2473                crate::audio::follow_route();
2474                self.reload_dsp();
2475            }
2476            PlayerCommand::UseRenderer(connection) => {
2477                self.remember_renderer(connection.as_deref());
2478                self.use_renderer(connection);
2479            }
2480            PlayerCommand::ResumeRenderer(connection) => {
2481                if std::mem::take(&mut self.resume_renderer) && self.renderer.is_none() {
2482                    log::info!(
2483                        "upnp: back to {}, as last time",
2484                        connection.session.renderer().name
2485                    );
2486                    self.use_renderer(Some(connection));
2487                    if self.held_run.take() == Some(Run::Playing) {
2488                        self.resume();
2489                    }
2490                } else {
2491                    log::info!(
2492                        "upnp: not going back to {}: playback or the output moved first",
2493                        connection.session.renderer().name
2494                    );
2495                }
2496            }
2497            PlayerCommand::ResumeRendererMissed => {
2498                if std::mem::take(&mut self.resume_renderer)
2499                    && self.held_run.take() == Some(Run::Playing)
2500                {
2501                    log::info!("upnp: playing here what was held for the renderer");
2502                    self.resume();
2503                }
2504            }
2505            PlayerCommand::ReleaseRenderer(reply) => {
2506                self.release_renderer();
2507                let _ = reply.send(());
2508            }
2509            PlayerCommand::SetRendererVolume(volume) => self.set_renderer_volume(volume),
2510            PlayerCommand::Renderer { session, event } => self.on_renderer_event(session, event),
2511            PlayerCommand::SetShuffle(on) => self.set_shuffle(on),
2512            PlayerCommand::SetRepeat(repeat) => self.mode.repeat = repeat,
2513            PlayerCommand::RestorePlayMode(mode) => {
2514                self.mode = mode;
2515                self.shared_state.set_shuffled(mode.shuffle);
2516            }
2517            PlayerCommand::SetSleepTimer(timer) => self.set_sleep_timer(timer),
2518        }
2519    }
2520
2521    /// Turn shuffle on or off. Nothing in the queue moves, so it is not an
2522    /// edit to undo: on, the items yet to play this pass play in a random
2523    /// order kept beside the queue; off, the queue plays on in its own order
2524    /// from the cursor.
2525    fn set_shuffle(&mut self, on: bool) {
2526        self.mode.shuffle = on;
2527        self.shared_state.set_shuffled(on);
2528    }
2529
2530    /// Stop, then clear the playlist as one undoable step. Playback and display
2531    /// state go first, without touching the playlist, so the snapshot taken for
2532    /// undo is of the playlist as it was.
2533    fn clear_playlist(&mut self) {
2534        self.stop_playback_and_clear_state();
2535        let (items, cursor) = self.shared_state.snapshot_playlist();
2536        self.shared_state.clear_playlist();
2537        self.push_undo(UndoEntry::Replaced { items, cursor });
2538    }
2539
2540    /// Apply an undo/redo entry: mutate the playlist and return the inverse entry.
2541    fn apply_entry(&mut self, entry: UndoEntry) -> UndoEntry {
2542        match entry {
2543            UndoEntry::Added { ids } | UndoEntry::Inserted { ids } => {
2544                // Undo of "items were added": snapshot them with positions, then remove.
2545                let items_with_pos = self.snapshot_for_undo(&ids);
2546                self.shared_state.remove_items(&ids);
2547                UndoEntry::Removed {
2548                    items: items_with_pos,
2549                }
2550            }
2551            UndoEntry::Removed { items } => {
2552                // Undo of "items were removed": re-insert each at its position.
2553                let mut ids = Vec::with_capacity(items.len());
2554                for (item, after) in items {
2555                    ids.push(item.id);
2556                    self.shared_state.insert_item_at(*item, after);
2557                }
2558                UndoEntry::Added { ids }
2559            }
2560            UndoEntry::Moved { id, was_after } => {
2561                let current_after = self.shared_state.item_before(id);
2562                self.shared_state.move_item_to(id, was_after);
2563                UndoEntry::Moved {
2564                    id,
2565                    was_after: current_after,
2566                }
2567            }
2568            UndoEntry::MovedBatch { entries } => {
2569                let ids: Vec<QueueItemId> = entries.iter().map(|(id, _)| *id).collect();
2570                let current_positions = self.shared_state.items_before(&ids);
2571                self.shared_state.move_items_to(&entries);
2572                UndoEntry::MovedBatch {
2573                    entries: current_positions,
2574                }
2575            }
2576            UndoEntry::Replaced { items, cursor } => {
2577                let (current_items, current_cursor) = self.shared_state.snapshot_playlist();
2578                self.shared_state.restore_playlist(items, cursor);
2579                UndoEntry::Replaced {
2580                    items: current_items,
2581                    cursor: current_cursor,
2582                }
2583            }
2584            UndoEntry::Batch(entries) => {
2585                // Apply entries in reverse order, collect inverses.
2586                let mut inverses: Vec<_> = entries
2587                    .into_iter()
2588                    .rev()
2589                    .map(|e| self.apply_entry(e))
2590                    .collect();
2591                inverses.reverse();
2592                UndoEntry::Batch(inverses)
2593            }
2594        }
2595    }
2596
2597    /// Put playback back in agreement with the playlist.
2598    ///
2599    /// The engine keeps decoding whatever it was on while the playlist changes
2600    /// underneath it, which an undo can turn into a lie: undoing a replace
2601    /// restores the queue but leaves the engine playing a track that queue does
2602    /// not contain. The transport then describes an item nothing can select,
2603    /// and the decode lookahead — which finds the next track by locating the
2604    /// current one — has nothing to follow, so the queue ends at the end of the
2605    /// track instead of carrying on.
2606    ///
2607    /// Done once, after the entry is applied, rather than inside each variant:
2608    /// any undo that takes items away can orphan the engine, not only
2609    /// `Replaced`. A track waiting to open can be orphaned the same way, and
2610    /// would otherwise open when its download lands.
2611    ///
2612    /// Playing carries on playing and paused stays paused, the same as when
2613    /// the playing track is removed: an undo is not a reason to start the
2614    /// music, or to stop it.
2615    fn reconcile_playback(&mut self) {
2616        let orphaned = match &self.transport {
2617            Transport::Idle => false,
2618            Transport::Waiting(waiting) => self.shared_state.get_item(waiting.id).is_none(),
2619            Transport::Loaded(session) => self.shared_state.get_item(session.track.id).is_none(),
2620        };
2621        if !orphaned {
2622            return;
2623        }
2624        // Pick the restored queue up where its cursor says it was, as the
2625        // listener had it. The position is not part of what was snapshotted,
2626        // so the track begins again.
2627        let cursor = self.shared_state.cursor();
2628        self.carry_on(cursor, self.intent());
2629    }
2630
2631    /// Execute an undo operation, pushing the inverse onto the redo stack.
2632    fn execute_undo(&mut self) {
2633        let Some(entry) = self.undo_stack.pop_undo() else {
2634            return;
2635        };
2636        let inverse = self.apply_entry(entry);
2637        self.undo_stack.push_redo(inverse);
2638        self.reconcile_playback();
2639    }
2640
2641    /// Execute a redo operation, pushing the inverse onto the undo stack.
2642    fn execute_redo(&mut self) {
2643        let Some(entry) = self.undo_stack.pop_redo() else {
2644            return;
2645        };
2646        let inverse = self.apply_entry(entry);
2647        self.undo_stack.push_undo_keep_redo(inverse);
2648        self.reconcile_playback();
2649    }
2650
2651    /// Run the command loop. Blocks until the sender is dropped.
2652    ///
2653    /// Asleep until there is something to do: a command, or one of the two
2654    /// things that happen without one — see `next_wake`. A paused or stopped
2655    /// player does not wake at all.
2656    pub fn run(&mut self) {
2657        use crossbeam_channel::RecvTimeoutError;
2658
2659        let rx = self.commands.rx.clone();
2660        loop {
2661            let received = match self.next_wake() {
2662                Some(at) => rx.recv_deadline(at),
2663                None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
2664            };
2665            match received {
2666                Ok(cmd) => self.process_command(cmd),
2667                Err(RecvTimeoutError::Timeout) => {}
2668                Err(RecvTimeoutError::Disconnected) => break,
2669            }
2670            self.update_playback_state();
2671        }
2672        self.stop();
2673    }
2674
2675    /// Give the audio session back once output has been stopped for
2676    /// `SESSION_GRACE`, so another app can play. A resume inside it finds the
2677    /// session still active. Waiting for a track to arrive with play asked
2678    /// for counts as playing: the session is wanted again the moment it does.
2679    fn release_idle_session(&mut self) {
2680        if self.session_release_due(
2681            self.wants_output(),
2682            crate::audio::session_held(),
2683            std::time::Instant::now(),
2684        ) {
2685            log::info!("output idle: releasing the audio session");
2686            crate::audio::release_session();
2687        }
2688    }
2689
2690    /// Play asked for, or output still running (a pause fading out).
2691    fn wants_output(&self) -> bool {
2692        self.intent() == Some(Run::Playing)
2693            || self
2694                .session()
2695                .and_then(Session::engine)
2696                .is_some_and(|e| e.is_running())
2697    }
2698
2699    /// Whether the session is to be released now, starting the grace when
2700    /// output has just stopped.
2701    fn session_release_due(&mut self, playing: bool, held: bool, now: std::time::Instant) -> bool {
2702        if playing || !held {
2703            self.session_release_at = None;
2704            return false;
2705        }
2706        match self.session_release_at {
2707            None => {
2708                self.session_release_at = Some(now + SESSION_GRACE);
2709                false
2710            }
2711            Some(at) if now >= at => {
2712                self.session_release_at = None;
2713                true
2714            }
2715            Some(_) => false,
2716        }
2717    }
2718
2719    /// When something changes that no command announces: one of the
2720    /// session's events (`next_event`), a sleep timer's time coming, or the
2721    /// audio session's release.
2722    fn next_wake(&self) -> Option<std::time::Instant> {
2723        [
2724            self.next_event(),
2725            self.sleep.and_then(|s| s.at),
2726            self.sleep_wake(),
2727            self.session_release_at,
2728        ]
2729        .into_iter()
2730        .flatten()
2731        .min()
2732    }
2733
2734    /// The playhead reaching the next queued track, the silence after a rate
2735    /// switch running out, or a pause fading to silence.
2736    fn next_event(&self) -> Option<std::time::Instant> {
2737        let session = self.session()?;
2738        let now = std::time::Instant::now();
2739        let Output::Local(local) = &session.output else {
2740            // A stream's track changes are the timeline's, as they are here.
2741            let next_track = (session.run == Run::Playing && self.streaming_to_renderer())
2742                .then(|| self.timeline.until_next_track())
2743                .flatten()
2744                .map(|left| now + left + BOUNDARY_SLACK);
2745            return match (next_track, self.renderer_deadline()) {
2746                (Some(a), Some(b)) => Some(a.min(b)),
2747                (a, b) => a.or(b),
2748            };
2749        };
2750        if let Some(since) = self.dsp_restart {
2751            return Some(dsp_wake(since, local.engine.period(), now));
2752        }
2753        match session.run {
2754            Run::Playing => {
2755                let next_track = self
2756                    .timeline
2757                    .until_next_track()
2758                    .map(|left| now + left + BOUNDARY_SLACK);
2759                match (next_track, self.lead_in_ends) {
2760                    (Some(a), Some(b)) => Some(a.min(b)),
2761                    (a, b) => a.or(b),
2762                }
2763            }
2764            Run::Paused if local.engine.is_running() => Some(now + FADE_CHECK),
2765            Run::Paused => None,
2766        }
2767    }
2768
2769    /// Spawn the player on a background thread, returning the shared state,
2770    /// timeline, visualization snapshot, and command sender.
2771    /// Remember the renderer picked as the output, or that this device's own
2772    /// was, for the next launch. Only a choice is remembered: a renderer that
2773    /// drops off the network is gone back to next time.
2774    fn remember_renderer(&self, connection: Option<&crate::upnp::Connection>) {
2775        let renderer = connection.map(|c| c.session.renderer());
2776        if let Err(e) = crate::config::Config::persist(|cfg| {
2777            cfg.playback.renderer = renderer.map(|r| r.udn.clone());
2778            cfg.playback.renderer_name = renderer.map(|r| r.name.clone());
2779        }) {
2780            log::error!("failed to save the output: {e}");
2781        }
2782    }
2783
2784    /// Spawn a player for a process that does not own an output of its own:
2785    /// `koan serve`, `koan mcp`. It plays where it is told and nowhere else.
2786    pub fn spawn() -> (
2787        Arc<SharedPlayerState>,
2788        Arc<PlaybackTimeline>,
2789        Arc<VizSnapshot>,
2790        crossbeam_channel::Sender<PlayerCommand>,
2791    ) {
2792        Self::spawn_with(false)
2793    }
2794
2795    /// Spawn the player for an app someone listens through: the macOS and iOS
2796    /// apps and `koan play`. It goes back to the renderer used last time if
2797    /// that turns up at launch (`upnp::resume`). A headless process must not:
2798    /// the config is the machine's, and it would take the amplifier from the
2799    /// app.
2800    pub fn spawn_for_listening() -> (
2801        Arc<SharedPlayerState>,
2802        Arc<PlaybackTimeline>,
2803        Arc<VizSnapshot>,
2804        crossbeam_channel::Sender<PlayerCommand>,
2805    ) {
2806        Self::spawn_with(true)
2807    }
2808
2809    /// The renderer to go back to at launch: the one used last time, for a
2810    /// player someone listens through.
2811    fn renderer_to_resume(listening: bool) -> Option<String> {
2812        listening
2813            .then(|| crate::config::Config::cached().playback.renderer.clone())
2814            .flatten()
2815    }
2816
2817    fn spawn_with(
2818        listening: bool,
2819    ) -> (
2820        Arc<SharedPlayerState>,
2821        Arc<PlaybackTimeline>,
2822        Arc<VizSnapshot>,
2823        crossbeam_channel::Sender<PlayerCommand>,
2824    ) {
2825        // EQ from before presets and tunings of several EQs, brought up to
2826        // them before anything plays through it.
2827        if let Err(e) = crate::audio::dsp::profiles::migrate() {
2828            log::warn!("dsp: not brought up to date: {e}");
2829        }
2830        let mut player = Self::new();
2831        player.history = PlayRecorder::spawn();
2832        let state = player.shared_state();
2833        let timeline = player.timeline();
2834        let viz_snapshot = player.viz_snapshot();
2835        let tx = player.command_sender();
2836        // Downloads follow the playlist, so they come with the player rather
2837        // than being something each front end has to remember to ask for.
2838        player.downloads = Some(crate::remote::queue::DownloadQueue::spawn(
2839            tx.clone(),
2840            state.clone(),
2841        ));
2842
2843        if let Some(udn) = Self::renderer_to_resume(listening) {
2844            player.resume_renderer = true;
2845            crate::upnp::resume(udn, &tx);
2846        }
2847
2848        thread::Builder::new()
2849            .name("koan-player".into())
2850            .spawn(move || player.run())
2851            .expect("failed to spawn player thread");
2852
2853        (state, timeline, viz_snapshot, tx)
2854    }
2855}
2856
2857/// The pass the decoder's step put the track at `boundary` in: by id alone, a
2858/// row in this pass and the next is ambiguous. The last step at the boundary,
2859/// since one whose file failed to open is followed by another taken for the
2860/// same boundary.
2861fn step_pass(steps: &[state::Lookahead], boundary: usize) -> Option<u64> {
2862    steps
2863        .iter()
2864        .rfind(|step| step.boundary == boundary)
2865        .map(|step| step.pass)
2866}
2867
2868#[cfg(test)]
2869mod tests {
2870    /// `koan serve` and `koan mcp` read the same config as the app, and must
2871    /// not go looking for the app's amplifier.
2872    #[test]
2873    fn a_headless_player_does_not_go_back_to_a_renderer() {
2874        let _guard = crate::config::tests::PERSIST_LOCK
2875            .lock()
2876            .unwrap_or_else(|e| e.into_inner());
2877        let dir = tempfile::tempdir().unwrap();
2878        crate::config::set_config_dir(dir.path());
2879        crate::config::Config::persist(|cfg| {
2880            cfg.playback.renderer = Some("uuid:headless-test".into());
2881        })
2882        .unwrap();
2883        assert_eq!(Player::renderer_to_resume(false), None);
2884    }
2885
2886    #[test]
2887    fn a_download_in_progress_is_known_by_its_own_extension() {
2888        use std::path::Path;
2889        // `.part` is the transfer's, not the track's.
2890        assert_eq!(
2891            lengthless_mode_for(Path::new("/c/t.m4a.part")),
2892            streaming::ProbeMode::LengthlessWholeEnd
2893        );
2894        assert_eq!(
2895            lengthless_mode_for(Path::new("/c/t.OPUS.part")),
2896            streaming::ProbeMode::LengthlessWholeEnd
2897        );
2898        assert_eq!(
2899            lengthless_mode_for(Path::new("/c/t.flac.part")),
2900            streaming::ProbeMode::Lengthless
2901        );
2902        assert_eq!(
2903            media_extension(Path::new("/c/t.m4a.part")).as_deref(),
2904            Some("m4a")
2905        );
2906        assert_eq!(
2907            media_extension(Path::new("/c/t.mp3")).as_deref(),
2908            Some("mp3")
2909        );
2910    }
2911
2912    use super::*;
2913    use state::PlaylistItem;
2914    use std::path::PathBuf;
2915    use std::sync::atomic::{AtomicU64, Ordering};
2916
2917    fn make_item(title: &str) -> PlaylistItem {
2918        PlaylistItem {
2919            playlist_entry_id: None,
2920            id: QueueItemId::new(),
2921            db_id: None,
2922            path: PathBuf::from(format!("/music/{title}.flac")),
2923            title: title.to_string(),
2924            artist: String::new(),
2925            album_artist: String::new(),
2926            album: String::new(),
2927            year: None,
2928            codec: None,
2929            track_number: None,
2930            disc: None,
2931            duration_ms: None,
2932            state: ItemState::Ready,
2933            played: false,
2934        }
2935    }
2936
2937    pub(super) fn playlist_ids(player: &Player) -> Vec<QueueItemId> {
2938        let (items, _) = player.shared_state.snapshot_playlist();
2939        items.iter().map(|i| i.id).collect()
2940    }
2941
2942    fn playlist_titles(player: &Player) -> Vec<String> {
2943        let (items, _) = player.shared_state.snapshot_playlist();
2944        items.iter().map(|i| i.title.clone()).collect()
2945    }
2946
2947    fn pending_item(title: &str) -> PlaylistItem {
2948        PlaylistItem {
2949            playlist_entry_id: None,
2950            state: ItemState::Pending,
2951            ..make_item(title)
2952        }
2953    }
2954
2955    /// A session playing `id` on `engine`, with no decode thread behind it.
2956    fn test_session(id: QueueItemId, engine: Box<dyn AudioEngineHandle>) -> Session {
2957        Session {
2958            track: TrackInfo {
2959                id,
2960                path: PathBuf::from("/music/t.flac"),
2961                codec: String::new(),
2962                sample_rate: 44_100,
2963                bit_depth: None,
2964                bitrate_kbps: None,
2965                channels: 2,
2966                duration_ms: 1_000,
2967            },
2968            run: Run::Playing,
2969            lookahead: Default::default(),
2970            output: Output::Local(Local {
2971                engine,
2972                decode_handle: buffer::DecodeHandle::new_for_test(Default::default()),
2973                stream: None,
2974                _rate_watch: None,
2975                dsp: None,
2976            }),
2977        }
2978    }
2979
2980    /// Stand in for an engine that is playing `id`. The test items have no
2981    /// files behind them, so `start_playback` can never get far enough to leave
2982    /// this state on its own.
2983    fn pretend_playing(player: &mut Player, id: QueueItemId) {
2984        assert!(
2985            player.shared_state.get_item(id).is_some(),
2986            "item is in the queue"
2987        );
2988        player.stop_engine();
2989        player.transport = Transport::Loaded(test_session(
2990            id,
2991            Box::new(NullEngine {
2992                starts: Default::default(),
2993                running: Default::default(),
2994                lead_in: Default::default(),
2995            }),
2996        ));
2997        player.shared_state.set_cursor(Some(id));
2998        player.publish();
2999    }
3000
3001    fn playing_id(player: &Player) -> Option<QueueItemId> {
3002        player.shared_state.track_info().map(|t| t.id)
3003    }
3004
3005    /// Build `n` ready items, add them, and return their IDs.
3006    fn seed(player: &mut Player, n: usize) -> Vec<QueueItemId> {
3007        let items: Vec<_> = (0..n).map(|i| make_item(&format!("t{i}"))).collect();
3008        let ids = items.iter().map(|i| i.id).collect();
3009        player.process_command(PlayerCommand::AddToPlaylist(items));
3010        ids
3011    }
3012
3013    // --- cursor transitions ---
3014
3015    /// Feed the player a track's worth of playback ticks, as the 50ms poll would.
3016    fn listen(player: &mut Player, from_ms: u64, to_ms: u64) {
3017        let mut at = from_ms;
3018        if let Some(f) = player.in_flight.as_mut() {
3019            f.advance(at); // the position the needle landed on
3020        }
3021        while at < to_ms {
3022            at = (at + 50).min(to_ms);
3023            if let Some(f) = player.in_flight.as_mut() {
3024                f.advance(at);
3025            }
3026        }
3027    }
3028
3029    fn start(player: &mut Player, track_id: i64) -> QueueItemId {
3030        let id = QueueItemId::new();
3031        player.on_track_changed(id, 0);
3032        // The item is not in a playlist here, so there is no db_id to find.
3033        player
3034            .in_flight
3035            .as_mut()
3036            .unwrap()
3037            .track_id_for_test(track_id);
3038        id
3039    }
3040
3041    #[test]
3042    fn a_gapless_transition_closes_the_outgoing_track_and_opens_the_next() {
3043        let mut player = Player::new();
3044        start(&mut player, 11);
3045        listen(&mut player, 0, 200_000);
3046
3047        let b = QueueItemId::new();
3048        player.on_track_changed(b, 0);
3049        let f = player
3050            .in_flight
3051            .as_ref()
3052            .expect("the next track is counting");
3053        assert_eq!(f.item, b);
3054        assert_eq!(f.listened_ms(), 0, "and starts from nothing");
3055    }
3056
3057    #[test]
3058    fn a_track_skipped_seconds_in_is_still_history() {
3059        let mut player = Player::new();
3060        start(&mut player, 7);
3061        listen(&mut player, 0, 2_000);
3062
3063        let event = player
3064            .finish_play()
3065            .expect("putting something on is a thing you did, however briefly");
3066        assert!(matches!(
3067            event,
3068            history::PlayEvent::Finished {
3069                track_id: 7,
3070                listened_ms: 2_000
3071            }
3072        ));
3073    }
3074
3075    #[test]
3076    fn a_track_is_closed_out_once() {
3077        let mut player = Player::new();
3078        start(&mut player, 7);
3079        listen(&mut player, 0, 200_000);
3080
3081        assert!(player.finish_play().is_some());
3082        assert!(player.finish_play().is_none());
3083    }
3084
3085    #[test]
3086    fn seeking_around_a_track_does_not_enter_it_twice() {
3087        let mut player = Player::new();
3088        let id = start(&mut player, 7);
3089        listen(&mut player, 0, 120_000);
3090
3091        // A seek restarts playback of the same item.
3092        player.on_track_changed(id, 30_000);
3093        assert_eq!(
3094            player.in_flight.as_ref().unwrap().listened_ms(),
3095            120_000,
3096            "the seek kept the count rather than restarting it"
3097        );
3098        listen(&mut player, 30_000, 40_000);
3099
3100        let Some(history::PlayEvent::Finished { listened_ms, .. }) = player.finish_play() else {
3101            panic!("still one play");
3102        };
3103        assert_eq!(listened_ms, 130_000);
3104        assert!(player.finish_play().is_none());
3105    }
3106
3107    #[test]
3108    fn a_track_that_is_not_in_the_library_is_not_recorded() {
3109        let mut player = Player::new();
3110        let id = QueueItemId::new();
3111        player.on_track_changed(id, 0);
3112        listen(&mut player, 0, 200_000);
3113        assert!(player.finish_play().is_none());
3114    }
3115
3116    #[test]
3117    fn stopping_closes_out_what_was_heard() {
3118        let mut player = Player::new();
3119        start(&mut player, 7);
3120        listen(&mut player, 0, 150_000);
3121
3122        player.stop_playback_and_clear_state();
3123        assert!(player.in_flight.is_none(), "the stop consumed it");
3124    }
3125
3126    #[test]
3127    fn resume_with_nothing_loaded_plays_the_cursor() {
3128        let dir = tempfile::tempdir().unwrap();
3129        let path = dir.path().join("t.wav");
3130        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3131
3132        let mut player = Player::new();
3133        player.backend = Box::new(StuckBackend {
3134            rate: 8_000.0,
3135            asked: Default::default(),
3136            starts: Default::default(),
3137        });
3138        let item = PlaylistItem {
3139            db_id: Some(5),
3140            path,
3141            ..make_item("t")
3142        };
3143        let id = item.id;
3144        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3145        player.shared_state.set_cursor(Some(id));
3146        assert!(player.session().is_none());
3147
3148        player.process_command(PlayerCommand::Resume);
3149        assert!(player.session().is_some(), "the cursor's track started");
3150        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3151        player.process_command(PlayerCommand::Stop);
3152    }
3153
3154    #[test]
3155    fn the_audio_session_is_released_only_after_output_has_been_idle_a_while() {
3156        let mut player = Player::new();
3157        let now = std::time::Instant::now();
3158        assert!(
3159            !player.session_release_due(false, true, now),
3160            "the grace starts"
3161        );
3162        assert!(!player.session_release_due(false, true, now + SESSION_GRACE / 2));
3163        assert!(
3164            player.session_release_due(false, true, now + SESSION_GRACE),
3165            "released once it runs out"
3166        );
3167        assert_eq!(player.session_release_at, None);
3168
3169        assert!(!player.session_release_due(false, true, now));
3170        assert!(
3171            !player.session_release_due(true, true, now + SESSION_GRACE / 2),
3172            "playing again inside the grace keeps the session"
3173        );
3174        assert_eq!(player.session_release_at, None);
3175        assert!(
3176            !player.session_release_due(false, false, now),
3177            "nothing held"
3178        );
3179        assert_eq!(player.session_release_at, None);
3180    }
3181
3182    #[test]
3183    fn waiting_to_play_a_track_keeps_the_audio_session() {
3184        let mut player = Player::new();
3185        let item = make_item("t");
3186        player.transport = Transport::Waiting(Waiting {
3187            id: item.id,
3188            position_ms: 0,
3189            start: Run::Playing,
3190        });
3191        assert!(player.wants_output(), "play was asked for");
3192        let playing = player.wants_output();
3193        let now = std::time::Instant::now();
3194        for later in [now, now + SESSION_GRACE * 2] {
3195            assert!(!player.session_release_due(playing, true, later));
3196        }
3197        assert_eq!(player.session_release_at, None);
3198    }
3199
3200    #[test]
3201    fn a_player_with_nothing_coming_has_nothing_to_wake_for() {
3202        let dir = tempfile::tempdir().unwrap();
3203        let path = dir.path().join("t.wav");
3204        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3205
3206        let mut player = Player::new();
3207        player.backend = Box::new(StuckBackend {
3208            rate: 8_000.0,
3209            asked: Default::default(),
3210            starts: Default::default(),
3211        });
3212        let item = PlaylistItem {
3213            path,
3214            ..make_item("t")
3215        };
3216        let id = item.id;
3217        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3218        assert_eq!(player.next_wake(), None, "stopped");
3219
3220        player.process_command(PlayerCommand::Play(id));
3221        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3222        assert_eq!(
3223            player.next_wake(),
3224            None,
3225            "playing, with no track queued after it"
3226        );
3227
3228        if let Transport::Loaded(session) = &mut player.transport {
3229            session.engine().unwrap().stop().unwrap();
3230            session.run = Run::Paused;
3231        }
3232        assert_eq!(player.next_wake(), None, "paused");
3233        player.process_command(PlayerCommand::Stop);
3234    }
3235
3236    #[test]
3237    fn a_track_cued_paused_never_starts_the_output() {
3238        let dir = tempfile::tempdir().unwrap();
3239        let path = dir.path().join("t.wav");
3240        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3241
3242        let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3243        let mut player = Player::new();
3244        player.backend = Box::new(StuckBackend {
3245            rate: 8_000.0,
3246            asked: Default::default(),
3247            starts: starts.clone(),
3248        });
3249        let item = PlaylistItem {
3250            path,
3251            ..make_item("t")
3252        };
3253        let id = item.id;
3254        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3255
3256        player.process_command(PlayerCommand::Cue {
3257            id,
3258            position_ms: 2_000,
3259            play: false,
3260        });
3261        assert!(player.session().is_some(), "loaded");
3262        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3263        // The decoder says where the seek landed when it queues the track,
3264        // and the playhead reads that from then on: the start of the packet
3265        // holding 2s, which can be a little before it.
3266        loop {
3267            match player
3268                .commands
3269                .rx
3270                .recv_timeout(std::time::Duration::from_secs(5))
3271            {
3272                Ok(PlayerCommand::TrackQueued) => break,
3273                Ok(_) => {}
3274                Err(e) => panic!("the decoder never queued the track: {e}"),
3275            }
3276        }
3277        let at = player.shared_state.position_ms();
3278        assert!((1_750..=2_000).contains(&at), "cued at {at}ms");
3279        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3280
3281        // Seeking while paused reopens the track, and stays quiet too.
3282        player.process_command(PlayerCommand::Seek(4_000));
3283        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3284        assert_eq!(starts.load(Ordering::Relaxed), 0);
3285
3286        player.process_command(PlayerCommand::Resume);
3287        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3288        assert_eq!(starts.load(Ordering::Relaxed), 1);
3289        player.process_command(PlayerCommand::Stop);
3290    }
3291
3292    /// Load `t.wav` as an item whose download has not landed, under a player
3293    /// on the fake output.
3294    fn downloading_wav(dir: &Path) -> (Player, QueueItemId, Arc<std::sync::atomic::AtomicUsize>) {
3295        let path = dir.join("t.wav");
3296        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3297        let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3298        let mut player = Player::new();
3299        player.backend = Box::new(StuckBackend {
3300            rate: 8_000.0,
3301            asked: Default::default(),
3302            starts: starts.clone(),
3303        });
3304        let item = PlaylistItem {
3305            path,
3306            state: ItemState::Pending,
3307            ..make_item("t")
3308        };
3309        let id = item.id;
3310        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3311        (player, id, starts)
3312    }
3313
3314    /// The download lands, as the downloader says so: the item first, then
3315    /// the player.
3316    fn land(player: &mut Player, id: QueueItemId) {
3317        player.shared_state.update_item_state(id, ItemState::Ready);
3318        player.process_command(PlayerCommand::TrackReady(id));
3319    }
3320
3321    #[test]
3322    fn track_ready_never_resurrects_an_item_that_failed() {
3323        let mut player = Player::new();
3324        let item = PlaylistItem {
3325            state: ItemState::Failed("gone".into()),
3326            ..make_item("t")
3327        };
3328        let id = item.id;
3329        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
3330
3331        player.process_command(PlayerCommand::TrackReady(id));
3332
3333        assert!(matches!(
3334            player.shared_state.get_item(id).map(|i| i.state),
3335            Some(ItemState::Failed(_))
3336        ));
3337    }
3338
3339    fn await_queued(player: &Player) {
3340        loop {
3341            match player
3342                .commands
3343                .rx
3344                .recv_timeout(std::time::Duration::from_secs(5))
3345            {
3346                Ok(PlayerCommand::TrackQueued) => return,
3347                Ok(_) => {}
3348                Err(e) => panic!("the decoder never queued the track: {e}"),
3349            }
3350        }
3351    }
3352
3353    #[test]
3354    fn a_cue_on_a_downloading_track_opens_at_its_position_once_it_lands() {
3355        let dir = tempfile::tempdir().unwrap();
3356        let (mut player, id, starts) = downloading_wav(dir.path());
3357
3358        player.process_command(PlayerCommand::Cue {
3359            id,
3360            position_ms: 6_000,
3361            play: true,
3362        });
3363        assert!(player.session().is_none(), "nothing opened early");
3364        assert_eq!(player.shared_state.playback_state(), PlaybackState::Stopped);
3365        assert_eq!(starts.load(Ordering::Relaxed), 0);
3366
3367        land(&mut player, id);
3368        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3369        assert_eq!(player.playback_starts, 1, "opened once, at the position");
3370        assert_eq!(starts.load(Ordering::Relaxed), 1);
3371        await_queued(&player);
3372        let at = player.shared_state.position_ms();
3373        assert!((5_750..=6_000).contains(&at), "opened at {at}ms");
3374        player.process_command(PlayerCommand::Stop);
3375    }
3376
3377    #[test]
3378    fn a_paused_cue_on_a_downloading_track_lands_paused() {
3379        let dir = tempfile::tempdir().unwrap();
3380        let (mut player, id, starts) = downloading_wav(dir.path());
3381
3382        player.process_command(PlayerCommand::Cue {
3383            id,
3384            position_ms: 3_000,
3385            play: false,
3386        });
3387        land(&mut player, id);
3388        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3389        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3390        await_queued(&player);
3391        let at = player.shared_state.position_ms();
3392        assert!((2_750..=3_000).contains(&at), "cued at {at}ms");
3393        player.process_command(PlayerCommand::Stop);
3394    }
3395
3396    #[test]
3397    fn playing_something_else_forgets_a_waiting_cue() {
3398        let dir = tempfile::tempdir().unwrap();
3399        let (mut player, id, _) = downloading_wav(dir.path());
3400        let other = seed(&mut player, 1)[0];
3401
3402        player.process_command(PlayerCommand::Cue {
3403            id,
3404            position_ms: 6_000,
3405            play: true,
3406        });
3407        player.process_command(PlayerCommand::Play(other));
3408        player.process_command(PlayerCommand::Play(id));
3409        assert!(player.waiting().is_some_and(|w| w.position_ms == 0));
3410        player.process_command(PlayerCommand::Stop);
3411    }
3412
3413    #[test]
3414    fn a_paused_track_whose_download_lands_stays_paused_where_it_was() {
3415        let dir = tempfile::tempdir().unwrap();
3416        let (mut player, id, starts) = downloading_wav(dir.path());
3417        // Loaded paused mid-track, as a stream is when its download lands.
3418        player.shared_state.update_item_state(id, ItemState::Ready);
3419        player.process_command(PlayerCommand::Cue {
3420            id,
3421            position_ms: 3_000,
3422            play: false,
3423        });
3424        await_queued(&player);
3425
3426        player.process_command(PlayerCommand::TrackReady(id));
3427        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3428        assert_eq!(player.playback_starts, 1, "not reopened");
3429        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3430        player.process_command(PlayerCommand::Stop);
3431    }
3432
3433    #[test]
3434    fn pausing_a_track_on_its_way_opens_it_paused() {
3435        let dir = tempfile::tempdir().unwrap();
3436        let (mut player, id, starts) = downloading_wav(dir.path());
3437
3438        player.process_command(PlayerCommand::Play(id));
3439        assert!(player.shared_state.is_waiting());
3440        assert!(player.shared_state.wants_to_play(), "a toggle pauses it");
3441        assert!(!player.shared_state.is_idle(), "adding tracks leaves it be");
3442        player.process_command(PlayerCommand::Pause);
3443        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3444        assert!(!player.shared_state.wants_to_play());
3445
3446        player.shared_state.update_item_state(id, ItemState::Ready);
3447        player.process_command(PlayerCommand::TrackReady(id));
3448        assert!(player.session().is_some(), "loaded");
3449        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3450        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3451        player.process_command(PlayerCommand::Stop);
3452    }
3453
3454    #[test]
3455    fn a_cue_paused_and_resumed_on_its_way_keeps_its_position() {
3456        let dir = tempfile::tempdir().unwrap();
3457        let (mut player, id, starts) = downloading_wav(dir.path());
3458
3459        player.process_command(PlayerCommand::Cue {
3460            id,
3461            position_ms: 6_000,
3462            play: true,
3463        });
3464        player.process_command(PlayerCommand::Pause);
3465        player.process_command(PlayerCommand::Resume);
3466        assert!(player.session().is_none(), "still on its way");
3467        assert_eq!(player.shared_state.playback_state(), PlaybackState::Stopped);
3468
3469        player.shared_state.update_item_state(id, ItemState::Ready);
3470        player.process_command(PlayerCommand::TrackReady(id));
3471        assert_eq!(player.shared_state.playback_state(), PlaybackState::Playing);
3472        assert_eq!(starts.load(Ordering::Relaxed), 1);
3473        await_queued(&player);
3474        let at = player.shared_state.position_ms();
3475        assert!((5_750..=6_000).contains(&at), "opened at {at}ms");
3476        player.process_command(PlayerCommand::Stop);
3477    }
3478
3479    #[test]
3480    fn a_restored_cursor_does_not_start_when_its_download_lands() {
3481        let dir = tempfile::tempdir().unwrap();
3482        let (mut player, id, starts) = downloading_wav(dir.path());
3483        player.shared_state.set_cursor(Some(id));
3484
3485        player.shared_state.update_item_state(id, ItemState::Ready);
3486        player.process_command(PlayerCommand::TrackReady(id));
3487        assert!(player.session().is_none());
3488        assert_eq!(starts.load(Ordering::Relaxed), 0);
3489    }
3490
3491    #[test]
3492    fn replacing_the_queue_keeps_a_transfer_both_queues_want() {
3493        // The download queue syncs from its own thread on each playlist
3494        // change. Replaced as a clear then an add, the playlist was empty in
3495        // between, and a sync there let go of every waiter: the transfer for a
3496        // track in both queues was abandoned and started over. The replace
3497        // must be one change, and that change must still want the track.
3498        let pending = |title: &str| PlaylistItem {
3499            db_id: Some(7),
3500            state: ItemState::Pending,
3501            ..make_item(title)
3502        };
3503        let mut player = Player::new();
3504        let old = pending("old");
3505        let old_id = old.id;
3506        player.process_command(PlayerCommand::AddToPlaylist(vec![old]));
3507        let store = player.shared_state.downloads().clone();
3508        store.claim(7, Some(old_id));
3509
3510        let before = player.shared_state.pending_version();
3511        player.process_command(PlayerCommand::ReplacePlaylist {
3512            items: vec![pending("again")],
3513            start: 0,
3514            position_ms: 0,
3515            play: false,
3516            mode: QueueMode::Keep,
3517        });
3518        assert_eq!(
3519            player.shared_state.pending_version(),
3520            before + 1,
3521            "one change, so no reader can see the playlist between two"
3522        );
3523
3524        // A sync against the one state it published.
3525        store.resync(&player.shared_state.pending_downloads());
3526        assert!(!store.abandoned(7));
3527    }
3528
3529    #[test]
3530    fn a_hand_off_opens_paused_at_its_position_in_one_command() {
3531        let dir = tempfile::tempdir().unwrap();
3532        let path = dir.path().join("t.wav");
3533        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3534        let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3535        let mut player = Player::new();
3536        player.backend = Box::new(StuckBackend {
3537            rate: 8_000.0,
3538            asked: Default::default(),
3539            starts: starts.clone(),
3540        });
3541        seed(&mut player, 2);
3542        let item = PlaylistItem {
3543            path,
3544            ..make_item("t")
3545        };
3546        let id = item.id;
3547
3548        player.process_command(PlayerCommand::ReplacePlaylist {
3549            items: vec![make_item("before"), item],
3550            start: 1,
3551            position_ms: 4_000,
3552            play: false,
3553            mode: QueueMode::Keep,
3554        });
3555        assert_eq!(
3556            playlist_titles(&player),
3557            vec!["before", "t"],
3558            "replaced, not added to"
3559        );
3560        assert_eq!(player.shared_state.cursor(), Some(id));
3561        assert_eq!(player.shared_state.playback_state(), PlaybackState::Paused);
3562        assert_eq!(player.playback_starts, 1, "opened once, at the position");
3563        assert_eq!(starts.load(Ordering::Relaxed), 0, "not a sample let out");
3564        await_queued(&player);
3565        let at = player.shared_state.position_ms();
3566        assert!((3_750..=4_000).contains(&at), "opened at {at}ms");
3567
3568        player.process_command(PlayerCommand::Undo);
3569        assert_eq!(playlist_titles(&player), vec!["t0", "t1"], "one undo step");
3570        player.process_command(PlayerCommand::Stop);
3571    }
3572
3573    #[test]
3574    fn a_decode_end_from_a_replaced_session_is_ignored() {
3575        let dir = tempfile::tempdir().unwrap();
3576        let mut player = Player::new();
3577        player.backend = Box::new(StuckBackend {
3578            rate: 8_000.0,
3579            asked: Default::default(),
3580            starts: Default::default(),
3581        });
3582        let items: Vec<_> = ["a", "b", "c"]
3583            .iter()
3584            .map(|name| {
3585                let path = dir.path().join(format!("{name}.wav"));
3586                crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
3587                PlaylistItem {
3588                    path,
3589                    ..make_item(name)
3590                }
3591            })
3592            .collect();
3593        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
3594        player.process_command(PlayerCommand::AddToPlaylist(items));
3595
3596        player.process_command(PlayerCommand::Play(ids[0]));
3597        let stale = player.session;
3598        player.process_command(PlayerCommand::Play(ids[1]));
3599        player.process_command(PlayerCommand::DecodeFinished(stale));
3600
3601        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
3602        assert_eq!(player.playback_starts, 2);
3603        player.process_command(PlayerCommand::Stop);
3604    }
3605
3606    /// Short tracks of one format under an output that never plays: the
3607    /// decoder queues them all behind the first.
3608    fn queued_wavs(dir: &Path, names: &[&str]) -> (Player, Vec<QueueItemId>) {
3609        let (player, ids) = wavs_playing(dir, names, 1.0);
3610        for _ in &ids {
3611            await_queued(&player);
3612        }
3613        (player, ids)
3614    }
3615
3616    /// `names` as WAVs of `seconds` each, the first playing. Long enough ones
3617    /// fill the ring before the decoder reaches the end of the queue.
3618    fn wavs_playing(dir: &Path, names: &[&str], seconds: f32) -> (Player, Vec<QueueItemId>) {
3619        wavs_in(dir, names, seconds, Repeat::Off)
3620    }
3621
3622    /// `wavs_playing`, under `repeat` from the start. Each has a library id,
3623    /// its index plus one, so history records it.
3624    fn wavs_in(
3625        dir: &Path,
3626        names: &[&str],
3627        seconds: f32,
3628        repeat: Repeat,
3629    ) -> (Player, Vec<QueueItemId>) {
3630        let mut player = Player::new();
3631        player.backend = Box::new(StuckBackend {
3632            rate: 8_000.0,
3633            asked: Default::default(),
3634            starts: Default::default(),
3635        });
3636        let items: Vec<_> = names
3637            .iter()
3638            .zip(1..)
3639            .map(|(name, track)| {
3640                let path = dir.join(format!("{name}.wav"));
3641                crate::test_utils::generate_wav(&path, 8_000, 1, seconds, 16);
3642                PlaylistItem {
3643                    path,
3644                    db_id: Some(track),
3645                    ..make_item(name)
3646                }
3647            })
3648            .collect();
3649        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
3650        player.process_command(PlayerCommand::AddToPlaylist(items));
3651        player.process_command(PlayerCommand::SetRepeat(repeat));
3652        player.process_command(PlayerCommand::Play(ids[0]));
3653        (player, ids)
3654    }
3655
3656    /// What the decoder has queued after the playhead, once it is at least `n`.
3657    fn queued_at_least(player: &Player, n: usize) -> Vec<QueueItemId> {
3658        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3659        loop {
3660            let queued = player.timeline.queued_after_playhead();
3661            if queued.len() >= n {
3662                return queued;
3663            }
3664            assert!(
3665                std::time::Instant::now() < deadline,
3666                "the decoder queued {queued:?}, never {n}"
3667            );
3668            std::thread::yield_now();
3669        }
3670    }
3671
3672    // --- play modes ---
3673
3674    #[test]
3675    fn repeating_the_queue_runs_the_last_track_into_the_first() {
3676        let dir = tempfile::tempdir().unwrap();
3677        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::Queue);
3678
3679        let queued = queued_at_least(&player, 3);
3680        assert_eq!(
3681            queued[..3],
3682            [ids[1], ids[0], ids[1]],
3683            "gapless, round again"
3684        );
3685        let steps = player.session().unwrap().lookahead.lock().clone();
3686        assert!(!steps[0].wrapped);
3687        let wrap = steps.iter().find(|s| s.after == ids[1]).unwrap();
3688        assert_eq!(wrap.next, Some(ids[0]));
3689        assert!(wrap.wrapped);
3690        assert_eq!(player.playback_starts, 1);
3691        player.process_command(PlayerCommand::Stop);
3692    }
3693
3694    #[test]
3695    fn turning_repeat_off_takes_back_a_wrap_already_queued() {
3696        let dir = tempfile::tempdir().unwrap();
3697        let (mut player, ids) = wavs_in(dir.path(), &["a"], 1.0, Repeat::Queue);
3698        assert_eq!(queued_at_least(&player, 1)[0], ids[0]);
3699
3700        player.process_command(PlayerCommand::SetRepeat(Repeat::Off));
3701        assert_eq!(player.playback_starts, 2, "restarted at the playhead");
3702        wait_for_lookahead(&player);
3703        assert!(player.timeline.queued_after_playhead().is_empty());
3704        player.process_command(PlayerCommand::Stop);
3705    }
3706
3707    #[test]
3708    fn repeating_one_track_queues_it_again_and_each_pass_is_a_play() {
3709        let dir = tempfile::tempdir().unwrap();
3710        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::One);
3711        let (recorder, events) = history::PlayRecorder::capture();
3712        player.history = Some(recorder);
3713        // The first pass as a play would have it, now there is somewhere to
3714        // record it.
3715        player.in_flight = None;
3716        player.on_track_changed(ids[0], 0);
3717
3718        assert_eq!(queued_at_least(&player, 2)[..2], [ids[0], ids[0]]);
3719        // Into the second pass: one second of 8 kHz mono and a little more.
3720        player
3721            .timeline
3722            .samples_played
3723            .store(8_400, Ordering::Relaxed);
3724        player.update_playback_state();
3725
3726        let flight = player.in_flight.as_ref().unwrap();
3727        assert_eq!((flight.item, flight.boundary), (ids[0], 1));
3728        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
3729        let events: Vec<_> = events.try_iter().collect();
3730        let started = events
3731            .iter()
3732            .filter(|e| matches!(e, PlayEvent::Started { track_id: 1, .. }))
3733            .count();
3734        assert_eq!(started, 2, "two plays: {events:?}");
3735        assert!(
3736            events.contains(&PlayEvent::Finished {
3737                track_id: 1,
3738                listened_ms: 1_000
3739            }),
3740            "the first pass banked whole: {events:?}"
3741        );
3742        player.process_command(PlayerCommand::Stop);
3743    }
3744
3745    #[test]
3746    fn next_moves_on_from_a_track_repeating() {
3747        let dir = tempfile::tempdir().unwrap();
3748        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::One);
3749
3750        player.process_command(PlayerCommand::NextTrack);
3751        assert_eq!(playing_id(&player), Some(ids[1]));
3752        player.process_command(PlayerCommand::NextTrack);
3753        assert_eq!(
3754            playing_id(&player),
3755            Some(ids[0]),
3756            "round from the last, as repeating does"
3757        );
3758        player.process_command(PlayerCommand::Stop);
3759    }
3760
3761    #[test]
3762    fn a_repeat_of_nothing_heard_stops_rather_than_going_round() {
3763        let dir = tempfile::tempdir().unwrap();
3764        let (mut player, _) = wavs_in(dir.path(), &["a"], 1.0, Repeat::One);
3765
3766        // Nothing plays through the stuck output, so the session that ends
3767        // has nothing heard of it: a file that decodes to nothing.
3768        player.process_command(PlayerCommand::DecodeFinished(player.session));
3769        assert!(matches!(player.transport, Transport::Idle));
3770    }
3771
3772    // --- sleep timer ---
3773
3774    fn session_run(player: &Player) -> Option<Run> {
3775        player.session().map(|s| s.run)
3776    }
3777
3778    #[test]
3779    fn a_sleep_timer_fades_out_at_its_time_and_keeps_the_queue() {
3780        let dir = tempfile::tempdir().unwrap();
3781        let (mut player, ids) = wavs_playing(dir.path(), &["a", "b"], 1.0);
3782
3783        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
3784            minutes: 30,
3785        })));
3786        let Some(Sleep::At { unix_ms }) = player.shared_state.sleep() else {
3787            panic!("published as a time");
3788        };
3789        let now = std::time::SystemTime::now()
3790            .duration_since(std::time::UNIX_EPOCH)
3791            .unwrap()
3792            .as_millis() as u64;
3793        assert!(unix_ms.abs_diff(now + 30 * 60_000) < 5_000);
3794        let at = player.sleep.and_then(|s| s.at).unwrap();
3795        assert!(player.next_wake().is_some_and(|w| w <= at), "woken for it");
3796
3797        // Its time comes, the fade having run down to silence.
3798        player.sleep.as_mut().unwrap().at = Some(std::time::Instant::now());
3799        player.update_playback_state();
3800        assert_eq!(session_run(&player), Some(Run::Paused));
3801        assert!(
3802            !player.session().unwrap().engine().unwrap().is_running(),
3803            "stopped at silence: the fade did the fading"
3804        );
3805        assert!(
3806            player.sleep_fade.is_none(),
3807            "full level for what plays next"
3808        );
3809        assert_eq!(player.shared_state.sleep(), None, "spent");
3810        assert_eq!(playlist_ids(&player), ids, "the queue as it was");
3811        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
3812        player.process_command(PlayerCommand::Stop);
3813    }
3814
3815    fn fading_gain(player: &Player) -> Option<f32> {
3816        match player.sleep_fade {
3817            Some(sleep::SleepFade::Falling { gain, .. }) => Some(gain),
3818            _ => None,
3819        }
3820    }
3821
3822    fn db(gain: f32) -> f32 {
3823        20.0 * gain.log10()
3824    }
3825
3826    /// A 15-minute timer fades for 90 s: nothing before the deadline less
3827    /// that, then down evenly in decibels to silence at the deadline.
3828    #[test]
3829    fn a_sleep_timer_fades_over_the_run_up_to_its_deadline() {
3830        let dir = tempfile::tempdir().unwrap();
3831        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
3832        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
3833            minutes: 15,
3834        })));
3835        let now = std::time::Instant::now();
3836        let secs = std::time::Duration::from_secs;
3837
3838        player.sleep.as_mut().unwrap().at = Some(now + secs(91));
3839        player.update_playback_state();
3840        assert_eq!(fading_gain(&player), None, "not yet: 91 s out");
3841        assert!(!player.shared_state.sleep_fading());
3842        let wake = player.next_wake().unwrap();
3843        assert!(
3844            wake <= now + secs(2),
3845            "woken for the fade's start, a second away"
3846        );
3847
3848        player.sleep.as_mut().unwrap().at = Some(now + secs(45));
3849        player.update_playback_state();
3850        let half = fading_gain(&player).expect("half way through the fade");
3851        assert!((db(half) + 30.0).abs() < 1.0, "{} dB", db(half));
3852        assert!(player.shared_state.sleep_fading(), "published as fading");
3853        assert!(player.next_wake().unwrap() <= std::time::Instant::now() + secs(1));
3854
3855        player.sleep.as_mut().unwrap().at = Some(now + std::time::Duration::from_millis(900));
3856        player.update_playback_state();
3857        let late = fading_gain(&player).unwrap();
3858        assert!(
3859            db(late) < -59.0,
3860            "all but silent at the end: {} dB",
3861            db(late)
3862        );
3863        player.process_command(PlayerCommand::Stop);
3864    }
3865
3866    /// Cancelled half way down, the level comes back up over a second.
3867    #[test]
3868    fn cancelling_a_sleep_timer_mid_fade_brings_the_level_back() {
3869        let dir = tempfile::tempdir().unwrap();
3870        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
3871        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
3872            minutes: 15,
3873        })));
3874        player.sleep.as_mut().unwrap().at =
3875            Some(std::time::Instant::now() + std::time::Duration::from_secs(45));
3876        player.update_playback_state();
3877        assert!(fading_gain(&player).is_some());
3878
3879        player.process_command(PlayerCommand::SetSleepTimer(None));
3880        let Some(sleep::SleepFade::Restoring { from, .. }) = player.sleep_fade else {
3881            panic!("coming back up: {:?}", player.sleep_fade);
3882        };
3883        assert!((db(from) + 30.0).abs() < 1.0, "from where it had got to");
3884        assert!(!player.shared_state.sleep_fading());
3885        // A second on.
3886        if let Some(sleep::SleepFade::Restoring { since, .. }) = player.sleep_fade.as_mut() {
3887            *since -= std::time::Duration::from_secs(1);
3888        }
3889        player.update_playback_state();
3890        assert!(player.sleep_fade.is_none(), "at full level, and left alone");
3891        assert_eq!(session_run(&player), Some(Run::Playing));
3892        player.process_command(PlayerCommand::Stop);
3893    }
3894
3895    /// A pause by hand during the fade is someone awake: the timer is off,
3896    /// and playing again is at full level.
3897    #[test]
3898    fn pausing_during_the_fade_cancels_the_timer() {
3899        let dir = tempfile::tempdir().unwrap();
3900        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
3901        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
3902            minutes: 15,
3903        })));
3904        player.sleep.as_mut().unwrap().at =
3905            Some(std::time::Instant::now() + std::time::Duration::from_secs(45));
3906        player.update_playback_state();
3907        assert!(fading_gain(&player).is_some());
3908
3909        player.process_command(PlayerCommand::Pause);
3910        assert_eq!(player.shared_state.sleep(), None, "cancelled");
3911        assert!(player.sleep_fade.is_none());
3912        assert!(player.sleep_snap_on_resume);
3913        player.process_command(PlayerCommand::Resume);
3914        assert!(!player.sleep_snap_on_resume, "full level taken on resuming");
3915        player.process_command(PlayerCommand::Stop);
3916    }
3917
3918    /// The end of a track fades over its last minute, or all of it if it is
3919    /// shorter, as these one-second tracks are; the end of a record only on
3920    /// its last track.
3921    #[test]
3922    fn the_end_of_a_track_or_record_fades_into_the_boundary() {
3923        let dir = tempfile::tempdir().unwrap();
3924        let (mut player, _) = queued_wavs(dir.path(), &["a", "b"]);
3925        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
3926        player.update_playback_state();
3927        assert!(
3928            fading_gain(&player).is_some(),
3929            "a one-second track fades whole"
3930        );
3931
3932        // Both tracks are on one record (none named), so the first is not
3933        // its end.
3934        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfRecord)));
3935        if let Some(sleep::SleepFade::Restoring { since, .. }) = player.sleep_fade.as_mut() {
3936            *since -= std::time::Duration::from_secs(1);
3937        }
3938        player.update_playback_state();
3939        assert_eq!(fading_gain(&player), None, "the record goes on");
3940        player.process_command(PlayerCommand::Stop);
3941    }
3942
3943    #[test]
3944    fn a_sleep_timer_going_off_while_paused_only_ends() {
3945        let dir = tempfile::tempdir().unwrap();
3946        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
3947        player.process_command(PlayerCommand::Pause);
3948        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
3949            minutes: 1,
3950        })));
3951        player.sleep.as_mut().unwrap().at = Some(std::time::Instant::now());
3952        player.update_playback_state();
3953        assert_eq!(player.shared_state.sleep(), None);
3954        player.process_command(PlayerCommand::Resume);
3955        assert_eq!(
3956            session_run(&player),
3957            Some(Run::Playing),
3958            "nothing left to go off"
3959        );
3960        player.process_command(PlayerCommand::Stop);
3961    }
3962
3963    #[test]
3964    fn cancelling_a_sleep_timer_leaves_playback_alone() {
3965        let dir = tempfile::tempdir().unwrap();
3966        let (mut player, _) = wavs_playing(dir.path(), &["a"], 1.0);
3967        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::After {
3968            minutes: 1,
3969        })));
3970        player.process_command(PlayerCommand::SetSleepTimer(None));
3971        assert_eq!(player.shared_state.sleep(), None);
3972        assert!(player.sleep.is_none());
3973        player.update_playback_state();
3974        assert_eq!(session_run(&player), Some(Run::Playing));
3975        player.process_command(PlayerCommand::Stop);
3976    }
3977
3978    /// Gapless: the next track is already playing when the playhead crosses,
3979    /// and it is stopped there, not part way through the one before.
3980    #[test]
3981    fn a_sleep_timer_for_the_end_of_the_track_stops_at_the_boundary() {
3982        let dir = tempfile::tempdir().unwrap();
3983        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b"]);
3984        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
3985        assert_eq!(player.shared_state.sleep(), Some(Sleep::EndOfTrack));
3986
3987        // Half way through the first: playing on.
3988        player
3989            .timeline
3990            .samples_played
3991            .store(4_000, Ordering::Relaxed);
3992        player.update_playback_state();
3993        assert_eq!(session_run(&player), Some(Run::Playing));
3994        assert_eq!(player.shared_state.sleep(), Some(Sleep::EndOfTrack));
3995
3996        // Into the second.
3997        player
3998            .timeline
3999            .samples_played
4000            .store(8_400, Ordering::Relaxed);
4001        player.update_playback_state();
4002        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4003        assert_eq!(session_run(&player), Some(Run::Paused));
4004        assert!(
4005            !player.session().unwrap().engine().unwrap().is_running(),
4006            "stopped at once, nothing of the next track faded through"
4007        );
4008        assert_eq!(player.shared_state.sleep(), None);
4009        assert_eq!(playlist_ids(&player), ids);
4010        player.process_command(PlayerCommand::Stop);
4011    }
4012
4013    /// A track repeating runs into itself, and that is the end of the track
4014    /// too.
4015    #[test]
4016    fn a_sleep_timer_for_the_end_of_the_track_stops_a_track_repeating() {
4017        let dir = tempfile::tempdir().unwrap();
4018        let (mut player, ids) = wavs_in(dir.path(), &["a", "b"], 1.0, Repeat::One);
4019        assert_eq!(queued_at_least(&player, 1)[0], ids[0]);
4020        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
4021        player
4022            .timeline
4023            .samples_played
4024            .store(8_400, Ordering::Relaxed);
4025        player.update_playback_state();
4026        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4027        assert_eq!(session_run(&player), Some(Run::Paused));
4028        assert_eq!(player.shared_state.sleep(), None);
4029        player.process_command(PlayerCommand::Stop);
4030    }
4031
4032    /// A track that cannot follow gaplessly ends the decode instead, and the
4033    /// next opens paused.
4034    #[test]
4035    fn a_sleep_timer_for_the_end_of_the_track_opens_the_next_paused() {
4036        let dir = tempfile::tempdir().unwrap();
4037        let (mut player, ids) = wavs_playing(dir.path(), &["a", "b"], 1.0);
4038        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfTrack)));
4039        player.process_command(PlayerCommand::DecodeFinished(player.session));
4040        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4041        assert_eq!(player.intent(), Some(Run::Paused));
4042        assert_eq!(player.shared_state.sleep(), None);
4043        player.process_command(PlayerCommand::Stop);
4044    }
4045
4046    #[test]
4047    fn a_sleep_timer_for_the_end_of_the_record_plays_the_record_out() {
4048        let dir = tempfile::tempdir().unwrap();
4049        let mut player = Player::new();
4050        player.backend = Box::new(StuckBackend {
4051            rate: 8_000.0,
4052            asked: Default::default(),
4053            starts: Default::default(),
4054        });
4055        let items: Vec<_> = [("a", "Low"), ("b", "Low"), ("c", "Heroes")]
4056            .iter()
4057            .map(|(name, album)| {
4058                let path = dir.path().join(format!("{name}.wav"));
4059                crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4060                PlaylistItem {
4061                    path,
4062                    album: album.to_string(),
4063                    album_artist: "David Bowie".into(),
4064                    ..make_item(name)
4065                }
4066            })
4067            .collect();
4068        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
4069        player.process_command(PlayerCommand::AddToPlaylist(items));
4070        player.process_command(PlayerCommand::Play(ids[0]));
4071        player.process_command(PlayerCommand::SetSleepTimer(Some(SleepTimer::EndOfRecord)));
4072
4073        player.process_command(PlayerCommand::DecodeFinished(player.session));
4074        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4075        assert_eq!(player.intent(), Some(Run::Playing), "the same record");
4076        assert_eq!(player.shared_state.sleep(), Some(Sleep::EndOfRecord));
4077
4078        player.process_command(PlayerCommand::DecodeFinished(player.session));
4079        assert_eq!(player.shared_state.cursor(), Some(ids[2]));
4080        assert_eq!(player.intent(), Some(Run::Paused), "the next record waits");
4081        assert_eq!(player.shared_state.sleep(), None);
4082        player.process_command(PlayerCommand::Stop);
4083    }
4084
4085    #[test]
4086    fn shuffle_is_not_an_undo_step() {
4087        let mut player = Player::new();
4088        let ids = seed(&mut player, 10);
4089        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[4]));
4090        player.process_command(PlayerCommand::SetShuffle(true));
4091        assert_eq!(playlist_ids(&player).len(), 9, "the queue is as it was");
4092
4093        player.process_command(PlayerCommand::Undo);
4094        assert_eq!(playlist_ids(&player), ids, "the removal undone");
4095        assert!(player.shared_state.play_mode().shuffle, "shuffle left on");
4096    }
4097
4098    #[test]
4099    fn a_queue_replaced_while_shuffled_starts_where_asked_and_shuffles_the_rest() {
4100        let mut player = Player::new();
4101        seed(&mut player, 3);
4102        player.process_command(PlayerCommand::SetShuffle(true));
4103
4104        let items: Vec<_> = (0..20).map(|i| make_item(&format!("n{i}"))).collect();
4105        let given: Vec<_> = items.iter().map(|i| i.id).collect();
4106        player.process_command(PlayerCommand::ReplacePlaylist {
4107            items,
4108            start: 5,
4109            position_ms: 0,
4110            play: false,
4111            mode: QueueMode::Keep,
4112        });
4113        assert_eq!(playlist_ids(&player), given, "in the order given");
4114        assert_eq!(player.shared_state.cursor(), Some(given[5]));
4115
4116        let mut heard = vec![given[5]];
4117        while let Some(id) = player.shared_state.advance_cursor_loadable() {
4118            heard.push(id);
4119        }
4120        assert_ne!(heard, given);
4121        heard.sort_by_key(|id| given.iter().position(|i| i == id));
4122        assert_eq!(heard, given, "each once");
4123    }
4124
4125    #[test]
4126    fn the_pass_of_a_track_is_the_step_that_opened_it() {
4127        let step = |boundary, pass| state::Lookahead {
4128            after: QueueItemId::new(),
4129            next: None,
4130            chosen: None,
4131            wrapped: false,
4132            boundary,
4133            after_pass: 0,
4134            pass,
4135        };
4136        // The first pick at boundary 1 failed to open; the decoder stepped
4137        // again for the same boundary, into the next pass.
4138        let steps = [step(0, 0), step(1, 0), step(1, 1), step(2, 1)];
4139        assert_eq!(step_pass(&steps, 1), Some(1));
4140        assert_eq!(step_pass(&steps, 3), None);
4141    }
4142
4143    /// Every row of a shuffled queue played, the last under the cursor, as a
4144    /// session saved at the end of a pass is.
4145    fn played_out(player: &mut Player) -> Vec<QueueItemId> {
4146        let ids = seed(player, 4);
4147        player.process_command(PlayerCommand::SetShuffle(true));
4148        let state = &player.shared_state;
4149        state.advance_cursor_loadable();
4150        loop {
4151            state.mark_played(state.cursor().unwrap());
4152            if state.advance_cursor_loadable().is_none() {
4153                break;
4154            }
4155        }
4156        ids
4157    }
4158
4159    fn played_count(player: &Player) -> usize {
4160        player.shared_state.derive_visible_queue().finished_count
4161    }
4162
4163    #[test]
4164    fn a_session_restored_at_the_end_of_a_pass_does_not_start_another() {
4165        let mut player = Player::new();
4166        let ids = played_out(&mut player);
4167        let last = player.shared_state.cursor().unwrap();
4168
4169        player.process_command(PlayerCommand::Cue {
4170            id: last,
4171            position_ms: 0,
4172            play: false,
4173        });
4174        assert_eq!(played_count(&player), ids.len() - 1, "the marks kept");
4175        assert_eq!(
4176            player.shared_state.advance_cursor_loadable(),
4177            None,
4178            "repeat off: the queue ends"
4179        );
4180    }
4181
4182    #[test]
4183    fn a_track_repeating_keeps_the_marks() {
4184        let mut player = Player::new();
4185        let ids = played_out(&mut player);
4186        player.process_command(PlayerCommand::SetRepeat(Repeat::One));
4187        let last = player.shared_state.cursor().unwrap();
4188        // Repeat one without gapless: the track is opened again by a cue.
4189        player.cue(last, 0, Run::Paused);
4190        assert_eq!(played_count(&player), ids.len() - 1);
4191        player.process_command(PlayerCommand::Stop);
4192    }
4193
4194    #[test]
4195    fn picking_a_row_of_a_played_out_queue_starts_a_new_pass() {
4196        let mut player = Player::new();
4197        let ids = played_out(&mut player);
4198        player.process_command(PlayerCommand::Play(ids[1]));
4199        assert_eq!(played_count(&player), 0);
4200        assert_eq!(player.shared_state.upcoming().len(), ids.len() - 1);
4201        player.process_command(PlayerCommand::Stop);
4202    }
4203
4204    fn replace(player: &mut Player, n: usize, mode: QueueMode) -> Vec<QueueItemId> {
4205        let items: Vec<_> = (0..n).map(|i| make_item(&format!("r{i}"))).collect();
4206        let ids = items.iter().map(|i| i.id).collect();
4207        player.process_command(PlayerCommand::ReplacePlaylist {
4208            items,
4209            start: 0,
4210            position_ms: 0,
4211            play: false,
4212            mode,
4213        });
4214        ids
4215    }
4216
4217    fn shuffled_and_repeating() -> Player {
4218        let mut player = Player::new();
4219        seed(&mut player, 3);
4220        player.process_command(PlayerCommand::SetShuffle(true));
4221        player.process_command(PlayerCommand::SetRepeat(Repeat::Queue));
4222        player
4223    }
4224
4225    #[test]
4226    fn playing_from_a_play_button_turns_shuffle_and_repeat_off() {
4227        let mut player = shuffled_and_repeating();
4228        let ids = replace(&mut player, 8, QueueMode::InOrder);
4229        assert_eq!(player.shared_state.play_mode(), PlayMode::default());
4230        assert!(!player.shared_state.is_shuffled());
4231        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4232    }
4233
4234    #[test]
4235    fn shuffling_from_a_play_button_turns_shuffle_on_and_repeat_off() {
4236        let mut player = Player::new();
4237        player.process_command(PlayerCommand::SetRepeat(Repeat::One));
4238        let ids = replace(&mut player, 12, QueueMode::Shuffled);
4239        assert_eq!(
4240            player.shared_state.play_mode(),
4241            PlayMode {
4242                shuffle: true,
4243                repeat: Repeat::Off
4244            }
4245        );
4246        assert_eq!(playlist_ids(&player), ids, "the queue in the order given");
4247
4248        let mut heard = vec![player.shared_state.cursor().unwrap()];
4249        while let Some(id) = player.shared_state.advance_cursor_loadable() {
4250            heard.push(id);
4251        }
4252        assert_ne!(heard, ids, "started and played in a shuffled order");
4253        heard.sort_by_key(|id| ids.iter().position(|i| i == id));
4254        assert_eq!(heard, ids);
4255    }
4256
4257    #[test]
4258    fn a_queue_restored_or_handed_over_keeps_the_modes() {
4259        let mut player = shuffled_and_repeating();
4260        let mode = player.shared_state.play_mode();
4261        replace(&mut player, 5, QueueMode::Keep);
4262        assert_eq!(player.shared_state.play_mode(), mode);
4263        assert!(player.shared_state.is_shuffled());
4264    }
4265
4266    #[test]
4267    fn adding_to_the_queue_keeps_the_modes() {
4268        let mut player = shuffled_and_repeating();
4269        let mode = player.shared_state.play_mode();
4270        seed(&mut player, 4);
4271        let ids = playlist_ids(&player);
4272        player.process_command(PlayerCommand::InsertInPlaylist {
4273            items: vec![make_item("next")],
4274            after: ids[0],
4275        });
4276        assert_eq!(player.shared_state.play_mode(), mode);
4277    }
4278
4279    #[test]
4280    fn removing_the_playing_track_while_shuffled_moves_on_in_the_play_order() {
4281        let mut player = Player::new();
4282        seed(&mut player, 10);
4283        player.process_command(PlayerCommand::SetShuffle(true));
4284        let playing = player.shared_state.advance_cursor_loadable().unwrap();
4285        pretend_playing(&mut player, playing);
4286        let next = player.shared_state.lookahead_after(playing).unwrap().next;
4287
4288        player.process_command(PlayerCommand::RemoveFromPlaylist(playing));
4289        assert_eq!(player.shared_state.cursor(), next);
4290        player.process_command(PlayerCommand::Stop);
4291    }
4292
4293    #[test]
4294    fn shuffled_the_decoder_queues_the_play_order() {
4295        let dir = tempfile::tempdir().unwrap();
4296        let names = ["a", "b", "c", "d", "e", "f"];
4297        let (mut player, ids) = wavs_playing(dir.path(), &names, 1.0);
4298        queued_at_least(&player, names.len() - 1);
4299
4300        player.process_command(PlayerCommand::SetShuffle(true));
4301        let order = player.shared_state.upcoming();
4302        assert_eq!(order.len(), names.len() - 1);
4303        assert_eq!(queued_at_least(&player, order.len()), order);
4304        assert_eq!(playlist_ids(&player), ids, "the queue never moved");
4305        player.process_command(PlayerCommand::Stop);
4306    }
4307
4308    #[test]
4309    fn removing_the_last_track_playing_carries_on_from_the_top_when_repeating() {
4310        let mut player = Player::new();
4311        let ids = seed(&mut player, 3);
4312        player.process_command(PlayerCommand::SetRepeat(Repeat::Queue));
4313        player.shared_state.set_cursor(Some(ids[2]));
4314
4315        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
4316        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4317    }
4318
4319    #[test]
4320    fn removing_a_track_the_decoder_queued_takes_it_back() {
4321        let dir = tempfile::tempdir().unwrap();
4322        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b", "c"]);
4323        assert_eq!(player.timeline.queued_after_playhead(), ids[1..]);
4324
4325        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[1]));
4326        assert_eq!(player.playback_starts, 2, "restarted at the playhead");
4327        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4328        await_queued(&player);
4329        await_queued(&player);
4330        assert_eq!(player.timeline.queued_after_playhead(), vec![ids[2]]);
4331        player.process_command(PlayerCommand::Stop);
4332    }
4333
4334    #[test]
4335    fn a_track_inserted_to_play_next_is_not_skipped() {
4336        let dir = tempfile::tempdir().unwrap();
4337        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b"]);
4338        let path = dir.path().join("next.wav");
4339        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4340        let next = PlaylistItem {
4341            path,
4342            ..make_item("next")
4343        };
4344        let next_id = next.id;
4345
4346        player.process_command(PlayerCommand::InsertInPlaylist {
4347            items: vec![next],
4348            after: ids[0],
4349        });
4350        assert_eq!(player.playback_starts, 2);
4351        for _ in 0..3 {
4352            await_queued(&player);
4353        }
4354        assert_eq!(
4355            player.timeline.queued_after_playhead(),
4356            vec![next_id, ids[1]]
4357        );
4358        player.process_command(PlayerCommand::Stop);
4359    }
4360
4361    #[test]
4362    fn a_track_the_decoder_could_not_open_does_not_make_every_edit_restart() {
4363        let dir = tempfile::tempdir().unwrap();
4364        let mut player = Player::new();
4365        player.backend = Box::new(StuckBackend {
4366            rate: 8_000.0,
4367            asked: Default::default(),
4368            starts: Default::default(),
4369        });
4370        let wav = |name: &str| {
4371            let path = dir.path().join(format!("{name}.wav"));
4372            crate::test_utils::generate_wav(&path, 8_000, 1, 30.0, 16);
4373            PlaylistItem {
4374                path,
4375                ..make_item(name)
4376            }
4377        };
4378        // Ready, but with no file behind it: the decoder skips it.
4379        let items = vec![wav("a"), make_item("missing"), wav("c")];
4380        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
4381        player.process_command(PlayerCommand::AddToPlaylist(items));
4382        player.process_command(PlayerCommand::Play(ids[0]));
4383        await_queued(&player);
4384        await_queued(&player);
4385
4386        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("later")]));
4387        assert_eq!(
4388            player.playback_starts, 1,
4389            "nothing the decoder decided changed"
4390        );
4391        assert_eq!(player.timeline.queued_after_playhead(), vec![ids[2]]);
4392        player.process_command(PlayerCommand::Stop);
4393    }
4394
4395    #[test]
4396    fn a_track_added_after_the_decoder_reached_the_end_follows_gaplessly() {
4397        let dir = tempfile::tempdir().unwrap();
4398        let (mut player, ids) = queued_wavs(dir.path(), &["a"]);
4399        wait_for_lookahead(&player);
4400        let path = dir.path().join("b.wav");
4401        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4402        let b = PlaylistItem {
4403            path,
4404            ..make_item("b")
4405        };
4406        let b_id = b.id;
4407
4408        player.process_command(PlayerCommand::AddToPlaylist(vec![b]));
4409        assert_eq!(player.playback_starts, 2, "restarted to queue it");
4410        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
4411        await_queued(&player);
4412        await_queued(&player);
4413        assert_eq!(player.timeline.queued_after_playhead(), vec![b_id]);
4414        player.process_command(PlayerCommand::Stop);
4415    }
4416
4417    #[test]
4418    fn playing_next_a_track_still_downloading_takes_back_the_lookahead() {
4419        let dir = tempfile::tempdir().unwrap();
4420        let (mut player, ids) = queued_wavs(dir.path(), &["a", "b"]);
4421        let remote = pending_item("remote");
4422        let remote_id = remote.id;
4423
4424        player.process_command(PlayerCommand::InsertInPlaylist {
4425            items: vec![remote],
4426            after: ids[0],
4427        });
4428        assert_eq!(player.playback_starts, 2, "b is taken back out of the ring");
4429        await_queued(&player);
4430        wait_for_lookahead(&player);
4431        let session = player.session().unwrap();
4432        let step = session.lookahead.lock().last().cloned().unwrap();
4433        assert_eq!(step.next, Some(remote_id), "the decoder waits for it");
4434        assert!(player.timeline.queued_after_playhead().is_empty());
4435        player.process_command(PlayerCommand::Stop);
4436    }
4437
4438    #[test]
4439    fn gapless_playback_waits_for_a_track_still_downloading() {
4440        let dir = tempfile::tempdir().unwrap();
4441        let mut player = Player::new();
4442        player.backend = Box::new(StuckBackend {
4443            rate: 8_000.0,
4444            asked: Default::default(),
4445            starts: Default::default(),
4446        });
4447        let wav = |name: &str| {
4448            let path = dir.path().join(format!("{name}.wav"));
4449            crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4450            PlaylistItem {
4451                path,
4452                ..make_item(name)
4453            }
4454        };
4455        let items = vec![wav("a"), pending_item("arriving"), wav("c")];
4456        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
4457        player.process_command(PlayerCommand::AddToPlaylist(items));
4458        player.process_command(PlayerCommand::Play(ids[0]));
4459        await_queued(&player);
4460        wait_for_lookahead(&player);
4461
4462        assert!(
4463            player.timeline.queued_after_playhead().is_empty(),
4464            "c is not queued over the track before it"
4465        );
4466        // The session drains and ends; the advance parks on the track.
4467        player.process_command(PlayerCommand::DecodeFinished(player.session));
4468        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
4469        assert!(player.waiting().is_some());
4470        player.process_command(PlayerCommand::Stop);
4471    }
4472
4473    /// Until the decoder has taken a step past the playing track.
4474    fn wait_for_lookahead(player: &Player) {
4475        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
4476        while player
4477            .session()
4478            .is_some_and(|s| s.lookahead.lock().is_empty())
4479        {
4480            assert!(
4481                std::time::Instant::now() < deadline,
4482                "the decoder never looked ahead"
4483            );
4484            std::thread::yield_now();
4485        }
4486    }
4487
4488    #[test]
4489    fn an_edit_after_what_the_decoder_queued_leaves_playback_alone() {
4490        let dir = tempfile::tempdir().unwrap();
4491        let (mut player, ids) = wavs_playing(dir.path(), &["a", "b"], 30.0);
4492        await_queued(&player);
4493        await_queued(&player);
4494        let path = dir.path().join("later.wav");
4495        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4496
4497        player.process_command(PlayerCommand::AddToPlaylist(vec![PlaylistItem {
4498            path,
4499            ..make_item("later")
4500        }]));
4501        assert_eq!(player.playback_starts, 1);
4502        assert_eq!(player.timeline.queued_after_playhead(), vec![ids[1]]);
4503        player.process_command(PlayerCommand::Stop);
4504    }
4505
4506    #[test]
4507    fn undoing_the_add_of_a_track_on_its_way_forgets_it() {
4508        let dir = tempfile::tempdir().unwrap();
4509        let (mut player, _, starts) = downloading_wav(dir.path());
4510        let path = dir.path().join("added.wav");
4511        crate::test_utils::generate_wav(&path, 8_000, 1, 1.0, 16);
4512        let added = PlaylistItem {
4513            path,
4514            state: ItemState::Pending,
4515            ..make_item("added")
4516        };
4517        let added_id = added.id;
4518        player.process_command(PlayerCommand::AddToPlaylist(vec![added]));
4519        player.process_command(PlayerCommand::Play(added_id));
4520
4521        player.process_command(PlayerCommand::Undo);
4522        assert!(player.waiting().is_none());
4523        assert_eq!(player.shared_state.playback_state(), PlaybackState::Stopped);
4524
4525        // Its download lands anyway; nothing asked for it any more.
4526        player.process_command(PlayerCommand::Redo);
4527        player
4528            .shared_state
4529            .update_item_state(added_id, ItemState::Ready);
4530        player.process_command(PlayerCommand::TrackReady(added_id));
4531        assert!(player.session().is_none());
4532        assert_eq!(starts.load(Ordering::Relaxed), 0);
4533    }
4534
4535    /// xorshift64: enough randomness to drive the player, reproducible from
4536    /// its seed, and no dependency for it.
4537    pub(super) struct Rng(pub(super) u64);
4538
4539    impl Rng {
4540        pub(super) fn next(&mut self) -> u64 {
4541            self.0 ^= self.0 << 13;
4542            self.0 ^= self.0 >> 7;
4543            self.0 ^= self.0 << 17;
4544            self.0
4545        }
4546
4547        pub(super) fn below(&mut self, n: usize) -> usize {
4548            (self.next() % n.max(1) as u64) as usize
4549        }
4550
4551        pub(super) fn coin(&mut self) -> bool {
4552            self.next() & 1 == 1
4553        }
4554    }
4555
4556    /// Commands a listener can send that ask for sound. Anything else may only
4557    /// keep playing what was already playing or on its way to.
4558    pub(super) fn asks_to_play(cmd: &PlayerCommand) -> bool {
4559        cmd.asks_to_play()
4560    }
4561
4562    /// Check the invariants #679 sets out, as far as they can be seen from
4563    /// outside the audio thread.
4564    pub(super) fn check_invariants(
4565        player: &Player,
4566        wanted_before: bool,
4567        asked: bool,
4568    ) -> Result<(), String> {
4569        let state = &player.shared_state;
4570        let ids = playlist_ids(player);
4571        let listed = |id: QueueItemId| ids.contains(&id);
4572        let mut seen = std::collections::HashSet::new();
4573        if !ids.iter().all(|id| seen.insert(*id)) {
4574            return Err("an item is in the playlist twice".into());
4575        }
4576        let cursor = state.cursor();
4577        if cursor.is_some_and(|c| !listed(c)) {
4578            return Err("the cursor is on an item not in the playlist".into());
4579        }
4580        let info = state.track_info();
4581        match (&player.transport, &info) {
4582            (Transport::Loaded(_), None) => return Err("loaded, but no track published".into()),
4583            (Transport::Loaded(_), Some(info)) => {
4584                if !listed(info.id) {
4585                    return Err("playing an item not in the playlist".into());
4586                }
4587                if cursor != Some(info.id) {
4588                    return Err("the cursor is not on what is playing".into());
4589                }
4590                if !matches!(
4591                    state.playback_state(),
4592                    PlaybackState::Playing | PlaybackState::Paused
4593                ) {
4594                    return Err("loaded, but published as stopped".into());
4595                }
4596            }
4597            (_, Some(_)) => return Err("a track is published with nothing loaded".into()),
4598            (Transport::Waiting(w), None) => {
4599                if cursor != Some(w.id) || !listed(w.id) {
4600                    return Err("waiting on an item that is not the cursor's".into());
4601                }
4602                let expected = match w.start {
4603                    Run::Playing => PlaybackState::Stopped,
4604                    Run::Paused => PlaybackState::Paused,
4605                };
4606                if state.playback_state() != expected {
4607                    return Err("a wait published in the wrong state".into());
4608                }
4609            }
4610            (Transport::Idle, None) => {
4611                if state.playback_state() != PlaybackState::Stopped {
4612                    return Err("idle, but not published as stopped".into());
4613                }
4614            }
4615        }
4616        if state.is_waiting() != player.waiting().is_some() {
4617            return Err("the published wait disagrees with the player".into());
4618        }
4619        if player
4620            .timeline
4621            .queued_after_playhead()
4622            .into_iter()
4623            .any(|id| !listed(id))
4624        {
4625            return Err("the decoder has queued an item no longer in the playlist".into());
4626        }
4627        if !asked && !wanted_before && state.wants_to_play() {
4628            return Err("started without being asked".into());
4629        }
4630        if state.play_mode() != player.mode {
4631            return Err("the published mode disagrees with the player".into());
4632        }
4633        if state.is_shuffled() != player.mode.shuffle {
4634            return Err("a play order kept, or not, against the mode".into());
4635        }
4636        if player.mode.repeat == Repeat::Off
4637            && let Some(session) = player.session()
4638            && let Some(playhead) = player.timeline.playhead()
4639            && session.track.id == playhead.id
4640            && session
4641                .lookahead
4642                .lock()
4643                .iter()
4644                .filter(|step| step.boundary > playhead.boundary)
4645                .any(|step| step.wrapped || step.next == Some(step.after))
4646        {
4647            return Err("repeat is off, but the decoder has queued a wrap".into());
4648        }
4649        Ok(())
4650    }
4651
4652    #[test]
4653    fn random_use_keeps_the_player_honest() {
4654        let dir = tempfile::tempdir().unwrap();
4655        let path = dir.path().join("t.wav");
4656        crate::test_utils::generate_wav(&path, 8_000, 1, 0.5, 16);
4657
4658        for seed in 1..=40u64 {
4659            let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
4660            let mut player = Player::new();
4661            player.backend = Box::new(StuckBackend {
4662                rate: 8_000.0,
4663                asked: Default::default(),
4664                starts: Default::default(),
4665            });
4666            let fresh = |rng: &mut Rng| {
4667                let state = match rng.below(3) {
4668                    0 => ItemState::Pending,
4669                    _ => ItemState::Ready,
4670                };
4671                PlaylistItem {
4672                    path: path.clone(),
4673                    state,
4674                    ..make_item("t")
4675                }
4676            };
4677            let start: Vec<_> = (0..5).map(|_| fresh(&mut rng)).collect();
4678            player.process_command(PlayerCommand::AddToPlaylist(start));
4679
4680            let mut history = Vec::new();
4681            for step in 0..150 {
4682                let ids = playlist_ids(&player);
4683                let pick = |rng: &mut Rng| ids.get(rng.below(ids.len())).copied();
4684                let cmd = match rng.below(23) {
4685                    0 => pick(&mut rng).map(PlayerCommand::Play),
4686                    1 => pick(&mut rng).map(|id| PlayerCommand::Cue {
4687                        id,
4688                        position_ms: if rng.coin() { 0 } else { 200 },
4689                        play: rng.coin(),
4690                    }),
4691                    2 => Some(PlayerCommand::Pause),
4692                    3 => Some(PlayerCommand::Resume),
4693                    4 => Some(PlayerCommand::NextTrack),
4694                    5 => Some(PlayerCommand::PrevTrack),
4695                    6 => Some(PlayerCommand::Seek(100)),
4696                    7 => pick(&mut rng).map(PlayerCommand::RemoveFromPlaylist),
4697                    8 => Some(PlayerCommand::RemoveFromPlaylistBatch(
4698                        (0..2).filter_map(|_| pick(&mut rng)).collect(),
4699                    )),
4700                    9 => pick(&mut rng).zip(pick(&mut rng)).map(|(id, target)| {
4701                        PlayerCommand::MoveInPlaylist {
4702                            id,
4703                            target,
4704                            after: rng.coin(),
4705                        }
4706                    }),
4707                    10 => pick(&mut rng).map(|after| PlayerCommand::InsertInPlaylist {
4708                        items: vec![fresh(&mut rng)],
4709                        after,
4710                    }),
4711                    11 => Some(PlayerCommand::AddToPlaylist(vec![fresh(&mut rng)])),
4712                    12 => Some(PlayerCommand::Undo),
4713                    13 => Some(PlayerCommand::Redo),
4714                    14 | 15 => {
4715                        let (items, _) = player.shared_state.snapshot_playlist();
4716                        let pending: Vec<_> = items
4717                            .iter()
4718                            .filter(|i| matches!(i.state, ItemState::Pending))
4719                            .map(|i| i.id)
4720                            .collect();
4721                        pending.get(rng.below(pending.len())).map(|&id| {
4722                            if rng.below(4) == 0 {
4723                                player
4724                                    .shared_state
4725                                    .update_item_state(id, ItemState::Failed("gone".into()));
4726                                PlayerCommand::TrackFailed(id)
4727                            } else {
4728                                player.shared_state.update_item_state(id, ItemState::Ready);
4729                                PlayerCommand::TrackReady(id)
4730                            }
4731                        })
4732                    }
4733                    16 => Some(PlayerCommand::DecodeFinished(player.session)),
4734                    17 => Some(PlayerCommand::DecodeFinished(
4735                        player.session.wrapping_sub(1),
4736                    )),
4737                    18 => Some(PlayerCommand::ClearPlaylist),
4738                    20 => Some(PlayerCommand::SetShuffle(rng.coin())),
4739                    21 => Some(PlayerCommand::SetRepeat(
4740                        [Repeat::Off, Repeat::Queue, Repeat::One][rng.below(3)],
4741                    )),
4742                    _ => Some(PlayerCommand::ReplacePlaylist {
4743                        items: (0..3).map(|_| fresh(&mut rng)).collect(),
4744                        start: rng.below(4),
4745                        position_ms: if rng.coin() { 0 } else { 200 },
4746                        play: rng.coin(),
4747                        mode: [QueueMode::Keep, QueueMode::InOrder, QueueMode::Shuffled]
4748                            [rng.below(3)],
4749                    }),
4750                };
4751                let Some(cmd) = cmd else { continue };
4752                let label = format!("{cmd:?}");
4753                let asked = asks_to_play(&cmd);
4754                let wanted_before = player.shared_state.wants_to_play();
4755                player.process_command(cmd);
4756                // What the decode threads sent meanwhile, as the loop would
4757                // see it. A full channel would stall a decode thread on its
4758                // way out.
4759                while let Ok(sent) = player.commands.rx.try_recv() {
4760                    player.process_command(sent);
4761                }
4762                player.update_playback_state();
4763                history.push(label);
4764                if let Err(broken) = check_invariants(&player, wanted_before, asked) {
4765                    let tail = history[history.len().saturating_sub(8)..].join("\n  ");
4766                    panic!("seed {seed}, step {step}: {broken}\nlast commands:\n  {tail}");
4767                }
4768            }
4769            player.process_command(PlayerCommand::Stop);
4770        }
4771    }
4772
4773    struct QuickEngine {
4774        silent: Arc<std::sync::atomic::AtomicBool>,
4775        quick_outs: Arc<std::sync::atomic::AtomicUsize>,
4776        outs: Arc<std::sync::atomic::AtomicUsize>,
4777        period: std::time::Duration,
4778    }
4779    impl AudioEngineHandle for QuickEngine {
4780        fn start(&self) -> Result<(), BackendError> {
4781            Ok(())
4782        }
4783        fn stop(&self) -> Result<(), BackendError> {
4784            Ok(())
4785        }
4786        fn is_running(&self) -> bool {
4787            true
4788        }
4789        fn fade_out(&self) {
4790            self.outs.fetch_add(1, Ordering::Relaxed);
4791        }
4792        fn fade_out_quickly(&self) {
4793            self.quick_outs.fetch_add(1, Ordering::Relaxed);
4794        }
4795        fn fade_in(&self) -> Result<(), BackendError> {
4796            Ok(())
4797        }
4798        fn is_silent(&self) -> bool {
4799            self.silent.load(Ordering::Relaxed)
4800        }
4801        fn period(&self) -> std::time::Duration {
4802            self.period
4803        }
4804    }
4805
4806    /// A player playing through a `QuickEngine` with callbacks of `frames` at
4807    /// `rate`, and the engine's silence and fade counts.
4808    fn quick_player(
4809        frames: u32,
4810        rate: u32,
4811    ) -> (
4812        Player,
4813        Arc<std::sync::atomic::AtomicBool>,
4814        Arc<std::sync::atomic::AtomicUsize>,
4815        Arc<std::sync::atomic::AtomicUsize>,
4816    ) {
4817        let silent = Arc::new(std::sync::atomic::AtomicBool::new(false));
4818        let quick_outs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4819        let outs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4820        let mut player = Player::new();
4821        player.transport = Transport::Loaded(test_session(
4822            QueueItemId::new(),
4823            Box::new(QuickEngine {
4824                silent: silent.clone(),
4825                quick_outs: quick_outs.clone(),
4826                outs: outs.clone(),
4827                period: std::time::Duration::from_secs_f64(frames as f64 / rate as f64),
4828            }),
4829        ));
4830        (player, silent, quick_outs, outs)
4831    }
4832
4833    /// A DSP change while playing fades the output out quickly and restarts
4834    /// only once that is silent; changes made meanwhile join it, so a run of
4835    /// them is one dip and one restart.
4836    #[test]
4837    fn a_dsp_change_restarts_after_a_quick_fade_and_coalesces() {
4838        let (mut player, silent, quick_outs, outs) = quick_player(512, 44100);
4839
4840        player.restart_for_dsp();
4841        assert_eq!(quick_outs.load(Ordering::Relaxed), 1);
4842        assert_eq!(outs.load(Ordering::Relaxed), 0, "not the pause's fade");
4843
4844        // Another change mid-fade: the same dip.
4845        player.restart_for_dsp();
4846        assert_eq!(quick_outs.load(Ordering::Relaxed), 1);
4847        player.update_playback_state();
4848        assert!(player.dsp_restart.is_some(), "not restarted while audible");
4849
4850        silent.store(true, Ordering::Relaxed);
4851        player.update_playback_state();
4852        assert!(player.dsp_restart.is_none(), "restarted once silent");
4853        assert!(
4854            !player.quick_start,
4855            "the quick start is the restart's alone"
4856        );
4857    }
4858
4859    /// The fade can begin up to one callback after it is asked for, so the
4860    /// first wake allows a period for that; a callback later still is waited
4861    /// for one period more, well inside the limit, for the buffers a Mac and
4862    /// PipeWire use by default.
4863    #[test]
4864    fn a_dsp_change_wakes_a_buffer_period_after_its_fade() {
4865        use crate::audio::fade::QUICK_FADE;
4866        for (frames, rate) in [(512, 44100), (1024, 48000)] {
4867            let period = std::time::Duration::from_secs_f64(frames as f64 / rate as f64);
4868            let (mut player, silent, _, _) = quick_player(frames, rate);
4869            player.restart_for_dsp();
4870            let since = player.dsp_restart.expect("waiting on the fade");
4871
4872            let first = player.next_event().expect("a wake when the fade ends");
4873            assert_eq!(first, since + QUICK_FADE + period + BOUNDARY_SLACK);
4874            assert_eq!(dsp_wake(since, period, since), first);
4875
4876            // Woken then, still sounding: once more, a period on, inside the
4877            // limit; past that, the limit.
4878            player.update_playback_state();
4879            assert!(player.dsp_restart.is_some());
4880            let second = dsp_wake(since, period, first);
4881            assert_eq!(second, first + period);
4882            assert!(
4883                second < since + QUICK_FADE_LIMIT,
4884                "{frames} frames: inside the limit"
4885            );
4886            assert_eq!(dsp_wake(since, period, second), since + QUICK_FADE_LIMIT);
4887
4888            silent.store(true, Ordering::Relaxed);
4889            player.update_playback_state();
4890            assert!(
4891                player.dsp_restart.is_none(),
4892                "{frames} frames: restarted at silence"
4893            );
4894        }
4895    }
4896
4897    #[test]
4898    fn a_pause_reports_where_the_fade_went_silent() {
4899        use std::sync::atomic::AtomicBool;
4900
4901        struct FadingEngine {
4902            running: Arc<AtomicBool>,
4903            silent: Arc<AtomicBool>,
4904        }
4905        impl AudioEngineHandle for FadingEngine {
4906            fn start(&self) -> Result<(), BackendError> {
4907                self.running.store(true, Ordering::Relaxed);
4908                Ok(())
4909            }
4910            fn stop(&self) -> Result<(), BackendError> {
4911                self.running.store(false, Ordering::Relaxed);
4912                Ok(())
4913            }
4914            fn is_running(&self) -> bool {
4915                self.running.load(Ordering::Relaxed)
4916            }
4917            fn fade_out(&self) {}
4918            fn fade_in(&self) -> Result<(), BackendError> {
4919                Ok(())
4920            }
4921            fn is_silent(&self) -> bool {
4922                self.silent.load(Ordering::Relaxed)
4923            }
4924        }
4925
4926        let running = Arc::new(AtomicBool::new(true));
4927        let silent = Arc::new(AtomicBool::new(false));
4928        let mut player = Player::new();
4929        player.transport = Transport::Loaded(test_session(
4930            QueueItemId::new(),
4931            Box::new(FadingEngine {
4932                running: running.clone(),
4933                silent: silent.clone(),
4934            }),
4935        ));
4936        player
4937            .shared_state
4938            .set_playback_state(PlaybackState::Playing);
4939        player.shared_state.set_position_ms(5_000);
4940
4941        let (reply, answer) = crossbeam_channel::bounded(1);
4942        player.process_command(PlayerCommand::PauseAndReport(reply));
4943
4944        if crate::config::Config::cached().playback.fade_on_pause {
4945            assert!(answer.try_recv().is_err(), "not while the fade is audible");
4946            // The fade plays on, and the playhead with it.
4947            player.shared_state.set_position_ms(5_150);
4948            player.update_playback_state();
4949            assert!(answer.try_recv().is_err());
4950            silent.store(true, Ordering::Relaxed);
4951            player.update_playback_state();
4952            assert!(!running.load(Ordering::Relaxed));
4953            assert_eq!(answer.try_recv().unwrap(), 5_150);
4954        } else {
4955            assert_eq!(answer.try_recv().unwrap(), 5_000);
4956        }
4957    }
4958
4959    #[test]
4960    fn the_server_hears_each_turn_playback_takes() {
4961        use PlaybackReportState::{Paused, Playing, Stopped};
4962
4963        let dir = tempfile::tempdir().unwrap();
4964        let path = dir.path().join("t.wav");
4965        crate::test_utils::generate_wav(&path, 8_000, 1, 10.0, 16);
4966
4967        let mut player = Player::new();
4968        player.backend = Box::new(StuckBackend {
4969            rate: 8_000.0,
4970            asked: Default::default(),
4971            starts: Default::default(),
4972        });
4973        let (recorder, events) = PlayRecorder::capture();
4974        player.history = Some(recorder);
4975
4976        let item = PlaylistItem {
4977            db_id: Some(5),
4978            path,
4979            ..make_item("t")
4980        };
4981        let id = item.id;
4982        player.process_command(PlayerCommand::AddToPlaylist(vec![item]));
4983        player.process_command(PlayerCommand::Play(id));
4984        player.process_command(PlayerCommand::Pause);
4985        player.process_command(PlayerCommand::Seek(4_000));
4986        // Once the decoder has queued the track, the playhead reads the start
4987        // of the packet holding 4 s, which can be a little before it; until
4988        // then it reads the 4 s asked for. Waiting makes the read the same on
4989        // every run.
4990        await_queued(&player);
4991        player.process_command(PlayerCommand::Resume);
4992        player.process_command(PlayerCommand::Stop);
4993
4994        let events: Vec<_> = events.try_iter().collect();
4995        let near_seek = |position_ms: u64| (3_750..=4_000).contains(&position_ms);
4996        let reports: Vec<_> = events
4997            .iter()
4998            .filter_map(|e| match e {
4999                PlayEvent::Playback(r) => Some((r.track_id, r.state, r.position_ms)),
5000                _ => None,
5001            })
5002            .collect();
5003        assert_eq!(
5004            events.first(),
5005            Some(&PlayEvent::Started {
5006                track_id: 5,
5007                position_ms: 0
5008            })
5009        );
5010        assert_eq!(reports.len(), 4, "{events:?}");
5011        assert_eq!(reports[0], (5, Paused, 0));
5012        for (report, state) in reports[1..].iter().zip([Paused, Playing, Stopped]) {
5013            assert_eq!((report.0, report.1), (5, state), "{events:?}");
5014            assert!(near_seek(report.2), "{events:?}");
5015        }
5016        assert_eq!(
5017            events.last(),
5018            Some(&PlayEvent::Finished {
5019                track_id: 5,
5020                listened_ms: 0
5021            })
5022        );
5023    }
5024
5025    #[test]
5026    fn removing_the_playing_track_resumes_at_its_successor() {
5027        let mut player = Player::new();
5028        let ids = seed(&mut player, 5);
5029        pretend_playing(&mut player, ids[2]);
5030
5031        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[2]));
5032
5033        assert_eq!(
5034            player.shared_state.cursor(),
5035            Some(ids[3]),
5036            "playback must continue at the next track, not restart the queue"
5037        );
5038        assert_eq!(player.playback_starts, 1);
5039    }
5040
5041    #[test]
5042    fn removing_the_paused_track_moves_on_paused() {
5043        let mut player = Player::new();
5044        let ids = seed(&mut player, 3);
5045        pretend_playing(&mut player, ids[1]);
5046        if let Transport::Loaded(session) = &mut player.transport {
5047            session.run = Run::Paused;
5048        }
5049
5050        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[1]));
5051        assert_eq!(player.shared_state.cursor(), Some(ids[2]));
5052        assert!(!player.shared_state.wants_to_play(), "still paused");
5053    }
5054
5055    #[test]
5056    fn removing_the_cursor_with_nothing_loaded_starts_nothing() {
5057        let mut player = Player::new();
5058        let ids = seed(&mut player, 3);
5059        player.shared_state.set_cursor(Some(ids[1]));
5060
5061        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[1]));
5062        assert_eq!(player.shared_state.cursor(), Some(ids[2]));
5063        assert_eq!(player.playback_starts, 0);
5064    }
5065
5066    #[test]
5067    fn removing_the_first_playing_track_resumes_at_the_new_first() {
5068        let mut player = Player::new();
5069        let ids = seed(&mut player, 3);
5070        pretend_playing(&mut player, ids[0]);
5071
5072        player.process_command(PlayerCommand::RemoveFromPlaylist(ids[0]));
5073
5074        assert_eq!(player.shared_state.cursor(), Some(ids[1]));
5075    }
5076
5077    #[test]
5078    fn next_track_parks_on_a_track_that_has_not_downloaded_yet() {
5079        let mut player = Player::new();
5080        let playing = make_item("playing");
5081        let waiting = pending_item("waiting");
5082        let later = make_item("later");
5083        let (playing_id, waiting_id) = (playing.id, waiting.id);
5084        player.process_command(PlayerCommand::AddToPlaylist(vec![playing, waiting, later]));
5085        pretend_playing(&mut player, playing_id);
5086
5087        player.process_command(PlayerCommand::DecodeFinished(player.session));
5088
5089        assert_eq!(
5090            player.shared_state.cursor(),
5091            Some(waiting_id),
5092            "the cursor parks on the track being fetched"
5093        );
5094        assert_eq!(
5095            player.playback_starts, 0,
5096            "nothing to play until its bytes land"
5097        );
5098
5099        // The download completes. Because the cursor is parked here, the
5100        // TrackReady actually reaches the player and the queue resumes.
5101        player
5102            .shared_state
5103            .update_item_state(waiting_id, ItemState::Ready);
5104        player.process_command(PlayerCommand::TrackReady(waiting_id));
5105
5106        assert_eq!(player.playback_starts, 1);
5107        assert_eq!(player.shared_state.cursor(), Some(waiting_id));
5108    }
5109
5110    /// Play pressed on an album whose first track is still on the server and
5111    /// whose next two are cached: the first is waited for and played first,
5112    /// not passed over for the first that happens to be on disk.
5113    #[test]
5114    fn an_album_starts_at_its_first_track_however_much_is_cached() {
5115        let mut player = Player::new();
5116        let remote = pending_item("remote");
5117        let cached = [make_item("cached 2"), make_item("cached 3")];
5118        let first = remote.id;
5119        player.process_command(PlayerCommand::ReplacePlaylist {
5120            items: [vec![remote], cached.to_vec()].concat(),
5121            start: 0,
5122            position_ms: 0,
5123            play: true,
5124            mode: QueueMode::Keep,
5125        });
5126
5127        assert_eq!(player.shared_state.cursor(), Some(first));
5128        assert_eq!(player.playback_starts, 0, "nothing decodes before it lands");
5129
5130        player
5131            .shared_state
5132            .update_item_state(first, ItemState::Ready);
5133        player.process_command(PlayerCommand::TrackReady(first));
5134        assert_eq!(player.playback_starts, 1);
5135        assert_eq!(
5136            player.shared_state.cursor(),
5137            Some(first),
5138            "the first track decodes first"
5139        );
5140    }
5141
5142    /// A stream opened ahead of its download that gives out with nothing
5143    /// heard has not played: the track is waited for, not skipped.
5144    #[test]
5145    fn a_stream_that_ends_unheard_mid_download_waits_for_its_track() {
5146        let mut player = Player::new();
5147        let streaming = pending_item("streaming");
5148        let next = make_item("next");
5149        let id = streaming.id;
5150        player.process_command(PlayerCommand::AddToPlaylist(vec![streaming, next]));
5151        pretend_playing(&mut player, id);
5152
5153        player.process_command(PlayerCommand::DecodeFinished(player.session));
5154        assert_eq!(player.shared_state.cursor(), Some(id), "not passed over");
5155        assert_eq!(player.playback_starts, 0);
5156
5157        player.shared_state.update_item_state(id, ItemState::Ready);
5158        player.process_command(PlayerCommand::TrackReady(id));
5159        assert_eq!(player.playback_starts, 1);
5160        assert_eq!(player.shared_state.cursor(), Some(id));
5161    }
5162
5163    #[test]
5164    fn a_download_that_cannot_land_moves_the_cursor_on() {
5165        let mut player = Player::new();
5166        let waiting = pending_item("waiting");
5167        let later = make_item("later");
5168        let (waiting_id, later_id) = (waiting.id, later.id);
5169        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, later]));
5170
5171        player.process_command(PlayerCommand::Play(waiting_id));
5172        assert_eq!(player.playback_starts, 0, "nothing to play yet");
5173
5174        // The download gives up. Ready will never come.
5175        player
5176            .shared_state
5177            .update_item_state(waiting_id, ItemState::Failed("remote unavailable".into()));
5178        player.process_command(PlayerCommand::TrackFailed(waiting_id));
5179
5180        assert_eq!(
5181            player.shared_state.cursor(),
5182            Some(later_id),
5183            "the queue moves past a track that can never load"
5184        );
5185        assert_eq!(player.playback_starts, 1);
5186    }
5187
5188    #[test]
5189    fn a_queue_that_can_never_load_stops_rather_than_waiting() {
5190        let mut player = Player::new();
5191        let first = pending_item("first");
5192        let second = pending_item("second");
5193        let (first_id, second_id) = (first.id, second.id);
5194        player.process_command(PlayerCommand::AddToPlaylist(vec![first, second]));
5195
5196        player.process_command(PlayerCommand::Play(first_id));
5197        for id in [first_id, second_id] {
5198            player
5199                .shared_state
5200                .update_item_state(id, ItemState::Failed("remote unavailable".into()));
5201            player.process_command(PlayerCommand::TrackFailed(id));
5202        }
5203
5204        assert_eq!(player.playback_starts, 0);
5205        assert_eq!(
5206            player.shared_state.playback_state(),
5207            PlaybackState::Stopped,
5208            "a stop the UI can see, not an indefinite wait for TrackReady"
5209        );
5210    }
5211
5212    #[test]
5213    fn a_failure_elsewhere_in_the_queue_leaves_the_cursor_alone() {
5214        let mut player = Player::new();
5215        let waiting = pending_item("waiting");
5216        let other = pending_item("other");
5217        let (waiting_id, other_id) = (waiting.id, other.id);
5218        player.process_command(PlayerCommand::AddToPlaylist(vec![waiting, other]));
5219        player.process_command(PlayerCommand::Play(waiting_id));
5220
5221        player
5222            .shared_state
5223            .update_item_state(other_id, ItemState::Failed("remote unavailable".into()));
5224        player.process_command(PlayerCommand::TrackFailed(other_id));
5225
5226        assert_eq!(
5227            player.shared_state.cursor(),
5228            Some(waiting_id),
5229            "a track still downloading keeps the cursor"
5230        );
5231    }
5232
5233    #[test]
5234    fn batch_delete_containing_the_cursor_restarts_the_engine_once() {
5235        let mut player = Player::new();
5236        let ids = seed(&mut player, 5);
5237        pretend_playing(&mut player, ids[2]);
5238
5239        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![
5240            ids[1], ids[2], ids[3],
5241        ]));
5242
5243        assert_eq!(playlist_titles(&player), vec!["t0", "t4"]);
5244        assert_eq!(player.shared_state.cursor(), Some(ids[4]));
5245        assert_eq!(
5246            player.playback_starts, 1,
5247            "one resume for the whole selection, not one per deleted track"
5248        );
5249    }
5250
5251    #[test]
5252    fn batch_delete_below_the_cursor_leaves_playback_alone() {
5253        let mut player = Player::new();
5254        let ids = seed(&mut player, 4);
5255        player.shared_state.set_cursor(Some(ids[0]));
5256
5257        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![ids[2], ids[3]]));
5258
5259        assert_eq!(player.shared_state.cursor(), Some(ids[0]));
5260        assert_eq!(player.playback_starts, 0);
5261    }
5262
5263    #[test]
5264    fn undo_of_a_batch_delete_restores_the_original_order() {
5265        // The TUI collects a selection from a HashSet, so the IDs arrive in
5266        // arbitrary order — scrambled here so a snapshot that trusts that order
5267        // re-inserts C before B and lands it at the end of the playlist.
5268        let mut player = Player::new();
5269        let items = vec![
5270            make_item("A"),
5271            make_item("B"),
5272            make_item("C"),
5273            make_item("D"),
5274        ];
5275        let (b_id, c_id) = (items[1].id, items[2].id);
5276        player.process_command(PlayerCommand::AddToPlaylist(items));
5277
5278        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![c_id, b_id]));
5279        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
5280
5281        player.process_command(PlayerCommand::Undo);
5282        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
5283    }
5284
5285    // --- AddToPlaylist undo/redo ---
5286
5287    #[test]
5288    fn undo_add_removes_items() {
5289        let mut player = Player::new();
5290        let items = vec![make_item("A"), make_item("B")];
5291        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
5292
5293        player.process_command(PlayerCommand::AddToPlaylist(items));
5294        assert_eq!(playlist_ids(&player), ids);
5295        assert!(player.undo_stack().can_undo());
5296
5297        player.process_command(PlayerCommand::Undo);
5298        assert!(playlist_ids(&player).is_empty());
5299        assert!(player.undo_stack().can_redo());
5300    }
5301
5302    #[test]
5303    fn redo_add_restores_items() {
5304        let mut player = Player::new();
5305        let items = vec![make_item("A"), make_item("B")];
5306
5307        player.process_command(PlayerCommand::AddToPlaylist(items));
5308        player.process_command(PlayerCommand::Undo);
5309        assert!(playlist_ids(&player).is_empty());
5310
5311        player.process_command(PlayerCommand::Redo);
5312        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5313    }
5314
5315    // --- RemoveFromPlaylist undo/redo ---
5316
5317    #[test]
5318    fn undo_remove_restores_item_at_position() {
5319        let mut player = Player::new();
5320        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5321        let b_id = items[1].id;
5322
5323        player.process_command(PlayerCommand::AddToPlaylist(items));
5324        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
5325        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5326
5327        player.process_command(PlayerCommand::Undo);
5328        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5329    }
5330
5331    #[test]
5332    fn undo_remove_first_item() {
5333        let mut player = Player::new();
5334        let items = vec![make_item("A"), make_item("B")];
5335        let a_id = items[0].id;
5336
5337        player.process_command(PlayerCommand::AddToPlaylist(items));
5338        player.process_command(PlayerCommand::RemoveFromPlaylist(a_id));
5339        assert_eq!(playlist_titles(&player), vec!["B"]);
5340
5341        player.process_command(PlayerCommand::Undo);
5342        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5343    }
5344
5345    #[test]
5346    fn undo_batch_remove_restores_all() {
5347        let mut player = Player::new();
5348        let items = vec![
5349            make_item("A"),
5350            make_item("B"),
5351            make_item("C"),
5352            make_item("D"),
5353        ];
5354        let b_id = items[1].id;
5355        let c_id = items[2].id;
5356
5357        player.process_command(PlayerCommand::AddToPlaylist(items));
5358        let version_before = player.shared_state.playlist_version();
5359        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![b_id, c_id]));
5360        assert_eq!(playlist_titles(&player), vec!["A", "D"]);
5361        // One bump for the whole batch. Bumping per item is what made clearing
5362        // a large queue crawl, and every bump wakes every client watching.
5363        assert_eq!(
5364            player.shared_state.playlist_version(),
5365            version_before + 1,
5366            "batch removal must bump the playlist version exactly once"
5367        );
5368
5369        // Single undo restores both
5370        player.process_command(PlayerCommand::Undo);
5371        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
5372    }
5373
5374    #[test]
5375    fn redo_batch_remove() {
5376        let mut player = Player::new();
5377        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5378        let a_id = items[0].id;
5379        let b_id = items[1].id;
5380
5381        player.process_command(PlayerCommand::AddToPlaylist(items));
5382        player.process_command(PlayerCommand::RemoveFromPlaylistBatch(vec![a_id, b_id]));
5383        player.process_command(PlayerCommand::Undo);
5384        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5385
5386        player.process_command(PlayerCommand::Redo);
5387        assert_eq!(playlist_titles(&player), vec!["C"]);
5388    }
5389
5390    #[test]
5391    fn redo_remove() {
5392        let mut player = Player::new();
5393        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5394        let b_id = items[1].id;
5395
5396        player.process_command(PlayerCommand::AddToPlaylist(items));
5397        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
5398        player.process_command(PlayerCommand::Undo);
5399        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5400
5401        player.process_command(PlayerCommand::Redo);
5402        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5403    }
5404
5405    // --- InsertInPlaylist undo/redo ---
5406
5407    #[test]
5408    fn undo_insert_removes_inserted_items() {
5409        let mut player = Player::new();
5410        let items = vec![make_item("A"), make_item("C")];
5411        let a_id = items[0].id;
5412
5413        player.process_command(PlayerCommand::AddToPlaylist(items));
5414
5415        let inserted = vec![make_item("B")];
5416        player.process_command(PlayerCommand::InsertInPlaylist {
5417            items: inserted,
5418            after: a_id,
5419        });
5420        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5421
5422        player.process_command(PlayerCommand::Undo);
5423        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5424    }
5425
5426    // --- MoveInPlaylist undo/redo ---
5427
5428    #[test]
5429    fn undo_move_restores_position() {
5430        let mut player = Player::new();
5431        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5432        let a_id = items[0].id;
5433        let c_id = items[2].id;
5434
5435        player.process_command(PlayerCommand::AddToPlaylist(items));
5436
5437        // Move A after C: [B, C, A]
5438        player.process_command(PlayerCommand::MoveInPlaylist {
5439            id: a_id,
5440            target: c_id,
5441            after: true,
5442        });
5443        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5444
5445        player.process_command(PlayerCommand::Undo);
5446        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5447    }
5448
5449    #[test]
5450    fn redo_move() {
5451        let mut player = Player::new();
5452        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5453        let a_id = items[0].id;
5454        let c_id = items[2].id;
5455
5456        player.process_command(PlayerCommand::AddToPlaylist(items));
5457        player.process_command(PlayerCommand::MoveInPlaylist {
5458            id: a_id,
5459            target: c_id,
5460            after: true,
5461        });
5462        player.process_command(PlayerCommand::Undo);
5463        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5464
5465        player.process_command(PlayerCommand::Redo);
5466        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5467    }
5468
5469    // --- MoveItemsInPlaylist (batch) undo/redo ---
5470
5471    #[test]
5472    fn undo_batch_move() {
5473        let mut player = Player::new();
5474        let items = vec![
5475            make_item("A"),
5476            make_item("B"),
5477            make_item("C"),
5478            make_item("D"),
5479        ];
5480        let a_id = items[0].id;
5481        let b_id = items[1].id;
5482        let d_id = items[3].id;
5483
5484        player.process_command(PlayerCommand::AddToPlaylist(items));
5485
5486        // Move A,B after D: [C, D, A, B]
5487        player.process_command(PlayerCommand::MoveItemsInPlaylist {
5488            ids: vec![a_id, b_id],
5489            target: d_id,
5490            after: true,
5491        });
5492        assert_eq!(playlist_titles(&player), vec!["C", "D", "A", "B"]);
5493
5494        player.process_command(PlayerCommand::Undo);
5495        assert_eq!(playlist_titles(&player), vec!["A", "B", "C", "D"]);
5496    }
5497
5498    // --- ClearPlaylist undo/redo ---
5499
5500    #[test]
5501    fn undo_clear_restores_playlist() {
5502        let mut player = Player::new();
5503        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5504
5505        player.process_command(PlayerCommand::AddToPlaylist(items));
5506        player.process_command(PlayerCommand::ClearPlaylist);
5507        assert!(playlist_ids(&player).is_empty());
5508
5509        player.process_command(PlayerCommand::Undo);
5510        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5511    }
5512
5513    /// The bug: replacing the queue starts the new track, and undoing restored
5514    /// the old queue while leaving the engine on a track that queue no longer
5515    /// contains — a transport describing a row nobody can see, and a decode
5516    /// lookahead with nothing to follow.
5517    #[test]
5518    fn undoing_a_replace_does_not_leave_the_engine_on_an_orphaned_track() {
5519        let mut player = Player::new();
5520        let original = seed(&mut player, 3);
5521        player.shared_state.set_cursor(Some(original[0]));
5522        pretend_playing(&mut player, original[0]);
5523
5524        let replacement = vec![make_item("something else")];
5525        let orphan = replacement[0].id;
5526        player.process_command(PlayerCommand::ReplacePlaylist {
5527            items: replacement,
5528            start: 0,
5529            position_ms: 0,
5530            play: true,
5531            mode: QueueMode::Keep,
5532        });
5533        // What `play()` would have left behind if the file existed.
5534        pretend_playing(&mut player, orphan);
5535
5536        player.process_command(PlayerCommand::Undo);
5537
5538        assert_eq!(playlist_ids(&player), original, "the queue comes back");
5539        assert!(
5540            player.shared_state.get_item(orphan).is_none(),
5541            "and the replacement is gone from it"
5542        );
5543        assert!(
5544            playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
5545            "so nothing may still be playing out of it"
5546        );
5547    }
5548
5549    /// The same orphaning, reached by undoing an add rather than a replace.
5550    #[test]
5551    fn undoing_an_add_does_not_leave_the_engine_on_a_removed_track() {
5552        let mut player = Player::new();
5553        seed(&mut player, 2);
5554        let added = seed(&mut player, 1);
5555        pretend_playing(&mut player, added[0]);
5556
5557        player.process_command(PlayerCommand::Undo);
5558
5559        assert!(player.shared_state.get_item(added[0]).is_none());
5560        assert!(
5561            playing_id(&player).is_none_or(|id| player.shared_state.get_item(id).is_some()),
5562            "the engine cannot be left on the item the undo removed"
5563        );
5564    }
5565
5566    /// An undo that leaves the playing item where it is must not restart it.
5567    #[test]
5568    fn undoing_a_move_leaves_playback_alone() {
5569        let mut player = Player::new();
5570        let ids = seed(&mut player, 3);
5571        player.shared_state.set_cursor(Some(ids[0]));
5572        pretend_playing(&mut player, ids[0]);
5573        let starts = player.playback_starts;
5574
5575        player.process_command(PlayerCommand::MoveInPlaylist {
5576            id: ids[2],
5577            target: ids[0],
5578            after: false,
5579        });
5580        player.process_command(PlayerCommand::Undo);
5581
5582        assert_eq!(playlist_ids(&player), ids);
5583        assert_eq!(playing_id(&player), Some(ids[0]), "still on the same track");
5584        assert_eq!(player.playback_starts, starts, "and not restarted");
5585    }
5586
5587    #[test]
5588    fn redo_clear() {
5589        let mut player = Player::new();
5590        let items = vec![make_item("A"), make_item("B")];
5591
5592        player.process_command(PlayerCommand::AddToPlaylist(items));
5593        player.process_command(PlayerCommand::ClearPlaylist);
5594        player.process_command(PlayerCommand::Undo);
5595        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5596
5597        player.process_command(PlayerCommand::Redo);
5598        assert!(playlist_ids(&player).is_empty());
5599    }
5600
5601    // --- Multi-step undo/redo ---
5602
5603    #[test]
5604    fn multiple_undos_in_sequence() {
5605        let mut player = Player::new();
5606
5607        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("A")]));
5608        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
5609        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("C")]));
5610        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5611
5612        player.process_command(PlayerCommand::Undo);
5613        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5614
5615        player.process_command(PlayerCommand::Undo);
5616        assert_eq!(playlist_titles(&player), vec!["A"]);
5617
5618        player.process_command(PlayerCommand::Undo);
5619        assert!(playlist_ids(&player).is_empty());
5620    }
5621
5622    #[test]
5623    fn undo_redo_undo_cycle() {
5624        let mut player = Player::new();
5625        let items = vec![make_item("A"), make_item("B")];
5626
5627        player.process_command(PlayerCommand::AddToPlaylist(items));
5628        player.process_command(PlayerCommand::Undo);
5629        assert!(playlist_ids(&player).is_empty());
5630
5631        player.process_command(PlayerCommand::Redo);
5632        assert_eq!(playlist_titles(&player), vec!["A", "B"]);
5633
5634        player.process_command(PlayerCommand::Undo);
5635        assert!(playlist_ids(&player).is_empty());
5636    }
5637
5638    #[test]
5639    fn new_action_clears_redo_stack() {
5640        let mut player = Player::new();
5641        let items = vec![make_item("A")];
5642
5643        player.process_command(PlayerCommand::AddToPlaylist(items));
5644        player.process_command(PlayerCommand::Undo);
5645        assert!(player.undo_stack().can_redo());
5646
5647        // New action should clear redo
5648        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("B")]));
5649        assert!(!player.undo_stack().can_redo());
5650    }
5651
5652    #[test]
5653    fn undo_on_empty_stack_is_noop() {
5654        let mut player = Player::new();
5655        player.process_command(PlayerCommand::Undo);
5656        assert!(playlist_ids(&player).is_empty());
5657    }
5658
5659    #[test]
5660    fn redo_on_empty_stack_is_noop() {
5661        let mut player = Player::new();
5662        player.process_command(PlayerCommand::Redo);
5663        assert!(playlist_ids(&player).is_empty());
5664    }
5665
5666    // --- Non-undoable commands don't push entries ---
5667
5668    #[test]
5669    fn playback_commands_not_undoable() {
5670        let mut player = Player::new();
5671        player.process_command(PlayerCommand::Pause);
5672        player.process_command(PlayerCommand::Resume);
5673        player.process_command(PlayerCommand::NextTrack);
5674        player.process_command(PlayerCommand::PrevTrack);
5675        assert!(!player.undo_stack().can_undo());
5676    }
5677
5678    #[test]
5679    fn update_paths_not_undoable() {
5680        let mut player = Player::new();
5681        let items = vec![make_item("A")];
5682        let id = items[0].id;
5683        player.process_command(PlayerCommand::AddToPlaylist(items));
5684
5685        let undo_count = player.undo_stack().undo_len();
5686        player.process_command(PlayerCommand::UpdatePaths(vec![(
5687            id,
5688            PathBuf::from("/new/path.flac"),
5689        )]));
5690        assert_eq!(player.undo_stack().undo_len(), undo_count);
5691    }
5692
5693    // --- Complex scenarios ---
5694
5695    #[test]
5696    fn add_remove_undo_undo_produces_original() {
5697        let mut player = Player::new();
5698        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5699        let b_id = items[1].id;
5700        let original_titles = vec!["A", "B", "C"];
5701
5702        player.process_command(PlayerCommand::AddToPlaylist(items));
5703        player.process_command(PlayerCommand::RemoveFromPlaylist(b_id));
5704        assert_eq!(playlist_titles(&player), vec!["A", "C"]);
5705
5706        // Undo remove → back to A, B, C
5707        player.process_command(PlayerCommand::Undo);
5708        assert_eq!(playlist_titles(&player), original_titles);
5709
5710        // Undo add → empty
5711        player.process_command(PlayerCommand::Undo);
5712        assert!(playlist_ids(&player).is_empty());
5713    }
5714
5715    #[test]
5716    fn interleaved_adds_and_moves_undo() {
5717        let mut player = Player::new();
5718        let items = vec![make_item("A"), make_item("B"), make_item("C")];
5719        let a_id = items[0].id;
5720        let c_id = items[2].id;
5721
5722        player.process_command(PlayerCommand::AddToPlaylist(items));
5723
5724        // Move A after C: [B, C, A]
5725        player.process_command(PlayerCommand::MoveInPlaylist {
5726            id: a_id,
5727            target: c_id,
5728            after: true,
5729        });
5730        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5731
5732        // Add D: [B, C, A, D]
5733        player.process_command(PlayerCommand::AddToPlaylist(vec![make_item("D")]));
5734        assert_eq!(playlist_titles(&player), vec!["B", "C", "A", "D"]);
5735
5736        // Undo add D: [B, C, A]
5737        player.process_command(PlayerCommand::Undo);
5738        assert_eq!(playlist_titles(&player), vec!["B", "C", "A"]);
5739
5740        // Undo move: [A, B, C]
5741        player.process_command(PlayerCommand::Undo);
5742        assert_eq!(playlist_titles(&player), vec!["A", "B", "C"]);
5743    }
5744
5745    /// Regression test for GitHub #89: AudioEngine must be dropped synchronously
5746    /// in stop_engine() before the caller changes sample rates. If the engine is
5747    /// dropped on a background thread, CoreAudio's internal buffer list can be
5748    /// freed while AudioUnitUninitialize is still tearing it down → crash.
5749    #[test]
5750    fn stop_engine_drops_engine_synchronously() {
5751        use std::sync::atomic::{AtomicBool, Ordering};
5752
5753        struct MockEngine {
5754            dropped: Arc<AtomicBool>,
5755        }
5756        impl AudioEngineHandle for MockEngine {
5757            fn start(&self) -> Result<(), BackendError> {
5758                Ok(())
5759            }
5760            fn stop(&self) -> Result<(), BackendError> {
5761                Ok(())
5762            }
5763            fn is_running(&self) -> bool {
5764                false
5765            }
5766            fn fade_out(&self) {}
5767            fn fade_in(&self) -> Result<(), BackendError> {
5768                Ok(())
5769            }
5770            fn is_silent(&self) -> bool {
5771                false
5772            }
5773        }
5774        impl Drop for MockEngine {
5775            fn drop(&mut self) {
5776                self.dropped.store(true, Ordering::SeqCst);
5777            }
5778        }
5779
5780        let dropped = Arc::new(AtomicBool::new(false));
5781
5782        let mut player = Player::new();
5783        player.transport = Transport::Loaded(test_session(
5784            QueueItemId::new(),
5785            Box::new(MockEngine {
5786                dropped: dropped.clone(),
5787            }),
5788        ));
5789
5790        player.stop_engine();
5791
5792        // The engine must already be dropped when stop_engine returns.
5793        // If this fails, the engine was moved to a background thread — the
5794        // exact race condition that causes the #89 crash.
5795        assert!(
5796            dropped.load(Ordering::SeqCst),
5797            "AudioEngine must be dropped synchronously in stop_engine (GitHub #89)"
5798        );
5799    }
5800
5801    #[test]
5802    fn abandoning_a_stream_wakes_a_reader_parked_at_the_write_head() {
5803        let live = LiveStream {
5804            feed: crate::remote::downloads::ByteFeed::new(),
5805            abandoned: Default::default(),
5806        };
5807        let feed = live.feed.clone();
5808        let started = std::time::Instant::now();
5809        let reader = thread::spawn(move || {
5810            feed.wait_past(
5811                0,
5812                std::time::Instant::now() + std::time::Duration::from_secs(30),
5813            )
5814        });
5815        thread::sleep(std::time::Duration::from_millis(50));
5816        live.abandon();
5817        reader.join().unwrap();
5818
5819        assert!(live.abandoned.load(std::sync::atomic::Ordering::Acquire));
5820        assert!(started.elapsed() < std::time::Duration::from_secs(5));
5821    }
5822
5823    // --- Engine format matches the decoded PCM ---
5824
5825    /// Backend pinned to one sample rate that refuses every switch, recording
5826    /// the format the engine is asked for.
5827    pub(super) struct StuckBackend {
5828        pub(super) rate: f64,
5829        pub(super) asked: Arc<std::sync::Mutex<Option<(f64, u32)>>>,
5830        /// How many times an engine it made was started.
5831        pub(super) starts: Arc<std::sync::atomic::AtomicUsize>,
5832    }
5833
5834    struct NullEngine {
5835        starts: Arc<std::sync::atomic::AtomicUsize>,
5836        running: std::sync::atomic::AtomicBool,
5837        lead_in: Arc<AtomicU64>,
5838    }
5839    impl AudioEngineHandle for NullEngine {
5840        fn start(&self) -> Result<(), BackendError> {
5841            self.starts.fetch_add(1, Ordering::Relaxed);
5842            self.running.store(true, Ordering::Relaxed);
5843            Ok(())
5844        }
5845        fn stop(&self) -> Result<(), BackendError> {
5846            self.running.store(false, Ordering::Relaxed);
5847            Ok(())
5848        }
5849        fn is_running(&self) -> bool {
5850            self.running.load(Ordering::Relaxed)
5851        }
5852        fn fade_out(&self) {}
5853        fn fade_in(&self) -> Result<(), BackendError> {
5854            Ok(())
5855        }
5856        fn is_silent(&self) -> bool {
5857            false
5858        }
5859        fn lead_in(&self, frames: u64) {
5860            self.lead_in.store(frames, Ordering::Relaxed);
5861        }
5862    }
5863
5864    impl AudioBackend for StuckBackend {
5865        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
5866            Ok(vec![self.default_device()?])
5867        }
5868        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
5869            Ok(backend::DeviceInfo {
5870                name: "Stuck DAC".into(),
5871                sample_rates: vec![self.rate],
5872                platform_id: 0,
5873                kind: Default::default(),
5874            })
5875        }
5876        fn supported_sample_rates(
5877            &self,
5878            _device: &backend::DeviceInfo,
5879        ) -> Result<Vec<f64>, BackendError> {
5880            Ok(vec![self.rate])
5881        }
5882        fn get_device_sample_rate(
5883            &self,
5884            _device: &backend::DeviceInfo,
5885        ) -> Result<f64, BackendError> {
5886            Ok(self.rate)
5887        }
5888        fn set_device_sample_rate(
5889            &self,
5890            _device: &backend::DeviceInfo,
5891            rate: f64,
5892        ) -> Result<f64, BackendError> {
5893            Err(BackendError::UnsupportedSampleRate(rate))
5894        }
5895        fn create_engine(
5896            &self,
5897            _device: &backend::DeviceInfo,
5898            sample_rate: f64,
5899            channels: u32,
5900            _consumer: rtrb::Consumer<f32>,
5901            _samples_played: Arc<AtomicU64>,
5902        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
5903            *self.asked.lock().unwrap() = Some((sample_rate, channels));
5904            Ok(Box::new(NullEngine {
5905                starts: self.starts.clone(),
5906                running: Default::default(),
5907                lead_in: Default::default(),
5908            }))
5909        }
5910    }
5911
5912    /// Hands the test every ring buffer an engine is made with, so what
5913    /// reaches the device can be read back.
5914    struct CaptureBackend {
5915        consumers: Arc<std::sync::Mutex<Vec<rtrb::Consumer<f32>>>>,
5916    }
5917
5918    impl AudioBackend for CaptureBackend {
5919        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
5920            Ok(vec![self.default_device()?])
5921        }
5922        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
5923            Ok(backend::DeviceInfo {
5924                name: "Capture DAC".into(),
5925                sample_rates: vec![44100.0],
5926                platform_id: 0,
5927                kind: Default::default(),
5928            })
5929        }
5930        fn supported_sample_rates(
5931            &self,
5932            _device: &backend::DeviceInfo,
5933        ) -> Result<Vec<f64>, BackendError> {
5934            Ok(vec![44100.0])
5935        }
5936        fn get_device_sample_rate(
5937            &self,
5938            _device: &backend::DeviceInfo,
5939        ) -> Result<f64, BackendError> {
5940            Ok(44100.0)
5941        }
5942        fn set_device_sample_rate(
5943            &self,
5944            _device: &backend::DeviceInfo,
5945            rate: f64,
5946        ) -> Result<f64, BackendError> {
5947            Ok(rate)
5948        }
5949        fn create_engine(
5950            &self,
5951            _device: &backend::DeviceInfo,
5952            _sample_rate: f64,
5953            _channels: u32,
5954            consumer: rtrb::Consumer<f32>,
5955            _samples_played: Arc<AtomicU64>,
5956        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
5957            self.consumers.lock().unwrap().push(consumer);
5958            Ok(Box::new(NullEngine {
5959                starts: Default::default(),
5960                running: Default::default(),
5961                lead_in: Default::default(),
5962            }))
5963        }
5964    }
5965
5966    /// The loudest sample of a session's first `want` samples.
5967    fn peak_reaching_the_device(
5968        consumers: &std::sync::Mutex<Vec<rtrb::Consumer<f32>>>,
5969        want: usize,
5970    ) -> f32 {
5971        let mut consumer = consumers.lock().unwrap().pop().expect("an engine was made");
5972        let mut peak = 0.0f32;
5973        let mut got = 0;
5974        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
5975        while got < want && std::time::Instant::now() < deadline {
5976            let n = consumer.slots();
5977            if n == 0 {
5978                std::thread::sleep(std::time::Duration::from_millis(2));
5979                continue;
5980            }
5981            let chunk = consumer.read_chunk(n).unwrap();
5982            let (a, b) = chunk.as_slices();
5983            peak = a.iter().chain(b).fold(peak, |p, s| p.max(s.abs()));
5984            got += n;
5985            chunk.commit_all();
5986        }
5987        assert!(got >= want, "only {got} of {want} samples arrived");
5988        peak
5989    }
5990
5991    /// A file on disk and a download still landing open through the same
5992    /// session, so both are processed. The streaming path is the one that
5993    /// used to be its own function, and the easy one to leave behind.
5994    #[test]
5995    fn a_profile_processes_files_and_streams_alike() {
5996        let dir = tempfile::tempdir().unwrap();
5997        let tone = dir.path().join("tone.wav");
5998        crate::test_utils::generate_wav_tone(&tone, 44100, 440.0, 0.5);
5999        let want = 22050;
6000
6001        let consumers = Arc::new(std::sync::Mutex::new(Vec::new()));
6002        let mut player = Player::new();
6003        player.backend = Box::new(CaptureBackend {
6004            consumers: consumers.clone(),
6005        });
6006        let mut item = make_item("tone");
6007        item.path = tone.clone();
6008        let id = item.id;
6009        player.shared_state.add_items(vec![item]);
6010        player.shared_state.set_cursor(Some(id));
6011
6012        let stream = || {
6013            let feed = crate::remote::downloads::ByteFeed::new();
6014            feed.set(std::fs::metadata(&tone).unwrap().len());
6015            Source::Stream(StreamSource {
6016                path: tone.clone(),
6017                bytes_written: feed,
6018                total: 0,
6019                mode: streaming::ProbeMode::Full,
6020            })
6021        };
6022        let peaks = |player: &mut Player| {
6023            let mut out = Vec::new();
6024            for source in [Source::File(tone.clone()), stream()] {
6025                player
6026                    .try_open_session(id, source, None, 0, Run::Playing)
6027                    .unwrap();
6028                player.publish();
6029                out.push((
6030                    peak_reaching_the_device(&consumers, want),
6031                    player.shared_state.dsp().map(|d| d.profile),
6032                ));
6033                player.stop_engine();
6034            }
6035            out
6036        };
6037
6038        let untouched = peaks(&mut player);
6039
6040        player.dsp_override = Some(Arc::new(
6041            crate::audio::dsp::Setup::new(vec![], vec![]).with_preamp(-6.0206),
6042        ));
6043        let processed = peaks(&mut player);
6044
6045        for (kind, ((before, none), (after, half))) in ["file", "stream"]
6046            .iter()
6047            .zip(untouched.iter().zip(&processed))
6048        {
6049            assert_eq!(
6050                none, &None,
6051                "{kind}: nothing is published without a profile"
6052            );
6053            assert_eq!(half.as_deref(), Some("test"), "{kind}: the badge names it");
6054            assert!(*before > 0.1, "{kind}: the tone reached the device");
6055            assert!(
6056                (after / before - 0.5).abs() < 0.01,
6057                "{kind}: {before} → {after}, not halved"
6058            );
6059        }
6060    }
6061
6062    /// Replacing the queue while the track playing is paused, with a track
6063    /// that has still to arrive: nothing sounds until the new one can open,
6064    /// the old one is never reopened, and the new one is what is published
6065    /// from the start. The iOS report this answers: a playlist's play button,
6066    /// pressed over a paused track, was heard as that track carrying on.
6067    #[test]
6068    fn a_replaced_queue_never_sounds_the_track_it_replaced() {
6069        let dir = tempfile::tempdir().unwrap();
6070        let a = dir.path().join("a.wav");
6071        crate::test_utils::generate_wav_tone(&a, 44100, 440.0, 5.0);
6072        let consumers = Arc::new(std::sync::Mutex::new(Vec::new()));
6073        let mut player = Player::new();
6074        player.backend = Box::new(CaptureBackend {
6075            consumers: consumers.clone(),
6076        });
6077        let engines = || consumers.lock().unwrap().len();
6078
6079        let item_a = PlaylistItem {
6080            path: a,
6081            ..make_item("paused")
6082        };
6083        let a_id = item_a.id;
6084        player.process_command(PlayerCommand::AddToPlaylist(vec![item_a]));
6085        player.process_command(PlayerCommand::Play(a_id));
6086        player.process_command(PlayerCommand::Pause);
6087        assert_eq!(engines(), 1);
6088
6089        let landed = dir.path().join("long.opus");
6090        let item_b = PlaylistItem {
6091            db_id: Some(77),
6092            state: ItemState::Pending,
6093            duration_ms: Some(32_523_781),
6094            path: landed.clone(),
6095            ..make_item("long")
6096        };
6097        let b_id = item_b.id;
6098        player.process_command(PlayerCommand::ReplacePlaylist {
6099            items: vec![item_b],
6100            start: 0,
6101            position_ms: 0,
6102            play: true,
6103            mode: QueueMode::Keep,
6104        });
6105        player.publish();
6106        let state = player.shared_state.clone();
6107        assert_eq!(state.cursor(), Some(b_id), "the new track, at once");
6108        assert!(
6109            state.is_waiting(),
6110            "waiting for it, which a client shows as loading"
6111        );
6112        assert_eq!(state.playback_state(), PlaybackState::Stopped);
6113        assert_eq!(
6114            state.track_info(),
6115            None,
6116            "the paused track is no longer published"
6117        );
6118        assert_eq!(engines(), 1, "nothing opened while it is on its way");
6119
6120        // Its download lands.
6121        let store = state.downloads().clone();
6122        store.claim(77, Some(b_id));
6123        std::fs::write(&landed, include_bytes!("testdata/thirty-seconds.opus")).unwrap();
6124        // Told to the player below, as the download queue would.
6125        let _settled = crate::remote::downloads::settle(&state, 77, &Ok(landed));
6126        player.process_command(PlayerCommand::TrackReady(b_id));
6127        player.publish();
6128
6129        assert_eq!(
6130            engines(),
6131            2,
6132            "one engine for the new track, none for the old"
6133        );
6134        assert_eq!(state.track_info().map(|t| t.id), Some(b_id));
6135        assert_eq!(state.playback_state(), PlaybackState::Playing);
6136        player.process_command(PlayerCommand::Stop);
6137    }
6138
6139    /// A long Ogg is opened before its last page has arrived, which is where
6140    /// Ogg keeps its length, so the stream itself cannot say how long it is.
6141    /// The library can: a nine-hour remote track showed no duration until
6142    /// the whole file had downloaded.
6143    #[test]
6144    fn a_stream_opened_without_a_length_takes_the_librarys_duration() {
6145        let dir = tempfile::tempdir().unwrap();
6146        let bytes = include_bytes!("testdata/thirty-seconds.opus");
6147        let half = bytes.len() / 2;
6148        let part = dir.path().join("long.opus.part");
6149        std::fs::write(&part, &bytes[..half]).unwrap();
6150        let feed = crate::remote::downloads::ByteFeed::new();
6151        feed.set(half as u64);
6152
6153        let mut player = Player::new();
6154        player.backend = Box::new(CaptureBackend {
6155            consumers: Default::default(),
6156        });
6157        let item = PlaylistItem {
6158            db_id: Some(9),
6159            state: ItemState::Pending,
6160            duration_ms: Some(32_523_781),
6161            path: dir.path().join("long.opus"),
6162            ..make_item("long")
6163        };
6164        let id = item.id;
6165        player.shared_state.add_items(vec![item]);
6166        player.shared_state.set_cursor(Some(id));
6167
6168        let lengthless = buffer::StreamInfo {
6169            codec: "Opus".into(),
6170            sample_rate: 48_000,
6171            channels: 1,
6172            bit_depth: None,
6173            bitrate_kbps: None,
6174            duration_ms: 0,
6175        };
6176        player
6177            .try_open_session(
6178                id,
6179                Source::Stream(StreamSource {
6180                    path: part,
6181                    bytes_written: feed,
6182                    total: bytes.len() as u64,
6183                    mode: streaming::ProbeMode::Lengthless,
6184                }),
6185                Some(lengthless),
6186                0,
6187                Run::Playing,
6188            )
6189            .unwrap();
6190        player.publish();
6191
6192        assert_eq!(player.shared_state.duration_ms(), 32_523_781);
6193        assert_eq!(
6194            player.shared_state.track_info().map(|t| t.duration_ms),
6195            Some(32_523_781)
6196        );
6197        player.stop_engine();
6198    }
6199
6200    fn engine_format_for(source_rate: u32, channels: u16, device_rate: f64) -> (f64, u32) {
6201        let asked = Arc::new(std::sync::Mutex::new(None));
6202        let mut player = Player::new();
6203        player.backend = Box::new(StuckBackend {
6204            rate: device_rate,
6205            asked: asked.clone(),
6206            starts: Default::default(),
6207        });
6208
6209        let info = buffer::StreamInfo {
6210            codec: "MP3".into(),
6211            sample_rate: source_rate,
6212            channels,
6213            bit_depth: Some(16),
6214            bitrate_kbps: None,
6215            duration_ms: 1000,
6216        };
6217        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6218        player
6219            .create_engine_for(&info, consumer)
6220            .expect("engine creation should succeed");
6221        let asked = *asked.lock().unwrap();
6222        asked.expect("engine was never created")
6223    }
6224
6225    #[test]
6226    fn engine_uses_source_rate_when_device_refuses_switch() {
6227        // MPEG-2 MP3 rates are routinely rejected by output devices. The engine
6228        // must still be told the rate the PCM actually is.
6229        assert_eq!(engine_format_for(22050, 2, 48000.0), (22050.0, 2));
6230        assert_eq!(engine_format_for(32000, 2, 44100.0), (32000.0, 2));
6231    }
6232
6233    #[test]
6234    fn engine_uses_source_channel_count() {
6235        assert_eq!(engine_format_for(44100, 1, 44100.0), (44100.0, 1));
6236    }
6237
6238    /// The rate the device settled at, as the front ends read it.
6239    fn settled_rate_for(source_rate: u32, device_rate: f64) -> Option<u32> {
6240        let mut player = Player::new();
6241        player.backend = Box::new(StuckBackend {
6242            rate: device_rate,
6243            asked: Arc::new(std::sync::Mutex::new(None)),
6244            starts: Default::default(),
6245        });
6246        let state = player.shared_state.clone();
6247
6248        let info = buffer::StreamInfo {
6249            codec: "MP3".into(),
6250            sample_rate: source_rate,
6251            channels: 2,
6252            bit_depth: Some(16),
6253            bitrate_kbps: None,
6254            duration_ms: 1000,
6255        };
6256        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6257        player
6258            .create_engine_for(&info, consumer)
6259            .expect("engine creation should succeed");
6260        state.output_sample_rate()
6261    }
6262
6263    #[test]
6264    fn settled_device_rate_reaches_the_shared_state() {
6265        // A device that refuses the switch is being fed resampled audio, and
6266        // that is the case the front ends have to be able to see. Before this
6267        // the comparison happened once, in a log line.
6268        assert_eq!(settled_rate_for(22050, 48000.0), Some(48000));
6269        // No switch needed, so nothing resampled: the two rates agree.
6270        assert_eq!(settled_rate_for(44100, 44100.0), Some(44100));
6271    }
6272
6273    /// A device that takes its time reclocking, as real hardware does.
6274    struct SlowBackend {
6275        observed: Arc<std::sync::Mutex<Vec<Option<u32>>>>,
6276        state: Arc<SharedPlayerState>,
6277        lead_in: Arc<AtomicU64>,
6278    }
6279
6280    impl AudioBackend for SlowBackend {
6281        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6282            Ok(vec![self.default_device()?])
6283        }
6284        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6285            Ok(backend::DeviceInfo {
6286                name: "Slow DAC".into(),
6287                sample_rates: vec![44100.0, 48000.0],
6288                platform_id: 0,
6289                kind: Default::default(),
6290            })
6291        }
6292        fn supported_sample_rates(
6293            &self,
6294            _device: &backend::DeviceInfo,
6295        ) -> Result<Vec<f64>, BackendError> {
6296            Ok(vec![44100.0, 48000.0])
6297        }
6298        fn get_device_sample_rate(
6299            &self,
6300            _device: &backend::DeviceInfo,
6301        ) -> Result<f64, BackendError> {
6302            Ok(48000.0)
6303        }
6304        fn set_device_sample_rate(
6305            &self,
6306            _device: &backend::DeviceInfo,
6307            rate: f64,
6308        ) -> Result<f64, BackendError> {
6309            // What a front end polling mid-switch would see.
6310            self.observed
6311                .lock()
6312                .unwrap()
6313                .push(self.state.output_sample_rate());
6314            Ok(rate)
6315        }
6316        fn create_engine(
6317            &self,
6318            _device: &backend::DeviceInfo,
6319            _sample_rate: f64,
6320            _channels: u32,
6321            _consumer: rtrb::Consumer<f32>,
6322            _samples_played: Arc<AtomicU64>,
6323        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6324            Ok(Box::new(NullEngine {
6325                starts: Default::default(),
6326                running: Default::default(),
6327                lead_in: self.lead_in.clone(),
6328            }))
6329        }
6330    }
6331
6332    #[test]
6333    fn the_previous_rate_is_not_published_while_the_device_reclocks() {
6334        // A 48 kHz track followed by a 44.1 kHz one: for as long as the switch
6335        // takes — the better part of a second on USB — the new track's info is
6336        // published against the old track's output rate. A front end polling in
6337        // that window used to latch "44.1 → 48" and, since nothing about the
6338        // codec or the source rate changed afterwards, never let go of it.
6339        let mut player = Player::new();
6340        let state = player.shared_state.clone();
6341        state.set_output_sample_rate(48000);
6342
6343        let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
6344        player.backend = Box::new(SlowBackend {
6345            observed: observed.clone(),
6346            state: state.clone(),
6347            lead_in: Default::default(),
6348        });
6349
6350        let info = buffer::StreamInfo {
6351            codec: "FLAC".into(),
6352            sample_rate: 44100,
6353            channels: 2,
6354            bit_depth: Some(16),
6355            bitrate_kbps: None,
6356            duration_ms: 1000,
6357        };
6358        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6359        player
6360            .create_engine_for(&info, consumer)
6361            .expect("engine creation should succeed");
6362
6363        assert_eq!(
6364            *observed.lock().unwrap(),
6365            vec![None],
6366            "mid-switch the output rate must read as unknown, not as the last track's"
6367        );
6368        assert_eq!(state.output_sample_rate(), Some(44100));
6369    }
6370
6371    /// The lead-in an engine was given, for a track at `source_rate` on a
6372    /// device sitting at 48 kHz.
6373    fn lead_in_for(source_rate: u32) -> u64 {
6374        let mut player = Player::new();
6375        let lead_in = Arc::new(AtomicU64::new(0));
6376        player.backend = Box::new(SlowBackend {
6377            observed: Default::default(),
6378            state: player.shared_state.clone(),
6379            lead_in: lead_in.clone(),
6380        });
6381        let info = buffer::StreamInfo {
6382            codec: "FLAC".into(),
6383            sample_rate: source_rate,
6384            channels: 2,
6385            bit_depth: Some(16),
6386            bitrate_kbps: None,
6387            duration_ms: 1000,
6388        };
6389        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6390        player
6391            .create_engine_for(&info, consumer)
6392            .expect("engine creation should succeed");
6393        lead_in.load(Ordering::Relaxed)
6394    }
6395
6396    #[test]
6397    fn an_engine_made_inside_the_silence_keeps_the_rest_of_it() {
6398        // A seek straight after a rate switch: the device is still relocking,
6399        // though the new engine finds its rate already matching.
6400        let mut player = Player::new();
6401        let lead_in = Arc::new(AtomicU64::new(0));
6402        player.backend = Box::new(SlowBackend {
6403            observed: Default::default(),
6404            state: player.shared_state.clone(),
6405            lead_in: lead_in.clone(),
6406        });
6407        let info = |sample_rate| buffer::StreamInfo {
6408            codec: "FLAC".into(),
6409            sample_rate,
6410            channels: 2,
6411            bit_depth: Some(16),
6412            bitrate_kbps: None,
6413            duration_ms: 1000,
6414        };
6415        let (_p, consumer) = rtrb::RingBuffer::new(16);
6416        player.create_engine_for(&info(44100), consumer).unwrap();
6417        let first = lead_in.swap(0, Ordering::Relaxed);
6418        assert!(first > 0);
6419
6420        let (_p, consumer) = rtrb::RingBuffer::new(16);
6421        player.create_engine_for(&info(48000), consumer).unwrap();
6422        let carried = lead_in.load(Ordering::Relaxed);
6423        // Frames at the new engine's rate: what is left of the same second.
6424        assert!(
6425            carried > 0 && carried < first * 48000 / 44100,
6426            "carried {carried} of {first}"
6427        );
6428    }
6429
6430    #[test]
6431    fn silence_leads_in_only_after_the_device_changed_rate() {
6432        assert!(
6433            lead_in_for(44100) > 0,
6434            "the device is relocking, so the start of the track would be lost"
6435        );
6436        assert_eq!(lead_in_for(48000), 0, "no switch, nothing to wait for");
6437    }
6438
6439    /// iOS's rates on an engine that plays nothing.
6440    #[cfg(target_os = "macos")]
6441    struct IosRates {
6442        lead_in: Arc<AtomicU64>,
6443    }
6444
6445    #[cfg(target_os = "macos")]
6446    impl AudioBackend for IosRates {
6447        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6448            crate::audio::ios_backend::IosAudioBackend.list_devices()
6449        }
6450        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6451            crate::audio::ios_backend::IosAudioBackend.default_device()
6452        }
6453        fn supported_sample_rates(
6454            &self,
6455            device: &backend::DeviceInfo,
6456        ) -> Result<Vec<f64>, BackendError> {
6457            crate::audio::ios_backend::IosAudioBackend.supported_sample_rates(device)
6458        }
6459        fn get_device_sample_rate(
6460            &self,
6461            device: &backend::DeviceInfo,
6462        ) -> Result<f64, BackendError> {
6463            crate::audio::ios_backend::IosAudioBackend.get_device_sample_rate(device)
6464        }
6465        fn set_device_sample_rate(
6466            &self,
6467            device: &backend::DeviceInfo,
6468            rate: f64,
6469        ) -> Result<f64, BackendError> {
6470            crate::audio::ios_backend::IosAudioBackend.set_device_sample_rate(device, rate)
6471        }
6472        fn create_engine(
6473            &self,
6474            _device: &backend::DeviceInfo,
6475            _sample_rate: f64,
6476            _channels: u32,
6477            _consumer: rtrb::Consumer<f32>,
6478            _samples_played: Arc<AtomicU64>,
6479        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6480            Ok(Box::new(NullEngine {
6481                starts: Default::default(),
6482                running: Default::default(),
6483                lead_in: self.lead_in.clone(),
6484            }))
6485        }
6486    }
6487
6488    /// A DAC that runs at whichever of 44.1 and 48 kHz it is asked for.
6489    #[cfg(target_os = "macos")]
6490    struct SwitchingDac;
6491
6492    #[cfg(target_os = "macos")]
6493    impl crate::audio::ios_backend::AudioSession for SwitchingDac {
6494        fn activate(&self, sample_rate: f64) -> Option<f64> {
6495            Some(self.follow(sample_rate))
6496        }
6497        fn follow(&self, sample_rate: f64) -> f64 {
6498            if sample_rate == 44100.0 {
6499                44100.0
6500            } else {
6501                48000.0
6502            }
6503        }
6504        fn release(&self) {}
6505    }
6506
6507    #[test]
6508    #[cfg(target_os = "macos")]
6509    fn a_session_let_go_is_no_rate_switch_to_wait_out() {
6510        use crate::audio::ios_backend;
6511        let _session = ios_backend::TEST_SESSION.lock();
6512        ios_backend::set_session(Arc::new(SwitchingDac));
6513        let lead_in_at = |sample_rate| {
6514            let mut player = Player::new();
6515            let lead_in = Arc::new(AtomicU64::new(0));
6516            player.backend = Box::new(IosRates {
6517                lead_in: lead_in.clone(),
6518            });
6519            let info = buffer::StreamInfo {
6520                codec: "FLAC".into(),
6521                sample_rate,
6522                channels: 2,
6523                bit_depth: Some(16),
6524                bitrate_kbps: None,
6525                duration_ms: 1000,
6526            };
6527            let (_producer, consumer) = rtrb::RingBuffer::new(16);
6528            player.create_engine_for(&info, consumer).unwrap();
6529            lead_in.load(Ordering::Relaxed)
6530        };
6531
6532        // Held at 48 kHz, a 44.1 kHz track switches the DAC.
6533        assert!(ios_backend::activate_session(48000.0));
6534        assert!(lead_in_at(44100) > 0);
6535
6536        // Played at 48 kHz, then idle long enough to let the session go: the
6537        // rate it last ran at is no longer the hardware's, and the next
6538        // track, at 44.1 kHz, starts without a second of silence.
6539        assert!(ios_backend::activate_session(48000.0));
6540        ios_backend::release_session();
6541        assert_eq!(lead_in_at(44100), 0);
6542    }
6543
6544    /// Backend that hands its rate-change callback back to the test.
6545    struct WatchedBackend {
6546        inner: StuckBackend,
6547        #[allow(clippy::type_complexity)]
6548        captured: Arc<std::sync::Mutex<Option<Box<dyn Fn(f64) + Send + Sync>>>>,
6549    }
6550
6551    struct NullWatch;
6552    impl backend::SampleRateWatch for NullWatch {}
6553
6554    impl AudioBackend for WatchedBackend {
6555        fn list_devices(&self) -> Result<Vec<backend::DeviceInfo>, BackendError> {
6556            self.inner.list_devices()
6557        }
6558        fn default_device(&self) -> Result<backend::DeviceInfo, BackendError> {
6559            self.inner.default_device()
6560        }
6561        fn supported_sample_rates(
6562            &self,
6563            device: &backend::DeviceInfo,
6564        ) -> Result<Vec<f64>, BackendError> {
6565            self.inner.supported_sample_rates(device)
6566        }
6567        fn get_device_sample_rate(
6568            &self,
6569            device: &backend::DeviceInfo,
6570        ) -> Result<f64, BackendError> {
6571            self.inner.get_device_sample_rate(device)
6572        }
6573        fn set_device_sample_rate(
6574            &self,
6575            device: &backend::DeviceInfo,
6576            rate: f64,
6577        ) -> Result<f64, BackendError> {
6578            self.inner.set_device_sample_rate(device, rate)
6579        }
6580        fn watch_device_sample_rate(
6581            &self,
6582            _device: &backend::DeviceInfo,
6583            on_change: Box<dyn Fn(f64) + Send + Sync>,
6584        ) -> Option<Box<dyn backend::SampleRateWatch>> {
6585            *self.captured.lock().unwrap() = Some(on_change);
6586            Some(Box::new(NullWatch))
6587        }
6588        fn create_engine(
6589            &self,
6590            device: &backend::DeviceInfo,
6591            sample_rate: f64,
6592            channels: u32,
6593            consumer: rtrb::Consumer<f32>,
6594            samples_played: Arc<AtomicU64>,
6595        ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
6596            self.inner
6597                .create_engine(device, sample_rate, channels, consumer, samples_played)
6598        }
6599    }
6600
6601    #[test]
6602    fn external_rate_change_reaches_the_shared_state() {
6603        // The device is shared. Another client moving the rate mid-track used
6604        // to leave the front ends asserting bit-perfection while the HAL
6605        // resampled underneath them.
6606        let captured = Arc::new(std::sync::Mutex::new(None));
6607        let mut player = Player::new();
6608        player.backend = Box::new(WatchedBackend {
6609            inner: StuckBackend {
6610                rate: 44100.0,
6611                asked: Arc::new(std::sync::Mutex::new(None)),
6612                starts: Default::default(),
6613            },
6614            captured: captured.clone(),
6615        });
6616        let state = player.shared_state.clone();
6617
6618        let info = buffer::StreamInfo {
6619            codec: "FLAC".into(),
6620            sample_rate: 44100,
6621            channels: 2,
6622            bit_depth: Some(16),
6623            bitrate_kbps: None,
6624            duration_ms: 1000,
6625        };
6626        let (_producer, consumer) = rtrb::RingBuffer::new(16);
6627        player
6628            .create_engine_for(&info, consumer)
6629            .expect("engine creation should succeed");
6630        assert_eq!(state.output_sample_rate(), Some(44100));
6631
6632        let on_change = captured.lock().unwrap().take().expect("watch registered");
6633        on_change(48000.0);
6634        assert_eq!(state.output_sample_rate(), Some(48000));
6635    }
6636}