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    /// The pass of the queue `after` is in, and the one `next` is in: one
54    /// more once a step has gone round. A row can be in this pass and the
55    /// next at once, so its id alone cannot say which a step is from.
56    pub after_pass: u64,
57    pub pass: u64,
58}
59
60/// What follows a track once it ends, beyond the queue's own order.
61#[derive(
62    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
63)]
64#[serde(rename_all = "lowercase")]
65pub enum Repeat {
66    /// The queue ends at its last item.
67    #[default]
68    Off,
69    /// The last item runs on into the first.
70    Queue,
71    /// The item plays again. An explicit next or previous still moves on.
72    One,
73}
74
75impl Repeat {
76    pub fn is_off(&self) -> bool {
77        *self == Repeat::Off
78    }
79
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Repeat::Off => "off",
83            Repeat::Queue => "queue",
84            Repeat::One => "one",
85        }
86    }
87
88    pub fn parse(s: &str) -> Option<Self> {
89        match s {
90            "off" => Some(Repeat::Off),
91            "queue" => Some(Repeat::Queue),
92            "one" => Some(Repeat::One),
93            _ => None,
94        }
95    }
96
97    /// The next mode a single repeat button steps to: off, the queue, one.
98    pub fn cycled(self) -> Self {
99        match self {
100            Repeat::Off => Repeat::Queue,
101            Repeat::Queue => Repeat::One,
102            Repeat::One => Repeat::Off,
103        }
104    }
105}
106
107/// The transport's play mode. Shuffle never moves the queue: it plays the
108/// queue in an order kept beside it (`PlayOrder`), and repeat says what
109/// follows the last track of that order or of the queue.
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
111pub struct PlayMode {
112    pub shuffle: bool,
113    pub repeat: Repeat,
114}
115
116impl PlayMode {
117    fn to_bits(self) -> u8 {
118        let repeat = match self.repeat {
119            Repeat::Off => 0,
120            Repeat::Queue => 1,
121            Repeat::One => 2,
122        };
123        repeat << 1 | self.shuffle as u8
124    }
125
126    fn from_bits(bits: u8) -> Self {
127        Self {
128            shuffle: bits & 1 != 0,
129            repeat: match bits >> 1 {
130                1 => Repeat::Queue,
131                2 => Repeat::One,
132                _ => Repeat::Off,
133            },
134        }
135    }
136}
137
138/// What replacing the queue does to the play mode. Something played from
139/// its play button — a record, an artist, a playlist, a selection — starts
140/// as asked, in order or shuffled, with repeat off, whatever the modes were.
141/// A queue restored, synced or handed over keeps them.
142#[derive(
143    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
144)]
145#[serde(rename_all = "camelCase")]
146pub enum QueueMode {
147    #[default]
148    Keep,
149    InOrder,
150    Shuffled,
151}
152
153impl QueueMode {
154    pub fn is_keep(&self) -> bool {
155        *self == QueueMode::Keep
156    }
157
158    /// The mode a queue started this way plays in, or `None` to keep it.
159    pub fn play_mode(self) -> Option<PlayMode> {
160        let shuffle = match self {
161            QueueMode::Keep => return None,
162            QueueMode::InOrder => false,
163            QueueMode::Shuffled => true,
164        };
165        Some(PlayMode {
166            shuffle,
167            repeat: Repeat::Off,
168        })
169    }
170}
171
172/// A sleep timer as asked for: stop after a while, or at the end of the
173/// track or record playing. It pauses, fading out, and leaves the queue as it
174/// was, so playing again carries on from there.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176#[serde(tag = "kind", rename_all = "camelCase")]
177pub enum SleepTimer {
178    After { minutes: u32 },
179    EndOfTrack,
180    EndOfRecord,
181}
182
183/// A sleep timer that is set, as clients show it. A time rather than what
184/// is left of one, so it says the same thing for as long as it stands.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
186#[serde(tag = "kind", rename_all = "camelCase")]
187pub enum Sleep {
188    /// Milliseconds since the Unix epoch.
189    At {
190        unix_ms: u64,
191    },
192    EndOfTrack,
193    EndOfRecord,
194}
195
196/// What identifies a track for shuffle. Rows sharing one are one track, which
197/// a pass plays once: a queue holding a track twice does not give it two
198/// chances.
199#[derive(Clone, Copy, PartialEq, Eq, Hash)]
200enum TrackKey<'a> {
201    Library(i64),
202    File(&'a std::path::Path),
203}
204
205impl PlaylistItem {
206    fn key(&self) -> TrackKey<'_> {
207        match self.db_id {
208            Some(id) => TrackKey::Library(id),
209            None => TrackKey::File(&self.path),
210        }
211    }
212
213    fn playable(&self) -> bool {
214        !matches!(self.state, ItemState::Failed(_))
215    }
216}
217
218/// The order shuffle plays the queue in, held beside it and never shown as
219/// its order. Present while shuffle is on.
220///
221/// A pass plays every track in the queue once. `upcoming` is what is left of
222/// this one; it never holds a played row, the cursor's track, or two rows of
223/// one track. Rows queued while shuffle is on take random places in it, and
224/// rows removed leave it — `Playlist::reconcile` keeps it so after every
225/// edit. What plays next is `upcoming`'s first playable row, so the
226/// decoder's lookahead and the advance read one order and make one pick.
227#[derive(Debug, Clone, Default)]
228pub struct PlayOrder {
229    upcoming: Vec<QueueItemId>,
230    /// The order of the pass after this one, made when this one has nothing
231    /// left and the queue repeats. Kept rather than drawn at each look, so a
232    /// lookahead taken across the end of a pass is the pass that plays.
233    next_pass: Option<Vec<QueueItemId>>,
234    /// The rows the cursor has been on, oldest first: what Previous goes
235    /// back along.
236    history: Vec<QueueItemId>,
237}
238
239/// Put `fresh` into `order` at random places, leaving the order of what is
240/// there alone.
241fn scatter(order: &mut Vec<QueueItemId>, mut fresh: Vec<QueueItemId>) {
242    if fresh.is_empty() {
243        return;
244    }
245    crate::helpers::shuffle(&mut fresh);
246    let Some(mut rng) = crate::helpers::Rng::seeded() else {
247        order.extend(fresh);
248        return;
249    };
250    let old = std::mem::take(order);
251    let (mut left, mut right) = (old.len(), fresh.len());
252    let (mut old, mut fresh) = (old.into_iter(), fresh.into_iter());
253    order.reserve(left + right);
254    while left + right > 0 {
255        if rng.below(left + right) < left {
256            order.extend(old.next());
257            left -= 1;
258        } else {
259            order.extend(fresh.next());
260            right -= 1;
261        }
262    }
263}
264
265/// What follows `after` under `repeat`: `Some(None)` at the end of the queue,
266/// `None` when `after` is not in it — a removed item has nothing following it,
267/// whatever the mode, since wrapping from it would replay the queue from a
268/// place nobody is at. The flag says the step went back to the top.
269///
270/// Failed items are passed over. Repeating one item plays it again unless it
271/// has failed, in which case the queue carries on as it would when repeating
272/// the queue.
273fn follows(
274    items: &[PlaylistItem],
275    after: QueueItemId,
276    repeat: Repeat,
277) -> Option<(Option<&PlaylistItem>, bool)> {
278    let at = items.iter().position(|item| item.id == after)?;
279    let playable = |item: &&PlaylistItem| !matches!(item.state, ItemState::Failed(_));
280    if repeat == Repeat::One && playable(&&items[at]) {
281        return Some((Some(&items[at]), false));
282    }
283    if let Some(next) = items[at + 1..].iter().find(playable) {
284        return Some((Some(next), false));
285    }
286    if repeat == Repeat::Off {
287        return Some((None, false));
288    }
289    Some((items[..=at].iter().find(playable), true))
290}
291
292/// Set in `SharedPlayerState::state` beside the playback state while the
293/// player waits for a track.
294const WAITING: u8 = 0x80;
295
296/// Playback state.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298#[repr(u8)]
299pub enum PlaybackState {
300    Stopped = 0,
301    Playing = 1,
302    Paused = 2,
303}
304
305impl PlaybackState {
306    pub fn from_u8(v: u8) -> Self {
307        match v {
308            1 => Self::Playing,
309            2 => Self::Paused,
310            _ => Self::Stopped,
311        }
312    }
313}
314
315/// Audio format info for the currently playing track.
316#[derive(Debug, Clone, PartialEq)]
317pub struct TrackInfo {
318    pub id: QueueItemId,
319    pub path: PathBuf,
320    pub codec: String,
321    pub sample_rate: u32,
322    pub bit_depth: Option<u16>,
323    pub bitrate_kbps: Option<u32>,
324    pub channels: u16,
325    pub duration_ms: u64,
326}
327
328// --- Playlist data model ---
329
330/// Minimum bytes written before streaming playback can begin.
331pub const STREAM_THRESHOLD: u64 = 256 * 1024;
332
333/// Held back from the seekable extent of a downloading track.
334///
335/// Bytes are converted to time at the average bitrate, so on VBR the estimate
336/// wanders either side of the truth; landing short of the write head costs a
337/// couple of seconds of reach and landing past it costs a stall.
338pub const SEEK_SAFETY_MS: u64 = 2_000;
339
340/// What a playlist item can say about itself.
341///
342/// Only what is true of the item regardless of any transfer: whether the bytes
343/// at its path can be played. Whether one is *arriving* is the download store's
344/// business, and asking the item would mean two accounts of one fact that have
345/// to be kept in step. Read [`LoadState`] for the two
346/// together.
347///
348/// Once a transfer ends, this is written before anything is woken: a reader
349/// waiting on the transfer learns how it ended from here.
350#[derive(Debug, Clone, Default, PartialEq, Eq)]
351pub enum ItemState {
352    /// Nothing has resolved this yet.
353    #[default]
354    Pending,
355    /// The file at `path` is there and playable.
356    Ready,
357    /// It cannot be made playable, and this is why. Not only download
358    /// failures: a track with no local file and no remote copy fails here
359    /// without a transfer ever being attempted.
360    Failed(String),
361}
362
363/// An item's state, and any transfer against it, as one answer.
364///
365/// Derived rather than stored. `Downloading` carries the store's own figures —
366/// the very same counter the downloader writes — so there is nothing to copy
367/// and nothing that can drift.
368#[derive(Debug, Clone)]
369pub enum LoadState {
370    Pending,
371    Downloading {
372        /// Where the bytes are going: the in-progress `.part` file, not the
373        /// destination it is renamed to at the end.
374        path: PathBuf,
375        /// Total bytes expected, or 0 when the server sent no Content-Length.
376        total: u64,
377        /// How many bytes have landed. The download thread writes it per chunk
378        /// without taking any lock the player holds.
379        bytes_written: Arc<ByteFeed>,
380    },
381    Ready,
382    Failed(String),
383}
384
385impl LoadState {
386    /// An item's state, with whatever the download store says about it.
387    ///
388    /// The item's own state stands once it is anything but `Pending`: a
389    /// transfer's end is written to every entry waiting on it before the
390    /// transfer itself is marked settled. While it is pending, a listed
391    /// transfer for its track is what is happening to it — whichever entry
392    /// that transfer was started for.
393    pub fn of(item: &PlaylistItem, downloads: &DownloadStore) -> Self {
394        match &item.state {
395            ItemState::Ready => Self::Ready,
396            ItemState::Failed(reason) => Self::Failed(reason.clone()),
397            ItemState::Pending => match item.db_id.and_then(|id| downloads.live(id)) {
398                Some(live) => Self::Downloading {
399                    path: live.source,
400                    total: live.total,
401                    bytes_written: live.written,
402                },
403                None => Self::Pending,
404            },
405        }
406    }
407}
408
409/// Resolved playback source for a playlist item.
410pub enum PlaybackSource {
411    /// File fully downloaded — play from path.
412    Ready(PathBuf),
413    /// File being downloaded — enough data buffered to start streaming.
414    Streaming {
415        path: PathBuf,
416        bytes_written: Arc<crate::remote::downloads::ByteFeed>,
417        total: u64,
418    },
419}
420
421/// A single item in the playlist. Created once when tracks are added to the playlist.
422#[derive(Debug, Clone)]
423pub struct PlaylistItem {
424    pub id: QueueItemId,
425    /// Database track ID — set for tracks loaded from DB, used for downloads.
426    pub db_id: Option<i64>,
427    /// The playlist entry this came from, when it came from a playlist.
428    ///
429    /// A playlist may hold the same track twice, and two copies are two queue
430    /// items. Without this a playlist row can only ask "is my *track* playing?"
431    /// and both copies answer yes. The entry id is the one thing that tells
432    /// them apart, so the queue carries it.
433    pub playlist_entry_id: Option<i64>,
434    pub path: PathBuf,
435    pub title: String,
436    pub artist: String,
437    pub album_artist: String,
438    pub album: String,
439    pub year: Option<String>,
440    pub codec: Option<String>,
441    pub track_number: Option<i64>,
442    pub disc: Option<i64>,
443    pub duration_ms: Option<u64>,
444    /// What the item can say about itself. Ask [`SharedPlayerState::load_state`]
445    /// for this together with any transfer against it.
446    pub state: ItemState,
447    /// The item has played in this pass of the queue: set when the
448    /// playhead reaches it, cleared when a repeating queue starts over. A
449    /// fact about the row, not its place relative to the cursor, so a queue
450    /// played out of order — shuffled, or jumped about in — shows what was
451    /// heard.
452    pub played: bool,
453}
454
455/// The playlist — one flat array, one cursor, and shuffle's order beside it.
456#[derive(Debug, Clone, Default)]
457pub struct Playlist {
458    pub items: Vec<PlaylistItem>,
459    pub cursor: Option<QueueItemId>,
460    pub order: Option<PlayOrder>,
461    /// Counts the times the queue has started over.
462    pub pass: u64,
463}
464
465impl Playlist {
466    fn find(&self, id: QueueItemId) -> Option<&PlaylistItem> {
467        self.items.iter().find(|item| item.id == id)
468    }
469
470    /// Bring shuffle's order up to date with the queue: rows gone or played
471    /// leave it, and rows that could play but are not in it — queued since,
472    /// or a duplicate whose twin was removed — take random places in it.
473    fn reconcile(&mut self) {
474        let Playlist {
475            items,
476            cursor,
477            order,
478            ..
479        } = self;
480        let Some(order) = order.as_mut() else {
481            return;
482        };
483        let rows: HashMap<QueueItemId, &PlaylistItem> =
484            items.iter().map(|item| (item.id, item)).collect();
485
486        let mut seen: std::collections::HashSet<TrackKey> = items
487            .iter()
488            .filter(|item| item.played)
489            .map(PlaylistItem::key)
490            .chain(cursor.and_then(|c| rows.get(&c)).map(|item| item.key()))
491            .collect();
492        // A row stays in, or joins, an order when it can play, has not
493        // played (this pass's order only), and its track is not in it yet.
494        fn keep<'a>(
495            seen: &mut std::collections::HashSet<TrackKey<'a>>,
496            item: &'a PlaylistItem,
497            unplayed: bool,
498        ) -> bool {
499            (!unplayed || !item.played) && item.playable() && seen.insert(item.key())
500        }
501        order
502            .upcoming
503            .retain(|id| rows.get(id).is_some_and(|item| keep(&mut seen, item, true)));
504        let fresh = items
505            .iter()
506            .filter(|item| keep(&mut seen, item, true))
507            .map(|item| item.id)
508            .collect();
509        scatter(&mut order.upcoming, fresh);
510
511        if let Some(next) = order.next_pass.as_mut() {
512            let mut seen = std::collections::HashSet::new();
513            next.retain(|id| {
514                rows.get(id)
515                    .is_some_and(|item| keep(&mut seen, item, false))
516            });
517            let fresh = items
518                .iter()
519                .filter(|item| keep(&mut seen, item, false))
520                .map(|item| item.id)
521                .collect();
522            scatter(next, fresh);
523        }
524
525        order.history.retain(|id| rows.contains_key(id));
526    }
527
528    /// Every track in the queue once, in a random order: a pass. Not opening
529    /// on `last`'s track when there is another, so the turn of a pass does
530    /// not play one track twice running.
531    fn new_pass(&self, last: Option<QueueItemId>) -> Vec<QueueItemId> {
532        let mut seen = std::collections::HashSet::new();
533        let mut pass: Vec<QueueItemId> = self
534            .items
535            .iter()
536            .filter(|item| item.playable() && seen.insert(item.key()))
537            .map(|item| item.id)
538            .collect();
539        crate::helpers::shuffle(&mut pass);
540        let last = last.and_then(|id| self.find(id)).map(PlaylistItem::key);
541        if pass.len() > 1 && last.is_some() && self.find(pass[0]).map(PlaylistItem::key) == last {
542            let other =
543                crate::helpers::Rng::seeded().map_or(1, |mut rng| 1 + rng.below(pass.len() - 1));
544            pass.swap(0, other);
545        }
546        pass
547    }
548
549    /// What plays after `after` — after nothing, with `None` — under
550    /// `repeat`, and whether getting there starts the queue over.
551    /// `Some(None)` at the end; `None` when `after` is no longer queued,
552    /// since starting over from a row nobody is at would replay the queue.
553    ///
554    /// In order, that is `follows`. Shuffled, it is the next playable row of
555    /// the play order, and once this pass is spent and the queue repeats,
556    /// the next pass's, which is drawn here the first time it is needed.
557    ///
558    /// `ahead` says `after` is a step the lookahead took into the next pass,
559    /// which is searched from it rather than from the top.
560    fn follows(
561        &mut self,
562        after: Option<QueueItemId>,
563        ahead: bool,
564        repeat: Repeat,
565    ) -> Option<(Option<QueueItemId>, bool)> {
566        let Some(order) = self.order.as_ref() else {
567            return match after {
568                Some(after) => follows(&self.items, after, repeat)
569                    .map(|(next, wrapped)| (next.map(|item| item.id), wrapped)),
570                None => Some((
571                    self.items
572                        .iter()
573                        .find(|item| item.playable())
574                        .map(|item| item.id),
575                    false,
576                )),
577            };
578        };
579        if let Some(after) = after {
580            let item = self.find(after)?;
581            if repeat == Repeat::One && item.playable() {
582                return Some((Some(after), false));
583            }
584        }
585        let playable = |id: &&QueueItemId| self.find(**id).is_some_and(PlaylistItem::playable);
586        let from = |order: &[QueueItemId]| {
587            after
588                .and_then(|after| order.iter().position(|id| *id == after))
589                .map_or(0, |at| at + 1)
590        };
591        if ahead {
592            let Some(next) = order.next_pass.as_ref() else {
593                return Some((None, false));
594            };
595            let at = from(next);
596            let found = next[at..].iter().chain(&next[..at]).find(playable).copied();
597            return Some((found, true));
598        }
599        let upcoming = &order.upcoming;
600        if let Some(next) = upcoming[from(upcoming)..].iter().find(playable) {
601            return Some((Some(*next), false));
602        }
603        if repeat == Repeat::Off {
604            return Some((None, false));
605        }
606        if order.next_pass.is_none() {
607            let pass = self.new_pass(after.or(self.cursor));
608            self.order.as_mut().expect("checked above").next_pass = Some(pass);
609        }
610        let next = self.order.as_ref()?.next_pass.as_ref()?;
611        let playable = |id: &&QueueItemId| self.find(**id).is_some_and(PlaylistItem::playable);
612        Some((next.iter().find(playable).copied(), true))
613    }
614
615    /// Put the cursor on `id`. Shuffled, its track leaves what is still to
616    /// play this pass, and the row goes on the history Previous walks back.
617    fn place_cursor(&mut self, id: Option<QueueItemId>) {
618        let Playlist {
619            items,
620            cursor,
621            order,
622            ..
623        } = self;
624        *cursor = id;
625        let Some(id) = id else { return };
626        let Some(key) = items
627            .iter()
628            .find(|item| item.id == id)
629            .map(PlaylistItem::key)
630        else {
631            return;
632        };
633        let Some(order) = order.as_mut() else { return };
634        let keys: HashMap<QueueItemId, TrackKey> =
635            items.iter().map(|item| (item.id, item.key())).collect();
636        order.upcoming.retain(|row| keys.get(row) != Some(&key));
637        if order.history.last() != Some(&id) {
638            order.history.push(id);
639        }
640    }
641
642    /// The cursor moves on to `id`, the row that follows it: at the end of a
643    /// track, or by Next. Getting there by starting the queue over begins a
644    /// new pass — nothing has played in it yet, and shuffled, the next
645    /// pass's order becomes what is left to play.
646    ///
647    /// `pass` is the pass the step that chose `id` put it in, when known.
648    /// Without it, a move one step on from the cursor is read: a row of the
649    /// next pass is chosen only once nothing in this one can play.
650    fn move_on(&mut self, id: QueueItemId, pass: Option<u64>) {
651        let wrapped = match (&self.order, pass) {
652            (_, Some(pass)) => pass > self.pass,
653            (Some(order), None) => {
654                self.cursor != Some(id)
655                    && !order.upcoming.contains(&id)
656                    && order
657                        .next_pass
658                        .as_ref()
659                        .is_some_and(|pass| pass.contains(&id))
660            }
661            (None, None) => {
662                let at = |id| self.items.iter().position(|item| item.id == id);
663                matches!((self.cursor.and_then(at), at(id)), (Some(from), Some(to)) if to < from)
664            }
665        };
666        if wrapped {
667            for item in &mut self.items {
668                item.played = false;
669            }
670            self.pass += 1;
671            if let Some(order) = self.order.as_mut() {
672                order.upcoming = order.next_pass.take().unwrap_or_default();
673            }
674        }
675        self.place_cursor(Some(id));
676    }
677
678    /// Start a new pass when every row has played: a queue played out, then
679    /// played again from a row someone picked, or shuffled once it has.
680    /// Without this, a shuffled queue with nothing left unplayed would play
681    /// the one row and stop. Says whether it did.
682    ///
683    /// Only for what the listener asks for. A restore, a hand-off or a
684    /// track repeating sets the cursor too, and must leave the pass as it is.
685    fn start_over_if_spent(&mut self) -> bool {
686        let mut playable = self.items.iter().filter(|item| item.playable()).peekable();
687        if playable.peek().is_none() || !playable.all(|item| item.played) {
688            return false;
689        }
690        for item in &mut self.items {
691            item.played = false;
692        }
693        self.pass += 1;
694        if let Some(order) = self.order.as_mut() {
695            order.upcoming.clear();
696            order.next_pass = None;
697        }
698        self.reconcile();
699        true
700    }
701}
702
703// --- UI view types ---
704
705/// Status of a track in the queue — for UI display.
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub enum QueueEntryStatus {
708    Queued,
709    Playing,
710    Played,
711    Downloading,
712    /// User double-clicked — this track is priority, will play when ready.
713    PriorityPending,
714    Failed,
715}
716
717impl QueueEntryStatus {
718    /// The item under the cursor. Every front end maps it through here, so
719    /// the transport and the queue row agree on what it is doing.
720    ///
721    /// `PriorityPending` is waiting its turn with no bytes moving; once its
722    /// transfer starts it is `Downloading`, with progress to draw.
723    pub fn at_cursor(state: &ItemState, transferring: bool) -> Self {
724        match state {
725            ItemState::Ready => Self::Playing,
726            ItemState::Failed(_) => Self::Failed,
727            ItemState::Pending if transferring => Self::Downloading,
728            ItemState::Pending => Self::PriorityPending,
729        }
730    }
731}
732
733/// A single entry in the UI-visible queue snapshot.
734#[derive(Debug, Clone)]
735pub struct QueueEntry {
736    pub id: QueueItemId,
737    /// Database track ID — set for tracks loaded from DB, used for downloads.
738    pub db_id: Option<i64>,
739    /// The playlist row this came from — see `PlaylistItem::playlist_entry_id`.
740    pub playlist_entry_id: Option<i64>,
741    pub path: PathBuf,
742    pub title: String,
743    pub artist: String,
744    pub album_artist: String,
745    pub album: String,
746    pub year: Option<String>,
747    pub codec: Option<String>,
748    pub track_number: Option<i64>,
749    pub disc: Option<i64>,
750    pub duration_ms: Option<u64>,
751    pub status: QueueEntryStatus,
752    pub download_progress: Option<(u64, u64)>,
753    /// Why this entry cannot play, when `status` is `Failed`.
754    pub error: Option<String>,
755}
756
757/// Pre-built visible queue — single atomic snapshot for the UI.
758#[derive(Debug, Clone, Default)]
759pub struct VisibleQueueSnapshot {
760    pub entries: Vec<QueueEntry>,
761    pub finished_count: usize,
762    pub has_playing: bool,
763    pub queue_count: usize,
764}
765
766/// The part of a visible queue row that moves while the queue stands still.
767/// See `SharedPlayerState::queue_readings`.
768#[derive(Debug, Clone, PartialEq)]
769pub struct QueueReading {
770    pub id: QueueItemId,
771    pub db_id: Option<i64>,
772    pub status: QueueEntryStatus,
773    pub duration_ms: Option<u64>,
774    pub download_progress: Option<(u64, u64)>,
775    pub error: Option<String>,
776}
777
778/// Shared player state — atomics for lock-free reads from UI thread.
779///
780/// The engine writes these, the UI reads them. No mutexes in the hot path.
781#[derive(Debug)]
782pub struct SharedPlayerState {
783    /// The playback state, with `WAITING` set while the player waits for a
784    /// track it was asked for. One atomic, so no reader sees one updated
785    /// without the other.
786    state: AtomicU8,
787    /// Where a session starts, and where a stopped one stands. While one is
788    /// running the playhead is read off the timeline instead.
789    position_ms: AtomicU64,
790    timeline: std::sync::OnceLock<Arc<crate::audio::buffer::PlaybackTimeline>>,
791    track_info: parking_lot::RwLock<Option<TrackInfo>>,
792
793    /// The playlist and its cursor, under one lock.
794    playlist: parking_lot::RwLock<Playlist>,
795
796    /// Bumped on every playlist mutation so UI can skip redundant redraws.
797    playlist_version: AtomicU64,
798
799    /// Bumped only when what a saved session holds changes: the items and
800    /// their metadata, not the cursor or load states. What decides whether
801    /// the saved queue has to be written again.
802    content_version: AtomicU64,
803
804    /// Bumped when the set of items still waiting for a file may have
805    /// changed: items added or removed, or one put back to `Pending`. What the
806    /// download queue follows; a download landing or the cursor moving does
807    /// not move it.
808    pending_version: AtomicU64,
809
810    /// Bumped when an item is marked played or a pass clears the marks: a
811    /// change to what a saved session holds that clients read as a status
812    /// change, not an edit. See `saved_version`.
813    played_version: AtomicU64,
814
815    /// Every transfer this player's items are fetched by.
816    downloads: Arc<DownloadStore>,
817
818    /// Set by external signals (e.g. souvlaki Quit event) to request clean shutdown.
819    quit_requested: AtomicBool,
820
821    /// Set when metadata has been refreshed (e.g. download completed while streaming).
822    /// The UI loop checks this to force a souvlaki/cover-art update without a track change.
823    metadata_refresh_pending: AtomicBool,
824
825    /// The rate the output device settled at for the current track, or 0 when
826    /// nothing has played yet. Compared against the source rate, it is the one
827    /// thing koan can say for certain about the path to the DAC: whether it
828    /// handed the device the samples as they are, or something had to resample
829    /// to reach it. Everything past that — other clients, the volume stage — is
830    /// the system's, and not ours to claim.
831    output_sample_rate: AtomicU64,
832
833    /// What DSP is doing to the current session's audio. `None` is the
834    /// bit-perfect path.
835    dsp: parking_lot::RwLock<Option<crate::audio::dsp::DspStatus>>,
836    /// The playhead of a renderer this koan is playing to, which keeps its
837    /// own clock: where it was last heard to be, and since when it has been
838    /// running from there. Read in place of the timeline while set.
839    renderer_clock: parking_lot::Mutex<Option<RendererClock>>,
840
841    /// The UPnP renderer playing in place of the local output, if one is.
842    renderer: parking_lot::RwLock<Option<crate::upnp::Output>>,
843
844    /// The play mode, as `PlayMode::to_bits`. Written by the player's
845    /// `publish` alone; the queue's own reads (the lookahead, advancing)
846    /// follow it.
847    play_mode: AtomicU8,
848
849    /// The sleep timer, while one is set. Written by the player's `publish`.
850    sleep: parking_lot::RwLock<Option<Sleep>>,
851    /// The sleep timer is fading playback out.
852    sleep_fading: AtomicBool,
853}
854
855/// A renderer's playhead: `position_ms`, plus the time since `running` if it
856/// is playing.
857#[derive(Debug, Clone, Copy, PartialEq)]
858pub struct RendererClock {
859    pub position_ms: u64,
860    pub running: Option<std::time::Instant>,
861}
862
863impl RendererClock {
864    pub fn now_ms(&self) -> u64 {
865        self.position_ms + self.running.map_or(0, |at| at.elapsed().as_millis() as u64)
866    }
867}
868
869impl SharedPlayerState {
870    pub fn new() -> Arc<Self> {
871        Arc::new(Self {
872            state: AtomicU8::new(PlaybackState::Stopped as u8),
873            position_ms: AtomicU64::new(0),
874            timeline: std::sync::OnceLock::new(),
875            track_info: parking_lot::RwLock::new(None),
876            playlist: parking_lot::RwLock::new(Playlist::default()),
877            playlist_version: AtomicU64::new(0),
878            content_version: AtomicU64::new(0),
879            pending_version: AtomicU64::new(0),
880            played_version: AtomicU64::new(0),
881            downloads: DownloadStore::new(),
882            quit_requested: AtomicBool::new(false),
883            metadata_refresh_pending: AtomicBool::new(false),
884            output_sample_rate: AtomicU64::new(0),
885            dsp: parking_lot::RwLock::new(None),
886            renderer_clock: parking_lot::Mutex::new(None),
887            renderer: parking_lot::RwLock::new(None),
888            play_mode: AtomicU8::new(0),
889            sleep: parking_lot::RwLock::new(None),
890            sleep_fading: AtomicBool::new(false),
891        })
892    }
893
894    // --- Playback state ---
895
896    fn transport(&self) -> (PlaybackState, bool) {
897        let bits = self.state.load(Ordering::Acquire);
898        (PlaybackState::from_u8(bits & !WAITING), bits & WAITING != 0)
899    }
900
901    pub fn playback_state(&self) -> PlaybackState {
902        self.transport().0
903    }
904
905    pub fn set_playback_state(&self, state: PlaybackState) {
906        self.set_transport(state, false);
907    }
908
909    /// Whether the player is waiting for a track it was asked for to arrive.
910    /// Stopped while waiting, the track opens playing; paused, it opens paused.
911    pub fn is_waiting(&self) -> bool {
912        self.transport().1
913    }
914
915    /// Playing, or waiting for a track that will open playing: what a
916    /// play/pause toggle pauses.
917    pub fn wants_to_play(&self) -> bool {
918        matches!(
919            self.transport(),
920            (PlaybackState::Playing, _) | (PlaybackState::Stopped, true)
921        )
922    }
923
924    /// Nothing loaded and nothing waited for: where adding tracks starts them.
925    pub fn is_idle(&self) -> bool {
926        self.transport() == (PlaybackState::Stopped, false)
927    }
928
929    /// Publish the playback state and the wait together.
930    pub fn set_transport(&self, state: PlaybackState, waiting: bool) {
931        let bits = state as u8 | if waiting { WAITING } else { 0 };
932        if self.state.swap(bits, Ordering::AcqRel) != bits {
933            self.changed();
934        }
935    }
936
937    /// Where the playhead is, read off the samples the output has played, so
938    /// it is right whenever it is asked and nothing has to keep it up to date.
939    pub fn position_ms(&self) -> u64 {
940        let clock = *self.renderer_clock.lock();
941        if let Some(clock) = clock {
942            let at = clock.now_ms();
943            let duration = self.duration_ms();
944            return if duration > 0 { at.min(duration) } else { at };
945        }
946        if self.playback_state() != PlaybackState::Stopped
947            && let Some(playhead) = self.timeline.get().and_then(|t| t.playhead())
948        {
949            return playhead.position_ms;
950        }
951        self.position_ms.load(Ordering::Acquire)
952    }
953
954    /// The timeline the playhead is read from. Set once, by the player.
955    pub(crate) fn attach_timeline(&self, timeline: Arc<crate::audio::buffer::PlaybackTimeline>) {
956        let _ = self.timeline.set(timeline);
957    }
958
959    pub fn set_position_ms(&self, pos: u64) {
960        self.position_ms.store(pos, Ordering::Release);
961        // Deliberately silent. The playhead advances on its own and is
962        // published as an anchor rather than as a reading — a wake per
963        // position would be the tick this whole arrangement removes. A seek, a
964        // pause and a track change all move something else here as well, and
965        // those are exactly the ones a client has to be told about.
966    }
967
968    /// Set by the player while a renderer is the output. A change to it is a
969    /// change clients are told about: the clock only moves when the renderer
970    /// is heard from, or when a command moved it.
971    /// Whether the playhead is advancing on its own. Playing, but held, while
972    /// a renderer has been told to play and has not yet started: a client
973    /// counting on from the command would run ahead of the sound.
974    ///
975    /// A renderer playing a file keeps its clock here; one playing a stream
976    /// processed here keeps it on the timeline, in time into the stream.
977    pub fn playhead_moving(&self) -> bool {
978        let clock =
979            (*self.renderer_clock.lock()).or_else(|| self.timeline.get().and_then(|t| t.clock()));
980        match clock {
981            Some(clock) => clock.running.is_some(),
982            None => self.playback_state() == PlaybackState::Playing,
983        }
984    }
985
986    pub(crate) fn renderer_clock(&self) -> Option<RendererClock> {
987        *self.renderer_clock.lock()
988    }
989
990    pub(crate) fn set_renderer_clock(&self, clock: Option<RendererClock>) {
991        let mut guard = self.renderer_clock.lock();
992        if *guard != clock {
993            *guard = clock;
994            drop(guard);
995            self.changed();
996        }
997    }
998
999    /// The renderer this koan is playing to, when it is not its own output.
1000    pub fn renderer(&self) -> Option<crate::upnp::Output> {
1001        self.renderer.read().clone()
1002    }
1003
1004    pub(crate) fn set_renderer(&self, output: Option<crate::upnp::Output>) {
1005        *self.renderer.write() = output;
1006        self.changed();
1007    }
1008
1009    pub(crate) fn update_renderer(&self, f: impl FnOnce(&mut crate::upnp::Output)) {
1010        let changed = match self.renderer.write().as_mut() {
1011            Some(out) => {
1012                let before = out.clone();
1013                f(out);
1014                *out != before
1015            }
1016            None => false,
1017        };
1018        if changed {
1019            self.changed();
1020        }
1021    }
1022
1023    pub fn track_info(&self) -> Option<TrackInfo> {
1024        self.track_info.read().clone()
1025    }
1026
1027    pub fn set_track_info(&self, info: Option<TrackInfo>) {
1028        *self.track_info.write() = info;
1029        self.changed();
1030    }
1031
1032    /// How far into the currently playing track a seek can land.
1033    ///
1034    /// A track on disk is seekable end to end. One still downloading is
1035    /// seekable only as far as its bytes reach: bytes map to time by the
1036    /// average bitrate, exact for lossless and CBR and drifting on VBR, which
1037    /// is what `SEEK_SAFETY_MS` covers. Zero when nothing is playing.
1038    ///
1039    /// The one value both the clamp in `Player::seek` and the extent front ends
1040    /// draw on the seek bar come from — a bar that shows a reachable position
1041    /// the player then refuses is worse than no bar.
1042    pub fn seekable_ms(&self) -> u64 {
1043        let Some(info) = self.track_info.read().clone() else {
1044            return 0;
1045        };
1046
1047        // Released before the playlist lock is taken: derive_visible_queue takes
1048        // these two in the opposite order, so holding both would close a cycle.
1049        let pl = self.playlist.read();
1050        let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
1051            return info.duration_ms;
1052        };
1053
1054        let LoadState::Downloading {
1055            total,
1056            bytes_written,
1057            ..
1058        } = self.load_state(item)
1059        else {
1060            return info.duration_ms;
1061        };
1062
1063        // A container that could not describe itself from the bytes downloaded
1064        // states no duration, and cannot be seeked at all until the rest of it
1065        // lands — there is no index to seek against and no end to seek within.
1066        // Ogg is the one that does this; it keeps its duration in its last page.
1067        if info.duration_ms == 0 {
1068            return 0;
1069        }
1070
1071        let written = bytes_written.load(Ordering::Acquire);
1072        let reached = if total > 0 && info.duration_ms > 0 {
1073            ((written as f64 / total as f64) * info.duration_ms as f64) as u64
1074        } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
1075            // No Content-Length. Bytes still say how much audio has arrived,
1076            // given what the probe measured the bitrate to be: 1 kbps is
1077            // 1 bit per ms, so bits divided by kbps is milliseconds.
1078            written.saturating_mul(8) / kbps as u64
1079        } else {
1080            // Nothing to derive a position from — forward seeking would be a
1081            // guess, so allow only what has already been played.
1082            return self.position_ms();
1083        };
1084
1085        reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
1086    }
1087
1088    /// The duration to show for what is playing.
1089    ///
1090    /// The container's own answer wherever it gave one. A partial file that
1091    /// could not be read far enough to state a duration has none, and the
1092    /// library's figure stands in — it came from the server, it is right, and
1093    /// a transport that reads 0:00 for nine hours of music is worse than one
1094    /// reading a figure the container has not caught up with yet.
1095    pub fn duration_ms(&self) -> u64 {
1096        let Some(info) = self.track_info.read().clone() else {
1097            return 0;
1098        };
1099        if info.duration_ms > 0 {
1100            return info.duration_ms;
1101        }
1102        // Released before the playlist lock, as everywhere else here.
1103        self.playlist
1104            .read()
1105            .items
1106            .iter()
1107            .find(|item| item.id == info.id)
1108            .and_then(|item| item.duration_ms)
1109            .unwrap_or(0)
1110    }
1111
1112    /// `seekable_ms`, but `None` when the whole track is reachable — which is
1113    /// every track that is not mid-download. What a front end draws a boundary
1114    /// from: no boundary is the normal case and should cost no mark.
1115    pub fn seek_ceiling_ms(&self) -> Option<u64> {
1116        let duration = self.duration_ms();
1117        if duration == 0 {
1118            return None;
1119        }
1120        let seekable = self.seekable_ms();
1121        (seekable < duration).then_some(seekable)
1122    }
1123
1124    /// Download fraction (0.0..1.0) for the currently playing track, if streaming.
1125    /// Returns `None` for fully-downloaded or non-playing tracks.
1126    pub fn current_download_fraction(&self) -> Option<f64> {
1127        // Released before the playlist lock is taken: derive_visible_queue takes
1128        // these two in the opposite order, so holding both would close a cycle.
1129        let id = self.track_info.read().as_ref()?.id;
1130        let pl = self.playlist.read();
1131        pl.items
1132            .iter()
1133            .find(|item| item.id == id)
1134            .and_then(|item| match self.load_state(item) {
1135                LoadState::Downloading {
1136                    bytes_written,
1137                    total,
1138                    ..
1139                } => {
1140                    let written = bytes_written.load(Ordering::Acquire);
1141                    (total > 0).then(|| (written as f64 / total as f64).min(1.0))
1142                }
1143                _ => None,
1144            })
1145    }
1146
1147    // --- Quit ---
1148
1149    pub fn request_quit(&self) {
1150        self.quit_requested.store(true, Ordering::Release);
1151    }
1152
1153    pub fn quit_requested(&self) -> bool {
1154        self.quit_requested.load(Ordering::Acquire)
1155    }
1156
1157    // --- Metadata refresh ---
1158
1159    /// Signal that metadata has been refreshed mid-stream (e.g. download completed).
1160    /// The UI loop calls `take_metadata_refresh()` to consume this flag and
1161    /// force a souvlaki/cover-art update without waiting for a track change.
1162    pub fn signal_metadata_refresh(&self) {
1163        self.metadata_refresh_pending.store(true, Ordering::Release);
1164        self.changed();
1165    }
1166
1167    /// Returns true and clears the flag if a metadata refresh is pending.
1168    pub fn take_metadata_refresh(&self) -> bool {
1169        self.metadata_refresh_pending
1170            .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
1171            .is_ok()
1172    }
1173
1174    // --- Output device rate ---
1175
1176    /// `None` until a track has started and the device rate is known.
1177    pub fn output_sample_rate(&self) -> Option<u32> {
1178        match self.output_sample_rate.load(Ordering::Acquire) {
1179            0 => None,
1180            rate => Some(rate as u32),
1181        }
1182    }
1183
1184    pub fn set_output_sample_rate(&self, rate: u32) {
1185        self.output_sample_rate
1186            .store(u64::from(rate), Ordering::Release);
1187        self.changed();
1188    }
1189
1190    /// Back to "not known yet", for the window where the device is between
1191    /// rates. A switch takes as long as the hardware needs to reclock — the
1192    /// better part of a second on USB — and the previous track's rate is not
1193    /// an answer for this one.
1194    pub fn clear_output_sample_rate(&self) {
1195        self.output_sample_rate.store(0, Ordering::Release);
1196    }
1197
1198    // --- DSP ---
1199
1200    pub fn dsp(&self) -> Option<crate::audio::dsp::DspStatus> {
1201        self.dsp.read().clone()
1202    }
1203
1204    pub fn set_dsp(&self, status: Option<crate::audio::dsp::DspStatus>) {
1205        let mut dsp = self.dsp.write();
1206        if *dsp != status {
1207            *dsp = status;
1208            drop(dsp);
1209            self.changed();
1210        }
1211    }
1212
1213    // --- Play mode ---
1214
1215    pub fn play_mode(&self) -> PlayMode {
1216        PlayMode::from_bits(self.play_mode.load(Ordering::Acquire))
1217    }
1218
1219    /// The player's `publish`, and nothing else — but for a front end that
1220    /// mirrors another process's player into a state of its own, as it
1221    /// mirrors the playback state. The mode is saved with the queue, so a
1222    /// change moves the content version.
1223    pub fn set_play_mode(&self, mode: PlayMode) {
1224        if self.play_mode.swap(mode.to_bits(), Ordering::AcqRel) != mode.to_bits() {
1225            self.content_version.fetch_add(1, Ordering::AcqRel);
1226            self.bump_version();
1227        }
1228    }
1229
1230    pub fn sleep(&self) -> Option<Sleep> {
1231        *self.sleep.read()
1232    }
1233
1234    pub fn set_sleep(&self, sleep: Option<Sleep>) {
1235        let mut held = self.sleep.write();
1236        if *held != sleep {
1237            *held = sleep;
1238            drop(held);
1239            self.changed();
1240        }
1241    }
1242
1243    pub fn sleep_fading(&self) -> bool {
1244        self.sleep_fading.load(Ordering::Acquire)
1245    }
1246
1247    pub fn set_sleep_fading(&self, fading: bool) {
1248        if self.sleep_fading.swap(fading, Ordering::AcqRel) != fading {
1249            self.changed();
1250        }
1251    }
1252
1253    // --- Playlist version ---
1254
1255    pub fn playlist_version(&self) -> u64 {
1256        self.playlist_version.load(Ordering::Acquire)
1257    }
1258
1259    fn bump_version(&self) {
1260        self.playlist_version.fetch_add(1, Ordering::AcqRel);
1261        self.changed();
1262    }
1263
1264    pub fn content_version(&self) -> u64 {
1265        self.content_version.load(Ordering::Acquire)
1266    }
1267
1268    /// Moves whenever anything a saved session holds changes: the content,
1269    /// and which items have played. What a saver compares to know whether to
1270    /// write the queue again.
1271    pub fn saved_version(&self) -> u64 {
1272        self.content_version()
1273            .wrapping_add(self.played_version.load(Ordering::Acquire))
1274    }
1275
1276    /// `bump_version`, for a change to what a saved session holds. Shuffle's
1277    /// order is brought up to date with it here, so no edit can leave it
1278    /// naming a row that is gone or missing one that was queued.
1279    fn bump_content(&self) {
1280        self.playlist.write().reconcile();
1281        self.content_version.fetch_add(1, Ordering::AcqRel);
1282        self.pending_version.fetch_add(1, Ordering::AcqRel);
1283        self.bump_version();
1284    }
1285
1286    /// See the field. Moves with every content change, and when an item goes
1287    /// back to `Pending`.
1288    pub fn pending_version(&self) -> u64 {
1289        self.pending_version.load(Ordering::Acquire)
1290    }
1291
1292    /// The transfers this player's items are fetched by.
1293    pub fn downloads(&self) -> &Arc<DownloadStore> {
1294        &self.downloads
1295    }
1296
1297    fn load_state(&self, item: &PlaylistItem) -> LoadState {
1298        LoadState::of(item, &self.downloads)
1299    }
1300
1301    /// Say that something here moved, without saying what.
1302    ///
1303    /// Every version and atomic in this struct stays exactly as it was — they
1304    /// are what a watcher consults to find out what changed. This is what
1305    /// spares it looking when nothing did. See `crate::signal`.
1306    pub fn changed(&self) {
1307        crate::signal::engine_changed().bump();
1308    }
1309
1310    // --- Playlist mutations (called from player thread via commands) ---
1311
1312    /// Append items to the playlist.
1313    pub fn add_items(&self, items: Vec<PlaylistItem>) {
1314        let mut pl = self.playlist.write();
1315        pl.items.extend(items);
1316        drop(pl);
1317        self.bump_content();
1318    }
1319
1320    /// Insert items after a specific queue item.
1321    pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
1322        let mut pl = self.playlist.write();
1323        let insert_at = match pl.items.iter().position(|item| item.id == after) {
1324            Some(pos) => pos + 1,
1325            None => pl.items.len(), // fallback: append
1326        };
1327        for (i, item) in items.into_iter().enumerate() {
1328            pl.items.insert(insert_at + i, item);
1329        }
1330        drop(pl);
1331        self.bump_content();
1332    }
1333
1334    /// Update file paths for playlist items (after organize moves files).
1335    pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
1336        let mut pl = self.playlist.write();
1337        for (id, new_path) in updates {
1338            if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
1339                item.path = new_path.clone();
1340            }
1341        }
1342        drop(pl);
1343        self.bump_content();
1344    }
1345
1346    /// Remove an item by ID.
1347    pub fn remove_item(&self, id: QueueItemId) {
1348        let mut pl = self.playlist.write();
1349        pl.items.retain(|item| item.id != id);
1350        // If cursor was on removed item, clear it (caller handles next_track).
1351        if pl.cursor == Some(id) {
1352            pl.cursor = None;
1353        }
1354        drop(pl);
1355        self.bump_content();
1356    }
1357
1358    /// Move an item relative to another entry.
1359    pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
1360        let mut pl = self.playlist.write();
1361        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1362            return;
1363        };
1364        let item = pl.items.remove(from);
1365        let Some(to) = pl.items.iter().position(|item| item.id == target) else {
1366            // Target gone — put it back.
1367            let pos = from.min(pl.items.len());
1368            pl.items.insert(pos, item);
1369            return;
1370        };
1371        let insert_at = if after { to + 1 } else { to };
1372        pl.items.insert(insert_at, item);
1373        drop(pl);
1374        self.bump_content();
1375    }
1376
1377    /// Batch move: extract items by ID, reinsert them at `target` position.
1378    /// Preserves the relative order of the moved items.
1379    pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
1380        use std::collections::HashSet;
1381        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
1382
1383        let mut pl = self.playlist.write();
1384
1385        // Partition: extract moved items, keep the rest.
1386        let mut remaining = Vec::with_capacity(pl.items.len());
1387        let mut moved = Vec::with_capacity(ids.len());
1388        for item in pl.items.drain(..) {
1389            if id_set.contains(&item.id) {
1390                moved.push(item);
1391            } else {
1392                remaining.push(item);
1393            }
1394        }
1395
1396        // Find target in the remaining items.
1397        let insert_at = match remaining.iter().position(|item| item.id == target) {
1398            Some(pos) => {
1399                if after {
1400                    pos + 1
1401                } else {
1402                    pos
1403                }
1404            }
1405            None => remaining.len(),
1406        };
1407
1408        // Splice moved items in at the target position.
1409        for (i, item) in moved.into_iter().enumerate() {
1410            remaining.insert(insert_at + i, item);
1411        }
1412
1413        pl.items = remaining;
1414        drop(pl);
1415        self.bump_content();
1416    }
1417
1418    /// Set the cursor (what's playing / should play).
1419    pub fn set_cursor(&self, id: Option<QueueItemId>) {
1420        let mut pl = self.playlist.write();
1421        pl.place_cursor(id);
1422        drop(pl);
1423        self.bump_version();
1424    }
1425
1426    /// The listener picked `id` to play: the cursor goes there, and if every
1427    /// row has played, a new pass starts from it.
1428    pub fn pick(&self, id: QueueItemId) {
1429        let mut pl = self.playlist.write();
1430        pl.place_cursor(Some(id));
1431        let replayed = pl.start_over_if_spent();
1432        drop(pl);
1433        if replayed {
1434            self.played_version.fetch_add(1, Ordering::AcqRel);
1435        }
1436        self.bump_version();
1437    }
1438
1439    /// The playhead has moved on to `id`, the item that followed the cursor:
1440    /// gaplessly, or a renderer taking the track it was handed. As an advance
1441    /// does, a move that starts the queue over begins a new pass. `pass` is
1442    /// the pass the lookahead step that chose `id` put it in, if one did.
1443    pub fn move_on_to(&self, id: QueueItemId, pass: Option<u64>) {
1444        let mut pl = self.playlist.write();
1445        pl.move_on(id, pass);
1446        drop(pl);
1447        self.played_version.fetch_add(1, Ordering::AcqRel);
1448        self.bump_version();
1449    }
1450
1451    /// The item has started playing: mark it played for this pass.
1452    pub fn mark_played(&self, id: QueueItemId) {
1453        let mut pl = self.playlist.write();
1454        let Some(item) = pl.items.iter_mut().find(|item| item.id == id) else {
1455            return;
1456        };
1457        if std::mem::replace(&mut item.played, true) {
1458            return;
1459        }
1460        drop(pl);
1461        self.played_version.fetch_add(1, Ordering::AcqRel);
1462        self.bump_version();
1463    }
1464
1465    /// Turn shuffle's play order on or off. On, it is drawn from the items
1466    /// yet to play this pass; off, it is dropped, and the queue — never moved
1467    /// — plays on in its own order from the cursor.
1468    pub fn set_shuffled(&self, on: bool) {
1469        let mut pl = self.playlist.write();
1470        if pl.order.is_some() == on {
1471            return;
1472        }
1473        pl.order = on.then(PlayOrder::default);
1474        let cursor = pl.cursor;
1475        pl.reconcile();
1476        pl.place_cursor(cursor);
1477        if on && pl.start_over_if_spent() {
1478            self.played_version.fetch_add(1, Ordering::AcqRel);
1479        }
1480        drop(pl);
1481        // The downloads follow the play order.
1482        self.pending_version.fetch_add(1, Ordering::AcqRel);
1483        self.bump_version();
1484    }
1485
1486    pub fn is_shuffled(&self) -> bool {
1487        self.playlist.read().order.is_some()
1488    }
1489
1490    /// What is left of this pass of the play order, next first.
1491    #[cfg(test)]
1492    pub(crate) fn upcoming(&self) -> Vec<QueueItemId> {
1493        let pl = self.playlist.read();
1494        pl.order
1495            .as_ref()
1496            .map(|o| o.upcoming.clone())
1497            .unwrap_or_default()
1498    }
1499
1500    pub fn is_empty(&self) -> bool {
1501        self.playlist.read().items.is_empty()
1502    }
1503
1504    pub fn cursor(&self) -> Option<QueueItemId> {
1505        self.playlist.read().cursor
1506    }
1507
1508    /// The path of the item under the cursor, without copying the playlist.
1509    pub fn cursor_path(&self) -> Option<PathBuf> {
1510        let pl = self.playlist.read();
1511        let cursor = pl.cursor?;
1512        pl.items
1513            .iter()
1514            .find(|item| item.id == cursor)
1515            .map(|item| item.path.clone())
1516    }
1517
1518    /// Clear the entire playlist + cursor.
1519    /// Swap the whole playlist for `items`, with no cursor, as one change.
1520    /// Returns what it held, for undo.
1521    ///
1522    /// One write and one version bump, not a clear and an add. The download
1523    /// queue reads the playlist on its own thread whenever it changes, and an
1524    /// empty playlist between the two would read as nothing wanted: every
1525    /// transfer for a track in both the old queue and the new one let go,
1526    /// cancelled, and started again.
1527    pub fn replace_playlist(
1528        &self,
1529        items: Vec<PlaylistItem>,
1530    ) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
1531        let mut pl = self.playlist.write();
1532        let old = std::mem::replace(&mut pl.items, items);
1533        let cursor = pl.cursor.take();
1534        drop(pl);
1535        self.bump_content();
1536        (old, cursor)
1537    }
1538
1539    pub fn clear_playlist(&self) {
1540        let mut pl = self.playlist.write();
1541        pl.items.clear();
1542        pl.cursor = None;
1543        drop(pl);
1544        self.bump_content();
1545    }
1546
1547    // --- Called from decode thread (gapless) ---
1548
1549    /// Move the cursor to the next item that can still play — the first item
1550    /// after the cursor that is not `Failed`, from the top again when the
1551    /// queue repeats — and return its ID. An advance is a move on, so
1552    /// repeating one item wraps the queue here as repeating the queue does;
1553    /// playing the item again at its end is the player's call.
1554    ///
1555    /// An item that is still downloading parks the cursor rather than being
1556    /// skipped, so playback resumes from it when its data lands. Skipping it
1557    /// would drop it from the queue for good.
1558    ///
1559    /// With no cursor set, starts from the top. A cursor pointing at an item
1560    /// that is no longer in the playlist yields `None` — restarting from the
1561    /// top would silently replay the queue.
1562    pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
1563        let repeat = match self.play_mode().repeat {
1564            Repeat::Off => Repeat::Off,
1565            Repeat::Queue | Repeat::One => Repeat::Queue,
1566        };
1567        let mut pl = self.playlist.write();
1568        let cursor = pl.cursor;
1569        let (next, wrapped) = pl.follows(cursor, false, repeat)?;
1570        let pass = pl.pass + u64::from(wrapped);
1571        pl.move_on(next?, Some(pass));
1572        drop(pl);
1573        self.played_version.fetch_add(1, Ordering::AcqRel);
1574        self.bump_version();
1575        next
1576    }
1577
1578    /// One step of the decoder's gapless lookahead from `after_id`. Does not
1579    /// move the cursor; `update_playback_state` does that when the playhead
1580    /// gets there.
1581    ///
1582    /// Failed items are passed over, as `advance_cursor_loadable` passes over
1583    /// them. A track still arriving is not: the decoder stops at it, the
1584    /// session drains, and the advance that follows waits for it. Passing over
1585    /// it would play the track after it and move the cursor beyond it, so it
1586    /// would never be heard.
1587    ///
1588    /// The play mode decides what follows: the queue's next item, the first
1589    /// again at its end when the queue repeats, or the same item when one
1590    /// repeats — gapless all three.
1591    ///
1592    /// None when `after_id` has been removed: the lookahead then has nothing
1593    /// to follow, and starting from the top would gaplessly replay the queue.
1594    pub fn lookahead_after(&self, after_id: QueueItemId) -> Option<Lookahead> {
1595        self.lookahead(after_id, None)
1596    }
1597
1598    /// The step after `step`, from what it chose: the decoder's next step
1599    /// through the queue, in the pass `step` reached.
1600    pub fn lookahead_from(&self, step: &Lookahead) -> Option<Lookahead> {
1601        self.lookahead(step.chosen.as_ref()?.0, Some(step.pass))
1602    }
1603
1604    fn lookahead(&self, after_id: QueueItemId, after_pass: Option<u64>) -> Option<Lookahead> {
1605        let repeat = self.play_mode().repeat;
1606        let mut pl = self.playlist.write();
1607        let after_pass = after_pass.unwrap_or(pl.pass);
1608        let ahead = after_pass > pl.pass;
1609        let (next, wrapped) = pl.follows(Some(after_id), ahead, repeat)?;
1610        let pass = pl.pass + u64::from(ahead || wrapped);
1611        let next = next.and_then(|id| pl.find(id));
1612        Some(Lookahead {
1613            after: after_id,
1614            next: next.map(|item| item.id),
1615            chosen: next
1616                .filter(|item| matches!(item.state, ItemState::Ready))
1617                .map(|item| (item.id, item.path.clone())),
1618            wrapped,
1619            boundary: 0,
1620            after_pass,
1621            pass,
1622        })
1623    }
1624
1625    /// Whether the decoder would still take `step`: under the play mode now,
1626    /// `next` still follows `after`. A track it stopped at landing since is
1627    /// not a change, since the advance at the end of the session plays it in
1628    /// order; an edit that puts another track first is, and so is a change of
1629    /// mode that sends the queue elsewhere — a wrap once repeat is off, or a
1630    /// track appended after the one a wrap left from.
1631    pub fn still_follows(&self, step: &Lookahead) -> bool {
1632        let repeat = self.play_mode().repeat;
1633        let mut pl = self.playlist.write();
1634        let ahead = step.after_pass > pl.pass;
1635        pl.follows(Some(step.after), ahead, repeat)
1636            .is_some_and(|(next, _)| next == step.next)
1637    }
1638
1639    /// Retreat cursor to the previous item. Returns (id, path) if found.
1640    /// For prev_track — goes to the item before cursor regardless of load state,
1641    /// and from the first to the last while repeat is on.
1642    ///
1643    /// Shuffled, the previous item is the one played before this, from the
1644    /// play order's history; with none, there is nothing to go back to.
1645    pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
1646        let mut pl = self.playlist.write();
1647        if pl.order.is_some() {
1648            let cursor = pl.cursor;
1649            let history = &pl.order.as_ref()?.history;
1650            let end =
1651                history.len() - usize::from(cursor.is_some() && history.last() == cursor.as_ref());
1652            let at = history[..end]
1653                .iter()
1654                .rposition(|id| pl.find(*id).is_some())?;
1655            let id = history[at];
1656            let path = pl.find(id)?.path.clone();
1657            let order = pl.order.as_mut()?;
1658            order.history.truncate(at + 1);
1659            pl.cursor = Some(id);
1660            drop(pl);
1661            self.bump_version();
1662            return Some((id, path));
1663        }
1664        let cursor_pos = match pl.cursor {
1665            Some(cid) => pl.items.iter().position(|item| item.id == cid),
1666            None => None,
1667        };
1668
1669        // From the first item, round to the last while the queue repeats. A
1670        // queue of one has nothing to go back to: the caller restarts it.
1671        let wraps = self.play_mode().repeat != Repeat::Off && pl.items.len() > 1;
1672        let prev_pos = cursor_pos.and_then(|p| match p.checked_sub(1) {
1673            None if wraps => Some(pl.items.len() - 1),
1674            prev => prev,
1675        });
1676
1677        match prev_pos {
1678            Some(pos) => {
1679                let item = &pl.items[pos];
1680                let result = (item.id, item.path.clone());
1681                pl.cursor = Some(item.id);
1682                drop(pl);
1683                self.bump_version();
1684                Some(result)
1685            }
1686            None => None,
1687        }
1688    }
1689
1690    // --- Called from resolve thread ---
1691
1692    /// Update the load state of a playlist item.
1693    pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
1694        let pending = new_state == ItemState::Pending;
1695        let mut pl = self.playlist.write();
1696        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
1697            item.state = new_state;
1698        }
1699        drop(pl);
1700        if pending {
1701            self.pending_version.fetch_add(1, Ordering::AcqRel);
1702        }
1703        self.bump_version();
1704    }
1705
1706    /// An item's own state, without the rest of it.
1707    pub fn item_state(&self, id: QueueItemId) -> Option<ItemState> {
1708        let pl = self.playlist.read();
1709        pl.items
1710            .iter()
1711            .find(|item| item.id == id)
1712            .map(|item| item.state.clone())
1713    }
1714
1715    /// Take what a finished download's own tags can add.
1716    ///
1717    /// Streaming starts on partial Symphonia tags, so an item with nothing
1718    /// behind it takes the lot once the whole file is there. An item that came
1719    /// out of the library does not: the record is what the queue was built
1720    /// from and what every other track on it carries, and a file whose tags
1721    /// disagree — a server album titled one way, the file inside titled
1722    /// another — would split its album in two the moment it finished
1723    /// downloading. The duration is the file's to know either way.
1724    pub fn update_item_metadata(
1725        &self,
1726        id: QueueItemId,
1727        title: String,
1728        artist: String,
1729        album_artist: String,
1730        album: String,
1731        duration_ms: Option<u64>,
1732    ) {
1733        let mut pl = self.playlist.write();
1734        let mut retagged = false;
1735        let mut retimed = false;
1736        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
1737            if item.db_id.is_none() {
1738                retagged = item.title != title
1739                    || item.artist != artist
1740                    || item.album_artist != album_artist
1741                    || item.album != album;
1742                item.title = title;
1743                item.artist = artist;
1744                item.album_artist = album_artist;
1745                item.album = album;
1746            }
1747            if let Some(dur) = duration_ms
1748                && item.duration_ms != Some(dur)
1749            {
1750                item.duration_ms = Some(dur);
1751                retimed = true;
1752            }
1753        }
1754        drop(pl);
1755        // Every download landing comes through here. A content change rewrites
1756        // the saved queue and has every client read the whole queue again,
1757        // which on a long queue is not something to do per track: so only new
1758        // tags are one. A duration is corrected on nearly every streamed track
1759        // — a server gives whole seconds, the file milliseconds — and goes to
1760        // clients as a change to that row alone.
1761        if retagged {
1762            self.bump_content();
1763        } else if retimed {
1764            self.bump_version();
1765        }
1766    }
1767
1768    /// Get the playback source for an item if it's ready to play.
1769    /// Returns `None` if not enough data is available yet.
1770    pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
1771        let pl = self.playlist.read();
1772        pl.items
1773            .iter()
1774            .find(|item| item.id == id)
1775            .and_then(|item| match self.load_state(item) {
1776                LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
1777                LoadState::Downloading {
1778                    path,
1779                    total,
1780                    bytes_written,
1781                } => {
1782                    let written = bytes_written.load(Ordering::Acquire);
1783                    (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
1784                        path,
1785                        bytes_written,
1786                        total,
1787                    })
1788                }
1789                _ => None,
1790            })
1791    }
1792
1793    /// Put back to `Pending` every queue item whose file has gone, and say
1794    /// which they were so they can be fetched again.
1795    ///
1796    /// The queue holds paths, and clearing downloads deletes the files under
1797    /// them. An item left claiming `Ready` opens nothing when it is played —
1798    /// it is not broken, it is a remote track that has to be fetched a second
1799    /// time. Only items with a database row behind them: one without has
1800    /// nowhere to be fetched from, and parking the cursor on it would be worse
1801    /// than letting it fail honestly.
1802    pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
1803        let mut pl = self.playlist.write();
1804        let mut reset = Vec::new();
1805        for item in pl.items.iter_mut() {
1806            let Some(db_id) = item.db_id else { continue };
1807            if !matches!(item.state, ItemState::Ready) {
1808                continue;
1809            }
1810            if item.path.exists() {
1811                continue;
1812            }
1813            item.state = ItemState::Pending;
1814            reset.push((db_id, item.id));
1815        }
1816        drop(pl);
1817        if !reset.is_empty() {
1818            self.pending_version.fetch_add(1, Ordering::AcqRel);
1819            self.bump_version();
1820        }
1821        reset
1822    }
1823
1824    /// The item's path if it is `Ready`. A caller that can stream wants `item_playback_source`.
1825    pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
1826        let pl = self.playlist.read();
1827        pl.items.iter().find(|item| item.id == id).and_then(|item| {
1828            if matches!(item.state, ItemState::Ready) {
1829                Some(item.path.clone())
1830            } else {
1831                None
1832            }
1833        })
1834    }
1835
1836    pub fn is_cursor(&self, id: QueueItemId) -> bool {
1837        self.playlist.read().cursor == Some(id)
1838    }
1839
1840    /// Get QueueItemIds of all playlist items sharing the same album as the given item.
1841    /// Matches on both album name and album artist to avoid false positives
1842    /// (e.g. two different "Greatest Hits" by different artists).
1843    pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
1844        let pl = self.playlist.read();
1845        let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
1846            return vec![];
1847        };
1848        let album = cursor.album.clone();
1849        let album_artist = cursor.album_artist.clone();
1850        pl.items
1851            .iter()
1852            .filter(|item| {
1853                item.id != id && item.album == album && item.album_artist == album_artist
1854            })
1855            .map(|item| item.id)
1856            .collect()
1857    }
1858
1859    /// Every playlist item still waiting for its file that has a track to
1860    /// fetch, as `(db_id, QueueItemId)`, in the order the player will reach
1861    /// it — see `reach_order`. What the download queue fetches, and in that
1862    /// order.
1863    pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
1864        let pl = self.playlist.read();
1865        reach_order(&pl)
1866            .filter(|item| matches!(item.state, ItemState::Pending))
1867            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
1868            .collect()
1869    }
1870
1871    /// Every entry with a library track, in the order `pending_downloads`
1872    /// gives, whatever its state: what the cache has to hold, downloaded or
1873    /// not, for the player to reach it.
1874    pub fn playback_order(&self) -> Vec<(i64, QueueItemId)> {
1875        let pl = self.playlist.read();
1876        reach_order(&pl)
1877            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
1878            .collect()
1879    }
1880
1881    /// Get the db_id for a specific playlist item.
1882    pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
1883        let pl = self.playlist.read();
1884        pl.items
1885            .iter()
1886            .find(|item| item.id == id)
1887            .and_then(|item| item.db_id)
1888    }
1889
1890    /// Get the load state of a specific playlist item.
1891    pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
1892        let pl = self.playlist.read();
1893        pl.items
1894            .iter()
1895            .find(|item| item.id == id)
1896            .map(|item| self.load_state(item))
1897    }
1898
1899    // --- Snapshot helpers for undo ---
1900
1901    /// Get the full playlist snapshot (items + cursor) for undo of ClearPlaylist.
1902    pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
1903        let pl = self.playlist.read();
1904        (pl.items.clone(), pl.cursor)
1905    }
1906
1907    /// Get an item by ID (for undo of RemoveFromPlaylist).
1908    pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
1909        let pl = self.playlist.read();
1910        pl.items.iter().find(|item| item.id == id).cloned()
1911    }
1912
1913    /// Get the ID of the item immediately before the given ID (None if first).
1914    pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
1915        let pl = self.playlist.read();
1916        let pos = pl.items.iter().position(|item| item.id == id)?;
1917        if pos == 0 {
1918            None
1919        } else {
1920            Some(pl.items[pos - 1].id)
1921        }
1922    }
1923
1924    /// Put the items in exactly this order.
1925    ///
1926    /// Items not named keep their relative order and follow at the end, so a
1927    /// stale order cannot lose anything. The items themselves are moved, not
1928    /// rebuilt: their ids, load states and download progress are what the rest
1929    /// of the player is holding on to.
1930    pub fn reorder_to(&self, order: &[QueueItemId]) {
1931        let mut pl = self.playlist.write();
1932        let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
1933        let mut sorted = Vec::with_capacity(taken.len());
1934        for id in order {
1935            if let Some(slot) = taken
1936                .iter_mut()
1937                .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
1938                && let Some(item) = slot.take()
1939            {
1940                sorted.push(item);
1941            }
1942        }
1943        sorted.extend(taken.into_iter().flatten());
1944        pl.items = sorted;
1945        drop(pl);
1946        self.bump_content();
1947    }
1948
1949    /// For each ID, the ID of the item before it (or None if first), returned in
1950    /// playlist order regardless of the order `ids` arrives in.
1951    ///
1952    /// Undo replays these left to right, so an item whose recorded predecessor is
1953    /// also in `ids` must come after it — otherwise the predecessor is missing at
1954    /// replay time and the item lands at the end of the playlist instead.
1955    pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
1956        use std::collections::HashSet;
1957        let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
1958        let pl = self.playlist.read();
1959        pl.items
1960            .iter()
1961            .enumerate()
1962            .filter(|(_, item)| wanted.contains(&item.id))
1963            .map(|(pos, item)| {
1964                let before = if pos == 0 {
1965                    None
1966                } else {
1967                    Some(pl.items[pos - 1].id)
1968                };
1969                (item.id, before)
1970            })
1971            .collect()
1972    }
1973
1974    /// The nearest item before `id` that is not itself being removed — where
1975    /// playback resumes from after a batch delete that takes out the cursor.
1976    /// `None` means resume from the top of what survives.
1977    pub fn surviving_item_before(
1978        &self,
1979        id: QueueItemId,
1980        removed: &[QueueItemId],
1981    ) -> Option<QueueItemId> {
1982        use std::collections::HashSet;
1983        let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
1984        let pl = self.playlist.read();
1985        let pos = pl.items.iter().position(|item| item.id == id)?;
1986        pl.items[..pos]
1987            .iter()
1988            .rev()
1989            .find(|item| !removed.contains(&item.id))
1990            .map(|item| item.id)
1991    }
1992
1993    /// Restore a full playlist from snapshot (for redo of ClearPlaylist undo).
1994    pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
1995        let mut pl = self.playlist.write();
1996        pl.items = items;
1997        pl.cursor = cursor;
1998        drop(pl);
1999        self.bump_content();
2000    }
2001
2002    /// Remove multiple items by IDs.
2003    pub fn remove_items(&self, ids: &[QueueItemId]) {
2004        use std::collections::HashSet;
2005        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
2006        let mut pl = self.playlist.write();
2007        pl.items.retain(|item| !id_set.contains(&item.id));
2008        if let Some(cursor) = pl.cursor
2009            && id_set.contains(&cursor)
2010        {
2011            pl.cursor = None;
2012        }
2013        drop(pl);
2014        self.bump_content();
2015    }
2016
2017    /// Insert a single item after a given ID (or at front if None).
2018    pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
2019        let mut pl = self.playlist.write();
2020        let insert_at = match after {
2021            Some(after_id) => {
2022                match pl.items.iter().position(|i| i.id == after_id) {
2023                    Some(pos) => pos + 1,
2024                    None => pl.items.len(), // fallback
2025                }
2026            }
2027            None => 0,
2028        };
2029        pl.items.insert(insert_at, item);
2030        drop(pl);
2031        self.bump_content();
2032    }
2033
2034    /// Move a single item to after `after` (or to front if None).
2035    pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
2036        let mut pl = self.playlist.write();
2037        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
2038            return;
2039        };
2040        let item = pl.items.remove(from);
2041        let insert_at = match after {
2042            Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
2043                Some(pos) => pos + 1,
2044                None => pl.items.len(),
2045            },
2046            None => 0,
2047        };
2048        pl.items.insert(insert_at, item);
2049        drop(pl);
2050        self.bump_content();
2051    }
2052
2053    /// Batch move: reposition each item to after its given predecessor.
2054    /// Processes in order so earlier insertions don't corrupt later positions.
2055    pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
2056        for &(id, after) in entries {
2057            self.move_item_to(id, after);
2058        }
2059    }
2060
2061    // --- Called from UI thread (read lock) ---
2062
2063    /// Derive the visible queue from the playlist + cursor. O(n), and a copy
2064    /// of every row's text: for a front end that draws the whole queue. One
2065    /// that only needs to know what moved reads `queue_readings`.
2066    pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
2067        let mut entries = Vec::with_capacity(self.playlist.read().items.len());
2068        let mut finished_count = 0;
2069        let mut has_playing = false;
2070        let mut queue_count = 0;
2071        self.each_visible(|item, place, reading| {
2072            match place {
2073                Place::Played => finished_count += 1,
2074                Place::Cursor => has_playing = true,
2075                Place::Unplayed => queue_count += 1,
2076            }
2077            entries.push(QueueEntry {
2078                id: item.id,
2079                db_id: item.db_id,
2080                playlist_entry_id: item.playlist_entry_id,
2081                path: item.path.clone(),
2082                title: item.title.clone(),
2083                artist: item.artist.clone(),
2084                album_artist: item.album_artist.clone(),
2085                album: item.album.clone(),
2086                year: item.year.clone(),
2087                codec: item.codec.clone(),
2088                track_number: item.track_number,
2089                disc: item.disc,
2090                duration_ms: reading.duration_ms,
2091                status: reading.status,
2092                download_progress: reading.download_progress,
2093                error: reading.error,
2094            });
2095        });
2096
2097        VisibleQueueSnapshot {
2098            entries,
2099            finished_count,
2100            has_playing,
2101            queue_count,
2102        }
2103    }
2104
2105    /// What each row of the visible queue says that can change without the
2106    /// queue being edited — its status, its duration, why it failed — in
2107    /// queue order, and none of its text.
2108    ///
2109    /// The cursor moving and a download landing change these and nothing
2110    /// else. A front end holding the rows already can find what moved from
2111    /// this at a small fraction of what deriving the whole queue costs.
2112    pub fn queue_readings(&self) -> Vec<QueueReading> {
2113        let mut readings = Vec::with_capacity(self.playlist.read().items.len());
2114        self.each_visible(|_, _, reading| readings.push(reading));
2115        readings
2116    }
2117
2118    /// Walk the playlist under its read lock, with each item's place — the
2119    /// cursor, played or not — and its reading. The one place a row's status
2120    /// is decided, so the derived queue and its readings cannot disagree.
2121    ///
2122    /// Played is the item's own mark, never its position: a row behind the
2123    /// cursor that was jumped over or not yet reached by shuffle is queued.
2124    fn each_visible(&self, mut f: impl FnMut(&PlaylistItem, Place, QueueReading)) {
2125        // Read before the playlist lock — see current_download_fraction.
2126        let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
2127        // One pass over the transfers rather than one lookup, and a path
2128        // cloned, per row.
2129        let transfers: HashMap<i64, (u64, u64)> = self
2130            .downloads
2131            .readings()
2132            .into_iter()
2133            .map(|r| (r.track_id, (r.written, r.total)))
2134            .collect();
2135        let pl = self.playlist.read();
2136
2137        for item in &pl.items {
2138            let place = if pl.cursor == Some(item.id) {
2139                Place::Cursor
2140            } else if item.played {
2141                Place::Played
2142            } else {
2143                Place::Unplayed
2144            };
2145
2146            // The byte count is the download thread's own counter, written per
2147            // chunk without the playlist lock, so a transfer never bumps the
2148            // playlist version.
2149            let download_progress = match item.state {
2150                ItemState::Pending => item.db_id.and_then(|id| transfers.get(&id).copied()),
2151                _ => None,
2152            };
2153            let transferring = download_progress.is_some();
2154
2155            let status = match (place, &item.state) {
2156                (Place::Cursor, state) => QueueEntryStatus::at_cursor(state, transferring),
2157                (_, ItemState::Failed(_)) => QueueEntryStatus::Failed,
2158                (_, ItemState::Pending) if transferring => QueueEntryStatus::Downloading,
2159                (Place::Played, _) => QueueEntryStatus::Played,
2160                // Waiting its turn, not arriving: a spinner on every one of
2161                // these read as the whole album downloading at once.
2162                (Place::Unplayed, _) => QueueEntryStatus::Queued,
2163            };
2164
2165            // The playing track's duration from its stream, when the item
2166            // had none of its own.
2167            let duration_ms = if status == QueueEntryStatus::Playing && item.duration_ms.is_none() {
2168                playing_duration_ms
2169            } else {
2170                item.duration_ms
2171            };
2172
2173            f(
2174                item,
2175                place,
2176                QueueReading {
2177                    id: item.id,
2178                    db_id: item.db_id,
2179                    status,
2180                    duration_ms,
2181                    download_progress,
2182                    error: match &item.state {
2183                        ItemState::Failed(reason) => Some(reason.clone()),
2184                        _ => None,
2185                    },
2186                },
2187            );
2188        }
2189    }
2190
2191    /// At most `max` items from `before` ahead of the cursor, and the cursor.
2192    ///
2193    /// What a window of the queue needs, without copying the rest of it: on a
2194    /// queue of tens of thousands, `snapshot_playlist` is a copy of every row
2195    /// for the sake of a few hundred.
2196    pub fn playlist_window(
2197        &self,
2198        before: usize,
2199        max: usize,
2200    ) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
2201        let pl = self.playlist.read();
2202        let at = pl
2203            .cursor
2204            .and_then(|c| pl.items.iter().position(|i| i.id == c))
2205            .unwrap_or(0);
2206        let start = at.saturating_sub(before);
2207        let end = pl.items.len().min(start + max);
2208        (pl.items[start..end].to_vec(), pl.cursor)
2209    }
2210}
2211
2212/// Every item once, in the order the player will reach it: from the cursor
2213/// to the end, then from the top — the tracks before the cursor are the ones
2214/// least likely to be played next. Shuffled, the cursor and then the play
2215/// order go first, and the rest follow in that order.
2216fn reach_order(pl: &Playlist) -> impl Iterator<Item = &PlaylistItem> {
2217    let from = pl
2218        .cursor
2219        .and_then(|c| pl.items.iter().position(|item| item.id == c))
2220        .unwrap_or(0);
2221    let (before, after) = pl.items.split_at(from);
2222    let queue = after.iter().chain(before);
2223    let mut first: Vec<&PlaylistItem> = Vec::new();
2224    if let Some(order) = &pl.order {
2225        let rows: HashMap<QueueItemId, &PlaylistItem> =
2226            pl.items.iter().map(|item| (item.id, item)).collect();
2227        first.extend(pl.cursor.and_then(|c| rows.get(&c).copied()));
2228        first.extend(order.upcoming.iter().filter_map(|id| rows.get(id).copied()));
2229    }
2230    let led: std::collections::HashSet<QueueItemId> = first.iter().map(|item| item.id).collect();
2231    first
2232        .into_iter()
2233        .chain(queue.filter(move |item| !led.contains(&item.id)))
2234}
2235
2236/// Whether a row is the cursor's, or has played or is yet to.
2237#[derive(Clone, Copy)]
2238enum Place {
2239    Played,
2240    Cursor,
2241    Unplayed,
2242}
2243
2244#[cfg(test)]
2245mod tests {
2246    use super::*;
2247
2248    // --- helpers ---
2249
2250    fn make_item(title: &str, state: ItemState) -> PlaylistItem {
2251        PlaylistItem {
2252            playlist_entry_id: None,
2253            id: QueueItemId::new(),
2254            db_id: None,
2255            path: PathBuf::from(format!("/music/{title}.flac")),
2256            title: title.to_string(),
2257            artist: "Artist".to_string(),
2258            album_artist: "Artist".to_string(),
2259            album: "Album".to_string(),
2260            year: None,
2261            codec: Some("FLAC".to_string()),
2262            track_number: None,
2263            disc: None,
2264            duration_ms: Some(200_000),
2265            state,
2266            played: false,
2267        }
2268    }
2269
2270    // --- shuffle ---
2271
2272    /// `n` ready items, each its own library track.
2273    fn tracks(n: usize) -> Vec<PlaylistItem> {
2274        (0..n)
2275            .map(|i| PlaylistItem {
2276                db_id: Some(i as i64 + 1),
2277                ..make_item(&format!("t{i}"), ItemState::Ready)
2278            })
2279            .collect()
2280    }
2281
2282    fn shuffled(items: Vec<PlaylistItem>) -> (Arc<SharedPlayerState>, Vec<QueueItemId>) {
2283        let state = SharedPlayerState::new();
2284        let ids = items.iter().map(|i| i.id).collect();
2285        state.add_items(items);
2286        state.set_shuffled(true);
2287        (state, ids)
2288    }
2289
2290    fn queue_ids(state: &SharedPlayerState) -> Vec<QueueItemId> {
2291        state.snapshot_playlist().0.iter().map(|i| i.id).collect()
2292    }
2293
2294    /// Play what is under the cursor, then move on as the end of a track
2295    /// does: what the advance lands on.
2296    fn play_on(state: &SharedPlayerState) -> Option<QueueItemId> {
2297        if let Some(id) = state.cursor() {
2298            state.mark_played(id);
2299        }
2300        state.advance_cursor_loadable()
2301    }
2302
2303    fn status_of(state: &SharedPlayerState, id: QueueItemId) -> QueueEntryStatus {
2304        let snap = state.derive_visible_queue();
2305        snap.entries.iter().find(|e| e.id == id).unwrap().status
2306    }
2307
2308    fn set_repeat(state: &SharedPlayerState, repeat: Repeat) {
2309        let shuffle = state.play_mode().shuffle;
2310        state.set_play_mode(PlayMode { shuffle, repeat });
2311    }
2312
2313    #[test]
2314    fn shuffle_on_and_off_never_moves_the_queue() {
2315        let state = SharedPlayerState::new();
2316        let items = tracks(20);
2317        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
2318        state.add_items(items);
2319        state.set_cursor(Some(ids[3]));
2320
2321        state.set_shuffled(true);
2322        assert_eq!(queue_ids(&state), ids);
2323        for _ in 0..6 {
2324            play_on(&state).unwrap();
2325        }
2326        assert_eq!(queue_ids(&state), ids, "playing shuffled moves nothing");
2327        state.set_shuffled(false);
2328        assert_eq!(queue_ids(&state), ids);
2329    }
2330
2331    #[test]
2332    fn the_rows_that_played_shuffled_stay_marked_once_it_is_off() {
2333        let (state, ids) = shuffled(tracks(20));
2334        let mut heard = vec![state.advance_cursor_loadable().unwrap()];
2335        for _ in 0..7 {
2336            heard.push(play_on(&state).unwrap());
2337        }
2338        let playing = state.cursor().unwrap();
2339        state.set_shuffled(false);
2340
2341        for &id in &ids {
2342            let expected = if id == playing {
2343                QueueEntryStatus::Playing
2344            } else if heard.contains(&id) {
2345                QueueEntryStatus::Played
2346            } else {
2347                QueueEntryStatus::Queued
2348            };
2349            assert_eq!(
2350                status_of(&state, id),
2351                expected,
2352                "row {}",
2353                ids.iter().position(|i| *i == id).unwrap()
2354            );
2355        }
2356        let snap = state.derive_visible_queue();
2357        assert_eq!(snap.finished_count, 7);
2358        assert_eq!(snap.queue_count, 12);
2359
2360        // Off, the queue plays on in its own order from the cursor.
2361        let at = ids.iter().position(|id| *id == playing).unwrap();
2362        assert_eq!(play_on(&state), ids.get(at + 1).copied());
2363    }
2364
2365    #[test]
2366    fn a_row_is_played_because_it_played_not_for_being_behind_the_cursor() {
2367        let state = SharedPlayerState::new();
2368        let items = tracks(6);
2369        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
2370        state.add_items(items);
2371        state.set_cursor(Some(ids[0]));
2372        play_on(&state);
2373        state.set_cursor(Some(ids[4]));
2374
2375        assert_eq!(status_of(&state, ids[0]), QueueEntryStatus::Played);
2376        for &id in &ids[1..4] {
2377            assert_eq!(
2378                status_of(&state, id),
2379                QueueEntryStatus::Queued,
2380                "jumped over"
2381            );
2382        }
2383    }
2384
2385    #[test]
2386    fn a_shuffled_pass_plays_every_track_once() {
2387        let (state, ids) = shuffled(tracks(30));
2388        let mut heard = vec![state.advance_cursor_loadable().unwrap()];
2389        while let Some(id) = play_on(&state) {
2390            heard.push(id);
2391        }
2392        assert_ne!(heard, ids, "a shuffled order");
2393        let mut sorted = heard.clone();
2394        sorted.sort_by_key(|id| ids.iter().position(|i| i == id));
2395        assert_eq!(sorted, ids, "each once");
2396    }
2397
2398    #[test]
2399    fn a_track_queued_twice_plays_once_a_pass() {
2400        let mut items = tracks(5);
2401        let twins: Vec<_> = [1, 3, 3]
2402            .iter()
2403            .map(|&i| PlaylistItem {
2404                id: QueueItemId::new(),
2405                ..items[i].clone()
2406            })
2407            .collect();
2408        items.extend(twins);
2409        let (state, _) = shuffled(items);
2410        let track = |id| state.item_db_id(id).unwrap();
2411
2412        let mut heard = vec![track(state.advance_cursor_loadable().unwrap())];
2413        while let Some(id) = play_on(&state) {
2414            heard.push(track(id));
2415        }
2416        heard.sort();
2417        assert_eq!(heard, vec![1, 2, 3, 4, 5]);
2418    }
2419
2420    #[test]
2421    fn rows_queued_while_shuffled_play_later_in_the_pass() {
2422        let (state, ids) = shuffled(tracks(10));
2423        let mut heard = vec![state.advance_cursor_loadable().unwrap()];
2424        for _ in 0..3 {
2425            heard.push(play_on(&state).unwrap());
2426        }
2427        let more: Vec<_> = (10..15)
2428            .map(|i| PlaylistItem {
2429                db_id: Some(i + 1),
2430                ..make_item(&format!("t{i}"), ItemState::Ready)
2431            })
2432            .collect();
2433        let added: Vec<_> = more.iter().map(|i| i.id).collect();
2434        state.add_items(more);
2435        assert_eq!(queue_ids(&state)[10..], added, "added where they were put");
2436
2437        while let Some(id) = play_on(&state) {
2438            heard.push(id);
2439        }
2440        assert_eq!(heard.len(), 15);
2441        assert!(added.iter().all(|id| heard[4..].contains(id)));
2442        assert!(ids.iter().all(|id| heard.contains(id)));
2443    }
2444
2445    #[test]
2446    fn a_row_removed_while_shuffled_does_not_play() {
2447        let (state, ids) = shuffled(tracks(10));
2448        let mut heard = vec![state.advance_cursor_loadable().unwrap()];
2449        let gone = *ids.iter().find(|id| !heard.contains(id)).unwrap();
2450        state.remove_items(&[gone]);
2451        while let Some(id) = play_on(&state) {
2452            heard.push(id);
2453        }
2454        assert_eq!(heard.len(), 9);
2455        assert!(!heard.contains(&gone));
2456    }
2457
2458    #[test]
2459    fn previous_goes_back_along_what_played_while_shuffled() {
2460        let (state, ids) = shuffled(tracks(12));
2461        let mut heard = vec![state.advance_cursor_loadable().unwrap()];
2462        for _ in 0..4 {
2463            heard.push(play_on(&state).unwrap());
2464        }
2465        assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[3]));
2466        assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[2]));
2467        assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[1]));
2468        assert_eq!(state.retreat_cursor().map(|(id, _)| id), Some(heard[0]));
2469        assert_eq!(state.retreat_cursor(), None, "nothing before the first");
2470        assert_eq!(state.cursor(), Some(heard[0]));
2471
2472        // Back is not a new pass: what has played does not come round again.
2473        let next = play_on(&state).unwrap();
2474        assert!(!heard.contains(&next));
2475        assert_eq!(queue_ids(&state), ids);
2476    }
2477
2478    #[test]
2479    fn a_repeating_shuffled_queue_starts_a_new_pass_once_every_track_played() {
2480        let (state, ids) = shuffled(tracks(8));
2481        set_repeat(&state, Repeat::Queue);
2482        let mut heard = vec![state.advance_cursor_loadable().unwrap()];
2483        for _ in 0..(3 * ids.len() - 1) {
2484            heard.push(play_on(&state).unwrap());
2485        }
2486        for (n, pass) in heard.chunks(ids.len()).enumerate() {
2487            let mut sorted = pass.to_vec();
2488            sorted.sort_by_key(|id| ids.iter().position(|i| i == id));
2489            assert_eq!(sorted, ids, "pass {n} plays each track once");
2490        }
2491        for turn in heard.windows(2) {
2492            assert_ne!(turn[0], turn[1], "no track twice running at a turn");
2493        }
2494
2495        // The third pass's last track playing: the rest of it has played.
2496        // Moving on to the fourth clears the marks.
2497        let snap = state.derive_visible_queue();
2498        assert_eq!(snap.finished_count, ids.len() - 1);
2499        play_on(&state);
2500        let snap = state.derive_visible_queue();
2501        assert_eq!(snap.finished_count, 0, "a new pass clears the marks");
2502        assert!(snap.has_playing);
2503    }
2504
2505    #[test]
2506    fn repeating_in_order_starts_a_new_pass_at_the_top() {
2507        let state = SharedPlayerState::new();
2508        let items = tracks(4);
2509        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
2510        state.add_items(items);
2511        set_repeat(&state, Repeat::Queue);
2512        state.set_cursor(Some(ids[0]));
2513        for _ in 0..4 {
2514            play_on(&state);
2515        }
2516        assert_eq!(state.cursor(), Some(ids[0]));
2517        for &id in &ids[1..] {
2518            assert_eq!(status_of(&state, id), QueueEntryStatus::Queued);
2519        }
2520    }
2521
2522    #[test]
2523    fn the_lookahead_picks_what_the_advance_plays() {
2524        let (state, ids) = shuffled(tracks(7));
2525        set_repeat(&state, Repeat::Queue);
2526        state.advance_cursor_loadable();
2527        // Across three passes, the turns between them included: each step the
2528        // decoder would queue — two ahead, as it looks ahead — is the step
2529        // the cursor then takes.
2530        for _ in 0..(3 * ids.len()) {
2531            let cursor = state.cursor().unwrap();
2532            let next = state.lookahead_after(cursor).unwrap();
2533            let after = state.lookahead_from(&next).unwrap();
2534            assert!(state.still_follows(&next));
2535            assert_eq!(play_on(&state), next.next);
2536            assert_eq!(play_on(&state), after.next);
2537            assert_eq!(next.chosen.map(|(id, _)| id), next.next);
2538        }
2539    }
2540
2541    #[test]
2542    fn a_lookahead_steps_deep_crosses_into_the_next_pass_as_the_advance_does() {
2543        let (state, ids) = shuffled(tracks(4));
2544        set_repeat(&state, Repeat::Queue);
2545        state.advance_cursor_loadable();
2546        // As the decoder chains steps when tracks are shorter than the ring:
2547        // the rest of this pass and all of the next, before the playhead
2548        // reaches any of them.
2549        let mut step = state.lookahead_after(state.cursor().unwrap()).unwrap();
2550        let mut chain = vec![step.next.unwrap()];
2551        for _ in 1..(ids.len() - 1 + ids.len()) {
2552            step = state.lookahead_from(&step).unwrap();
2553            chain.push(step.next.unwrap());
2554        }
2555        let played: Vec<_> = chain.iter().map(|_| play_on(&state).unwrap()).collect();
2556        assert_eq!(played, chain);
2557        let mut next_pass = chain[ids.len() - 1..].to_vec();
2558        next_pass.sort_by_key(|id| ids.iter().position(|i| i == id));
2559        assert_eq!(next_pass, ids, "the next pass whole, not this one again");
2560    }
2561
2562    #[test]
2563    fn a_played_out_shuffled_queue_plays_again_from_a_row_picked() {
2564        let (state, ids) = shuffled(tracks(5));
2565        state.advance_cursor_loadable();
2566        while play_on(&state).is_some() {}
2567
2568        state.pick(ids[2]);
2569        let mut heard = vec![ids[2]];
2570        while let Some(id) = play_on(&state) {
2571            heard.push(id);
2572        }
2573        heard.sort_by_key(|id| ids.iter().position(|i| i == id));
2574        assert_eq!(heard, ids, "a new pass from the row picked");
2575    }
2576
2577    #[test]
2578    fn shuffling_a_played_out_queue_plays_it_again() {
2579        let state = SharedPlayerState::new();
2580        let items = tracks(5);
2581        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
2582        state.add_items(items);
2583        state.set_cursor(Some(ids[0]));
2584        while play_on(&state).is_some() {}
2585
2586        state.set_shuffled(true);
2587        let mut heard = vec![state.cursor().unwrap()];
2588        while let Some(id) = play_on(&state) {
2589            heard.push(id);
2590        }
2591        assert_eq!(heard.len(), ids.len());
2592    }
2593
2594    #[test]
2595    fn the_downloads_follow_the_play_order() {
2596        let state = SharedPlayerState::new();
2597        let items: Vec<_> = tracks(10)
2598            .into_iter()
2599            .map(|item| PlaylistItem {
2600                state: ItemState::Pending,
2601                ..item
2602            })
2603            .collect();
2604        state.add_items(items);
2605        state.set_shuffled(true);
2606        let first = state.advance_cursor_loadable().unwrap();
2607        let second = state.lookahead_after(first).unwrap().next.unwrap();
2608        let order = state.pending_downloads();
2609        assert_eq!(order[0].1, first);
2610        assert_eq!(order[1].1, second);
2611        assert_eq!(order.len(), 10);
2612    }
2613
2614    /// An item with a transfer running against it, told to the state's store
2615    /// the way the downloader tells it. Returns the transfer's byte feed.
2616    fn downloading_item(
2617        state: &SharedPlayerState,
2618        title: &str,
2619        total: u64,
2620    ) -> (PlaylistItem, Arc<ByteFeed>) {
2621        static NEXT_TRACK: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1);
2622        let mut item = make_item(title, ItemState::Pending);
2623        item.db_id = Some(NEXT_TRACK.fetch_add(1, Ordering::Relaxed));
2624        let feed = start_transfer(state, &item, total);
2625        (item, feed)
2626    }
2627
2628    /// Claim, announce and start the transfer for `item`'s track.
2629    fn start_transfer(state: &SharedPlayerState, item: &PlaylistItem, total: u64) -> Arc<ByteFeed> {
2630        let track_id = item.db_id.expect("a transfer is for a library track");
2631        let store = state.downloads();
2632        store.claim(track_id, Some(item.id));
2633        let feed = store.announce(
2634            track_id,
2635            item.title.clone(),
2636            String::new(),
2637            PathBuf::from(format!("/cache/{}.flac.part", item.title)),
2638            PathBuf::from(format!("/cache/{}.flac", item.title)),
2639        );
2640        store.started(track_id, total);
2641        feed
2642    }
2643
2644    fn ready_item(title: &str) -> PlaylistItem {
2645        make_item(title, ItemState::Ready)
2646    }
2647
2648    fn pending_item(title: &str) -> PlaylistItem {
2649        make_item(title, ItemState::Pending)
2650    }
2651
2652    fn failed_item(title: &str) -> PlaylistItem {
2653        make_item(title, ItemState::Failed("nope".into()))
2654    }
2655
2656    const DURATION_MS: u64 = 32_523_787;
2657
2658    /// A nine-hour track under the cursor, `downloaded` bytes of `total` in.
2659    /// `total` of 0 stands for a server that sent no Content-Length.
2660    fn streaming_state(
2661        downloaded: u64,
2662        total: u64,
2663        bitrate_kbps: Option<u32>,
2664    ) -> Arc<SharedPlayerState> {
2665        streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
2666    }
2667
2668    /// The same, but saying what the container managed to state about itself.
2669    /// A partial Ogg states nothing, which is zero here.
2670    fn streaming_state_with_duration(
2671        downloaded: u64,
2672        total: u64,
2673        bitrate_kbps: Option<u32>,
2674        container_duration_ms: u64,
2675    ) -> Arc<SharedPlayerState> {
2676        let mut item = make_item("train", ItemState::Pending);
2677        item.db_id = Some(1);
2678        let id = item.id;
2679        let path = item.path.clone();
2680
2681        let state = SharedPlayerState::new();
2682        start_transfer(&state, &item, total).set(downloaded);
2683        state.add_items(vec![item]);
2684        state.set_cursor(Some(id));
2685        state.set_track_info(Some(TrackInfo {
2686            id,
2687            path,
2688            codec: "Opus".into(),
2689            sample_rate: 48_000,
2690            bit_depth: None,
2691            bitrate_kbps,
2692            channels: 2,
2693            duration_ms: container_duration_ms,
2694        }));
2695        state
2696    }
2697
2698    // --- seekable_ms ---
2699
2700    #[test]
2701    fn a_track_on_disk_is_seekable_end_to_end() {
2702        let item = ready_item("done");
2703        let id = item.id;
2704        let path = item.path.clone();
2705        let state = SharedPlayerState::new();
2706        state.add_items(vec![item]);
2707        state.set_cursor(Some(id));
2708        state.set_track_info(Some(TrackInfo {
2709            id,
2710            path,
2711            codec: "FLAC".into(),
2712            sample_rate: 44_100,
2713            bit_depth: Some(16),
2714            bitrate_kbps: None,
2715            channels: 2,
2716            duration_ms: 200_000,
2717        }));
2718
2719        assert_eq!(state.seekable_ms(), 200_000);
2720        // Nothing to draw a boundary for, so front ends are told there isn't one.
2721        assert_eq!(state.seek_ceiling_ms(), None);
2722    }
2723
2724    #[test]
2725    fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
2726        // A quarter of a nine-hour file in: a quarter of the way through it,
2727        // less the margin the byte-to-time estimate is worth.
2728        let state = streaming_state(100, 400, None);
2729        assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
2730        assert_eq!(
2731            state.seek_ceiling_ms(),
2732            Some(32_523_787 / 4 - SEEK_SAFETY_MS)
2733        );
2734    }
2735
2736    #[test]
2737    fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
2738        // No total to take a fraction of. 128 kbps is 128 bits per ms, so a
2739        // megabyte is 8 388 608 bits and a little over 65 seconds.
2740        let state = streaming_state(1024 * 1024, 0, Some(128));
2741        assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
2742    }
2743
2744    #[test]
2745    fn nothing_to_estimate_from_allows_no_forward_seek() {
2746        // Neither a length nor a bitrate: anywhere past the playhead is a
2747        // guess, and a guess that lands past the write head is a stall.
2748        let state = streaming_state(1024 * 1024, 0, None);
2749        state.set_position_ms(12_000);
2750        assert_eq!(state.seekable_ms(), 12_000);
2751    }
2752
2753    #[test]
2754    fn the_seekable_extent_never_exceeds_the_track() {
2755        // A download reporting more bytes than it advertised must not offer a
2756        // seek past the end of the music.
2757        let state = streaming_state(500, 400, None);
2758        assert_eq!(state.seekable_ms(), 32_523_787);
2759    }
2760
2761    #[test]
2762    fn nothing_playing_is_seekable_nowhere() {
2763        assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
2764        assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
2765    }
2766
2767    #[test]
2768    fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
2769        // A partial Ogg keeps its duration in a last page that has not arrived,
2770        // so it opens and plays but has nothing to seek against. Half the bytes
2771        // being present does not change that.
2772        let state = streaming_state_with_duration(200, 400, Some(128), 0);
2773        assert_eq!(state.seekable_ms(), 0);
2774    }
2775
2776    #[test]
2777    fn the_library_duration_stands_in_for_a_silent_container() {
2778        // What is shown on the transport, so nine hours of music does not read
2779        // as 0:00 while it caches.
2780        let state = streaming_state_with_duration(200, 400, Some(128), 0);
2781        assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
2782        // And it is a display figure only — it grants no seeking.
2783        assert_eq!(state.seekable_ms(), 0);
2784        assert_eq!(state.seek_ceiling_ms(), Some(0));
2785    }
2786
2787    #[test]
2788    fn the_container_duration_wins_where_there_is_one() {
2789        let state = streaming_state(200, 400, None);
2790        assert_eq!(state.duration_ms(), DURATION_MS);
2791    }
2792
2793    #[test]
2794    fn the_download_landing_restores_seeking() {
2795        // The sequence the whole design turns on: a track that opened without a
2796        // duration gets one when the finished file is re-read, and is seekable
2797        // end to end from that moment — no restart, no handover.
2798        let state = streaming_state_with_duration(400, 400, Some(128), 0);
2799        assert_eq!(state.seekable_ms(), 0);
2800
2801        // What the downloader does when the bytes land: settle the transfer,
2802        // then say the file is playable. In that order — while the store still
2803        // says a transfer is running, it is.
2804        let id = state.cursor().expect("cursor");
2805        let _ =
2806            crate::remote::downloads::settle(&state, 1, &Ok(PathBuf::from("/cache/train.flac")));
2807        assert_eq!(state.item_state(id), Some(ItemState::Ready));
2808        let info = state.track_info().expect("track info");
2809        state.set_track_info(Some(TrackInfo {
2810            duration_ms: DURATION_MS,
2811            ..info
2812        }));
2813
2814        assert_eq!(state.seekable_ms(), DURATION_MS);
2815        assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
2816    }
2817
2818    // --- advance_cursor_loadable ---
2819
2820    #[test]
2821    fn advance_parks_on_a_still_downloading_track() {
2822        // A track that has not arrived yet must hold the cursor, not be skipped:
2823        // skipping it drops it from the queue for good, and it is the item whose
2824        // TrackReady has to resume playback.
2825        let state = SharedPlayerState::new();
2826        let item0 = ready_item("track-0");
2827        let item1 = pending_item("track-1");
2828        let item2 = ready_item("track-2");
2829        let (id0, id1) = (item0.id, item1.id);
2830
2831        state.add_items(vec![item0, item1, item2]);
2832
2833        assert_eq!(state.advance_cursor_loadable(), Some(id0));
2834        assert_eq!(state.advance_cursor_loadable(), Some(id1));
2835        assert_eq!(state.cursor(), Some(id1));
2836    }
2837
2838    #[test]
2839    fn advance_skips_failed_items() {
2840        let state = SharedPlayerState::new();
2841        let item0 = ready_item("track-0");
2842        let item1 = failed_item("track-1");
2843        let item2 = ready_item("track-2");
2844        let (id0, id2) = (item0.id, item2.id);
2845
2846        state.add_items(vec![item0, item1, item2]);
2847        state.set_cursor(Some(id0));
2848
2849        assert_eq!(state.advance_cursor_loadable(), Some(id2));
2850    }
2851
2852    #[test]
2853    fn advance_stops_at_end_of_playlist() {
2854        let state = SharedPlayerState::new();
2855        let item0 = ready_item("track-0");
2856        let item1 = ready_item("track-1");
2857        let id1 = item1.id;
2858
2859        state.add_items(vec![item0, item1]);
2860        state.set_cursor(Some(id1));
2861
2862        assert_eq!(state.advance_cursor_loadable(), None);
2863        assert_eq!(
2864            state.cursor(),
2865            Some(id1),
2866            "cursor unchanged on a failed advance"
2867        );
2868    }
2869
2870    #[test]
2871    fn advance_with_only_failed_items_returns_none() {
2872        let state = SharedPlayerState::new();
2873        state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
2874
2875        assert_eq!(state.advance_cursor_loadable(), None);
2876    }
2877
2878    #[test]
2879    fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
2880        let state = SharedPlayerState::new();
2881        let item0 = ready_item("track-0");
2882        let item1 = ready_item("track-1");
2883        let id0 = item0.id;
2884
2885        state.add_items(vec![item0, item1]);
2886        let ghost = QueueItemId::new();
2887        state.set_cursor(Some(ghost));
2888
2889        assert_eq!(state.advance_cursor_loadable(), None);
2890        assert_ne!(state.cursor(), Some(id0));
2891    }
2892
2893    // --- lookahead_after ---
2894
2895    fn chosen_after(state: &SharedPlayerState, id: QueueItemId) -> Option<QueueItemId> {
2896        state
2897            .lookahead_after(id)
2898            .and_then(|step| step.chosen)
2899            .map(|(id, _)| id)
2900    }
2901
2902    #[test]
2903    fn a_step_stops_at_a_track_still_arriving() {
2904        let state = SharedPlayerState::new();
2905        let a = ready_item("a");
2906        let b = PlaylistItem {
2907            state: ItemState::Pending,
2908            ..ready_item("b")
2909        };
2910        let c = ready_item("c");
2911        let (ida, idb) = (a.id, b.id);
2912        state.add_items(vec![a, b, c]);
2913
2914        let step = state.lookahead_after(ida).unwrap();
2915        assert_eq!(step.next, Some(idb));
2916        assert!(step.chosen.is_none(), "c is not queued over it");
2917
2918        state.update_item_state(idb, ItemState::Ready);
2919        assert!(
2920            state.still_follows(&step),
2921            "its landing is not an edit: the advance plays it"
2922        );
2923
2924        state.add_items(vec![ready_item("d")]);
2925        assert!(state.still_follows(&step), "nor is adding after it");
2926
2927        state.insert_items_after(vec![ready_item("next")], ida);
2928        assert!(!state.still_follows(&step), "a track put before it is");
2929    }
2930
2931    #[test]
2932    fn a_step_passes_over_failed_tracks() {
2933        let state = SharedPlayerState::new();
2934        let a = ready_item("a");
2935        let failed = PlaylistItem {
2936            state: ItemState::Failed("gone".into()),
2937            ..ready_item("failed")
2938        };
2939        let c = ready_item("c");
2940        let (ida, idc) = (a.id, c.id);
2941        state.add_items(vec![a, failed, c]);
2942
2943        let step = state.lookahead_after(ida).unwrap();
2944        assert_eq!(step.chosen.as_ref().map(|(id, _)| *id), Some(idc));
2945
2946        let another = PlaylistItem {
2947            state: ItemState::Failed("gone".into()),
2948            ..ready_item("another")
2949        };
2950        state.insert_items_after(vec![another], ida);
2951        assert!(
2952            state.still_follows(&step),
2953            "another failed track before it changes nothing"
2954        );
2955
2956        let arriving = PlaylistItem {
2957            state: ItemState::Pending,
2958            ..ready_item("arriving")
2959        };
2960        state.insert_items_after(vec![arriving], ida);
2961        assert!(!state.still_follows(&step), "a track still arriving does");
2962    }
2963
2964    #[test]
2965    fn a_step_breaks_when_what_it_chose_moves_ahead_of_it() {
2966        let state = SharedPlayerState::new();
2967        let (a, c) = (ready_item("a"), ready_item("c"));
2968        let (ida, idc) = (a.id, c.id);
2969        state.add_items(vec![a, c]);
2970        let step = state.lookahead_after(ida).unwrap();
2971        state.move_item(idc, ida, false);
2972        assert!(!state.still_follows(&step));
2973    }
2974
2975    #[test]
2976    fn a_step_to_the_end_of_the_queue_breaks_when_a_track_is_added() {
2977        let state = SharedPlayerState::new();
2978        let a = ready_item("a");
2979        let ida = a.id;
2980        state.add_items(vec![a]);
2981        let step = state.lookahead_after(ida).unwrap();
2982        assert!(step.chosen.is_none());
2983        assert!(state.still_follows(&step));
2984        state.add_items(vec![ready_item("b")]);
2985        assert!(!state.still_follows(&step));
2986    }
2987
2988    #[test]
2989    fn peek_after_a_removed_item_returns_none() {
2990        // The decode thread's lookahead runs seconds ahead of what is audible.
2991        // Removing the track it is pre-decoding must end the lookahead, not send
2992        // it back to the top of the queue.
2993        let state = SharedPlayerState::new();
2994        let item0 = ready_item("track-0");
2995        let item1 = ready_item("track-1");
2996        let item2 = ready_item("track-2");
2997        let (id0, id1, id2) = (item0.id, item1.id, item2.id);
2998
2999        state.add_items(vec![item0, item1, item2]);
3000        assert_eq!(chosen_after(&state, id1), Some(id2));
3001
3002        state.remove_item(id2);
3003        assert!(
3004            state.lookahead_after(id2).is_none(),
3005            "a vanished reference must not resolve to the head of the queue"
3006        );
3007        assert_ne!(chosen_after(&state, id2), Some(id0));
3008    }
3009
3010    #[test]
3011    fn the_lookahead_steps_over_a_track_that_failed() {
3012        let state = SharedPlayerState::new();
3013        let playing = ready_item("playing");
3014        let failed = failed_item("failed");
3015        let after = ready_item("after");
3016        let (playing_id, after_id) = (playing.id, after.id);
3017        state.add_items(vec![playing, failed, after]);
3018
3019        assert_eq!(chosen_after(&state, playing_id), Some(after_id));
3020    }
3021
3022    // --- surviving_item_before ---
3023
3024    fn repeating(state: &SharedPlayerState, repeat: Repeat) {
3025        state.set_play_mode(PlayMode {
3026            shuffle: false,
3027            repeat,
3028        });
3029    }
3030
3031    #[test]
3032    fn a_step_from_the_last_item_wraps_only_when_the_queue_repeats() {
3033        let state = SharedPlayerState::new();
3034        let items: Vec<_> = ["a", "b"].map(ready_item).into();
3035        let (a, b) = (items[0].id, items[1].id);
3036        state.add_items(items);
3037
3038        assert_eq!(state.lookahead_after(b).unwrap().next, None);
3039        repeating(&state, Repeat::Queue);
3040        let step = state.lookahead_after(b).unwrap();
3041        assert_eq!((step.next, step.wrapped), (Some(a), true));
3042        assert!(state.still_follows(&step));
3043
3044        let later = ready_item("later");
3045        state.add_items(vec![later.clone()]);
3046        assert!(!state.still_follows(&step), "something follows b now");
3047        state.remove_items(&[later.id]);
3048        repeating(&state, Repeat::Off);
3049        assert!(!state.still_follows(&step), "nor does repeat off wrap");
3050    }
3051
3052    #[test]
3053    fn repeating_one_steps_to_the_same_item_but_an_advance_moves_on() {
3054        let state = SharedPlayerState::new();
3055        let items: Vec<_> = ["a", "b"].map(ready_item).into();
3056        let (a, b) = (items[0].id, items[1].id);
3057        state.add_items(items);
3058        repeating(&state, Repeat::One);
3059
3060        let step = state.lookahead_after(a).unwrap();
3061        assert_eq!((step.next, step.wrapped), (Some(a), false));
3062        state.set_cursor(Some(a));
3063        assert_eq!(state.advance_cursor_loadable(), Some(b));
3064        assert_eq!(
3065            state.advance_cursor_loadable(),
3066            Some(a),
3067            "round, as repeating"
3068        );
3069    }
3070
3071    #[test]
3072    fn previous_from_the_first_item_wraps_only_while_repeating() {
3073        let state = SharedPlayerState::new();
3074        let items: Vec<_> = ["a", "b", "c"].map(ready_item).into();
3075        let (a, c) = (items[0].id, items[2].id);
3076        state.add_items(items);
3077
3078        state.set_cursor(Some(a));
3079        assert!(state.retreat_cursor().is_none());
3080        for repeat in [Repeat::Queue, Repeat::One] {
3081            repeating(&state, repeat);
3082            state.set_cursor(Some(a));
3083            assert_eq!(
3084                state.retreat_cursor().map(|(id, _)| id),
3085                Some(c),
3086                "{repeat:?}"
3087            );
3088        }
3089    }
3090
3091    #[test]
3092    fn a_removed_item_has_nothing_after_it_even_when_the_queue_repeats() {
3093        let state = SharedPlayerState::new();
3094        let items: Vec<_> = ["a", "b"].map(ready_item).into();
3095        let b = items[1].id;
3096        state.add_items(items);
3097        repeating(&state, Repeat::Queue);
3098
3099        state.set_cursor(Some(b));
3100        state.remove_item(b);
3101        assert!(state.lookahead_after(b).is_none());
3102        state.set_cursor(Some(b));
3103        assert_eq!(state.advance_cursor_loadable(), None);
3104    }
3105
3106    #[test]
3107    fn a_wrap_passes_over_failed_items() {
3108        let state = SharedPlayerState::new();
3109        let items = vec![failed_item("a"), ready_item("b"), ready_item("c")];
3110        let (b, c) = (items[1].id, items[2].id);
3111        state.add_items(items);
3112        repeating(&state, Repeat::Queue);
3113
3114        assert_eq!(state.lookahead_after(c).unwrap().next, Some(b));
3115    }
3116
3117    #[test]
3118    fn surviving_predecessor_skips_items_being_removed() {
3119        let state = SharedPlayerState::new();
3120        let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
3121        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
3122        state.add_items(items);
3123
3124        // Deleting 1..=3 leaves 0 as the resume point for a cursor on 3.
3125        assert_eq!(
3126            state.surviving_item_before(ids[3], &ids[1..4]),
3127            Some(ids[0])
3128        );
3129        // Deleting everything from the top leaves nothing to resume after.
3130        assert_eq!(state.surviving_item_before(ids[2], &ids), None);
3131    }
3132
3133    // --- retreat_cursor ---
3134
3135    #[test]
3136    fn test_retreat_cursor_goes_to_previous_item() {
3137        let state = SharedPlayerState::new();
3138        let item0 = ready_item("track-0");
3139        let item1 = ready_item("track-1");
3140        let id0 = item0.id;
3141        let id1 = item1.id;
3142
3143        state.add_items(vec![item0, item1]);
3144        state.set_cursor(Some(id1));
3145
3146        let result = state.retreat_cursor();
3147        assert!(result.is_some(), "expected to retreat to previous item");
3148        assert_eq!(result.unwrap().0, id0, "should retreat to first item");
3149        assert_eq!(state.cursor(), Some(id0));
3150    }
3151
3152    #[test]
3153    fn test_retreat_cursor_returns_none_when_at_first_item() {
3154        let state = SharedPlayerState::new();
3155        let item0 = ready_item("only-track");
3156        let id0 = item0.id;
3157
3158        state.add_items(vec![item0]);
3159        state.set_cursor(Some(id0));
3160
3161        let result = state.retreat_cursor();
3162        assert!(result.is_none(), "cannot retreat before the first item");
3163        // Cursor stays on the first item.
3164        assert_eq!(state.cursor(), Some(id0));
3165    }
3166
3167    #[test]
3168    fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
3169        let state = SharedPlayerState::new();
3170        state.add_items(vec![ready_item("track-0")]);
3171
3172        let result = state.retreat_cursor();
3173        assert!(
3174            result.is_none(),
3175            "retreat with no cursor should return None"
3176        );
3177    }
3178
3179    // --- derive_visible_queue ---
3180
3181    #[test]
3182    fn test_derive_visible_queue_statuses() {
3183        // playlist: [played, playing, queued]
3184        let state = SharedPlayerState::new();
3185        let item0 = ready_item("played-track");
3186        let item1 = ready_item("playing-track");
3187        let item2 = ready_item("queued-track");
3188        let (id0, id1) = (item0.id, item1.id);
3189
3190        state.add_items(vec![item0, item1, item2]);
3191        state.set_cursor(Some(id0));
3192        state.mark_played(id0);
3193        state.set_cursor(Some(id1));
3194
3195        let snap = state.derive_visible_queue();
3196
3197        assert_eq!(snap.entries.len(), 3);
3198        assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
3199        assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
3200        assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
3201        assert!(snap.has_playing);
3202        assert_eq!(snap.finished_count, 1);
3203        assert_eq!(snap.queue_count, 1);
3204    }
3205
3206    #[test]
3207    fn test_derive_visible_queue_downloading_statuses() {
3208        // Downloading reads the same at the cursor as after it: bytes are
3209        // moving, and the row has progress to draw.
3210        let state = SharedPlayerState::new();
3211        let (dl_cursor, _) = downloading_item(&state, "downloading-at-cursor", 1_000_000);
3212        let (dl_queued, _) = downloading_item(&state, "downloading-queued", 500_000);
3213        let id_cursor = dl_cursor.id;
3214
3215        state.add_items(vec![dl_cursor, dl_queued]);
3216        state.set_cursor(Some(id_cursor));
3217
3218        let snap = state.derive_visible_queue();
3219
3220        assert_eq!(snap.entries[0].status, QueueEntryStatus::Downloading);
3221        assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
3222    }
3223
3224    #[test]
3225    fn a_cursor_waiting_its_turn_is_priority_pending() {
3226        let state = SharedPlayerState::new();
3227        let item = pending_item("waiting");
3228        let id = item.id;
3229        state.add_items(vec![item]);
3230        state.set_cursor(Some(id));
3231
3232        let snap = state.derive_visible_queue();
3233        assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
3234    }
3235
3236    #[test]
3237    fn a_second_entry_for_a_track_reads_the_transfer_running_for_it() {
3238        // One transfer per track: the entry queued second has none of its own,
3239        // and streams and reports from the first one's.
3240        let state = SharedPlayerState::new();
3241        let (first, bytes) = downloading_item(&state, "twice", 1_000_000);
3242        bytes.set(STREAM_THRESHOLD);
3243        let mut again = pending_item("twice");
3244        again.db_id = first.db_id;
3245        let again_id = again.id;
3246        state.add_items(vec![first, again]);
3247
3248        assert!(matches!(
3249            state.item_load_state(again_id),
3250            Some(LoadState::Downloading { .. })
3251        ));
3252        assert!(matches!(
3253            state.item_playback_source(again_id),
3254            Some(PlaybackSource::Streaming { .. })
3255        ));
3256    }
3257
3258    /// The saved queue is rewritten when its contents move, and only then:
3259    /// a track change or a download landing is a position save.
3260    #[test]
3261    fn only_a_change_to_what_is_saved_moves_the_content_version() {
3262        let state = SharedPlayerState::new();
3263        let a = make_item("a", ItemState::Pending);
3264        let b = make_item("b", ItemState::Ready);
3265        let (a_id, b_id) = (a.id, b.id);
3266
3267        let start = state.content_version();
3268        state.add_items(vec![a, b]);
3269        let added = state.content_version();
3270        assert_ne!(added, start);
3271
3272        state.set_cursor(Some(a_id));
3273        state.update_item_state(a_id, ItemState::Ready);
3274        state.advance_cursor_loadable();
3275        state.retreat_cursor();
3276        assert_eq!(state.content_version(), added);
3277        assert_eq!(state.cursor_path(), Some(PathBuf::from("/music/a.flac")));
3278
3279        state.move_item_to(b_id, None);
3280        assert_ne!(state.content_version(), added);
3281    }
3282
3283    #[test]
3284    fn progress_follows_the_counter_without_touching_the_playlist() {
3285        // The download thread writes bytes and nothing else. A queue derived
3286        // afterwards must see them — the version has not moved, and the load
3287        // state it was given is the one it still holds.
3288        let state = SharedPlayerState::new();
3289        let (item, bytes) = downloading_item(&state, "downloading", 1_000);
3290        state.add_items(vec![item]);
3291
3292        let version = state.playlist_version();
3293        bytes.set(250);
3294
3295        let snap = state.derive_visible_queue();
3296        assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
3297        assert_eq!(
3298            state.playlist_version(),
3299            version,
3300            "progress must not read as a queue mutation"
3301        );
3302        assert_eq!(state.downloads().readings()[0].written, 250);
3303    }
3304
3305    #[test]
3306    fn test_derive_visible_queue_no_cursor_all_queued() {
3307        let state = SharedPlayerState::new();
3308        state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
3309
3310        let snap = state.derive_visible_queue();
3311
3312        assert_eq!(snap.entries.len(), 3);
3313        for entry in &snap.entries {
3314            assert_eq!(entry.status, QueueEntryStatus::Queued);
3315        }
3316        assert!(!snap.has_playing);
3317        assert_eq!(snap.finished_count, 0);
3318        assert_eq!(snap.queue_count, 3);
3319    }
3320
3321    // --- same_album_item_ids ---
3322
3323    fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
3324        PlaylistItem {
3325            playlist_entry_id: None,
3326            id: QueueItemId::new(),
3327            db_id: None,
3328            path: PathBuf::from(format!("/music/{title}.flac")),
3329            title: title.to_string(),
3330            artist: "Artist".to_string(),
3331            album_artist: album_artist.to_string(),
3332            album: album.to_string(),
3333            year: None,
3334            codec: Some("FLAC".to_string()),
3335            track_number: None,
3336            disc: None,
3337            duration_ms: Some(200_000),
3338            state: ItemState::Ready,
3339            played: false,
3340        }
3341    }
3342
3343    #[test]
3344    fn test_same_album_item_ids_returns_album_mates() {
3345        let state = SharedPlayerState::new();
3346        let a1 = make_album_item("A1", "Album A", "Artist A");
3347        let a2 = make_album_item("A2", "Album A", "Artist A");
3348        let b1 = make_album_item("B1", "Album B", "Artist B");
3349        let a3 = make_album_item("A3", "Album A", "Artist A");
3350
3351        let id_a1 = a1.id;
3352        let id_a2 = a2.id;
3353        let id_a3 = a3.id;
3354
3355        state.add_items(vec![a1, a2, b1, a3]);
3356
3357        let mates = state.same_album_item_ids(id_a1);
3358        assert_eq!(mates.len(), 2);
3359        assert!(mates.contains(&id_a2));
3360        assert!(mates.contains(&id_a3));
3361    }
3362
3363    #[test]
3364    fn test_same_album_item_ids_distinguishes_album_artists() {
3365        // Two albums named the same but by different artists — should NOT match.
3366        let state = SharedPlayerState::new();
3367        let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
3368        let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
3369
3370        let id_a1 = a1.id;
3371
3372        state.add_items(vec![a1, b1]);
3373
3374        let mates = state.same_album_item_ids(id_a1);
3375        assert!(mates.is_empty(), "different album_artist should not match");
3376    }
3377
3378    #[test]
3379    fn test_same_album_item_ids_unknown_id_returns_empty() {
3380        let state = SharedPlayerState::new();
3381        state.add_items(vec![ready_item("track-0")]);
3382
3383        let bogus = QueueItemId::new();
3384        let mates = state.same_album_item_ids(bogus);
3385        assert!(mates.is_empty());
3386    }
3387
3388    // --- update_item_metadata ---
3389
3390    #[test]
3391    fn test_update_item_metadata_leaves_library_tags_alone() {
3392        let state = SharedPlayerState::new();
3393        let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
3394        item.db_id = Some(29615);
3395        let id = item.id;
3396        state.add_items(vec![item]);
3397
3398        state.update_item_metadata(
3399            id,
3400            "[unknown]".into(),
3401            "Soulwax".into(),
3402            "Soulwax".into(),
3403            "Nite Versions".into(),
3404            Some(54_000),
3405        );
3406
3407        let pl = state.playlist.read();
3408        assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
3409        assert_eq!(pl.items[0].title, "A1");
3410        assert_eq!(pl.items[0].duration_ms, Some(54_000));
3411    }
3412
3413    #[test]
3414    fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
3415        let state = SharedPlayerState::new();
3416        let item = make_album_item("A1", "", "");
3417        let id = item.id;
3418        state.add_items(vec![item]);
3419
3420        state.update_item_metadata(
3421            id,
3422            "Teachers".into(),
3423            "Soulwax".into(),
3424            "Soulwax".into(),
3425            "Nite Versions".into(),
3426            Some(148_000),
3427        );
3428
3429        let pl = state.playlist.read();
3430        assert_eq!(pl.items[0].title, "Teachers");
3431        assert_eq!(pl.items[0].album, "Nite Versions");
3432        assert_eq!(pl.items[0].duration_ms, Some(148_000));
3433    }
3434
3435    /// A download landing with tags that say what the queue already held is
3436    /// not an edit: nothing is saved again and no client reads the queue again.
3437    /// Nor is one that only corrects the duration — a server's whole seconds
3438    /// against the file's milliseconds — which clients take as a change to that
3439    /// row's reading.
3440    #[test]
3441    fn test_update_item_metadata_is_an_edit_only_for_new_tags() {
3442        let state = SharedPlayerState::new();
3443        let mut item = make_album_item("A1", "Nite Versions", "Soulwax");
3444        item.db_id = Some(1);
3445        let id = item.id;
3446        state.add_items(vec![item]);
3447        let content = state.content_version();
3448        let version = state.playlist_version();
3449
3450        let land = |duration_ms| {
3451            state.update_item_metadata(
3452                id,
3453                "A1".into(),
3454                "Soulwax".into(),
3455                "Soulwax".into(),
3456                "Nite Versions".into(),
3457                Some(duration_ms),
3458            )
3459        };
3460        land(200_000);
3461        assert_eq!(state.content_version(), content);
3462        assert_eq!(state.playlist_version(), version);
3463
3464        land(200_417);
3465        assert_eq!(state.content_version(), content);
3466        assert_ne!(state.playlist_version(), version);
3467        assert_eq!(state.queue_readings()[0].duration_ms, Some(200_417));
3468
3469        let mut untagged = make_album_item("", "", "");
3470        let untagged_id = untagged.id;
3471        untagged.db_id = None;
3472        state.add_items(vec![untagged]);
3473        let content = state.content_version();
3474        state.update_item_metadata(
3475            untagged_id,
3476            "Teachers".into(),
3477            "Soulwax".into(),
3478            "Soulwax".into(),
3479            "Nite Versions".into(),
3480            None,
3481        );
3482        assert_ne!(state.content_version(), content);
3483    }
3484
3485    // --- move_item_to ---
3486
3487    #[test]
3488    fn test_move_item_to_reorders_playlist() {
3489        // Start: [A, B, C]. Move C to after A → [A, C, B].
3490        let state = SharedPlayerState::new();
3491        let item_a = ready_item("A");
3492        let item_b = ready_item("B");
3493        let item_c = ready_item("C");
3494        let id_a = item_a.id;
3495        let id_b = item_b.id;
3496        let id_c = item_c.id;
3497
3498        state.add_items(vec![item_a, item_b, item_c]);
3499        state.move_item_to(id_c, Some(id_a));
3500
3501        let (items, _) = state.snapshot_playlist();
3502        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
3503        assert_eq!(titles, vec!["A", "C", "B"]);
3504        assert_eq!(items[0].id, id_a);
3505        assert_eq!(items[1].id, id_c);
3506        assert_eq!(items[2].id, id_b);
3507    }
3508
3509    #[test]
3510    fn test_move_item_to_front_when_after_is_none() {
3511        // Start: [A, B, C]. Move C to front (after=None) → [C, A, B].
3512        let state = SharedPlayerState::new();
3513        let item_a = ready_item("A");
3514        let item_b = ready_item("B");
3515        let item_c = ready_item("C");
3516        let id_c = item_c.id;
3517
3518        state.add_items(vec![item_a, item_b, item_c]);
3519        state.move_item_to(id_c, None);
3520
3521        let (items, _) = state.snapshot_playlist();
3522        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
3523        assert_eq!(titles, vec!["C", "A", "B"]);
3524    }
3525
3526    // --- move_items (batch) ---
3527
3528    #[test]
3529    fn test_move_items_batch_preserves_relative_order() {
3530        // Start: [A, B, C, D]. Move [A, C] after D → [B, D, A, C].
3531        let state = SharedPlayerState::new();
3532        let item_a = ready_item("A");
3533        let item_b = ready_item("B");
3534        let item_c = ready_item("C");
3535        let item_d = ready_item("D");
3536        let id_a = item_a.id;
3537        let id_b = item_b.id;
3538        let id_c = item_c.id;
3539        let id_d = item_d.id;
3540
3541        state.add_items(vec![item_a, item_b, item_c, item_d]);
3542        state.move_items(&[id_a, id_c], id_d, true);
3543
3544        let (items, _) = state.snapshot_playlist();
3545        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
3546        assert_eq!(titles, vec!["B", "D", "A", "C"]);
3547        assert_eq!(items[0].id, id_b);
3548        assert_eq!(items[1].id, id_d);
3549        assert_eq!(items[2].id, id_a);
3550        assert_eq!(items[3].id, id_c);
3551    }
3552
3553    // --- pending_downloads ---
3554
3555    #[test]
3556    fn test_pending_downloads_collects_pending_with_db_id() {
3557        let state = SharedPlayerState::new();
3558        let mut item_a = ready_item("local");
3559        item_a.db_id = None;
3560
3561        let mut item_b = pending_item("remote-1");
3562        item_b.db_id = Some(10);
3563        let id_b = item_b.id;
3564
3565        let mut item_c = ready_item("cached");
3566        item_c.db_id = Some(20);
3567
3568        let mut item_d = pending_item("remote-2");
3569        item_d.db_id = Some(30);
3570        let id_d = item_d.id;
3571
3572        // Pending without db_id — should NOT appear (no way to download).
3573        let item_e = pending_item("orphan");
3574
3575        state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
3576
3577        let pending = state.pending_downloads();
3578        assert_eq!(pending.len(), 2);
3579        assert_eq!(pending[0], (10, id_b));
3580        assert_eq!(pending[1], (30, id_d));
3581
3582        // From the cursor on, then from the top: playing the fourth, the
3583        // tracks before it come last.
3584        state.set_cursor(Some(id_d));
3585        assert_eq!(state.pending_downloads(), vec![(30, id_d), (10, id_b)]);
3586    }
3587
3588    #[test]
3589    fn test_item_db_id_and_load_state() {
3590        let state = SharedPlayerState::new();
3591        let mut item = pending_item("track");
3592        item.db_id = Some(42);
3593        let id = item.id;
3594        state.add_items(vec![item]);
3595
3596        assert_eq!(state.item_db_id(id), Some(42));
3597        assert!(matches!(
3598            state.item_load_state(id),
3599            Some(LoadState::Pending)
3600        ));
3601
3602        state.update_item_state(id, ItemState::Ready);
3603        assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
3604    }
3605}