Skip to main content

koan_core/player/
state.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
6
7use uuid::Uuid;
8
9use crate::remote::downloads::{ByteFeed, DownloadStore};
10
11/// Stable identity for a queue entry. UUIDv7 — time-ordered, unique across duplicates.
12#[derive(Clone, Copy, PartialEq, Eq, Hash)]
13pub struct QueueItemId(pub Uuid);
14
15impl QueueItemId {
16    pub fn new() -> Self {
17        Self(Uuid::now_v7())
18    }
19}
20
21impl Default for QueueItemId {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl fmt::Debug for QueueItemId {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        // The tail: a v7's leading hex is its timestamp, shared by a whole batch.
30        let hex = self.0.simple().to_string();
31        write!(f, "QId({})", &hex[hex.len() - 8..])
32    }
33}
34
35/// A step the decoder took through the queue from `after`, to `next`: the
36/// item the play mode says follows it, or the end of the queue. The decoder
37/// queues `next` as `chosen` when it is Ready, and stops at it when it is
38/// still arriving.
39#[derive(Debug, Clone)]
40pub struct Lookahead {
41    pub after: QueueItemId,
42    pub next: Option<QueueItemId>,
43    pub chosen: Option<(QueueItemId, PathBuf)>,
44    /// `next` was found by going back to the top of the queue, which only
45    /// repeating the queue does.
46    pub wrapped: bool,
47    /// The timeline boundary `chosen` opens as, if it opens: the session's
48    /// boundary count when the step was taken. A step whose file fails to
49    /// open leaves no boundary, and the decoder steps again for the same one,
50    /// so this rather than a step's index is what places it against the
51    /// playhead. Set by the decoder's cursor; 0 elsewhere.
52    pub boundary: usize,
53}
54
55/// What follows a track once it ends, beyond the queue's own order.
56#[derive(
57    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
58)]
59#[serde(rename_all = "lowercase")]
60pub enum Repeat {
61    /// The queue ends at its last item.
62    #[default]
63    Off,
64    /// The last item runs on into the first.
65    Queue,
66    /// The item plays again. An explicit next or previous still moves on.
67    One,
68}
69
70impl Repeat {
71    pub fn is_off(&self) -> bool {
72        *self == Repeat::Off
73    }
74
75    pub fn as_str(self) -> &'static str {
76        match self {
77            Repeat::Off => "off",
78            Repeat::Queue => "queue",
79            Repeat::One => "one",
80        }
81    }
82
83    pub fn parse(s: &str) -> Option<Self> {
84        match s {
85            "off" => Some(Repeat::Off),
86            "queue" => Some(Repeat::Queue),
87            "one" => Some(Repeat::One),
88            _ => None,
89        }
90    }
91
92    /// The next mode a single repeat button steps to: off, the queue, one.
93    pub fn cycled(self) -> Self {
94        match self {
95            Repeat::Off => Repeat::Queue,
96            Repeat::Queue => Repeat::One,
97            Repeat::One => Repeat::Off,
98        }
99    }
100}
101
102/// The transport's play mode. Shuffle is not an order kept beside the queue:
103/// turning it on reorders the queue itself, so what is listed is what plays.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
105pub struct PlayMode {
106    pub shuffle: bool,
107    pub repeat: Repeat,
108}
109
110impl PlayMode {
111    fn to_bits(self) -> u8 {
112        let repeat = match self.repeat {
113            Repeat::Off => 0,
114            Repeat::Queue => 1,
115            Repeat::One => 2,
116        };
117        repeat << 1 | self.shuffle as u8
118    }
119
120    fn from_bits(bits: u8) -> Self {
121        Self {
122            shuffle: bits & 1 != 0,
123            repeat: match bits >> 1 {
124                1 => Repeat::Queue,
125                2 => Repeat::One,
126                _ => Repeat::Off,
127            },
128        }
129    }
130}
131
132/// What follows `after` under `repeat`: `Some(None)` at the end of the queue,
133/// `None` when `after` is not in it — a removed item has nothing following it,
134/// whatever the mode, since wrapping from it would replay the queue from a
135/// place nobody is at. The flag says the step went back to the top.
136///
137/// Failed items are passed over. Repeating one item plays it again unless it
138/// has failed, in which case the queue carries on as it would when repeating
139/// the queue.
140fn follows(
141    items: &[PlaylistItem],
142    after: QueueItemId,
143    repeat: Repeat,
144) -> Option<(Option<&PlaylistItem>, bool)> {
145    let at = items.iter().position(|item| item.id == after)?;
146    let playable = |item: &&PlaylistItem| !matches!(item.state, ItemState::Failed(_));
147    if repeat == Repeat::One && playable(&&items[at]) {
148        return Some((Some(&items[at]), false));
149    }
150    if let Some(next) = items[at + 1..].iter().find(playable) {
151        return Some((Some(next), false));
152    }
153    if repeat == Repeat::Off {
154        return Some((None, false));
155    }
156    Some((items[..=at].iter().find(playable), true))
157}
158
159/// Set in `SharedPlayerState::state` beside the playback state while the
160/// player waits for a track.
161const WAITING: u8 = 0x80;
162
163/// Playback state.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165#[repr(u8)]
166pub enum PlaybackState {
167    Stopped = 0,
168    Playing = 1,
169    Paused = 2,
170}
171
172impl PlaybackState {
173    pub fn from_u8(v: u8) -> Self {
174        match v {
175            1 => Self::Playing,
176            2 => Self::Paused,
177            _ => Self::Stopped,
178        }
179    }
180}
181
182/// Audio format info for the currently playing track.
183#[derive(Debug, Clone, PartialEq)]
184pub struct TrackInfo {
185    pub id: QueueItemId,
186    pub path: PathBuf,
187    pub codec: String,
188    pub sample_rate: u32,
189    pub bit_depth: Option<u16>,
190    pub bitrate_kbps: Option<u32>,
191    pub channels: u16,
192    pub duration_ms: u64,
193}
194
195// --- Playlist data model ---
196
197/// Minimum bytes written before streaming playback can begin.
198pub const STREAM_THRESHOLD: u64 = 256 * 1024;
199
200/// Held back from the seekable extent of a downloading track.
201///
202/// Bytes are converted to time at the average bitrate, so on VBR the estimate
203/// wanders either side of the truth; landing short of the write head costs a
204/// couple of seconds of reach and landing past it costs a stall.
205pub const SEEK_SAFETY_MS: u64 = 2_000;
206
207/// What a playlist item can say about itself.
208///
209/// Only what is true of the item regardless of any transfer: whether the bytes
210/// at its path can be played. Whether one is *arriving* is the download store's
211/// business, and asking the item would mean two accounts of one fact that have
212/// to be kept in step. Read [`LoadState`] for the two
213/// together.
214///
215/// Once a transfer ends, this is written before anything is woken: a reader
216/// waiting on the transfer learns how it ended from here.
217#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub enum ItemState {
219    /// Nothing has resolved this yet.
220    #[default]
221    Pending,
222    /// The file at `path` is there and playable.
223    Ready,
224    /// It cannot be made playable, and this is why. Not only download
225    /// failures: a track with no local file and no remote copy fails here
226    /// without a transfer ever being attempted.
227    Failed(String),
228}
229
230/// An item's state, and any transfer against it, as one answer.
231///
232/// Derived rather than stored. `Downloading` carries the store's own figures —
233/// the very same counter the downloader writes — so there is nothing to copy
234/// and nothing that can drift.
235#[derive(Debug, Clone)]
236pub enum LoadState {
237    Pending,
238    Downloading {
239        /// Where the bytes are going: the in-progress `.part` file, not the
240        /// destination it is renamed to at the end.
241        path: PathBuf,
242        /// Total bytes expected, or 0 when the server sent no Content-Length.
243        total: u64,
244        /// How many bytes have landed. The download thread writes it per chunk
245        /// without taking any lock the player holds.
246        bytes_written: Arc<ByteFeed>,
247    },
248    Ready,
249    Failed(String),
250}
251
252impl LoadState {
253    /// An item's state, with whatever the download store says about it.
254    ///
255    /// The item's own state stands once it is anything but `Pending`: a
256    /// transfer's end is written to every entry waiting on it before the
257    /// transfer itself is marked settled. While it is pending, a listed
258    /// transfer for its track is what is happening to it — whichever entry
259    /// that transfer was started for.
260    pub fn of(item: &PlaylistItem, downloads: &DownloadStore) -> Self {
261        match &item.state {
262            ItemState::Ready => Self::Ready,
263            ItemState::Failed(reason) => Self::Failed(reason.clone()),
264            ItemState::Pending => match item.db_id.and_then(|id| downloads.live(id)) {
265                Some(live) => Self::Downloading {
266                    path: live.source,
267                    total: live.total,
268                    bytes_written: live.written,
269                },
270                None => Self::Pending,
271            },
272        }
273    }
274}
275
276/// Resolved playback source for a playlist item.
277pub enum PlaybackSource {
278    /// File fully downloaded — play from path.
279    Ready(PathBuf),
280    /// File being downloaded — enough data buffered to start streaming.
281    Streaming {
282        path: PathBuf,
283        bytes_written: Arc<crate::remote::downloads::ByteFeed>,
284        total: u64,
285    },
286}
287
288/// A single item in the playlist. Created once when tracks are added to the playlist.
289#[derive(Debug, Clone)]
290pub struct PlaylistItem {
291    pub id: QueueItemId,
292    /// Database track ID — set for tracks loaded from DB, used for downloads.
293    pub db_id: Option<i64>,
294    /// The playlist entry this came from, when it came from a playlist.
295    ///
296    /// A playlist may hold the same track twice, and two copies are two queue
297    /// items. Without this a playlist row can only ask "is my *track* playing?"
298    /// and both copies answer yes. The entry id is the one thing that tells
299    /// them apart, so the queue carries it.
300    pub playlist_entry_id: Option<i64>,
301    pub path: PathBuf,
302    pub title: String,
303    pub artist: String,
304    pub album_artist: String,
305    pub album: String,
306    pub year: Option<String>,
307    pub codec: Option<String>,
308    pub track_number: Option<i64>,
309    pub disc: Option<i64>,
310    pub duration_ms: Option<u64>,
311    /// What the item can say about itself. Ask [`SharedPlayerState::load_state`]
312    /// for this together with any transfer against it.
313    pub state: ItemState,
314    /// Where the item stood in the queue before shuffle was turned on, which
315    /// is where turning it off puts it back. `None` while shuffle is off, and
316    /// for an item added since it was turned on, which stays where it was put.
317    pub pre_shuffle: Option<u32>,
318}
319
320/// The playlist — one flat array, one cursor. Everything else derived.
321#[derive(Debug, Clone, Default)]
322pub struct Playlist {
323    pub items: Vec<PlaylistItem>,
324    pub cursor: Option<QueueItemId>,
325}
326
327// --- UI view types ---
328
329/// Status of a track in the queue — for UI display.
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub enum QueueEntryStatus {
332    Queued,
333    Playing,
334    Played,
335    Downloading,
336    /// User double-clicked — this track is priority, will play when ready.
337    PriorityPending,
338    Failed,
339}
340
341impl QueueEntryStatus {
342    /// The item under the cursor. Every front end maps it through here, so
343    /// the transport and the queue row agree on what it is doing.
344    ///
345    /// `PriorityPending` is waiting its turn with no bytes moving; once its
346    /// transfer starts it is `Downloading`, with progress to draw.
347    pub fn at_cursor(state: &ItemState, transferring: bool) -> Self {
348        match state {
349            ItemState::Ready => Self::Playing,
350            ItemState::Failed(_) => Self::Failed,
351            ItemState::Pending if transferring => Self::Downloading,
352            ItemState::Pending => Self::PriorityPending,
353        }
354    }
355}
356
357/// A single entry in the UI-visible queue snapshot.
358#[derive(Debug, Clone)]
359pub struct QueueEntry {
360    pub id: QueueItemId,
361    /// Database track ID — set for tracks loaded from DB, used for downloads.
362    pub db_id: Option<i64>,
363    /// The playlist row this came from — see `PlaylistItem::playlist_entry_id`.
364    pub playlist_entry_id: Option<i64>,
365    pub path: PathBuf,
366    pub title: String,
367    pub artist: String,
368    pub album_artist: String,
369    pub album: String,
370    pub year: Option<String>,
371    pub codec: Option<String>,
372    pub track_number: Option<i64>,
373    pub disc: Option<i64>,
374    pub duration_ms: Option<u64>,
375    pub status: QueueEntryStatus,
376    pub download_progress: Option<(u64, u64)>,
377    /// Why this entry cannot play, when `status` is `Failed`.
378    pub error: Option<String>,
379}
380
381/// Pre-built visible queue — single atomic snapshot for the UI.
382#[derive(Debug, Clone, Default)]
383pub struct VisibleQueueSnapshot {
384    pub entries: Vec<QueueEntry>,
385    pub finished_count: usize,
386    pub has_playing: bool,
387    pub queue_count: usize,
388}
389
390/// Shared player state — atomics for lock-free reads from UI thread.
391///
392/// The engine writes these, the UI reads them. No mutexes in the hot path.
393#[derive(Debug)]
394pub struct SharedPlayerState {
395    /// The playback state, with `WAITING` set while the player waits for a
396    /// track it was asked for. One atomic, so no reader sees one updated
397    /// without the other.
398    state: AtomicU8,
399    /// Where a session starts, and where a stopped one stands. While one is
400    /// running the playhead is read off the timeline instead.
401    position_ms: AtomicU64,
402    timeline: std::sync::OnceLock<Arc<crate::audio::buffer::PlaybackTimeline>>,
403    track_info: parking_lot::RwLock<Option<TrackInfo>>,
404
405    /// The playlist and its cursor, under one lock.
406    playlist: parking_lot::RwLock<Playlist>,
407
408    /// Bumped on every playlist mutation so UI can skip redundant redraws.
409    playlist_version: AtomicU64,
410
411    /// Bumped only when what a saved session holds changes: the items and
412    /// their metadata, not the cursor or load states. What decides whether
413    /// the saved queue has to be written again.
414    content_version: AtomicU64,
415
416    /// Bumped when the set of items still waiting for a file may have
417    /// changed: items added or removed, or one put back to `Pending`. What the
418    /// download queue follows; a download landing or the cursor moving does
419    /// not move it.
420    pending_version: AtomicU64,
421
422    /// Every transfer this player's items are fetched by.
423    downloads: Arc<DownloadStore>,
424
425    /// Set by external signals (e.g. souvlaki Quit event) to request clean shutdown.
426    quit_requested: AtomicBool,
427
428    /// Set when metadata has been refreshed (e.g. download completed while streaming).
429    /// The UI loop checks this to force a souvlaki/cover-art update without a track change.
430    metadata_refresh_pending: AtomicBool,
431
432    /// The rate the output device settled at for the current track, or 0 when
433    /// nothing has played yet. Compared against the source rate, it is the one
434    /// thing koan can say for certain about the path to the DAC: whether it
435    /// handed the device the samples as they are, or something had to resample
436    /// to reach it. Everything past that — other clients, the volume stage — is
437    /// the system's, and not ours to claim.
438    output_sample_rate: AtomicU64,
439
440    /// What DSP is doing to the current session's audio. `None` is the
441    /// bit-perfect path.
442    dsp: parking_lot::RwLock<Option<crate::audio::dsp::DspStatus>>,
443    /// The playhead of a renderer this koan is playing to, which keeps its
444    /// own clock: where it was last heard to be, and since when it has been
445    /// running from there. Read in place of the timeline while set.
446    renderer_clock: parking_lot::Mutex<Option<RendererClock>>,
447
448    /// The UPnP renderer playing in place of the local output, if one is.
449    renderer: parking_lot::RwLock<Option<crate::upnp::Output>>,
450
451    /// The play mode, as `PlayMode::to_bits`. Written by the player's
452    /// `publish` alone; the queue's own reads (the lookahead, advancing)
453    /// follow it.
454    play_mode: AtomicU8,
455}
456
457/// A renderer's playhead: `position_ms`, plus the time since `running` if it
458/// is playing.
459#[derive(Debug, Clone, Copy, PartialEq)]
460pub struct RendererClock {
461    pub position_ms: u64,
462    pub running: Option<std::time::Instant>,
463}
464
465impl RendererClock {
466    pub fn now_ms(&self) -> u64 {
467        self.position_ms + self.running.map_or(0, |at| at.elapsed().as_millis() as u64)
468    }
469}
470
471impl SharedPlayerState {
472    pub fn new() -> Arc<Self> {
473        Arc::new(Self {
474            state: AtomicU8::new(PlaybackState::Stopped as u8),
475            position_ms: AtomicU64::new(0),
476            timeline: std::sync::OnceLock::new(),
477            track_info: parking_lot::RwLock::new(None),
478            playlist: parking_lot::RwLock::new(Playlist::default()),
479            playlist_version: AtomicU64::new(0),
480            content_version: AtomicU64::new(0),
481            pending_version: AtomicU64::new(0),
482            downloads: DownloadStore::new(),
483            quit_requested: AtomicBool::new(false),
484            metadata_refresh_pending: AtomicBool::new(false),
485            output_sample_rate: AtomicU64::new(0),
486            dsp: parking_lot::RwLock::new(None),
487            renderer_clock: parking_lot::Mutex::new(None),
488            renderer: parking_lot::RwLock::new(None),
489            play_mode: AtomicU8::new(0),
490        })
491    }
492
493    // --- Playback state ---
494
495    fn transport(&self) -> (PlaybackState, bool) {
496        let bits = self.state.load(Ordering::Acquire);
497        (PlaybackState::from_u8(bits & !WAITING), bits & WAITING != 0)
498    }
499
500    pub fn playback_state(&self) -> PlaybackState {
501        self.transport().0
502    }
503
504    pub fn set_playback_state(&self, state: PlaybackState) {
505        self.set_transport(state, false);
506    }
507
508    /// Whether the player is waiting for a track it was asked for to arrive.
509    /// Stopped while waiting, the track opens playing; paused, it opens paused.
510    pub fn is_waiting(&self) -> bool {
511        self.transport().1
512    }
513
514    /// Playing, or waiting for a track that will open playing: what a
515    /// play/pause toggle pauses.
516    pub fn wants_to_play(&self) -> bool {
517        matches!(
518            self.transport(),
519            (PlaybackState::Playing, _) | (PlaybackState::Stopped, true)
520        )
521    }
522
523    /// Nothing loaded and nothing waited for: where adding tracks starts them.
524    pub fn is_idle(&self) -> bool {
525        self.transport() == (PlaybackState::Stopped, false)
526    }
527
528    /// Publish the playback state and the wait together.
529    pub fn set_transport(&self, state: PlaybackState, waiting: bool) {
530        let bits = state as u8 | if waiting { WAITING } else { 0 };
531        if self.state.swap(bits, Ordering::AcqRel) != bits {
532            self.changed();
533        }
534    }
535
536    /// Where the playhead is, read off the samples the output has played, so
537    /// it is right whenever it is asked and nothing has to keep it up to date.
538    pub fn position_ms(&self) -> u64 {
539        let clock = *self.renderer_clock.lock();
540        if let Some(clock) = clock {
541            let at = clock.now_ms();
542            let duration = self.duration_ms();
543            return if duration > 0 { at.min(duration) } else { at };
544        }
545        if self.playback_state() != PlaybackState::Stopped
546            && let Some(playhead) = self.timeline.get().and_then(|t| t.playhead())
547        {
548            return playhead.position_ms;
549        }
550        self.position_ms.load(Ordering::Acquire)
551    }
552
553    /// The timeline the playhead is read from. Set once, by the player.
554    pub(crate) fn attach_timeline(&self, timeline: Arc<crate::audio::buffer::PlaybackTimeline>) {
555        let _ = self.timeline.set(timeline);
556    }
557
558    pub fn set_position_ms(&self, pos: u64) {
559        self.position_ms.store(pos, Ordering::Release);
560        // Deliberately silent. The playhead advances on its own and is
561        // published as an anchor rather than as a reading — a wake per
562        // position would be the tick this whole arrangement removes. A seek, a
563        // pause and a track change all move something else here as well, and
564        // those are exactly the ones a client has to be told about.
565    }
566
567    /// Set by the player while a renderer is the output. A change to it is a
568    /// change clients are told about: the clock only moves when the renderer
569    /// is heard from, or when a command moved it.
570    /// Whether the playhead is advancing on its own. Playing, but held, while
571    /// a renderer has been told to play and has not yet started: a client
572    /// counting on from the command would run ahead of the sound.
573    ///
574    /// A renderer playing a file keeps its clock here; one playing a stream
575    /// processed here keeps it on the timeline, in time into the stream.
576    pub fn playhead_moving(&self) -> bool {
577        let clock =
578            (*self.renderer_clock.lock()).or_else(|| self.timeline.get().and_then(|t| t.clock()));
579        match clock {
580            Some(clock) => clock.running.is_some(),
581            None => self.playback_state() == PlaybackState::Playing,
582        }
583    }
584
585    pub(crate) fn renderer_clock(&self) -> Option<RendererClock> {
586        *self.renderer_clock.lock()
587    }
588
589    pub(crate) fn set_renderer_clock(&self, clock: Option<RendererClock>) {
590        let mut guard = self.renderer_clock.lock();
591        if *guard != clock {
592            *guard = clock;
593            drop(guard);
594            self.changed();
595        }
596    }
597
598    /// The renderer this koan is playing to, when it is not its own output.
599    pub fn renderer(&self) -> Option<crate::upnp::Output> {
600        self.renderer.read().clone()
601    }
602
603    pub(crate) fn set_renderer(&self, output: Option<crate::upnp::Output>) {
604        *self.renderer.write() = output;
605        self.changed();
606    }
607
608    pub(crate) fn update_renderer(&self, f: impl FnOnce(&mut crate::upnp::Output)) {
609        let changed = match self.renderer.write().as_mut() {
610            Some(out) => {
611                let before = out.clone();
612                f(out);
613                *out != before
614            }
615            None => false,
616        };
617        if changed {
618            self.changed();
619        }
620    }
621
622    pub fn track_info(&self) -> Option<TrackInfo> {
623        self.track_info.read().clone()
624    }
625
626    pub fn set_track_info(&self, info: Option<TrackInfo>) {
627        *self.track_info.write() = info;
628        self.changed();
629    }
630
631    /// How far into the currently playing track a seek can land.
632    ///
633    /// A track on disk is seekable end to end. One still downloading is
634    /// seekable only as far as its bytes reach: bytes map to time by the
635    /// average bitrate, exact for lossless and CBR and drifting on VBR, which
636    /// is what `SEEK_SAFETY_MS` covers. Zero when nothing is playing.
637    ///
638    /// The one value both the clamp in `Player::seek` and the extent front ends
639    /// draw on the seek bar come from — a bar that shows a reachable position
640    /// the player then refuses is worse than no bar.
641    pub fn seekable_ms(&self) -> u64 {
642        let Some(info) = self.track_info.read().clone() else {
643            return 0;
644        };
645
646        // Released before the playlist lock is taken: derive_visible_queue takes
647        // these two in the opposite order, so holding both would close a cycle.
648        let pl = self.playlist.read();
649        let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
650            return info.duration_ms;
651        };
652
653        let LoadState::Downloading {
654            total,
655            bytes_written,
656            ..
657        } = self.load_state(item)
658        else {
659            return info.duration_ms;
660        };
661
662        // A container that could not describe itself from the bytes downloaded
663        // states no duration, and cannot be seeked at all until the rest of it
664        // lands — there is no index to seek against and no end to seek within.
665        // Ogg is the one that does this; it keeps its duration in its last page.
666        if info.duration_ms == 0 {
667            return 0;
668        }
669
670        let written = bytes_written.load(Ordering::Acquire);
671        let reached = if total > 0 && info.duration_ms > 0 {
672            ((written as f64 / total as f64) * info.duration_ms as f64) as u64
673        } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
674            // No Content-Length. Bytes still say how much audio has arrived,
675            // given what the probe measured the bitrate to be: 1 kbps is
676            // 1 bit per ms, so bits divided by kbps is milliseconds.
677            written.saturating_mul(8) / kbps as u64
678        } else {
679            // Nothing to derive a position from — forward seeking would be a
680            // guess, so allow only what has already been played.
681            return self.position_ms();
682        };
683
684        reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
685    }
686
687    /// The duration to show for what is playing.
688    ///
689    /// The container's own answer wherever it gave one. A partial file that
690    /// could not be read far enough to state a duration has none, and the
691    /// library's figure stands in — it came from the server, it is right, and
692    /// a transport that reads 0:00 for nine hours of music is worse than one
693    /// reading a figure the container has not caught up with yet.
694    pub fn duration_ms(&self) -> u64 {
695        let Some(info) = self.track_info.read().clone() else {
696            return 0;
697        };
698        if info.duration_ms > 0 {
699            return info.duration_ms;
700        }
701        // Released before the playlist lock, as everywhere else here.
702        self.playlist
703            .read()
704            .items
705            .iter()
706            .find(|item| item.id == info.id)
707            .and_then(|item| item.duration_ms)
708            .unwrap_or(0)
709    }
710
711    /// `seekable_ms`, but `None` when the whole track is reachable — which is
712    /// every track that is not mid-download. What a front end draws a boundary
713    /// from: no boundary is the normal case and should cost no mark.
714    pub fn seek_ceiling_ms(&self) -> Option<u64> {
715        let duration = self.duration_ms();
716        if duration == 0 {
717            return None;
718        }
719        let seekable = self.seekable_ms();
720        (seekable < duration).then_some(seekable)
721    }
722
723    /// Download fraction (0.0..1.0) for the currently playing track, if streaming.
724    /// Returns `None` for fully-downloaded or non-playing tracks.
725    pub fn current_download_fraction(&self) -> Option<f64> {
726        // Released before the playlist lock is taken: derive_visible_queue takes
727        // these two in the opposite order, so holding both would close a cycle.
728        let id = self.track_info.read().as_ref()?.id;
729        let pl = self.playlist.read();
730        pl.items
731            .iter()
732            .find(|item| item.id == id)
733            .and_then(|item| match self.load_state(item) {
734                LoadState::Downloading {
735                    bytes_written,
736                    total,
737                    ..
738                } => {
739                    let written = bytes_written.load(Ordering::Acquire);
740                    (total > 0).then(|| (written as f64 / total as f64).min(1.0))
741                }
742                _ => None,
743            })
744    }
745
746    // --- Quit ---
747
748    pub fn request_quit(&self) {
749        self.quit_requested.store(true, Ordering::Release);
750    }
751
752    pub fn quit_requested(&self) -> bool {
753        self.quit_requested.load(Ordering::Acquire)
754    }
755
756    // --- Metadata refresh ---
757
758    /// Signal that metadata has been refreshed mid-stream (e.g. download completed).
759    /// The UI loop calls `take_metadata_refresh()` to consume this flag and
760    /// force a souvlaki/cover-art update without waiting for a track change.
761    pub fn signal_metadata_refresh(&self) {
762        self.metadata_refresh_pending.store(true, Ordering::Release);
763        self.changed();
764    }
765
766    /// Returns true and clears the flag if a metadata refresh is pending.
767    pub fn take_metadata_refresh(&self) -> bool {
768        self.metadata_refresh_pending
769            .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
770            .is_ok()
771    }
772
773    // --- Output device rate ---
774
775    /// `None` until a track has started and the device rate is known.
776    pub fn output_sample_rate(&self) -> Option<u32> {
777        match self.output_sample_rate.load(Ordering::Acquire) {
778            0 => None,
779            rate => Some(rate as u32),
780        }
781    }
782
783    pub fn set_output_sample_rate(&self, rate: u32) {
784        self.output_sample_rate
785            .store(u64::from(rate), Ordering::Release);
786        self.changed();
787    }
788
789    /// Back to "not known yet", for the window where the device is between
790    /// rates. A switch takes as long as the hardware needs to reclock — the
791    /// better part of a second on USB — and the previous track's rate is not
792    /// an answer for this one.
793    pub fn clear_output_sample_rate(&self) {
794        self.output_sample_rate.store(0, Ordering::Release);
795    }
796
797    // --- DSP ---
798
799    pub fn dsp(&self) -> Option<crate::audio::dsp::DspStatus> {
800        self.dsp.read().clone()
801    }
802
803    pub fn set_dsp(&self, status: Option<crate::audio::dsp::DspStatus>) {
804        let mut dsp = self.dsp.write();
805        if *dsp != status {
806            *dsp = status;
807            drop(dsp);
808            self.changed();
809        }
810    }
811
812    // --- Play mode ---
813
814    pub fn play_mode(&self) -> PlayMode {
815        PlayMode::from_bits(self.play_mode.load(Ordering::Acquire))
816    }
817
818    /// The player's `publish`, and nothing else — but for a front end that
819    /// mirrors another process's player into a state of its own, as it
820    /// mirrors the playback state. The mode is saved with the queue, so a
821    /// change moves the content version.
822    pub fn set_play_mode(&self, mode: PlayMode) {
823        if self.play_mode.swap(mode.to_bits(), Ordering::AcqRel) != mode.to_bits() {
824            self.content_version.fetch_add(1, Ordering::AcqRel);
825            self.bump_version();
826        }
827    }
828
829    // --- Playlist version ---
830
831    pub fn playlist_version(&self) -> u64 {
832        self.playlist_version.load(Ordering::Acquire)
833    }
834
835    fn bump_version(&self) {
836        self.playlist_version.fetch_add(1, Ordering::AcqRel);
837        self.changed();
838    }
839
840    pub fn content_version(&self) -> u64 {
841        self.content_version.load(Ordering::Acquire)
842    }
843
844    /// `bump_version`, for a change to what a saved session holds.
845    fn bump_content(&self) {
846        self.content_version.fetch_add(1, Ordering::AcqRel);
847        self.pending_version.fetch_add(1, Ordering::AcqRel);
848        self.bump_version();
849    }
850
851    /// See the field. Moves with every content change, and when an item goes
852    /// back to `Pending`.
853    pub fn pending_version(&self) -> u64 {
854        self.pending_version.load(Ordering::Acquire)
855    }
856
857    /// The transfers this player's items are fetched by.
858    pub fn downloads(&self) -> &Arc<DownloadStore> {
859        &self.downloads
860    }
861
862    fn load_state(&self, item: &PlaylistItem) -> LoadState {
863        LoadState::of(item, &self.downloads)
864    }
865
866    /// Say that something here moved, without saying what.
867    ///
868    /// Every version and atomic in this struct stays exactly as it was — they
869    /// are what a watcher consults to find out what changed. This is what
870    /// spares it looking when nothing did. See `crate::signal`.
871    pub fn changed(&self) {
872        crate::signal::engine_changed().bump();
873    }
874
875    // --- Playlist mutations (called from player thread via commands) ---
876
877    /// Append items to the playlist.
878    pub fn add_items(&self, items: Vec<PlaylistItem>) {
879        let mut pl = self.playlist.write();
880        pl.items.extend(items);
881        drop(pl);
882        self.bump_content();
883    }
884
885    /// Insert items after a specific queue item.
886    pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
887        let mut pl = self.playlist.write();
888        let insert_at = match pl.items.iter().position(|item| item.id == after) {
889            Some(pos) => pos + 1,
890            None => pl.items.len(), // fallback: append
891        };
892        for (i, item) in items.into_iter().enumerate() {
893            pl.items.insert(insert_at + i, item);
894        }
895        drop(pl);
896        self.bump_content();
897    }
898
899    /// Update file paths for playlist items (after organize moves files).
900    pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
901        let mut pl = self.playlist.write();
902        for (id, new_path) in updates {
903            if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
904                item.path = new_path.clone();
905            }
906        }
907        drop(pl);
908        self.bump_content();
909    }
910
911    /// Remove an item by ID.
912    pub fn remove_item(&self, id: QueueItemId) {
913        let mut pl = self.playlist.write();
914        pl.items.retain(|item| item.id != id);
915        // If cursor was on removed item, clear it (caller handles next_track).
916        if pl.cursor == Some(id) {
917            pl.cursor = None;
918        }
919        drop(pl);
920        self.bump_content();
921    }
922
923    /// Move an item relative to another entry.
924    pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
925        let mut pl = self.playlist.write();
926        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
927            return;
928        };
929        let item = pl.items.remove(from);
930        let Some(to) = pl.items.iter().position(|item| item.id == target) else {
931            // Target gone — put it back.
932            let pos = from.min(pl.items.len());
933            pl.items.insert(pos, item);
934            return;
935        };
936        let insert_at = if after { to + 1 } else { to };
937        pl.items.insert(insert_at, item);
938        drop(pl);
939        self.bump_content();
940    }
941
942    /// Batch move: extract items by ID, reinsert them at `target` position.
943    /// Preserves the relative order of the moved items.
944    pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
945        use std::collections::HashSet;
946        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
947
948        let mut pl = self.playlist.write();
949
950        // Partition: extract moved items, keep the rest.
951        let mut remaining = Vec::with_capacity(pl.items.len());
952        let mut moved = Vec::with_capacity(ids.len());
953        for item in pl.items.drain(..) {
954            if id_set.contains(&item.id) {
955                moved.push(item);
956            } else {
957                remaining.push(item);
958            }
959        }
960
961        // Find target in the remaining items.
962        let insert_at = match remaining.iter().position(|item| item.id == target) {
963            Some(pos) => {
964                if after {
965                    pos + 1
966                } else {
967                    pos
968                }
969            }
970            None => remaining.len(),
971        };
972
973        // Splice moved items in at the target position.
974        for (i, item) in moved.into_iter().enumerate() {
975            remaining.insert(insert_at + i, item);
976        }
977
978        pl.items = remaining;
979        drop(pl);
980        self.bump_content();
981    }
982
983    /// Set the cursor (what's playing / should play).
984    pub fn set_cursor(&self, id: Option<QueueItemId>) {
985        let mut pl = self.playlist.write();
986        pl.cursor = id;
987        drop(pl);
988        self.bump_version();
989    }
990
991    pub fn is_empty(&self) -> bool {
992        self.playlist.read().items.is_empty()
993    }
994
995    pub fn cursor(&self) -> Option<QueueItemId> {
996        self.playlist.read().cursor
997    }
998
999    /// The path of the item under the cursor, without copying the playlist.
1000    pub fn cursor_path(&self) -> Option<PathBuf> {
1001        let pl = self.playlist.read();
1002        let cursor = pl.cursor?;
1003        pl.items
1004            .iter()
1005            .find(|item| item.id == cursor)
1006            .map(|item| item.path.clone())
1007    }
1008
1009    /// Clear the entire playlist + cursor.
1010    /// Swap the whole playlist for `items`, with no cursor, as one change.
1011    /// Returns what it held, for undo.
1012    ///
1013    /// One write and one version bump, not a clear and an add. The download
1014    /// queue reads the playlist on its own thread whenever it changes, and an
1015    /// empty playlist between the two would read as nothing wanted: every
1016    /// transfer for a track in both the old queue and the new one let go,
1017    /// cancelled, and started again.
1018    pub fn replace_playlist(
1019        &self,
1020        items: Vec<PlaylistItem>,
1021    ) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
1022        let mut pl = self.playlist.write();
1023        let old = std::mem::replace(&mut pl.items, items);
1024        let cursor = pl.cursor.take();
1025        drop(pl);
1026        self.bump_content();
1027        (old, cursor)
1028    }
1029
1030    pub fn clear_playlist(&self) {
1031        let mut pl = self.playlist.write();
1032        pl.items.clear();
1033        pl.cursor = None;
1034        drop(pl);
1035        self.bump_content();
1036    }
1037
1038    // --- Called from decode thread (gapless) ---
1039
1040    /// Move the cursor to the next item that can still play — the first item
1041    /// after the cursor that is not `Failed`, from the top again when the
1042    /// queue repeats — and return its ID. An advance is a move on, so
1043    /// repeating one item wraps the queue here as repeating the queue does;
1044    /// playing the item again at its end is the player's call.
1045    ///
1046    /// An item that is still downloading parks the cursor rather than being
1047    /// skipped, so playback resumes from it when its data lands. Skipping it
1048    /// would drop it from the queue for good.
1049    ///
1050    /// With no cursor set, starts from the top. A cursor pointing at an item
1051    /// that is no longer in the playlist yields `None` — restarting from the
1052    /// top would silently replay the queue.
1053    pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
1054        let repeat = match self.play_mode().repeat {
1055            Repeat::Off => Repeat::Off,
1056            Repeat::Queue | Repeat::One => Repeat::Queue,
1057        };
1058        let mut pl = self.playlist.write();
1059        let next = match pl.cursor {
1060            Some(cid) => follows(&pl.items, cid, repeat)?.0?.id,
1061            None => {
1062                pl.items
1063                    .iter()
1064                    .find(|item| !matches!(item.state, ItemState::Failed(_)))?
1065                    .id
1066            }
1067        };
1068
1069        pl.cursor = Some(next);
1070        drop(pl);
1071        self.bump_version();
1072        Some(next)
1073    }
1074
1075    /// One step of the decoder's gapless lookahead from `after_id`. Does not
1076    /// move the cursor; `update_playback_state` does that when the playhead
1077    /// gets there.
1078    ///
1079    /// Failed items are passed over, as `advance_cursor_loadable` passes over
1080    /// them. A track still arriving is not: the decoder stops at it, the
1081    /// session drains, and the advance that follows waits for it. Passing over
1082    /// it would play the track after it and move the cursor beyond it, so it
1083    /// would never be heard.
1084    ///
1085    /// The play mode decides what follows: the queue's next item, the first
1086    /// again at its end when the queue repeats, or the same item when one
1087    /// repeats — gapless all three.
1088    ///
1089    /// None when `after_id` has been removed: the lookahead then has nothing
1090    /// to follow, and starting from the top would gaplessly replay the queue.
1091    pub fn lookahead_after(&self, after_id: QueueItemId) -> Option<Lookahead> {
1092        let repeat = self.play_mode().repeat;
1093        let pl = self.playlist.read();
1094        let (next, wrapped) = follows(&pl.items, after_id, repeat)?;
1095        Some(Lookahead {
1096            after: after_id,
1097            next: next.map(|item| item.id),
1098            chosen: next
1099                .filter(|item| matches!(item.state, ItemState::Ready))
1100                .map(|item| (item.id, item.path.clone())),
1101            wrapped,
1102            boundary: 0,
1103        })
1104    }
1105
1106    /// Whether the decoder would still take `step`: under the play mode now,
1107    /// `next` still follows `after`. A track it stopped at landing since is
1108    /// not a change, since the advance at the end of the session plays it in
1109    /// order; an edit that puts another track first is, and so is a change of
1110    /// mode that sends the queue elsewhere — a wrap once repeat is off, or a
1111    /// track appended after the one a wrap left from.
1112    pub fn still_follows(&self, step: &Lookahead) -> bool {
1113        let repeat = self.play_mode().repeat;
1114        let pl = self.playlist.read();
1115        follows(&pl.items, step.after, repeat)
1116            .is_some_and(|(next, _)| next.map(|item| item.id) == step.next)
1117    }
1118
1119    /// Retreat cursor to the previous item. Returns (id, path) if found.
1120    /// For prev_track — goes to the item before cursor regardless of load state,
1121    /// and from the first to the last while repeat is on.
1122    pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
1123        let mut pl = self.playlist.write();
1124        let cursor_pos = match pl.cursor {
1125            Some(cid) => pl.items.iter().position(|item| item.id == cid),
1126            None => None,
1127        };
1128
1129        // From the first item, round to the last while the queue repeats. A
1130        // queue of one has nothing to go back to: the caller restarts it.
1131        let wraps = self.play_mode().repeat != Repeat::Off && pl.items.len() > 1;
1132        let prev_pos = cursor_pos.and_then(|p| match p.checked_sub(1) {
1133            None if wraps => Some(pl.items.len() - 1),
1134            prev => prev,
1135        });
1136
1137        match prev_pos {
1138            Some(pos) => {
1139                let item = &pl.items[pos];
1140                let result = (item.id, item.path.clone());
1141                pl.cursor = Some(item.id);
1142                drop(pl);
1143                self.bump_version();
1144                Some(result)
1145            }
1146            None => None,
1147        }
1148    }
1149
1150    // --- Called from resolve thread ---
1151
1152    /// Update the load state of a playlist item.
1153    pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
1154        let pending = new_state == ItemState::Pending;
1155        let mut pl = self.playlist.write();
1156        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
1157            item.state = new_state;
1158        }
1159        drop(pl);
1160        if pending {
1161            self.pending_version.fetch_add(1, Ordering::AcqRel);
1162        }
1163        self.bump_version();
1164    }
1165
1166    /// An item's own state, without the rest of it.
1167    pub fn item_state(&self, id: QueueItemId) -> Option<ItemState> {
1168        let pl = self.playlist.read();
1169        pl.items
1170            .iter()
1171            .find(|item| item.id == id)
1172            .map(|item| item.state.clone())
1173    }
1174
1175    /// Take what a finished download's own tags can add.
1176    ///
1177    /// Streaming starts on partial Symphonia tags, so an item with nothing
1178    /// behind it takes the lot once the whole file is there. An item that came
1179    /// out of the library does not: the record is what the queue was built
1180    /// from and what every other track on it carries, and a file whose tags
1181    /// disagree — a server album titled one way, the file inside titled
1182    /// another — would split its album in two the moment it finished
1183    /// downloading. The duration is the file's to know either way.
1184    pub fn update_item_metadata(
1185        &self,
1186        id: QueueItemId,
1187        title: String,
1188        artist: String,
1189        album_artist: String,
1190        album: String,
1191        duration_ms: Option<u64>,
1192    ) {
1193        let mut pl = self.playlist.write();
1194        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
1195            if item.db_id.is_none() {
1196                item.title = title;
1197                item.artist = artist;
1198                item.album_artist = album_artist;
1199                item.album = album;
1200            }
1201            if let Some(dur) = duration_ms {
1202                item.duration_ms = Some(dur);
1203            }
1204        }
1205        drop(pl);
1206        self.bump_content();
1207    }
1208
1209    /// Get the playback source for an item if it's ready to play.
1210    /// Returns `None` if not enough data is available yet.
1211    pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
1212        let pl = self.playlist.read();
1213        pl.items
1214            .iter()
1215            .find(|item| item.id == id)
1216            .and_then(|item| match self.load_state(item) {
1217                LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
1218                LoadState::Downloading {
1219                    path,
1220                    total,
1221                    bytes_written,
1222                } => {
1223                    let written = bytes_written.load(Ordering::Acquire);
1224                    (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
1225                        path,
1226                        bytes_written,
1227                        total,
1228                    })
1229                }
1230                _ => None,
1231            })
1232    }
1233
1234    /// Put back to `Pending` every queue item whose file has gone, and say
1235    /// which they were so they can be fetched again.
1236    ///
1237    /// The queue holds paths, and clearing downloads deletes the files under
1238    /// them. An item left claiming `Ready` opens nothing when it is played —
1239    /// it is not broken, it is a remote track that has to be fetched a second
1240    /// time. Only items with a database row behind them: one without has
1241    /// nowhere to be fetched from, and parking the cursor on it would be worse
1242    /// than letting it fail honestly.
1243    pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
1244        let mut pl = self.playlist.write();
1245        let mut reset = Vec::new();
1246        for item in pl.items.iter_mut() {
1247            let Some(db_id) = item.db_id else { continue };
1248            if !matches!(item.state, ItemState::Ready) {
1249                continue;
1250            }
1251            if item.path.exists() {
1252                continue;
1253            }
1254            item.state = ItemState::Pending;
1255            reset.push((db_id, item.id));
1256        }
1257        drop(pl);
1258        if !reset.is_empty() {
1259            self.pending_version.fetch_add(1, Ordering::AcqRel);
1260            self.bump_version();
1261        }
1262        reset
1263    }
1264
1265    /// The item's path if it is `Ready`. A caller that can stream wants `item_playback_source`.
1266    pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
1267        let pl = self.playlist.read();
1268        pl.items.iter().find(|item| item.id == id).and_then(|item| {
1269            if matches!(item.state, ItemState::Ready) {
1270                Some(item.path.clone())
1271            } else {
1272                None
1273            }
1274        })
1275    }
1276
1277    pub fn is_cursor(&self, id: QueueItemId) -> bool {
1278        self.playlist.read().cursor == Some(id)
1279    }
1280
1281    /// Get QueueItemIds of all playlist items sharing the same album as the given item.
1282    /// Matches on both album name and album artist to avoid false positives
1283    /// (e.g. two different "Greatest Hits" by different artists).
1284    pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
1285        let pl = self.playlist.read();
1286        let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
1287            return vec![];
1288        };
1289        let album = cursor.album.clone();
1290        let album_artist = cursor.album_artist.clone();
1291        pl.items
1292            .iter()
1293            .filter(|item| {
1294                item.id != id && item.album == album && item.album_artist == album_artist
1295            })
1296            .map(|item| item.id)
1297            .collect()
1298    }
1299
1300    /// Every playlist item still waiting for its file that has a track to
1301    /// fetch, as `(db_id, QueueItemId)`, in the order the player will reach
1302    /// it: from the cursor to the end, then from the top. What the download
1303    /// queue fetches, and in that order — the tracks before the cursor are
1304    /// the ones least likely to be played next.
1305    pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
1306        let pl = self.playlist.read();
1307        let from = pl
1308            .cursor
1309            .and_then(|c| pl.items.iter().position(|item| item.id == c))
1310            .unwrap_or(0);
1311        let (before, after) = pl.items.split_at(from);
1312        after
1313            .iter()
1314            .chain(before)
1315            .filter(|item| matches!(item.state, ItemState::Pending))
1316            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
1317            .collect()
1318    }
1319
1320    /// Every entry with a library track, in the order `pending_downloads`
1321    /// gives, whatever its state: what the cache has to hold, downloaded or
1322    /// not, for the player to reach it.
1323    pub fn playback_order(&self) -> Vec<(i64, QueueItemId)> {
1324        let pl = self.playlist.read();
1325        let from = pl
1326            .cursor
1327            .and_then(|c| pl.items.iter().position(|item| item.id == c))
1328            .unwrap_or(0);
1329        let (before, after) = pl.items.split_at(from);
1330        after
1331            .iter()
1332            .chain(before)
1333            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
1334            .collect()
1335    }
1336
1337    /// Get the db_id for a specific playlist item.
1338    pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
1339        let pl = self.playlist.read();
1340        pl.items
1341            .iter()
1342            .find(|item| item.id == id)
1343            .and_then(|item| item.db_id)
1344    }
1345
1346    /// Get the load state of a specific playlist item.
1347    pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
1348        let pl = self.playlist.read();
1349        pl.items
1350            .iter()
1351            .find(|item| item.id == id)
1352            .map(|item| self.load_state(item))
1353    }
1354
1355    // --- Snapshot helpers for undo ---
1356
1357    /// Get the full playlist snapshot (items + cursor) for undo of ClearPlaylist.
1358    pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
1359        let pl = self.playlist.read();
1360        (pl.items.clone(), pl.cursor)
1361    }
1362
1363    /// Get an item by ID (for undo of RemoveFromPlaylist).
1364    pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
1365        let pl = self.playlist.read();
1366        pl.items.iter().find(|item| item.id == id).cloned()
1367    }
1368
1369    /// Get the ID of the item immediately before the given ID (None if first).
1370    pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
1371        let pl = self.playlist.read();
1372        let pos = pl.items.iter().position(|item| item.id == id)?;
1373        if pos == 0 {
1374            None
1375        } else {
1376            Some(pl.items[pos - 1].id)
1377        }
1378    }
1379
1380    /// Put the items in exactly this order.
1381    ///
1382    /// Items not named keep their relative order and follow at the end, so a
1383    /// stale order cannot lose anything. The items themselves are moved, not
1384    /// rebuilt: their ids, load states and download progress are what the rest
1385    /// of the player is holding on to.
1386    pub fn reorder_to(&self, order: &[QueueItemId]) {
1387        let mut pl = self.playlist.write();
1388        let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
1389        let mut sorted = Vec::with_capacity(taken.len());
1390        for id in order {
1391            if let Some(slot) = taken
1392                .iter_mut()
1393                .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
1394                && let Some(item) = slot.take()
1395            {
1396                sorted.push(item);
1397            }
1398        }
1399        sorted.extend(taken.into_iter().flatten());
1400        pl.items = sorted;
1401        drop(pl);
1402        self.bump_content();
1403    }
1404
1405    /// Every item's id and where it stood before shuffling, in queue order:
1406    /// what undoing a shuffle puts back.
1407    pub fn shuffle_order(&self) -> Vec<(QueueItemId, Option<u32>)> {
1408        let pl = self.playlist.read();
1409        pl.items
1410            .iter()
1411            .map(|item| (item.id, item.pre_shuffle))
1412            .collect()
1413    }
1414
1415    /// Put back an order `shuffle_order` read, positions before shuffling
1416    /// included. Items not named keep theirs and follow, as in `reorder_to`.
1417    pub fn restore_shuffle_order(&self, order: &[(QueueItemId, Option<u32>)]) {
1418        let ids: Vec<QueueItemId> = order.iter().map(|(id, _)| *id).collect();
1419        self.reorder_to(&ids);
1420        let was: HashMap<QueueItemId, Option<u32>> = order.iter().copied().collect();
1421        let mut pl = self.playlist.write();
1422        for item in pl.items.iter_mut() {
1423            if let Some(pre) = was.get(&item.id) {
1424                item.pre_shuffle = *pre;
1425            }
1426        }
1427        drop(pl);
1428        self.bump_content();
1429    }
1430
1431    /// Note where every item stands, then put the items after the cursor in a
1432    /// random order — all of them with no cursor. The playing item stays
1433    /// where it is, so nothing that is heard changes.
1434    pub fn shuffle_after_cursor(&self) {
1435        let mut pl = self.playlist.write();
1436        for (at, item) in pl.items.iter_mut().enumerate() {
1437            item.pre_shuffle = Some(at as u32);
1438        }
1439        let from = pl
1440            .cursor
1441            .and_then(|c| pl.items.iter().position(|item| item.id == c))
1442            .map_or(0, |at| at + 1);
1443        crate::helpers::shuffle(&mut pl.items[from..]);
1444        drop(pl);
1445        self.bump_content();
1446    }
1447
1448    /// A queue arriving whole while shuffle is on — replaced, or added to an
1449    /// empty one — plays shuffled: `start` first, the rest in a random order
1450    /// after it, each item noting where it was given so turning shuffle off
1451    /// puts the queue back as it came. A queue that already carries those
1452    /// notes, a shuffled session restored, is left as it was saved.
1453    pub fn shuffle_from(&self, start: QueueItemId) {
1454        let mut pl = self.playlist.write();
1455        if pl.items.iter().any(|item| item.pre_shuffle.is_some()) {
1456            return;
1457        }
1458        for (at, item) in pl.items.iter_mut().enumerate() {
1459            item.pre_shuffle = Some(at as u32);
1460        }
1461        if let Some(at) = pl.items.iter().position(|item| item.id == start) {
1462            let item = pl.items.remove(at);
1463            pl.items.insert(0, item);
1464        }
1465        crate::helpers::shuffle(&mut pl.items[1..]);
1466        drop(pl);
1467        self.bump_content();
1468    }
1469
1470    /// Put the items shuffle moved back in the order they had before it, in
1471    /// the places such items occupy now. An item added since keeps its place.
1472    pub fn unshuffle(&self) {
1473        let mut pl = self.playlist.write();
1474        let slots: Vec<usize> = (0..pl.items.len())
1475            .filter(|&at| pl.items[at].pre_shuffle.is_some())
1476            .collect();
1477        let mut moved: Vec<PlaylistItem> = slots.iter().map(|&at| pl.items[at].clone()).collect();
1478        moved.sort_by_key(|item| item.pre_shuffle);
1479        for (at, mut item) in slots.into_iter().zip(moved) {
1480            item.pre_shuffle = None;
1481            pl.items[at] = item;
1482        }
1483        drop(pl);
1484        self.bump_content();
1485    }
1486
1487    /// For each ID, the ID of the item before it (or None if first), returned in
1488    /// playlist order regardless of the order `ids` arrives in.
1489    ///
1490    /// Undo replays these left to right, so an item whose recorded predecessor is
1491    /// also in `ids` must come after it — otherwise the predecessor is missing at
1492    /// replay time and the item lands at the end of the playlist instead.
1493    pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
1494        use std::collections::HashSet;
1495        let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
1496        let pl = self.playlist.read();
1497        pl.items
1498            .iter()
1499            .enumerate()
1500            .filter(|(_, item)| wanted.contains(&item.id))
1501            .map(|(pos, item)| {
1502                let before = if pos == 0 {
1503                    None
1504                } else {
1505                    Some(pl.items[pos - 1].id)
1506                };
1507                (item.id, before)
1508            })
1509            .collect()
1510    }
1511
1512    /// The nearest item before `id` that is not itself being removed — where
1513    /// playback resumes from after a batch delete that takes out the cursor.
1514    /// `None` means resume from the top of what survives.
1515    pub fn surviving_item_before(
1516        &self,
1517        id: QueueItemId,
1518        removed: &[QueueItemId],
1519    ) -> Option<QueueItemId> {
1520        use std::collections::HashSet;
1521        let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
1522        let pl = self.playlist.read();
1523        let pos = pl.items.iter().position(|item| item.id == id)?;
1524        pl.items[..pos]
1525            .iter()
1526            .rev()
1527            .find(|item| !removed.contains(&item.id))
1528            .map(|item| item.id)
1529    }
1530
1531    /// Restore a full playlist from snapshot (for redo of ClearPlaylist undo).
1532    pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
1533        let mut pl = self.playlist.write();
1534        pl.items = items;
1535        pl.cursor = cursor;
1536        drop(pl);
1537        self.bump_content();
1538    }
1539
1540    /// Remove multiple items by IDs.
1541    pub fn remove_items(&self, ids: &[QueueItemId]) {
1542        use std::collections::HashSet;
1543        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
1544        let mut pl = self.playlist.write();
1545        pl.items.retain(|item| !id_set.contains(&item.id));
1546        if let Some(cursor) = pl.cursor
1547            && id_set.contains(&cursor)
1548        {
1549            pl.cursor = None;
1550        }
1551        drop(pl);
1552        self.bump_content();
1553    }
1554
1555    /// Insert a single item after a given ID (or at front if None).
1556    pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1557        let mut pl = self.playlist.write();
1558        let insert_at = match after {
1559            Some(after_id) => {
1560                match pl.items.iter().position(|i| i.id == after_id) {
1561                    Some(pos) => pos + 1,
1562                    None => pl.items.len(), // fallback
1563                }
1564            }
1565            None => 0,
1566        };
1567        pl.items.insert(insert_at, item);
1568        drop(pl);
1569        self.bump_content();
1570    }
1571
1572    /// Move a single item to after `after` (or to front if None).
1573    pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1574        let mut pl = self.playlist.write();
1575        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1576            return;
1577        };
1578        let item = pl.items.remove(from);
1579        let insert_at = match after {
1580            Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1581                Some(pos) => pos + 1,
1582                None => pl.items.len(),
1583            },
1584            None => 0,
1585        };
1586        pl.items.insert(insert_at, item);
1587        drop(pl);
1588        self.bump_content();
1589    }
1590
1591    /// Batch move: reposition each item to after its given predecessor.
1592    /// Processes in order so earlier insertions don't corrupt later positions.
1593    pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1594        for &(id, after) in entries {
1595            self.move_item_to(id, after);
1596        }
1597    }
1598
1599    // --- Called from UI thread (read lock) ---
1600
1601    /// Derive the visible queue from the playlist + cursor. O(n).
1602    /// Called once per UI tick.
1603    pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1604        // Read before the playlist lock — see current_download_fraction.
1605        let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1606        // One pass over the transfers rather than one lookup, and a path
1607        // cloned, per row.
1608        let transfers: HashMap<i64, (u64, u64)> = self
1609            .downloads
1610            .readings()
1611            .into_iter()
1612            .map(|r| (r.track_id, (r.written, r.total)))
1613            .collect();
1614        let pl = self.playlist.read();
1615
1616        let cursor_pos = match pl.cursor {
1617            Some(cid) => pl.items.iter().position(|item| item.id == cid),
1618            None => None,
1619        };
1620
1621        let mut entries = Vec::with_capacity(pl.items.len());
1622        let mut finished_count = 0;
1623        let mut has_playing = false;
1624        let mut queue_count = 0;
1625
1626        for (i, item) in pl.items.iter().enumerate() {
1627            let is_cursor = cursor_pos == Some(i);
1628            let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1629
1630            // The byte count is the download thread's own counter, written per
1631            // chunk without the playlist lock, so a transfer never bumps the
1632            // playlist version.
1633            let dl_progress = match item.state {
1634                ItemState::Pending => item.db_id.and_then(|id| transfers.get(&id).copied()),
1635                _ => None,
1636            };
1637            let transferring = dl_progress.is_some();
1638
1639            let status = if is_cursor {
1640                has_playing = true;
1641                QueueEntryStatus::at_cursor(&item.state, transferring)
1642            } else if is_before_cursor {
1643                finished_count += 1;
1644                match &item.state {
1645                    ItemState::Ready => QueueEntryStatus::Played,
1646                    // A spinner only while bytes are moving: a track skipped
1647                    // past is not being fetched just for being behind the
1648                    // cursor.
1649                    ItemState::Pending if transferring => QueueEntryStatus::Downloading,
1650                    ItemState::Pending => QueueEntryStatus::Queued,
1651                    ItemState::Failed(_) => QueueEntryStatus::Failed,
1652                }
1653            } else {
1654                queue_count += 1;
1655                match &item.state {
1656                    ItemState::Ready => QueueEntryStatus::Queued,
1657                    ItemState::Pending if transferring => QueueEntryStatus::Downloading,
1658                    // Waiting its turn, not arriving: a spinner on every one
1659                    // of these read as the whole album downloading at once.
1660                    ItemState::Pending => QueueEntryStatus::Queued,
1661                    ItemState::Failed(_) => QueueEntryStatus::Failed,
1662                }
1663            };
1664
1665            // Override duration from TrackInfo if we have it and this is playing.
1666            let duration_ms =
1667                if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1668                {
1669                    playing_duration_ms
1670                } else {
1671                    item.duration_ms
1672                };
1673
1674            entries.push(QueueEntry {
1675                id: item.id,
1676                db_id: item.db_id,
1677                playlist_entry_id: item.playlist_entry_id,
1678                path: item.path.clone(),
1679                title: item.title.clone(),
1680                artist: item.artist.clone(),
1681                album_artist: item.album_artist.clone(),
1682                album: item.album.clone(),
1683                year: item.year.clone(),
1684                codec: item.codec.clone(),
1685                track_number: item.track_number,
1686                disc: item.disc,
1687                duration_ms,
1688                status,
1689                download_progress: dl_progress,
1690                error: match &item.state {
1691                    ItemState::Failed(reason) => Some(reason.clone()),
1692                    _ => None,
1693                },
1694            });
1695        }
1696
1697        VisibleQueueSnapshot {
1698            entries,
1699            finished_count,
1700            has_playing,
1701            queue_count,
1702        }
1703    }
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708    use super::*;
1709
1710    // --- helpers ---
1711
1712    fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1713        PlaylistItem {
1714            playlist_entry_id: None,
1715            id: QueueItemId::new(),
1716            db_id: None,
1717            path: PathBuf::from(format!("/music/{title}.flac")),
1718            title: title.to_string(),
1719            artist: "Artist".to_string(),
1720            album_artist: "Artist".to_string(),
1721            album: "Album".to_string(),
1722            year: None,
1723            codec: Some("FLAC".to_string()),
1724            track_number: None,
1725            disc: None,
1726            duration_ms: Some(200_000),
1727            state,
1728            pre_shuffle: None,
1729        }
1730    }
1731
1732    /// An item with a transfer running against it, told to the state's store
1733    /// the way the downloader tells it. Returns the transfer's byte feed.
1734    fn downloading_item(
1735        state: &SharedPlayerState,
1736        title: &str,
1737        total: u64,
1738    ) -> (PlaylistItem, Arc<ByteFeed>) {
1739        static NEXT_TRACK: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1);
1740        let mut item = make_item(title, ItemState::Pending);
1741        item.db_id = Some(NEXT_TRACK.fetch_add(1, Ordering::Relaxed));
1742        let feed = start_transfer(state, &item, total);
1743        (item, feed)
1744    }
1745
1746    /// Claim, announce and start the transfer for `item`'s track.
1747    fn start_transfer(state: &SharedPlayerState, item: &PlaylistItem, total: u64) -> Arc<ByteFeed> {
1748        let track_id = item.db_id.expect("a transfer is for a library track");
1749        let store = state.downloads();
1750        store.claim(track_id, Some(item.id));
1751        let feed = store.announce(
1752            track_id,
1753            item.title.clone(),
1754            String::new(),
1755            PathBuf::from(format!("/cache/{}.flac.part", item.title)),
1756            PathBuf::from(format!("/cache/{}.flac", item.title)),
1757        );
1758        store.started(track_id, total);
1759        feed
1760    }
1761
1762    fn ready_item(title: &str) -> PlaylistItem {
1763        make_item(title, ItemState::Ready)
1764    }
1765
1766    fn pending_item(title: &str) -> PlaylistItem {
1767        make_item(title, ItemState::Pending)
1768    }
1769
1770    fn failed_item(title: &str) -> PlaylistItem {
1771        make_item(title, ItemState::Failed("nope".into()))
1772    }
1773
1774    const DURATION_MS: u64 = 32_523_787;
1775
1776    /// A nine-hour track under the cursor, `downloaded` bytes of `total` in.
1777    /// `total` of 0 stands for a server that sent no Content-Length.
1778    fn streaming_state(
1779        downloaded: u64,
1780        total: u64,
1781        bitrate_kbps: Option<u32>,
1782    ) -> Arc<SharedPlayerState> {
1783        streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1784    }
1785
1786    /// The same, but saying what the container managed to state about itself.
1787    /// A partial Ogg states nothing, which is zero here.
1788    fn streaming_state_with_duration(
1789        downloaded: u64,
1790        total: u64,
1791        bitrate_kbps: Option<u32>,
1792        container_duration_ms: u64,
1793    ) -> Arc<SharedPlayerState> {
1794        let mut item = make_item("train", ItemState::Pending);
1795        item.db_id = Some(1);
1796        let id = item.id;
1797        let path = item.path.clone();
1798
1799        let state = SharedPlayerState::new();
1800        start_transfer(&state, &item, total).set(downloaded);
1801        state.add_items(vec![item]);
1802        state.set_cursor(Some(id));
1803        state.set_track_info(Some(TrackInfo {
1804            id,
1805            path,
1806            codec: "Opus".into(),
1807            sample_rate: 48_000,
1808            bit_depth: None,
1809            bitrate_kbps,
1810            channels: 2,
1811            duration_ms: container_duration_ms,
1812        }));
1813        state
1814    }
1815
1816    // --- seekable_ms ---
1817
1818    #[test]
1819    fn a_track_on_disk_is_seekable_end_to_end() {
1820        let item = ready_item("done");
1821        let id = item.id;
1822        let path = item.path.clone();
1823        let state = SharedPlayerState::new();
1824        state.add_items(vec![item]);
1825        state.set_cursor(Some(id));
1826        state.set_track_info(Some(TrackInfo {
1827            id,
1828            path,
1829            codec: "FLAC".into(),
1830            sample_rate: 44_100,
1831            bit_depth: Some(16),
1832            bitrate_kbps: None,
1833            channels: 2,
1834            duration_ms: 200_000,
1835        }));
1836
1837        assert_eq!(state.seekable_ms(), 200_000);
1838        // Nothing to draw a boundary for, so front ends are told there isn't one.
1839        assert_eq!(state.seek_ceiling_ms(), None);
1840    }
1841
1842    #[test]
1843    fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1844        // A quarter of a nine-hour file in: a quarter of the way through it,
1845        // less the margin the byte-to-time estimate is worth.
1846        let state = streaming_state(100, 400, None);
1847        assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1848        assert_eq!(
1849            state.seek_ceiling_ms(),
1850            Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1851        );
1852    }
1853
1854    #[test]
1855    fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1856        // No total to take a fraction of. 128 kbps is 128 bits per ms, so a
1857        // megabyte is 8 388 608 bits and a little over 65 seconds.
1858        let state = streaming_state(1024 * 1024, 0, Some(128));
1859        assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1860    }
1861
1862    #[test]
1863    fn nothing_to_estimate_from_allows_no_forward_seek() {
1864        // Neither a length nor a bitrate: anywhere past the playhead is a
1865        // guess, and a guess that lands past the write head is a stall.
1866        let state = streaming_state(1024 * 1024, 0, None);
1867        state.set_position_ms(12_000);
1868        assert_eq!(state.seekable_ms(), 12_000);
1869    }
1870
1871    #[test]
1872    fn the_seekable_extent_never_exceeds_the_track() {
1873        // A download reporting more bytes than it advertised must not offer a
1874        // seek past the end of the music.
1875        let state = streaming_state(500, 400, None);
1876        assert_eq!(state.seekable_ms(), 32_523_787);
1877    }
1878
1879    #[test]
1880    fn nothing_playing_is_seekable_nowhere() {
1881        assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1882        assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1883    }
1884
1885    #[test]
1886    fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1887        // A partial Ogg keeps its duration in a last page that has not arrived,
1888        // so it opens and plays but has nothing to seek against. Half the bytes
1889        // being present does not change that.
1890        let state = streaming_state_with_duration(200, 400, Some(128), 0);
1891        assert_eq!(state.seekable_ms(), 0);
1892    }
1893
1894    #[test]
1895    fn the_library_duration_stands_in_for_a_silent_container() {
1896        // What is shown on the transport, so nine hours of music does not read
1897        // as 0:00 while it caches.
1898        let state = streaming_state_with_duration(200, 400, Some(128), 0);
1899        assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1900        // And it is a display figure only — it grants no seeking.
1901        assert_eq!(state.seekable_ms(), 0);
1902        assert_eq!(state.seek_ceiling_ms(), Some(0));
1903    }
1904
1905    #[test]
1906    fn the_container_duration_wins_where_there_is_one() {
1907        let state = streaming_state(200, 400, None);
1908        assert_eq!(state.duration_ms(), DURATION_MS);
1909    }
1910
1911    #[test]
1912    fn the_download_landing_restores_seeking() {
1913        // The sequence the whole design turns on: a track that opened without a
1914        // duration gets one when the finished file is re-read, and is seekable
1915        // end to end from that moment — no restart, no handover.
1916        let state = streaming_state_with_duration(400, 400, Some(128), 0);
1917        assert_eq!(state.seekable_ms(), 0);
1918
1919        // What the downloader does when the bytes land: settle the transfer,
1920        // then say the file is playable. In that order — while the store still
1921        // says a transfer is running, it is.
1922        let id = state.cursor().expect("cursor");
1923        let _ =
1924            crate::remote::downloads::settle(&state, 1, &Ok(PathBuf::from("/cache/train.flac")));
1925        assert_eq!(state.item_state(id), Some(ItemState::Ready));
1926        let info = state.track_info().expect("track info");
1927        state.set_track_info(Some(TrackInfo {
1928            duration_ms: DURATION_MS,
1929            ..info
1930        }));
1931
1932        assert_eq!(state.seekable_ms(), DURATION_MS);
1933        assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1934    }
1935
1936    // --- advance_cursor_loadable ---
1937
1938    #[test]
1939    fn advance_parks_on_a_still_downloading_track() {
1940        // A track that has not arrived yet must hold the cursor, not be skipped:
1941        // skipping it drops it from the queue for good, and it is the item whose
1942        // TrackReady has to resume playback.
1943        let state = SharedPlayerState::new();
1944        let item0 = ready_item("track-0");
1945        let item1 = pending_item("track-1");
1946        let item2 = ready_item("track-2");
1947        let (id0, id1) = (item0.id, item1.id);
1948
1949        state.add_items(vec![item0, item1, item2]);
1950
1951        assert_eq!(state.advance_cursor_loadable(), Some(id0));
1952        assert_eq!(state.advance_cursor_loadable(), Some(id1));
1953        assert_eq!(state.cursor(), Some(id1));
1954    }
1955
1956    #[test]
1957    fn advance_skips_failed_items() {
1958        let state = SharedPlayerState::new();
1959        let item0 = ready_item("track-0");
1960        let item1 = failed_item("track-1");
1961        let item2 = ready_item("track-2");
1962        let (id0, id2) = (item0.id, item2.id);
1963
1964        state.add_items(vec![item0, item1, item2]);
1965        state.set_cursor(Some(id0));
1966
1967        assert_eq!(state.advance_cursor_loadable(), Some(id2));
1968    }
1969
1970    #[test]
1971    fn advance_stops_at_end_of_playlist() {
1972        let state = SharedPlayerState::new();
1973        let item0 = ready_item("track-0");
1974        let item1 = ready_item("track-1");
1975        let id1 = item1.id;
1976
1977        state.add_items(vec![item0, item1]);
1978        state.set_cursor(Some(id1));
1979
1980        assert_eq!(state.advance_cursor_loadable(), None);
1981        assert_eq!(
1982            state.cursor(),
1983            Some(id1),
1984            "cursor unchanged on a failed advance"
1985        );
1986    }
1987
1988    #[test]
1989    fn advance_with_only_failed_items_returns_none() {
1990        let state = SharedPlayerState::new();
1991        state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1992
1993        assert_eq!(state.advance_cursor_loadable(), None);
1994    }
1995
1996    #[test]
1997    fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1998        let state = SharedPlayerState::new();
1999        let item0 = ready_item("track-0");
2000        let item1 = ready_item("track-1");
2001        let id0 = item0.id;
2002
2003        state.add_items(vec![item0, item1]);
2004        let ghost = QueueItemId::new();
2005        state.set_cursor(Some(ghost));
2006
2007        assert_eq!(state.advance_cursor_loadable(), None);
2008        assert_ne!(state.cursor(), Some(id0));
2009    }
2010
2011    // --- lookahead_after ---
2012
2013    fn chosen_after(state: &SharedPlayerState, id: QueueItemId) -> Option<QueueItemId> {
2014        state
2015            .lookahead_after(id)
2016            .and_then(|step| step.chosen)
2017            .map(|(id, _)| id)
2018    }
2019
2020    #[test]
2021    fn a_step_stops_at_a_track_still_arriving() {
2022        let state = SharedPlayerState::new();
2023        let a = ready_item("a");
2024        let b = PlaylistItem {
2025            state: ItemState::Pending,
2026            ..ready_item("b")
2027        };
2028        let c = ready_item("c");
2029        let (ida, idb) = (a.id, b.id);
2030        state.add_items(vec![a, b, c]);
2031
2032        let step = state.lookahead_after(ida).unwrap();
2033        assert_eq!(step.next, Some(idb));
2034        assert!(step.chosen.is_none(), "c is not queued over it");
2035
2036        state.update_item_state(idb, ItemState::Ready);
2037        assert!(
2038            state.still_follows(&step),
2039            "its landing is not an edit: the advance plays it"
2040        );
2041
2042        state.add_items(vec![ready_item("d")]);
2043        assert!(state.still_follows(&step), "nor is adding after it");
2044
2045        state.insert_items_after(vec![ready_item("next")], ida);
2046        assert!(!state.still_follows(&step), "a track put before it is");
2047    }
2048
2049    #[test]
2050    fn a_step_passes_over_failed_tracks() {
2051        let state = SharedPlayerState::new();
2052        let a = ready_item("a");
2053        let failed = PlaylistItem {
2054            state: ItemState::Failed("gone".into()),
2055            ..ready_item("failed")
2056        };
2057        let c = ready_item("c");
2058        let (ida, idc) = (a.id, c.id);
2059        state.add_items(vec![a, failed, c]);
2060
2061        let step = state.lookahead_after(ida).unwrap();
2062        assert_eq!(step.chosen.as_ref().map(|(id, _)| *id), Some(idc));
2063
2064        let another = PlaylistItem {
2065            state: ItemState::Failed("gone".into()),
2066            ..ready_item("another")
2067        };
2068        state.insert_items_after(vec![another], ida);
2069        assert!(
2070            state.still_follows(&step),
2071            "another failed track before it changes nothing"
2072        );
2073
2074        let arriving = PlaylistItem {
2075            state: ItemState::Pending,
2076            ..ready_item("arriving")
2077        };
2078        state.insert_items_after(vec![arriving], ida);
2079        assert!(!state.still_follows(&step), "a track still arriving does");
2080    }
2081
2082    #[test]
2083    fn a_step_breaks_when_what_it_chose_moves_ahead_of_it() {
2084        let state = SharedPlayerState::new();
2085        let (a, c) = (ready_item("a"), ready_item("c"));
2086        let (ida, idc) = (a.id, c.id);
2087        state.add_items(vec![a, c]);
2088        let step = state.lookahead_after(ida).unwrap();
2089        state.move_item(idc, ida, false);
2090        assert!(!state.still_follows(&step));
2091    }
2092
2093    #[test]
2094    fn a_step_to_the_end_of_the_queue_breaks_when_a_track_is_added() {
2095        let state = SharedPlayerState::new();
2096        let a = ready_item("a");
2097        let ida = a.id;
2098        state.add_items(vec![a]);
2099        let step = state.lookahead_after(ida).unwrap();
2100        assert!(step.chosen.is_none());
2101        assert!(state.still_follows(&step));
2102        state.add_items(vec![ready_item("b")]);
2103        assert!(!state.still_follows(&step));
2104    }
2105
2106    #[test]
2107    fn peek_after_a_removed_item_returns_none() {
2108        // The decode thread's lookahead runs seconds ahead of what is audible.
2109        // Removing the track it is pre-decoding must end the lookahead, not send
2110        // it back to the top of the queue.
2111        let state = SharedPlayerState::new();
2112        let item0 = ready_item("track-0");
2113        let item1 = ready_item("track-1");
2114        let item2 = ready_item("track-2");
2115        let (id0, id1, id2) = (item0.id, item1.id, item2.id);
2116
2117        state.add_items(vec![item0, item1, item2]);
2118        assert_eq!(chosen_after(&state, id1), Some(id2));
2119
2120        state.remove_item(id2);
2121        assert!(
2122            state.lookahead_after(id2).is_none(),
2123            "a vanished reference must not resolve to the head of the queue"
2124        );
2125        assert_ne!(chosen_after(&state, id2), Some(id0));
2126    }
2127
2128    #[test]
2129    fn the_lookahead_steps_over_a_track_that_failed() {
2130        let state = SharedPlayerState::new();
2131        let playing = ready_item("playing");
2132        let failed = failed_item("failed");
2133        let after = ready_item("after");
2134        let (playing_id, after_id) = (playing.id, after.id);
2135        state.add_items(vec![playing, failed, after]);
2136
2137        assert_eq!(chosen_after(&state, playing_id), Some(after_id));
2138    }
2139
2140    // --- surviving_item_before ---
2141
2142    fn repeating(state: &SharedPlayerState, repeat: Repeat) {
2143        state.set_play_mode(PlayMode {
2144            shuffle: false,
2145            repeat,
2146        });
2147    }
2148
2149    #[test]
2150    fn a_step_from_the_last_item_wraps_only_when_the_queue_repeats() {
2151        let state = SharedPlayerState::new();
2152        let items: Vec<_> = ["a", "b"].map(ready_item).into();
2153        let (a, b) = (items[0].id, items[1].id);
2154        state.add_items(items);
2155
2156        assert_eq!(state.lookahead_after(b).unwrap().next, None);
2157        repeating(&state, Repeat::Queue);
2158        let step = state.lookahead_after(b).unwrap();
2159        assert_eq!((step.next, step.wrapped), (Some(a), true));
2160        assert!(state.still_follows(&step));
2161
2162        let later = ready_item("later");
2163        state.add_items(vec![later.clone()]);
2164        assert!(!state.still_follows(&step), "something follows b now");
2165        state.remove_items(&[later.id]);
2166        repeating(&state, Repeat::Off);
2167        assert!(!state.still_follows(&step), "nor does repeat off wrap");
2168    }
2169
2170    #[test]
2171    fn repeating_one_steps_to_the_same_item_but_an_advance_moves_on() {
2172        let state = SharedPlayerState::new();
2173        let items: Vec<_> = ["a", "b"].map(ready_item).into();
2174        let (a, b) = (items[0].id, items[1].id);
2175        state.add_items(items);
2176        repeating(&state, Repeat::One);
2177
2178        let step = state.lookahead_after(a).unwrap();
2179        assert_eq!((step.next, step.wrapped), (Some(a), false));
2180        state.set_cursor(Some(a));
2181        assert_eq!(state.advance_cursor_loadable(), Some(b));
2182        assert_eq!(
2183            state.advance_cursor_loadable(),
2184            Some(a),
2185            "round, as repeating"
2186        );
2187    }
2188
2189    #[test]
2190    fn previous_from_the_first_item_wraps_only_while_repeating() {
2191        let state = SharedPlayerState::new();
2192        let items: Vec<_> = ["a", "b", "c"].map(ready_item).into();
2193        let (a, c) = (items[0].id, items[2].id);
2194        state.add_items(items);
2195
2196        state.set_cursor(Some(a));
2197        assert!(state.retreat_cursor().is_none());
2198        for repeat in [Repeat::Queue, Repeat::One] {
2199            repeating(&state, repeat);
2200            state.set_cursor(Some(a));
2201            assert_eq!(
2202                state.retreat_cursor().map(|(id, _)| id),
2203                Some(c),
2204                "{repeat:?}"
2205            );
2206        }
2207    }
2208
2209    #[test]
2210    fn a_removed_item_has_nothing_after_it_even_when_the_queue_repeats() {
2211        let state = SharedPlayerState::new();
2212        let items: Vec<_> = ["a", "b"].map(ready_item).into();
2213        let b = items[1].id;
2214        state.add_items(items);
2215        repeating(&state, Repeat::Queue);
2216
2217        state.set_cursor(Some(b));
2218        state.remove_item(b);
2219        assert!(state.lookahead_after(b).is_none());
2220        state.set_cursor(Some(b));
2221        assert_eq!(state.advance_cursor_loadable(), None);
2222    }
2223
2224    #[test]
2225    fn a_wrap_passes_over_failed_items() {
2226        let state = SharedPlayerState::new();
2227        let items = vec![failed_item("a"), ready_item("b"), ready_item("c")];
2228        let (b, c) = (items[1].id, items[2].id);
2229        state.add_items(items);
2230        repeating(&state, Repeat::Queue);
2231
2232        assert_eq!(state.lookahead_after(c).unwrap().next, Some(b));
2233    }
2234
2235    #[test]
2236    fn surviving_predecessor_skips_items_being_removed() {
2237        let state = SharedPlayerState::new();
2238        let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
2239        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
2240        state.add_items(items);
2241
2242        // Deleting 1..=3 leaves 0 as the resume point for a cursor on 3.
2243        assert_eq!(
2244            state.surviving_item_before(ids[3], &ids[1..4]),
2245            Some(ids[0])
2246        );
2247        // Deleting everything from the top leaves nothing to resume after.
2248        assert_eq!(state.surviving_item_before(ids[2], &ids), None);
2249    }
2250
2251    // --- retreat_cursor ---
2252
2253    #[test]
2254    fn test_retreat_cursor_goes_to_previous_item() {
2255        let state = SharedPlayerState::new();
2256        let item0 = ready_item("track-0");
2257        let item1 = ready_item("track-1");
2258        let id0 = item0.id;
2259        let id1 = item1.id;
2260
2261        state.add_items(vec![item0, item1]);
2262        state.set_cursor(Some(id1));
2263
2264        let result = state.retreat_cursor();
2265        assert!(result.is_some(), "expected to retreat to previous item");
2266        assert_eq!(result.unwrap().0, id0, "should retreat to first item");
2267        assert_eq!(state.cursor(), Some(id0));
2268    }
2269
2270    #[test]
2271    fn test_retreat_cursor_returns_none_when_at_first_item() {
2272        let state = SharedPlayerState::new();
2273        let item0 = ready_item("only-track");
2274        let id0 = item0.id;
2275
2276        state.add_items(vec![item0]);
2277        state.set_cursor(Some(id0));
2278
2279        let result = state.retreat_cursor();
2280        assert!(result.is_none(), "cannot retreat before the first item");
2281        // Cursor stays on the first item.
2282        assert_eq!(state.cursor(), Some(id0));
2283    }
2284
2285    #[test]
2286    fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
2287        let state = SharedPlayerState::new();
2288        state.add_items(vec![ready_item("track-0")]);
2289
2290        let result = state.retreat_cursor();
2291        assert!(
2292            result.is_none(),
2293            "retreat with no cursor should return None"
2294        );
2295    }
2296
2297    // --- derive_visible_queue ---
2298
2299    #[test]
2300    fn test_derive_visible_queue_statuses() {
2301        // playlist: [played, playing, queued]
2302        let state = SharedPlayerState::new();
2303        let item0 = ready_item("played-track");
2304        let item1 = ready_item("playing-track");
2305        let item2 = ready_item("queued-track");
2306        let id1 = item1.id;
2307
2308        state.add_items(vec![item0, item1, item2]);
2309        state.set_cursor(Some(id1));
2310
2311        let snap = state.derive_visible_queue();
2312
2313        assert_eq!(snap.entries.len(), 3);
2314        assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
2315        assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
2316        assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
2317        assert!(snap.has_playing);
2318        assert_eq!(snap.finished_count, 1);
2319        assert_eq!(snap.queue_count, 1);
2320    }
2321
2322    #[test]
2323    fn test_derive_visible_queue_downloading_statuses() {
2324        // Downloading reads the same at the cursor as after it: bytes are
2325        // moving, and the row has progress to draw.
2326        let state = SharedPlayerState::new();
2327        let (dl_cursor, _) = downloading_item(&state, "downloading-at-cursor", 1_000_000);
2328        let (dl_queued, _) = downloading_item(&state, "downloading-queued", 500_000);
2329        let id_cursor = dl_cursor.id;
2330
2331        state.add_items(vec![dl_cursor, dl_queued]);
2332        state.set_cursor(Some(id_cursor));
2333
2334        let snap = state.derive_visible_queue();
2335
2336        assert_eq!(snap.entries[0].status, QueueEntryStatus::Downloading);
2337        assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
2338    }
2339
2340    #[test]
2341    fn a_cursor_waiting_its_turn_is_priority_pending() {
2342        let state = SharedPlayerState::new();
2343        let item = pending_item("waiting");
2344        let id = item.id;
2345        state.add_items(vec![item]);
2346        state.set_cursor(Some(id));
2347
2348        let snap = state.derive_visible_queue();
2349        assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
2350    }
2351
2352    #[test]
2353    fn a_second_entry_for_a_track_reads_the_transfer_running_for_it() {
2354        // One transfer per track: the entry queued second has none of its own,
2355        // and streams and reports from the first one's.
2356        let state = SharedPlayerState::new();
2357        let (first, bytes) = downloading_item(&state, "twice", 1_000_000);
2358        bytes.set(STREAM_THRESHOLD);
2359        let mut again = pending_item("twice");
2360        again.db_id = first.db_id;
2361        let again_id = again.id;
2362        state.add_items(vec![first, again]);
2363
2364        assert!(matches!(
2365            state.item_load_state(again_id),
2366            Some(LoadState::Downloading { .. })
2367        ));
2368        assert!(matches!(
2369            state.item_playback_source(again_id),
2370            Some(PlaybackSource::Streaming { .. })
2371        ));
2372    }
2373
2374    /// The saved queue is rewritten when its contents move, and only then:
2375    /// a track change or a download landing is a position save.
2376    #[test]
2377    fn only_a_change_to_what_is_saved_moves_the_content_version() {
2378        let state = SharedPlayerState::new();
2379        let a = make_item("a", ItemState::Pending);
2380        let b = make_item("b", ItemState::Ready);
2381        let (a_id, b_id) = (a.id, b.id);
2382
2383        let start = state.content_version();
2384        state.add_items(vec![a, b]);
2385        let added = state.content_version();
2386        assert_ne!(added, start);
2387
2388        state.set_cursor(Some(a_id));
2389        state.update_item_state(a_id, ItemState::Ready);
2390        state.advance_cursor_loadable();
2391        state.retreat_cursor();
2392        assert_eq!(state.content_version(), added);
2393        assert_eq!(state.cursor_path(), Some(PathBuf::from("/music/a.flac")));
2394
2395        state.move_item_to(b_id, None);
2396        assert_ne!(state.content_version(), added);
2397    }
2398
2399    #[test]
2400    fn progress_follows_the_counter_without_touching_the_playlist() {
2401        // The download thread writes bytes and nothing else. A queue derived
2402        // afterwards must see them — the version has not moved, and the load
2403        // state it was given is the one it still holds.
2404        let state = SharedPlayerState::new();
2405        let (item, bytes) = downloading_item(&state, "downloading", 1_000);
2406        state.add_items(vec![item]);
2407
2408        let version = state.playlist_version();
2409        bytes.set(250);
2410
2411        let snap = state.derive_visible_queue();
2412        assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
2413        assert_eq!(
2414            state.playlist_version(),
2415            version,
2416            "progress must not read as a queue mutation"
2417        );
2418        assert_eq!(state.downloads().readings()[0].written, 250);
2419    }
2420
2421    #[test]
2422    fn test_derive_visible_queue_no_cursor_all_queued() {
2423        let state = SharedPlayerState::new();
2424        state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
2425
2426        let snap = state.derive_visible_queue();
2427
2428        assert_eq!(snap.entries.len(), 3);
2429        for entry in &snap.entries {
2430            assert_eq!(entry.status, QueueEntryStatus::Queued);
2431        }
2432        assert!(!snap.has_playing);
2433        assert_eq!(snap.finished_count, 0);
2434        assert_eq!(snap.queue_count, 3);
2435    }
2436
2437    // --- same_album_item_ids ---
2438
2439    fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
2440        PlaylistItem {
2441            playlist_entry_id: None,
2442            id: QueueItemId::new(),
2443            db_id: None,
2444            path: PathBuf::from(format!("/music/{title}.flac")),
2445            title: title.to_string(),
2446            artist: "Artist".to_string(),
2447            album_artist: album_artist.to_string(),
2448            album: album.to_string(),
2449            year: None,
2450            codec: Some("FLAC".to_string()),
2451            track_number: None,
2452            disc: None,
2453            duration_ms: Some(200_000),
2454            state: ItemState::Ready,
2455            pre_shuffle: None,
2456        }
2457    }
2458
2459    #[test]
2460    fn test_same_album_item_ids_returns_album_mates() {
2461        let state = SharedPlayerState::new();
2462        let a1 = make_album_item("A1", "Album A", "Artist A");
2463        let a2 = make_album_item("A2", "Album A", "Artist A");
2464        let b1 = make_album_item("B1", "Album B", "Artist B");
2465        let a3 = make_album_item("A3", "Album A", "Artist A");
2466
2467        let id_a1 = a1.id;
2468        let id_a2 = a2.id;
2469        let id_a3 = a3.id;
2470
2471        state.add_items(vec![a1, a2, b1, a3]);
2472
2473        let mates = state.same_album_item_ids(id_a1);
2474        assert_eq!(mates.len(), 2);
2475        assert!(mates.contains(&id_a2));
2476        assert!(mates.contains(&id_a3));
2477    }
2478
2479    #[test]
2480    fn test_same_album_item_ids_distinguishes_album_artists() {
2481        // Two albums named the same but by different artists — should NOT match.
2482        let state = SharedPlayerState::new();
2483        let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
2484        let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
2485
2486        let id_a1 = a1.id;
2487
2488        state.add_items(vec![a1, b1]);
2489
2490        let mates = state.same_album_item_ids(id_a1);
2491        assert!(mates.is_empty(), "different album_artist should not match");
2492    }
2493
2494    #[test]
2495    fn test_same_album_item_ids_unknown_id_returns_empty() {
2496        let state = SharedPlayerState::new();
2497        state.add_items(vec![ready_item("track-0")]);
2498
2499        let bogus = QueueItemId::new();
2500        let mates = state.same_album_item_ids(bogus);
2501        assert!(mates.is_empty());
2502    }
2503
2504    // --- update_item_metadata ---
2505
2506    #[test]
2507    fn test_update_item_metadata_leaves_library_tags_alone() {
2508        let state = SharedPlayerState::new();
2509        let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
2510        item.db_id = Some(29615);
2511        let id = item.id;
2512        state.add_items(vec![item]);
2513
2514        state.update_item_metadata(
2515            id,
2516            "[unknown]".into(),
2517            "Soulwax".into(),
2518            "Soulwax".into(),
2519            "Nite Versions".into(),
2520            Some(54_000),
2521        );
2522
2523        let pl = state.playlist.read();
2524        assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
2525        assert_eq!(pl.items[0].title, "A1");
2526        assert_eq!(pl.items[0].duration_ms, Some(54_000));
2527    }
2528
2529    #[test]
2530    fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
2531        let state = SharedPlayerState::new();
2532        let item = make_album_item("A1", "", "");
2533        let id = item.id;
2534        state.add_items(vec![item]);
2535
2536        state.update_item_metadata(
2537            id,
2538            "Teachers".into(),
2539            "Soulwax".into(),
2540            "Soulwax".into(),
2541            "Nite Versions".into(),
2542            Some(148_000),
2543        );
2544
2545        let pl = state.playlist.read();
2546        assert_eq!(pl.items[0].title, "Teachers");
2547        assert_eq!(pl.items[0].album, "Nite Versions");
2548        assert_eq!(pl.items[0].duration_ms, Some(148_000));
2549    }
2550
2551    // --- move_item_to ---
2552
2553    #[test]
2554    fn test_move_item_to_reorders_playlist() {
2555        // Start: [A, B, C]. Move C to after A → [A, C, B].
2556        let state = SharedPlayerState::new();
2557        let item_a = ready_item("A");
2558        let item_b = ready_item("B");
2559        let item_c = ready_item("C");
2560        let id_a = item_a.id;
2561        let id_b = item_b.id;
2562        let id_c = item_c.id;
2563
2564        state.add_items(vec![item_a, item_b, item_c]);
2565        state.move_item_to(id_c, Some(id_a));
2566
2567        let (items, _) = state.snapshot_playlist();
2568        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
2569        assert_eq!(titles, vec!["A", "C", "B"]);
2570        assert_eq!(items[0].id, id_a);
2571        assert_eq!(items[1].id, id_c);
2572        assert_eq!(items[2].id, id_b);
2573    }
2574
2575    #[test]
2576    fn test_move_item_to_front_when_after_is_none() {
2577        // Start: [A, B, C]. Move C to front (after=None) → [C, A, B].
2578        let state = SharedPlayerState::new();
2579        let item_a = ready_item("A");
2580        let item_b = ready_item("B");
2581        let item_c = ready_item("C");
2582        let id_c = item_c.id;
2583
2584        state.add_items(vec![item_a, item_b, item_c]);
2585        state.move_item_to(id_c, None);
2586
2587        let (items, _) = state.snapshot_playlist();
2588        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
2589        assert_eq!(titles, vec!["C", "A", "B"]);
2590    }
2591
2592    // --- move_items (batch) ---
2593
2594    #[test]
2595    fn test_move_items_batch_preserves_relative_order() {
2596        // Start: [A, B, C, D]. Move [A, C] after D → [B, D, A, C].
2597        let state = SharedPlayerState::new();
2598        let item_a = ready_item("A");
2599        let item_b = ready_item("B");
2600        let item_c = ready_item("C");
2601        let item_d = ready_item("D");
2602        let id_a = item_a.id;
2603        let id_b = item_b.id;
2604        let id_c = item_c.id;
2605        let id_d = item_d.id;
2606
2607        state.add_items(vec![item_a, item_b, item_c, item_d]);
2608        state.move_items(&[id_a, id_c], id_d, true);
2609
2610        let (items, _) = state.snapshot_playlist();
2611        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
2612        assert_eq!(titles, vec!["B", "D", "A", "C"]);
2613        assert_eq!(items[0].id, id_b);
2614        assert_eq!(items[1].id, id_d);
2615        assert_eq!(items[2].id, id_a);
2616        assert_eq!(items[3].id, id_c);
2617    }
2618
2619    // --- pending_downloads ---
2620
2621    #[test]
2622    fn test_pending_downloads_collects_pending_with_db_id() {
2623        let state = SharedPlayerState::new();
2624        let mut item_a = ready_item("local");
2625        item_a.db_id = None;
2626
2627        let mut item_b = pending_item("remote-1");
2628        item_b.db_id = Some(10);
2629        let id_b = item_b.id;
2630
2631        let mut item_c = ready_item("cached");
2632        item_c.db_id = Some(20);
2633
2634        let mut item_d = pending_item("remote-2");
2635        item_d.db_id = Some(30);
2636        let id_d = item_d.id;
2637
2638        // Pending without db_id — should NOT appear (no way to download).
2639        let item_e = pending_item("orphan");
2640
2641        state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
2642
2643        let pending = state.pending_downloads();
2644        assert_eq!(pending.len(), 2);
2645        assert_eq!(pending[0], (10, id_b));
2646        assert_eq!(pending[1], (30, id_d));
2647
2648        // From the cursor on, then from the top: playing the fourth, the
2649        // tracks before it come last.
2650        state.set_cursor(Some(id_d));
2651        assert_eq!(state.pending_downloads(), vec![(30, id_d), (10, id_b)]);
2652    }
2653
2654    #[test]
2655    fn test_item_db_id_and_load_state() {
2656        let state = SharedPlayerState::new();
2657        let mut item = pending_item("track");
2658        item.db_id = Some(42);
2659        let id = item.id;
2660        state.add_items(vec![item]);
2661
2662        assert_eq!(state.item_db_id(id), Some(42));
2663        assert!(matches!(
2664            state.item_load_state(id),
2665            Some(LoadState::Pending)
2666        ));
2667
2668        state.update_item_state(id, ItemState::Ready);
2669        assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
2670    }
2671}