Skip to main content

koan_core/player/
state.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
5
6use uuid::Uuid;
7
8/// Stable identity for a queue entry. UUIDv7 — time-ordered, unique across duplicates.
9#[derive(Clone, Copy, PartialEq, Eq, Hash)]
10pub struct QueueItemId(pub Uuid);
11
12impl QueueItemId {
13    pub fn new() -> Self {
14        Self(Uuid::now_v7())
15    }
16}
17
18impl Default for QueueItemId {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl fmt::Debug for QueueItemId {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        // Short form for logs: first 8 hex chars.
27        write!(f, "QId({})", &self.0.to_string()[..8])
28    }
29}
30
31/// Playback state.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[repr(u8)]
34pub enum PlaybackState {
35    Stopped = 0,
36    Playing = 1,
37    Paused = 2,
38}
39
40impl PlaybackState {
41    pub fn from_u8(v: u8) -> Self {
42        match v {
43            1 => Self::Playing,
44            2 => Self::Paused,
45            _ => Self::Stopped,
46        }
47    }
48}
49
50/// Audio format info for the currently playing track.
51#[derive(Debug, Clone)]
52pub struct TrackInfo {
53    pub id: QueueItemId,
54    pub path: PathBuf,
55    pub codec: String,
56    pub sample_rate: u32,
57    pub bit_depth: Option<u16>,
58    pub bitrate_kbps: Option<u32>,
59    pub channels: u16,
60    pub duration_ms: u64,
61}
62
63// --- Playlist data model (single source of truth) ---
64
65/// Minimum bytes written before streaming playback can begin.
66pub const STREAM_THRESHOLD: u64 = 256 * 1024; // 256 KB
67
68/// Held back from the seekable extent of a downloading track.
69///
70/// Bytes are converted to time at the average bitrate, so on VBR the estimate
71/// wanders either side of the truth; landing short of the write head costs a
72/// couple of seconds of reach and landing past it costs a stall.
73pub const SEEK_SAFETY_MS: u64 = 2_000;
74
75/// What a playlist item can say about itself.
76///
77/// Only what is true of the item regardless of any transfer: whether the bytes
78/// at its path can be played. Whether one is *arriving* is the download store's
79/// business, and asking the item would mean two accounts of one fact that have
80/// to be kept in step — which they were not. Read [`LoadState`] for the two
81/// together.
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub enum ItemState {
84    /// Nothing has resolved this yet.
85    #[default]
86    Pending,
87    /// The file at `path` is there and playable.
88    Ready,
89    /// It cannot be made playable, and this is why. Not only download
90    /// failures: a track with no local file and no remote copy fails here
91    /// without a transfer ever being attempted.
92    Failed(String),
93}
94
95/// An item's state, and any transfer against it, as one answer.
96///
97/// Derived rather than stored. `Downloading` carries the store's own figures —
98/// the very same counter the downloader writes — so there is nothing to copy
99/// and nothing that can drift.
100#[derive(Debug, Clone)]
101pub enum LoadState {
102    Pending,
103    Downloading {
104        /// Where the bytes are going: the in-progress `.part` file, not the
105        /// destination it is renamed to at the end.
106        path: PathBuf,
107        /// Total bytes expected, or 0 when the server sent no Content-Length.
108        total: u64,
109        /// How many bytes have landed. The download thread writes it per chunk
110        /// without taking any lock the player holds.
111        bytes_written: Arc<AtomicU64>,
112    },
113    Ready,
114    Failed(String),
115}
116
117impl LoadState {
118    /// An item's state, with whatever the download store says about it.
119    ///
120    /// The store wins while a transfer is live, because it is the thing being
121    /// told. Once one has settled the item's own state stands: a finished
122    /// transfer leaves a file, and a file is what playback cares about.
123    pub fn of(item: &PlaylistItem) -> Self {
124        use crate::remote::downloads::{DownloadState, store};
125
126        if let Some(transfer) = store().get(item.id) {
127            match transfer.state {
128                DownloadState::Queued | DownloadState::Running => {
129                    return Self::Downloading {
130                        path: transfer.source,
131                        total: transfer.total,
132                        bytes_written: transfer.written,
133                    };
134                }
135                // A transfer that failed explains an item that cannot play,
136                // but only while the item has not since been resolved some
137                // other way — a retry, or a copy found on disk.
138                DownloadState::Failed(reason) if item.state == ItemState::Pending => {
139                    return Self::Failed(reason);
140                }
141                DownloadState::Failed(_) | DownloadState::Done => {}
142            }
143        }
144
145        match &item.state {
146            ItemState::Pending => Self::Pending,
147            ItemState::Ready => Self::Ready,
148            ItemState::Failed(reason) => Self::Failed(reason.clone()),
149        }
150    }
151}
152
153/// Resolved playback source for a playlist item.
154pub enum PlaybackSource {
155    /// File fully downloaded — play from path.
156    Ready(PathBuf),
157    /// File being downloaded — enough data buffered to start streaming.
158    Streaming {
159        path: PathBuf,
160        bytes_written: Arc<AtomicU64>,
161        total: u64,
162    },
163}
164
165/// A single item in the playlist. Replaces QueueEntry + QueueEntryMeta + pending entries
166/// as the canonical data. Created once when tracks are added to the playlist.
167#[derive(Debug, Clone)]
168pub struct PlaylistItem {
169    pub id: QueueItemId,
170    /// Database track ID — set for tracks loaded from DB, used for downloads.
171    pub db_id: Option<i64>,
172    /// The playlist entry this came from, when it came from a playlist.
173    ///
174    /// A playlist may hold the same track twice, and two copies are two queue
175    /// items. Without this a playlist row can only ask "is my *track* playing?"
176    /// and both copies answer yes. The entry id is the one thing that tells
177    /// them apart, so the queue carries it.
178    pub playlist_entry_id: Option<i64>,
179    pub path: PathBuf,
180    pub title: String,
181    pub artist: String,
182    pub album_artist: String,
183    pub album: String,
184    pub year: Option<String>,
185    pub codec: Option<String>,
186    pub track_number: Option<i64>,
187    pub disc: Option<i64>,
188    pub duration_ms: Option<u64>,
189    /// What the item can say about itself. Ask [`SharedPlayerState::load_state`]
190    /// for this together with any transfer against it.
191    pub state: ItemState,
192}
193
194/// The playlist — one flat array, one cursor. Everything else derived.
195#[derive(Debug, Clone, Default)]
196pub struct Playlist {
197    pub items: Vec<PlaylistItem>,
198    pub cursor: Option<QueueItemId>,
199}
200
201// --- UI view types (kept for TUI compat) ---
202
203/// Status of a track in the queue — for UI display.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum QueueEntryStatus {
206    Queued,
207    Playing,
208    Played,
209    Downloading,
210    /// User double-clicked — this track is priority, will play when ready.
211    PriorityPending,
212    Failed,
213}
214
215/// A single entry in the UI-visible queue snapshot.
216#[derive(Debug, Clone)]
217pub struct QueueEntry {
218    pub id: QueueItemId,
219    /// Database track ID — set for tracks loaded from DB, used for downloads.
220    pub db_id: Option<i64>,
221    /// The playlist row this came from — see `PlaylistItem::playlist_entry_id`.
222    pub playlist_entry_id: Option<i64>,
223    pub path: PathBuf,
224    pub title: String,
225    pub artist: String,
226    pub album_artist: String,
227    pub album: String,
228    pub year: Option<String>,
229    pub codec: Option<String>,
230    pub track_number: Option<i64>,
231    pub disc: Option<i64>,
232    pub duration_ms: Option<u64>,
233    pub status: QueueEntryStatus,
234    pub download_progress: Option<(u64, u64)>,
235    /// Why this entry cannot play, when `status` is `Failed`.
236    pub error: Option<String>,
237}
238
239/// Pre-built visible queue — single atomic snapshot for the UI.
240#[derive(Debug, Clone, Default)]
241pub struct VisibleQueueSnapshot {
242    pub entries: Vec<QueueEntry>,
243    pub finished_count: usize,
244    pub has_playing: bool,
245    pub queue_count: usize,
246}
247
248/// Shared player state — atomics for lock-free reads from UI thread.
249///
250/// The engine writes these, the UI reads them. No mutexes in the hot path.
251#[derive(Debug)]
252pub struct SharedPlayerState {
253    state: AtomicU8,
254    position_ms: AtomicU64,
255    track_info: parking_lot::RwLock<Option<TrackInfo>>,
256
257    /// THE playlist + cursor — one lock, one truth.
258    playlist: parking_lot::RwLock<Playlist>,
259
260    /// Bumped on every playlist mutation so UI can skip redundant redraws.
261    playlist_version: AtomicU64,
262
263    /// Set by external signals (e.g. souvlaki Quit event) to request clean shutdown.
264    quit_requested: AtomicBool,
265
266    /// Set when metadata has been refreshed (e.g. download completed while streaming).
267    /// The UI loop checks this to force a souvlaki/cover-art update without a track change.
268    metadata_refresh_pending: AtomicBool,
269
270    /// The rate the output device settled at for the current track, or 0 when
271    /// nothing has played yet. Compared against the source rate, it is the one
272    /// thing koan can say for certain about the path to the DAC: whether it
273    /// handed the device the samples as they are, or something had to resample
274    /// to reach it. Everything past that — other clients, the volume stage — is
275    /// the system's, and not ours to claim.
276    output_sample_rate: AtomicU64,
277
278    /// Radio mode — automatically queue similar tracks when the queue runs low.
279    /// Shared so GQL/MCP can toggle it without going through the TUI.
280    radio_mode: AtomicBool,
281}
282
283impl SharedPlayerState {
284    pub fn new() -> Arc<Self> {
285        Arc::new(Self {
286            state: AtomicU8::new(PlaybackState::Stopped as u8),
287            position_ms: AtomicU64::new(0),
288            track_info: parking_lot::RwLock::new(None),
289            playlist: parking_lot::RwLock::new(Playlist::default()),
290            playlist_version: AtomicU64::new(0),
291            quit_requested: AtomicBool::new(false),
292            metadata_refresh_pending: AtomicBool::new(false),
293            output_sample_rate: AtomicU64::new(0),
294            radio_mode: AtomicBool::new(false),
295        })
296    }
297
298    // --- Playback state ---
299
300    pub fn playback_state(&self) -> PlaybackState {
301        PlaybackState::from_u8(self.state.load(Ordering::Acquire))
302    }
303
304    pub fn set_playback_state(&self, state: PlaybackState) {
305        self.state.store(state as u8, Ordering::Release);
306    }
307
308    pub fn position_ms(&self) -> u64 {
309        self.position_ms.load(Ordering::Acquire)
310    }
311
312    pub fn set_position_ms(&self, pos: u64) {
313        self.position_ms.store(pos, Ordering::Release);
314    }
315
316    pub fn track_info(&self) -> Option<TrackInfo> {
317        self.track_info.read().clone()
318    }
319
320    pub fn set_track_info(&self, info: Option<TrackInfo>) {
321        *self.track_info.write() = info;
322    }
323
324    /// How far into the currently playing track a seek can land.
325    ///
326    /// A track on disk is seekable end to end. One still downloading is
327    /// seekable only as far as its bytes reach: bytes map to time by the
328    /// average bitrate, exact for lossless and CBR and drifting on VBR, which
329    /// is what `SEEK_SAFETY_MS` covers. Zero when nothing is playing.
330    ///
331    /// The one value both the clamp in `Player::seek` and the extent front ends
332    /// draw on the seek bar come from — a bar that shows a reachable position
333    /// the player then refuses is worse than no bar.
334    pub fn seekable_ms(&self) -> u64 {
335        let Some(info) = self.track_info.read().clone() else {
336            return 0;
337        };
338
339        // Released before the playlist lock is taken: derive_visible_queue takes
340        // these two in the opposite order, so holding both would close a cycle.
341        let pl = self.playlist.read();
342        let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
343            return info.duration_ms;
344        };
345
346        let LoadState::Downloading {
347            total,
348            bytes_written,
349            ..
350        } = LoadState::of(item)
351        else {
352            return info.duration_ms;
353        };
354
355        // A container that could not describe itself from the bytes downloaded
356        // states no duration, and cannot be seeked at all until the rest of it
357        // lands — there is no index to seek against and no end to seek within.
358        // Ogg is the one that does this; it keeps its duration in its last page.
359        if info.duration_ms == 0 {
360            return 0;
361        }
362
363        let written = bytes_written.load(Ordering::Acquire);
364        let reached = if total > 0 && info.duration_ms > 0 {
365            ((written as f64 / total as f64) * info.duration_ms as f64) as u64
366        } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
367            // No Content-Length. Bytes still say how much audio has arrived,
368            // given what the probe measured the bitrate to be: 1 kbps is
369            // 1 bit per ms, so bits divided by kbps is milliseconds.
370            written.saturating_mul(8) / kbps as u64
371        } else {
372            // Nothing to derive a position from — forward seeking would be a
373            // guess, so allow only what has already been played.
374            return self.position_ms();
375        };
376
377        reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
378    }
379
380    /// The duration to show for what is playing.
381    ///
382    /// The container's own answer wherever it gave one. A partial file that
383    /// could not be read far enough to state a duration has none, and the
384    /// library's figure stands in — it came from the server, it is right, and
385    /// a transport that reads 0:00 for nine hours of music is worse than one
386    /// reading a figure the container has not caught up with yet.
387    pub fn duration_ms(&self) -> u64 {
388        let Some(info) = self.track_info.read().clone() else {
389            return 0;
390        };
391        if info.duration_ms > 0 {
392            return info.duration_ms;
393        }
394        // Released before the playlist lock, as everywhere else here.
395        self.playlist
396            .read()
397            .items
398            .iter()
399            .find(|item| item.id == info.id)
400            .and_then(|item| item.duration_ms)
401            .unwrap_or(0)
402    }
403
404    /// `seekable_ms`, but `None` when the whole track is reachable — which is
405    /// every track that is not mid-download. What a front end draws a boundary
406    /// from: no boundary is the normal case and should cost no mark.
407    pub fn seek_ceiling_ms(&self) -> Option<u64> {
408        let duration = self.duration_ms();
409        if duration == 0 {
410            return None;
411        }
412        let seekable = self.seekable_ms();
413        (seekable < duration).then_some(seekable)
414    }
415
416    /// Download fraction (0.0..1.0) for the currently playing track, if streaming.
417    /// Returns `None` for fully-downloaded or non-playing tracks.
418    pub fn current_download_fraction(&self) -> Option<f64> {
419        // Released before the playlist lock is taken: derive_visible_queue takes
420        // these two in the opposite order, so holding both would close a cycle.
421        let id = self.track_info.read().as_ref()?.id;
422        let pl = self.playlist.read();
423        pl.items
424            .iter()
425            .find(|item| item.id == id)
426            .and_then(|item| match LoadState::of(item) {
427                LoadState::Downloading {
428                    bytes_written,
429                    total,
430                    ..
431                } => {
432                    let written = bytes_written.load(Ordering::Acquire);
433                    (total > 0).then(|| (written as f64 / total as f64).min(1.0))
434                }
435                _ => None,
436            })
437    }
438
439    // --- Quit ---
440
441    pub fn request_quit(&self) {
442        self.quit_requested.store(true, Ordering::Release);
443    }
444
445    pub fn quit_requested(&self) -> bool {
446        self.quit_requested.load(Ordering::Acquire)
447    }
448
449    // --- Metadata refresh (progressive enhancement) ---
450
451    /// Signal that metadata has been refreshed mid-stream (e.g. download completed).
452    /// The UI loop calls `take_metadata_refresh()` to consume this flag and
453    /// force a souvlaki/cover-art update without waiting for a track change.
454    pub fn signal_metadata_refresh(&self) {
455        self.metadata_refresh_pending.store(true, Ordering::Release);
456    }
457
458    /// Returns true and clears the flag if a metadata refresh is pending.
459    pub fn take_metadata_refresh(&self) -> bool {
460        self.metadata_refresh_pending
461            .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
462            .is_ok()
463    }
464
465    // --- Radio mode ---
466
467    pub fn radio_mode(&self) -> bool {
468        self.radio_mode.load(Ordering::Acquire)
469    }
470
471    pub fn set_radio_mode(&self, enabled: bool) {
472        self.radio_mode.store(enabled, Ordering::Release);
473    }
474
475    // --- Output device rate ---
476
477    /// `None` until a track has started and the device rate is known.
478    pub fn output_sample_rate(&self) -> Option<u32> {
479        match self.output_sample_rate.load(Ordering::Acquire) {
480            0 => None,
481            rate => Some(rate as u32),
482        }
483    }
484
485    pub fn set_output_sample_rate(&self, rate: u32) {
486        self.output_sample_rate
487            .store(u64::from(rate), Ordering::Release);
488    }
489
490    /// Back to "not known yet", for the window where the device is between
491    /// rates. A switch takes as long as the hardware needs to reclock — the
492    /// better part of a second on USB — and the previous track's rate is not
493    /// an answer for this one.
494    pub fn clear_output_sample_rate(&self) {
495        self.output_sample_rate.store(0, Ordering::Release);
496    }
497
498    // --- Playlist version ---
499
500    pub fn playlist_version(&self) -> u64 {
501        self.playlist_version.load(Ordering::Acquire)
502    }
503
504    fn bump_version(&self) {
505        self.playlist_version.fetch_add(1, Ordering::AcqRel);
506    }
507
508    // --- Playlist mutations (called from player thread via commands) ---
509
510    /// Append items to the playlist.
511    pub fn add_items(&self, items: Vec<PlaylistItem>) {
512        let mut pl = self.playlist.write();
513        pl.items.extend(items);
514        drop(pl);
515        self.bump_version();
516    }
517
518    /// Insert items after a specific queue item.
519    pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
520        let mut pl = self.playlist.write();
521        let insert_at = match pl.items.iter().position(|item| item.id == after) {
522            Some(pos) => pos + 1,
523            None => pl.items.len(), // fallback: append
524        };
525        for (i, item) in items.into_iter().enumerate() {
526            pl.items.insert(insert_at + i, item);
527        }
528        drop(pl);
529        self.bump_version();
530    }
531
532    /// Update file paths for playlist items (after organize moves files).
533    pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
534        let mut pl = self.playlist.write();
535        for (id, new_path) in updates {
536            if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
537                item.path = new_path.clone();
538            }
539        }
540        drop(pl);
541        self.bump_version();
542    }
543
544    /// Remove an item by ID.
545    pub fn remove_item(&self, id: QueueItemId) {
546        let mut pl = self.playlist.write();
547        pl.items.retain(|item| item.id != id);
548        // If cursor was on removed item, clear it (caller handles next_track).
549        if pl.cursor == Some(id) {
550            pl.cursor = None;
551        }
552        drop(pl);
553        self.bump_version();
554    }
555
556    /// Move an item relative to another entry.
557    pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
558        let mut pl = self.playlist.write();
559        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
560            return;
561        };
562        let item = pl.items.remove(from);
563        let Some(to) = pl.items.iter().position(|item| item.id == target) else {
564            // Target gone — put it back.
565            let pos = from.min(pl.items.len());
566            pl.items.insert(pos, item);
567            return;
568        };
569        let insert_at = if after { to + 1 } else { to };
570        pl.items.insert(insert_at, item);
571        drop(pl);
572        self.bump_version();
573    }
574
575    /// Batch move: extract items by ID, reinsert them at `target` position.
576    /// Preserves the relative order of the moved items.
577    pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
578        use std::collections::HashSet;
579        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
580
581        let mut pl = self.playlist.write();
582
583        // Partition: extract moved items, keep the rest.
584        let mut remaining = Vec::with_capacity(pl.items.len());
585        let mut moved = Vec::with_capacity(ids.len());
586        for item in pl.items.drain(..) {
587            if id_set.contains(&item.id) {
588                moved.push(item);
589            } else {
590                remaining.push(item);
591            }
592        }
593
594        // Find target in the remaining items.
595        let insert_at = match remaining.iter().position(|item| item.id == target) {
596            Some(pos) => {
597                if after {
598                    pos + 1
599                } else {
600                    pos
601                }
602            }
603            None => remaining.len(),
604        };
605
606        // Splice moved items in at the target position.
607        for (i, item) in moved.into_iter().enumerate() {
608            remaining.insert(insert_at + i, item);
609        }
610
611        pl.items = remaining;
612        drop(pl);
613        self.bump_version();
614    }
615
616    /// Set the cursor (what's playing / should play).
617    pub fn set_cursor(&self, id: Option<QueueItemId>) {
618        let mut pl = self.playlist.write();
619        pl.cursor = id;
620        drop(pl);
621        self.bump_version();
622    }
623
624    /// Get the current cursor ID.
625    pub fn cursor(&self) -> Option<QueueItemId> {
626        self.playlist.read().cursor
627    }
628
629    /// Clear the entire playlist + cursor.
630    pub fn clear_playlist(&self) {
631        let mut pl = self.playlist.write();
632        pl.items.clear();
633        pl.cursor = None;
634        drop(pl);
635        self.bump_version();
636    }
637
638    // --- Called from decode thread (gapless) ---
639
640    /// Move the cursor to the next item that can still play — the first item
641    /// after the cursor that is not `Failed` — and return its ID.
642    ///
643    /// An item that is still downloading parks the cursor rather than being
644    /// skipped, so playback resumes from it when its data lands. Skipping it
645    /// would drop it from the queue for good.
646    ///
647    /// With no cursor set, starts from the top. A cursor pointing at an item
648    /// that is no longer in the playlist yields `None` — restarting from the
649    /// top would silently replay the queue.
650    pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
651        let mut pl = self.playlist.write();
652        let start = match pl.cursor {
653            Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
654            None => 0,
655        };
656
657        let next = pl
658            .items
659            .get(start..)?
660            .iter()
661            .find(|item| !matches!(item.state, ItemState::Failed(_)))
662            .map(|item| item.id)?;
663
664        pl.cursor = Some(next);
665        drop(pl);
666        self.bump_version();
667        Some(next)
668    }
669
670    /// Peek at the next Ready item after a given item ID WITHOUT moving the cursor.
671    /// Used by the decode thread for gapless lookahead — the cursor is moved
672    /// later by update_playback_state when playback actually reaches the track.
673    pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
674        let pl = self.playlist.read();
675        // A reference item that has been removed means the lookahead has nothing
676        // to follow; starting from the top would gaplessly replay the queue.
677        let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
678
679        for i in start..pl.items.len() {
680            if matches!(pl.items[i].state, ItemState::Ready) {
681                let item = &pl.items[i];
682                return Some((item.id, item.path.clone()));
683            }
684        }
685        None
686    }
687
688    /// Retreat cursor to the previous item. Returns (id, path) if found.
689    /// For prev_track — goes to the item before cursor regardless of load state.
690    pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
691        let mut pl = self.playlist.write();
692        let cursor_pos = match pl.cursor {
693            Some(cid) => pl.items.iter().position(|item| item.id == cid),
694            None => None,
695        };
696
697        let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
698
699        match prev_pos {
700            Some(pos) => {
701                let item = &pl.items[pos];
702                let result = (item.id, item.path.clone());
703                pl.cursor = Some(item.id);
704                drop(pl);
705                self.bump_version();
706                Some(result)
707            }
708            None => None,
709        }
710    }
711
712    // --- Called from resolve thread ---
713
714    /// Update the load state of a playlist item. Safe — just a field update under lock.
715    pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
716        let mut pl = self.playlist.write();
717        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
718            item.state = new_state;
719        }
720        drop(pl);
721        self.bump_version();
722    }
723
724    /// Update playlist item metadata after a full download completes.
725    /// Used for progressive enhancement: streaming started with partial Symphonia tags,
726    /// now the full file is available so we can refresh with complete lofty metadata.
727    pub fn update_item_metadata(
728        &self,
729        id: QueueItemId,
730        title: String,
731        artist: String,
732        album_artist: String,
733        album: String,
734        duration_ms: Option<u64>,
735    ) {
736        let mut pl = self.playlist.write();
737        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
738            item.title = title;
739            item.artist = artist;
740            item.album_artist = album_artist;
741            item.album = album;
742            if let Some(dur) = duration_ms {
743                item.duration_ms = Some(dur);
744            }
745        }
746        drop(pl);
747        self.bump_version();
748    }
749
750    /// Get the playback source for an item if it's ready to play.
751    /// Returns `None` if not enough data is available yet.
752    pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
753        let pl = self.playlist.read();
754        pl.items
755            .iter()
756            .find(|item| item.id == id)
757            .and_then(|item| match LoadState::of(item) {
758                LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
759                LoadState::Downloading {
760                    path,
761                    total,
762                    bytes_written,
763                } => {
764                    let written = bytes_written.load(Ordering::Acquire);
765                    (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
766                        path,
767                        bytes_written,
768                        total,
769                    })
770                }
771                _ => None,
772            })
773    }
774
775    /// Put back to `Pending` every queue item whose file has gone, and say
776    /// which they were so they can be fetched again.
777    ///
778    /// The queue holds paths, and clearing downloads deletes the files under
779    /// them. An item left claiming `Ready` opens nothing when it is played —
780    /// it is not broken, it is a remote track that has to be fetched a second
781    /// time. Only items with a database row behind them: one without has
782    /// nowhere to be fetched from, and parking the cursor on it would be worse
783    /// than letting it fail honestly.
784    pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
785        let mut pl = self.playlist.write();
786        let mut reset = Vec::new();
787        for item in pl.items.iter_mut() {
788            let Some(db_id) = item.db_id else { continue };
789            if !matches!(item.state, ItemState::Ready) {
790                continue;
791            }
792            if item.path.exists() {
793                continue;
794            }
795            item.state = ItemState::Pending;
796            reset.push((db_id, item.id));
797        }
798        drop(pl);
799        if !reset.is_empty() {
800            self.bump_version();
801        }
802        reset
803    }
804
805    /// Get the path of an item if it's Ready (legacy convenience — use item_playback_source for streaming).
806    pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
807        let pl = self.playlist.read();
808        pl.items.iter().find(|item| item.id == id).and_then(|item| {
809            if matches!(item.state, ItemState::Ready) {
810                Some(item.path.clone())
811            } else {
812                None
813            }
814        })
815    }
816
817    /// Check if the cursor is on the given item.
818    pub fn is_cursor(&self, id: QueueItemId) -> bool {
819        self.playlist.read().cursor == Some(id)
820    }
821
822    /// Get QueueItemIds of all playlist items sharing the same album as the given item.
823    /// Matches on both album name and album artist to avoid false positives
824    /// (e.g. two different "Greatest Hits" by different artists).
825    pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
826        let pl = self.playlist.read();
827        let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
828            return vec![];
829        };
830        let album = cursor.album.clone();
831        let album_artist = cursor.album_artist.clone();
832        pl.items
833            .iter()
834            .filter(|item| {
835                item.id != id && item.album == album && item.album_artist == album_artist
836            })
837            .map(|item| item.id)
838            .collect()
839    }
840
841    /// Get all playlist items that are Pending and have a db_id.
842    /// Returns `(db_id, QueueItemId)` pairs suitable for the download queue.
843    pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
844        let pl = self.playlist.read();
845        pl.items
846            .iter()
847            .filter(|item| matches!(item.state, ItemState::Pending))
848            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
849            .collect()
850    }
851
852    /// Every item mid-transfer, with the bytes it has and the bytes it expects.
853    ///
854    /// Progress moves without the playlist version moving — the download thread
855    /// writes the byte counter directly — so anything following the version
856    /// alone shows a frozen bar. This is how a watcher sees it move.
857    pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
858        crate::remote::downloads::store()
859            .all()
860            .iter()
861            .filter(|d| !d.state.is_settled())
862            .map(|d| (d.id, d.bytes_written(), d.total))
863            .collect()
864    }
865
866    /// Get the db_id for a specific playlist item.
867    pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
868        let pl = self.playlist.read();
869        pl.items
870            .iter()
871            .find(|item| item.id == id)
872            .and_then(|item| item.db_id)
873    }
874
875    /// Get the load state of a specific playlist item.
876    pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
877        let pl = self.playlist.read();
878        pl.items
879            .iter()
880            .find(|item| item.id == id)
881            .map(LoadState::of)
882    }
883
884    // --- Snapshot helpers for undo ---
885
886    /// Get the full playlist snapshot (items + cursor) for undo of ClearPlaylist.
887    pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
888        let pl = self.playlist.read();
889        (pl.items.clone(), pl.cursor)
890    }
891
892    /// Get an item by ID (for undo of RemoveFromPlaylist).
893    pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
894        let pl = self.playlist.read();
895        pl.items.iter().find(|item| item.id == id).cloned()
896    }
897
898    /// Get the ID of the item immediately before the given ID (None if first).
899    pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
900        let pl = self.playlist.read();
901        let pos = pl.items.iter().position(|item| item.id == id)?;
902        if pos == 0 {
903            None
904        } else {
905            Some(pl.items[pos - 1].id)
906        }
907    }
908
909    /// For each ID, the ID of the item before it (or None if first), returned in
910    /// playlist order regardless of the order `ids` arrives in.
911    ///
912    /// Undo replays these left to right, so an item whose recorded predecessor is
913    /// also in `ids` must come after it — otherwise the predecessor is missing at
914    /// replay time and the item lands at the end of the playlist instead.
915    /// Put the items in exactly this order.
916    ///
917    /// Items not named keep their relative order and follow at the end, so a
918    /// stale order cannot lose anything. The items themselves are moved, not
919    /// rebuilt: their ids, load states and download progress are what the rest
920    /// of the player is holding on to.
921    pub fn reorder_to(&self, order: &[QueueItemId]) {
922        let mut pl = self.playlist.write();
923        let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
924        let mut sorted = Vec::with_capacity(taken.len());
925        for id in order {
926            if let Some(slot) = taken
927                .iter_mut()
928                .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
929                && let Some(item) = slot.take()
930            {
931                sorted.push(item);
932            }
933        }
934        sorted.extend(taken.into_iter().flatten());
935        pl.items = sorted;
936        drop(pl);
937        self.bump_version();
938    }
939
940    pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
941        use std::collections::HashSet;
942        let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
943        let pl = self.playlist.read();
944        pl.items
945            .iter()
946            .enumerate()
947            .filter(|(_, item)| wanted.contains(&item.id))
948            .map(|(pos, item)| {
949                let before = if pos == 0 {
950                    None
951                } else {
952                    Some(pl.items[pos - 1].id)
953                };
954                (item.id, before)
955            })
956            .collect()
957    }
958
959    /// The nearest item before `id` that is not itself being removed — where
960    /// playback resumes from after a batch delete that takes out the cursor.
961    /// `None` means resume from the top of what survives.
962    pub fn surviving_item_before(
963        &self,
964        id: QueueItemId,
965        removed: &[QueueItemId],
966    ) -> Option<QueueItemId> {
967        use std::collections::HashSet;
968        let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
969        let pl = self.playlist.read();
970        let pos = pl.items.iter().position(|item| item.id == id)?;
971        pl.items[..pos]
972            .iter()
973            .rev()
974            .find(|item| !removed.contains(&item.id))
975            .map(|item| item.id)
976    }
977
978    /// Restore a full playlist from snapshot (for redo of ClearPlaylist undo).
979    pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
980        let mut pl = self.playlist.write();
981        pl.items = items;
982        pl.cursor = cursor;
983        drop(pl);
984        self.bump_version();
985    }
986
987    /// Remove multiple items by IDs.
988    pub fn remove_items(&self, ids: &[QueueItemId]) {
989        use std::collections::HashSet;
990        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
991        let mut pl = self.playlist.write();
992        pl.items.retain(|item| !id_set.contains(&item.id));
993        if let Some(cursor) = pl.cursor
994            && id_set.contains(&cursor)
995        {
996            pl.cursor = None;
997        }
998        drop(pl);
999        self.bump_version();
1000    }
1001
1002    /// Insert a single item after a given ID (or at front if None).
1003    pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1004        let mut pl = self.playlist.write();
1005        let insert_at = match after {
1006            Some(after_id) => {
1007                match pl.items.iter().position(|i| i.id == after_id) {
1008                    Some(pos) => pos + 1,
1009                    None => pl.items.len(), // fallback
1010                }
1011            }
1012            None => 0,
1013        };
1014        pl.items.insert(insert_at, item);
1015        drop(pl);
1016        self.bump_version();
1017    }
1018
1019    /// Move a single item to after `after` (or to front if None).
1020    pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1021        let mut pl = self.playlist.write();
1022        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1023            return;
1024        };
1025        let item = pl.items.remove(from);
1026        let insert_at = match after {
1027            Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1028                Some(pos) => pos + 1,
1029                None => pl.items.len(),
1030            },
1031            None => 0,
1032        };
1033        pl.items.insert(insert_at, item);
1034        drop(pl);
1035        self.bump_version();
1036    }
1037
1038    /// Batch move: reposition each item to after its given predecessor.
1039    /// Processes in order so earlier insertions don't corrupt later positions.
1040    pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1041        for &(id, after) in entries {
1042            self.move_item_to(id, after);
1043        }
1044    }
1045
1046    // --- Called from UI thread (read lock) ---
1047
1048    /// Derive the visible queue from the playlist + cursor. O(n).
1049    /// Called once per UI tick.
1050    pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1051        // Read before the playlist lock — see current_download_fraction.
1052        let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1053        let pl = self.playlist.read();
1054
1055        let cursor_pos = match pl.cursor {
1056            Some(cid) => pl.items.iter().position(|item| item.id == cid),
1057            None => None,
1058        };
1059
1060        let mut entries = Vec::with_capacity(pl.items.len());
1061        let mut finished_count = 0;
1062        let mut has_playing = false;
1063        let mut queue_count = 0;
1064
1065        for (i, item) in pl.items.iter().enumerate() {
1066            let is_cursor = cursor_pos == Some(i);
1067            let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1068
1069            // Byte count comes from the shared atomic, not from the load
1070            // state's own copy: the download thread writes it per chunk
1071            // without taking the playlist lock, which is what keeps a
1072            // transfer from bumping the playlist version a thousand times.
1073            // Once per row, because it is the item's state and any transfer
1074            // against it as one answer, and every branch below wants both.
1075            let load_state = LoadState::of(item);
1076
1077            let dl_progress = match &load_state {
1078                LoadState::Downloading {
1079                    total,
1080                    bytes_written,
1081                    ..
1082                } => Some((bytes_written.load(Ordering::Relaxed), *total)),
1083                _ => None,
1084            };
1085
1086            let status = if is_cursor {
1087                has_playing = true;
1088                match &load_state {
1089                    LoadState::Ready => QueueEntryStatus::Playing,
1090                    LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
1091                    LoadState::Pending => QueueEntryStatus::PriorityPending,
1092                    LoadState::Failed(_) => QueueEntryStatus::Failed,
1093                }
1094            } else if is_before_cursor {
1095                finished_count += 1;
1096                match &load_state {
1097                    LoadState::Ready => QueueEntryStatus::Played,
1098                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1099                    LoadState::Pending => QueueEntryStatus::Downloading,
1100                    LoadState::Failed(_) => QueueEntryStatus::Failed,
1101                }
1102            } else {
1103                queue_count += 1;
1104                match &load_state {
1105                    LoadState::Ready => QueueEntryStatus::Queued,
1106                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1107                    LoadState::Pending => QueueEntryStatus::Downloading,
1108                    LoadState::Failed(_) => QueueEntryStatus::Failed,
1109                }
1110            };
1111
1112            // Override duration from TrackInfo if we have it and this is playing.
1113            let duration_ms =
1114                if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1115                {
1116                    playing_duration_ms
1117                } else {
1118                    item.duration_ms
1119                };
1120
1121            entries.push(QueueEntry {
1122                id: item.id,
1123                db_id: item.db_id,
1124                playlist_entry_id: item.playlist_entry_id,
1125                path: item.path.clone(),
1126                title: item.title.clone(),
1127                artist: item.artist.clone(),
1128                album_artist: item.album_artist.clone(),
1129                album: item.album.clone(),
1130                year: item.year.clone(),
1131                codec: item.codec.clone(),
1132                track_number: item.track_number,
1133                disc: item.disc,
1134                duration_ms,
1135                status,
1136                download_progress: dl_progress,
1137                error: match &load_state {
1138                    LoadState::Failed(reason) => Some(reason.clone()),
1139                    _ => None,
1140                },
1141            });
1142        }
1143
1144        VisibleQueueSnapshot {
1145            entries,
1146            finished_count,
1147            has_playing,
1148            queue_count,
1149        }
1150    }
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156
1157    // --- helpers ---
1158
1159    fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1160        PlaylistItem {
1161            playlist_entry_id: None,
1162            id: QueueItemId::new(),
1163            db_id: None,
1164            path: PathBuf::from(format!("/music/{title}.flac")),
1165            title: title.to_string(),
1166            artist: "Artist".to_string(),
1167            album_artist: "Artist".to_string(),
1168            album: "Album".to_string(),
1169            year: None,
1170            codec: Some("FLAC".to_string()),
1171            track_number: None,
1172            disc: None,
1173            duration_ms: Some(200_000),
1174            state,
1175        }
1176    }
1177
1178    /// An item with a transfer running against it, told to the store the way
1179    /// the downloader tells it.
1180    fn downloading_item(title: &str, total: u64, written: Arc<AtomicU64>) -> PlaylistItem {
1181        let item = make_item(title, ItemState::Pending);
1182        let store = crate::remote::downloads::store();
1183        store.queued(crate::remote::downloads::Download {
1184            id: item.id,
1185            track_id: 1,
1186            title: title.into(),
1187            artist: String::new(),
1188            source: PathBuf::from(format!("/cache/{title}.flac.part")),
1189            dest: PathBuf::from(format!("/cache/{title}.flac")),
1190            total,
1191            written: written.clone(),
1192            state: crate::remote::downloads::DownloadState::Queued,
1193            bytes_per_second: 0,
1194        });
1195        store.started(item.id, total, written);
1196        item
1197    }
1198
1199    fn ready_item(title: &str) -> PlaylistItem {
1200        make_item(title, ItemState::Ready)
1201    }
1202
1203    fn pending_item(title: &str) -> PlaylistItem {
1204        make_item(title, ItemState::Pending)
1205    }
1206
1207    fn failed_item(title: &str) -> PlaylistItem {
1208        make_item(title, ItemState::Failed("nope".into()))
1209    }
1210
1211    const DURATION_MS: u64 = 32_523_787;
1212
1213    /// A nine-hour track under the cursor, `downloaded` bytes of `total` in.
1214    /// `total` of 0 stands for a server that sent no Content-Length.
1215    fn streaming_state(
1216        downloaded: u64,
1217        total: u64,
1218        bitrate_kbps: Option<u32>,
1219    ) -> Arc<SharedPlayerState> {
1220        streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1221    }
1222
1223    /// The same, but saying what the container managed to state about itself.
1224    /// A partial Ogg states nothing, which is zero here.
1225    fn streaming_state_with_duration(
1226        downloaded: u64,
1227        total: u64,
1228        bitrate_kbps: Option<u32>,
1229        container_duration_ms: u64,
1230    ) -> Arc<SharedPlayerState> {
1231        let written = Arc::new(AtomicU64::new(downloaded));
1232        let item = make_item("train", ItemState::Pending);
1233        let id = item.id;
1234        let path = item.path.clone();
1235
1236        // The transfer goes where transfers go. Ids are unique per item, so
1237        // tests sharing the process store never see each other's.
1238        let store = crate::remote::downloads::store();
1239        store.queued(crate::remote::downloads::Download {
1240            id,
1241            track_id: 1,
1242            title: "train".into(),
1243            artist: String::new(),
1244            source: PathBuf::from("/cache/train.opus.part"),
1245            dest: PathBuf::from("/cache/train.opus"),
1246            total,
1247            written: written.clone(),
1248            state: crate::remote::downloads::DownloadState::Queued,
1249            bytes_per_second: 0,
1250        });
1251        store.started(id, total, written);
1252
1253        let state = SharedPlayerState::new();
1254        state.add_items(vec![item]);
1255        state.set_cursor(Some(id));
1256        state.set_track_info(Some(TrackInfo {
1257            id,
1258            path,
1259            codec: "Opus".into(),
1260            sample_rate: 48_000,
1261            bit_depth: None,
1262            bitrate_kbps,
1263            channels: 2,
1264            duration_ms: container_duration_ms,
1265        }));
1266        state
1267    }
1268
1269    // --- seekable_ms ---
1270
1271    #[test]
1272    fn a_track_on_disk_is_seekable_end_to_end() {
1273        let item = ready_item("done");
1274        let id = item.id;
1275        let path = item.path.clone();
1276        let state = SharedPlayerState::new();
1277        state.add_items(vec![item]);
1278        state.set_cursor(Some(id));
1279        state.set_track_info(Some(TrackInfo {
1280            id,
1281            path,
1282            codec: "FLAC".into(),
1283            sample_rate: 44_100,
1284            bit_depth: Some(16),
1285            bitrate_kbps: None,
1286            channels: 2,
1287            duration_ms: 200_000,
1288        }));
1289
1290        assert_eq!(state.seekable_ms(), 200_000);
1291        // Nothing to draw a boundary for, so front ends are told there isn't one.
1292        assert_eq!(state.seek_ceiling_ms(), None);
1293    }
1294
1295    #[test]
1296    fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1297        // A quarter of a nine-hour file in: a quarter of the way through it,
1298        // less the margin the byte-to-time estimate is worth.
1299        let state = streaming_state(100, 400, None);
1300        assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1301        assert_eq!(
1302            state.seek_ceiling_ms(),
1303            Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1304        );
1305    }
1306
1307    #[test]
1308    fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1309        // No total to take a fraction of. 128 kbps is 128 bits per ms, so a
1310        // megabyte is 8 388 608 bits and a little over 65 seconds.
1311        let state = streaming_state(1024 * 1024, 0, Some(128));
1312        assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1313    }
1314
1315    #[test]
1316    fn nothing_to_estimate_from_allows_no_forward_seek() {
1317        // Neither a length nor a bitrate: anywhere past the playhead is a
1318        // guess, and a guess that lands past the write head is a stall.
1319        let state = streaming_state(1024 * 1024, 0, None);
1320        state.set_position_ms(12_000);
1321        assert_eq!(state.seekable_ms(), 12_000);
1322    }
1323
1324    #[test]
1325    fn the_seekable_extent_never_exceeds_the_track() {
1326        // A download reporting more bytes than it advertised must not offer a
1327        // seek past the end of the music.
1328        let state = streaming_state(500, 400, None);
1329        assert_eq!(state.seekable_ms(), 32_523_787);
1330    }
1331
1332    #[test]
1333    fn nothing_playing_is_seekable_nowhere() {
1334        assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1335        assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1336    }
1337
1338    #[test]
1339    fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1340        // A partial Ogg keeps its duration in a last page that has not arrived,
1341        // so it opens and plays but has nothing to seek against. Half the bytes
1342        // being present does not change that.
1343        let state = streaming_state_with_duration(200, 400, Some(128), 0);
1344        assert_eq!(state.seekable_ms(), 0);
1345    }
1346
1347    #[test]
1348    fn the_library_duration_stands_in_for_a_silent_container() {
1349        // What is shown on the transport, so nine hours of music does not read
1350        // as 0:00 while it caches.
1351        let state = streaming_state_with_duration(200, 400, Some(128), 0);
1352        assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1353        // And it is a display figure only — it grants no seeking.
1354        assert_eq!(state.seekable_ms(), 0);
1355        assert_eq!(state.seek_ceiling_ms(), Some(0));
1356    }
1357
1358    #[test]
1359    fn the_container_duration_wins_where_there_is_one() {
1360        let state = streaming_state(200, 400, None);
1361        assert_eq!(state.duration_ms(), DURATION_MS);
1362    }
1363
1364    #[test]
1365    fn the_download_landing_restores_seeking() {
1366        // The sequence the whole design turns on: a track that opened without a
1367        // duration gets one when the finished file is re-read, and is seekable
1368        // end to end from that moment — no restart, no handover.
1369        let state = streaming_state_with_duration(400, 400, Some(128), 0);
1370        assert_eq!(state.seekable_ms(), 0);
1371
1372        // What the downloader does when the bytes land: settle the transfer,
1373        // then say the file is playable. In that order — while the store still
1374        // says a transfer is running, it is.
1375        let id = state.cursor().expect("cursor");
1376        crate::remote::downloads::store().finished(id);
1377        state.update_item_state(id, ItemState::Ready);
1378        let info = state.track_info().expect("track info");
1379        state.set_track_info(Some(TrackInfo {
1380            duration_ms: DURATION_MS,
1381            ..info
1382        }));
1383
1384        assert_eq!(state.seekable_ms(), DURATION_MS);
1385        assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1386    }
1387
1388    // --- advance_cursor_loadable ---
1389
1390    #[test]
1391    fn advance_parks_on_a_still_downloading_track() {
1392        // A track that has not arrived yet must hold the cursor, not be skipped:
1393        // skipping it drops it from the queue for good, and it is the item whose
1394        // TrackReady has to resume playback.
1395        let state = SharedPlayerState::new();
1396        let item0 = ready_item("track-0");
1397        let item1 = pending_item("track-1");
1398        let item2 = ready_item("track-2");
1399        let (id0, id1) = (item0.id, item1.id);
1400
1401        state.add_items(vec![item0, item1, item2]);
1402
1403        assert_eq!(state.advance_cursor_loadable(), Some(id0));
1404        assert_eq!(state.advance_cursor_loadable(), Some(id1));
1405        assert_eq!(state.cursor(), Some(id1));
1406    }
1407
1408    #[test]
1409    fn advance_skips_failed_items() {
1410        let state = SharedPlayerState::new();
1411        let item0 = ready_item("track-0");
1412        let item1 = failed_item("track-1");
1413        let item2 = ready_item("track-2");
1414        let (id0, id2) = (item0.id, item2.id);
1415
1416        state.add_items(vec![item0, item1, item2]);
1417        state.set_cursor(Some(id0));
1418
1419        assert_eq!(state.advance_cursor_loadable(), Some(id2));
1420    }
1421
1422    #[test]
1423    fn advance_stops_at_end_of_playlist() {
1424        let state = SharedPlayerState::new();
1425        let item0 = ready_item("track-0");
1426        let item1 = ready_item("track-1");
1427        let id1 = item1.id;
1428
1429        state.add_items(vec![item0, item1]);
1430        state.set_cursor(Some(id1));
1431
1432        assert_eq!(state.advance_cursor_loadable(), None);
1433        assert_eq!(
1434            state.cursor(),
1435            Some(id1),
1436            "cursor unchanged on a failed advance"
1437        );
1438    }
1439
1440    #[test]
1441    fn advance_with_only_failed_items_returns_none() {
1442        let state = SharedPlayerState::new();
1443        state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1444
1445        assert_eq!(state.advance_cursor_loadable(), None);
1446    }
1447
1448    #[test]
1449    fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1450        let state = SharedPlayerState::new();
1451        let item0 = ready_item("track-0");
1452        let item1 = ready_item("track-1");
1453        let id0 = item0.id;
1454
1455        state.add_items(vec![item0, item1]);
1456        let ghost = QueueItemId::new();
1457        state.set_cursor(Some(ghost));
1458
1459        assert_eq!(state.advance_cursor_loadable(), None);
1460        assert_ne!(state.cursor(), Some(id0));
1461    }
1462
1463    // --- peek_next_ready_after ---
1464
1465    #[test]
1466    fn peek_after_a_removed_item_returns_none() {
1467        // The decode thread's lookahead runs seconds ahead of what is audible.
1468        // Removing the track it is pre-decoding must end the lookahead, not send
1469        // it back to the top of the queue.
1470        let state = SharedPlayerState::new();
1471        let item0 = ready_item("track-0");
1472        let item1 = ready_item("track-1");
1473        let item2 = ready_item("track-2");
1474        let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1475
1476        state.add_items(vec![item0, item1, item2]);
1477        assert_eq!(
1478            state.peek_next_ready_after(id1).map(|(id, _)| id),
1479            Some(id2)
1480        );
1481
1482        state.remove_item(id2);
1483        assert!(
1484            state.peek_next_ready_after(id2).is_none(),
1485            "a vanished reference must not resolve to the head of the queue"
1486        );
1487        assert_ne!(
1488            state.peek_next_ready_after(id2).map(|(id, _)| id),
1489            Some(id0)
1490        );
1491    }
1492
1493    // --- surviving_item_before ---
1494
1495    #[test]
1496    fn surviving_predecessor_skips_items_being_removed() {
1497        let state = SharedPlayerState::new();
1498        let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1499        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1500        state.add_items(items);
1501
1502        // Deleting 1..=3 leaves 0 as the resume point for a cursor on 3.
1503        assert_eq!(
1504            state.surviving_item_before(ids[3], &ids[1..4]),
1505            Some(ids[0])
1506        );
1507        // Deleting everything from the top leaves nothing to resume after.
1508        assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1509    }
1510
1511    // --- retreat_cursor ---
1512
1513    #[test]
1514    fn test_retreat_cursor_goes_to_previous_item() {
1515        let state = SharedPlayerState::new();
1516        let item0 = ready_item("track-0");
1517        let item1 = ready_item("track-1");
1518        let id0 = item0.id;
1519        let id1 = item1.id;
1520
1521        state.add_items(vec![item0, item1]);
1522        state.set_cursor(Some(id1));
1523
1524        let result = state.retreat_cursor();
1525        assert!(result.is_some(), "expected to retreat to previous item");
1526        assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1527        assert_eq!(state.cursor(), Some(id0));
1528    }
1529
1530    #[test]
1531    fn test_retreat_cursor_returns_none_when_at_first_item() {
1532        let state = SharedPlayerState::new();
1533        let item0 = ready_item("only-track");
1534        let id0 = item0.id;
1535
1536        state.add_items(vec![item0]);
1537        state.set_cursor(Some(id0));
1538
1539        let result = state.retreat_cursor();
1540        assert!(result.is_none(), "cannot retreat before the first item");
1541        // Cursor stays on the first item.
1542        assert_eq!(state.cursor(), Some(id0));
1543    }
1544
1545    #[test]
1546    fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1547        let state = SharedPlayerState::new();
1548        state.add_items(vec![ready_item("track-0")]);
1549
1550        let result = state.retreat_cursor();
1551        assert!(
1552            result.is_none(),
1553            "retreat with no cursor should return None"
1554        );
1555    }
1556
1557    // --- derive_visible_queue ---
1558
1559    #[test]
1560    fn test_derive_visible_queue_statuses() {
1561        // playlist: [played, playing, queued]
1562        let state = SharedPlayerState::new();
1563        let item0 = ready_item("played-track");
1564        let item1 = ready_item("playing-track");
1565        let item2 = ready_item("queued-track");
1566        let id1 = item1.id;
1567
1568        state.add_items(vec![item0, item1, item2]);
1569        state.set_cursor(Some(id1));
1570
1571        let snap = state.derive_visible_queue();
1572
1573        assert_eq!(snap.entries.len(), 3);
1574        assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1575        assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1576        assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1577        assert!(snap.has_playing);
1578        assert_eq!(snap.finished_count, 1);
1579        assert_eq!(snap.queue_count, 1);
1580    }
1581
1582    #[test]
1583    fn test_derive_visible_queue_downloading_statuses() {
1584        // A Downloading item at cursor → PriorityPending; after cursor → Downloading.
1585        let state = SharedPlayerState::new();
1586        let bytes_cursor = Arc::new(AtomicU64::new(0));
1587        let bytes_queued = Arc::new(AtomicU64::new(0));
1588        let dl_cursor = downloading_item("downloading-at-cursor", 1_000_000, bytes_cursor.clone());
1589        let dl_queued = downloading_item("downloading-queued", 500_000, bytes_queued.clone());
1590        let id_cursor = dl_cursor.id;
1591
1592        state.add_items(vec![dl_cursor, dl_queued]);
1593        state.set_cursor(Some(id_cursor));
1594
1595        let snap = state.derive_visible_queue();
1596
1597        assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1598        assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1599    }
1600
1601    #[test]
1602    fn progress_follows_the_counter_without_touching_the_playlist() {
1603        // The download thread writes bytes and nothing else. A queue derived
1604        // afterwards must see them — the version has not moved, and the load
1605        // state it was given is the one it still holds.
1606        let state = SharedPlayerState::new();
1607        let bytes = Arc::new(AtomicU64::new(0));
1608        let item = downloading_item("downloading", 1_000, bytes.clone());
1609        state.add_items(vec![item]);
1610
1611        let version = state.playlist_version();
1612        bytes.store(250, Ordering::Release);
1613
1614        let snap = state.derive_visible_queue();
1615        assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1616        assert_eq!(
1617            state.playlist_version(),
1618            version,
1619            "progress must not read as a queue mutation"
1620        );
1621        // Every transfer the process knows about, not just this playlist's —
1622        // a fetch with no queue item behind it is still a transfer, and the
1623        // store is what is asked. Other tests share it, so this looks for its
1624        // own rather than asserting the whole list.
1625        assert!(
1626            state
1627                .downloads_in_flight()
1628                .contains(&(snap.entries[0].id, 250, 1_000)),
1629            "the counter should be visible through the store"
1630        );
1631    }
1632
1633    #[test]
1634    fn test_derive_visible_queue_no_cursor_all_queued() {
1635        let state = SharedPlayerState::new();
1636        state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1637
1638        let snap = state.derive_visible_queue();
1639
1640        assert_eq!(snap.entries.len(), 3);
1641        for entry in &snap.entries {
1642            assert_eq!(entry.status, QueueEntryStatus::Queued);
1643        }
1644        assert!(!snap.has_playing);
1645        assert_eq!(snap.finished_count, 0);
1646        assert_eq!(snap.queue_count, 3);
1647    }
1648
1649    // --- same_album_item_ids ---
1650
1651    fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1652        PlaylistItem {
1653            playlist_entry_id: None,
1654            id: QueueItemId::new(),
1655            db_id: None,
1656            path: PathBuf::from(format!("/music/{title}.flac")),
1657            title: title.to_string(),
1658            artist: "Artist".to_string(),
1659            album_artist: album_artist.to_string(),
1660            album: album.to_string(),
1661            year: None,
1662            codec: Some("FLAC".to_string()),
1663            track_number: None,
1664            disc: None,
1665            duration_ms: Some(200_000),
1666            state: ItemState::Ready,
1667        }
1668    }
1669
1670    #[test]
1671    fn test_same_album_item_ids_returns_album_mates() {
1672        let state = SharedPlayerState::new();
1673        let a1 = make_album_item("A1", "Album A", "Artist A");
1674        let a2 = make_album_item("A2", "Album A", "Artist A");
1675        let b1 = make_album_item("B1", "Album B", "Artist B");
1676        let a3 = make_album_item("A3", "Album A", "Artist A");
1677
1678        let id_a1 = a1.id;
1679        let id_a2 = a2.id;
1680        let id_a3 = a3.id;
1681
1682        state.add_items(vec![a1, a2, b1, a3]);
1683
1684        let mates = state.same_album_item_ids(id_a1);
1685        assert_eq!(mates.len(), 2);
1686        assert!(mates.contains(&id_a2));
1687        assert!(mates.contains(&id_a3));
1688    }
1689
1690    #[test]
1691    fn test_same_album_item_ids_distinguishes_album_artists() {
1692        // Two albums named the same but by different artists — should NOT match.
1693        let state = SharedPlayerState::new();
1694        let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1695        let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1696
1697        let id_a1 = a1.id;
1698
1699        state.add_items(vec![a1, b1]);
1700
1701        let mates = state.same_album_item_ids(id_a1);
1702        assert!(mates.is_empty(), "different album_artist should not match");
1703    }
1704
1705    #[test]
1706    fn test_same_album_item_ids_unknown_id_returns_empty() {
1707        let state = SharedPlayerState::new();
1708        state.add_items(vec![ready_item("track-0")]);
1709
1710        let bogus = QueueItemId::new();
1711        let mates = state.same_album_item_ids(bogus);
1712        assert!(mates.is_empty());
1713    }
1714
1715    // --- move_item_to ---
1716
1717    #[test]
1718    fn test_move_item_to_reorders_playlist() {
1719        // Start: [A, B, C]. Move C to after A → [A, C, B].
1720        let state = SharedPlayerState::new();
1721        let item_a = ready_item("A");
1722        let item_b = ready_item("B");
1723        let item_c = ready_item("C");
1724        let id_a = item_a.id;
1725        let id_b = item_b.id;
1726        let id_c = item_c.id;
1727
1728        state.add_items(vec![item_a, item_b, item_c]);
1729        state.move_item_to(id_c, Some(id_a));
1730
1731        let (items, _) = state.snapshot_playlist();
1732        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1733        assert_eq!(titles, vec!["A", "C", "B"]);
1734        assert_eq!(items[0].id, id_a);
1735        assert_eq!(items[1].id, id_c);
1736        assert_eq!(items[2].id, id_b);
1737    }
1738
1739    #[test]
1740    fn test_move_item_to_front_when_after_is_none() {
1741        // Start: [A, B, C]. Move C to front (after=None) → [C, A, B].
1742        let state = SharedPlayerState::new();
1743        let item_a = ready_item("A");
1744        let item_b = ready_item("B");
1745        let item_c = ready_item("C");
1746        let id_c = item_c.id;
1747
1748        state.add_items(vec![item_a, item_b, item_c]);
1749        state.move_item_to(id_c, None);
1750
1751        let (items, _) = state.snapshot_playlist();
1752        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1753        assert_eq!(titles, vec!["C", "A", "B"]);
1754    }
1755
1756    // --- move_items (batch) ---
1757
1758    #[test]
1759    fn test_move_items_batch_preserves_relative_order() {
1760        // Start: [A, B, C, D]. Move [A, C] after D → [B, D, A, C].
1761        let state = SharedPlayerState::new();
1762        let item_a = ready_item("A");
1763        let item_b = ready_item("B");
1764        let item_c = ready_item("C");
1765        let item_d = ready_item("D");
1766        let id_a = item_a.id;
1767        let id_b = item_b.id;
1768        let id_c = item_c.id;
1769        let id_d = item_d.id;
1770
1771        state.add_items(vec![item_a, item_b, item_c, item_d]);
1772        state.move_items(&[id_a, id_c], id_d, true);
1773
1774        let (items, _) = state.snapshot_playlist();
1775        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1776        assert_eq!(titles, vec!["B", "D", "A", "C"]);
1777        assert_eq!(items[0].id, id_b);
1778        assert_eq!(items[1].id, id_d);
1779        assert_eq!(items[2].id, id_a);
1780        assert_eq!(items[3].id, id_c);
1781    }
1782
1783    // --- pending_downloads ---
1784
1785    #[test]
1786    fn test_pending_downloads_collects_pending_with_db_id() {
1787        let state = SharedPlayerState::new();
1788        let mut item_a = ready_item("local");
1789        item_a.db_id = None;
1790
1791        let mut item_b = pending_item("remote-1");
1792        item_b.db_id = Some(10);
1793        let id_b = item_b.id;
1794
1795        let mut item_c = ready_item("cached");
1796        item_c.db_id = Some(20);
1797
1798        let mut item_d = pending_item("remote-2");
1799        item_d.db_id = Some(30);
1800        let id_d = item_d.id;
1801
1802        // Pending without db_id — should NOT appear (no way to download).
1803        let item_e = pending_item("orphan");
1804
1805        state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1806
1807        let pending = state.pending_downloads();
1808        assert_eq!(pending.len(), 2);
1809        assert_eq!(pending[0], (10, id_b));
1810        assert_eq!(pending[1], (30, id_d));
1811    }
1812
1813    #[test]
1814    fn test_item_db_id_and_load_state() {
1815        let state = SharedPlayerState::new();
1816        let mut item = pending_item("track");
1817        item.db_id = Some(42);
1818        let id = item.id;
1819        state.add_items(vec![item]);
1820
1821        assert_eq!(state.item_db_id(id), Some(42));
1822        assert!(matches!(
1823            state.item_load_state(id),
1824            Some(LoadState::Pending)
1825        ));
1826
1827        state.update_item_state(id, ItemState::Ready);
1828        assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1829    }
1830}