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/// Load state of a playlist item — tracks download lifecycle.
69#[derive(Debug, Clone)]
70pub enum LoadState {
71    Pending,
72    Downloading {
73        /// Total bytes expected, or 0 when the server sent no Content-Length.
74        total: u64,
75        /// How many bytes have landed. The download thread writes it per chunk
76        /// without taking the playlist lock — which is the point: progress
77        /// moves far too often to be worth a queue mutation each time.
78        bytes_written: Arc<AtomicU64>,
79    },
80    Ready,
81    Failed(String),
82}
83
84/// Resolved playback source for a playlist item.
85pub enum PlaybackSource {
86    /// File fully downloaded — play from path.
87    Ready(PathBuf),
88    /// File being downloaded — enough data buffered to start streaming.
89    Streaming {
90        path: PathBuf,
91        bytes_written: Arc<AtomicU64>,
92        total: u64,
93    },
94}
95
96/// A single item in the playlist. Replaces QueueEntry + QueueEntryMeta + pending entries
97/// as the canonical data. Created once when tracks are added to the playlist.
98#[derive(Debug, Clone)]
99pub struct PlaylistItem {
100    pub id: QueueItemId,
101    /// Database track ID — set for tracks loaded from DB, used for downloads.
102    pub db_id: Option<i64>,
103    /// The playlist entry this came from, when it came from a playlist.
104    ///
105    /// A playlist may hold the same track twice, and two copies are two queue
106    /// items. Without this a playlist row can only ask "is my *track* playing?"
107    /// and both copies answer yes. The entry id is the one thing that tells
108    /// them apart, so the queue carries it.
109    pub playlist_entry_id: Option<i64>,
110    pub path: PathBuf,
111    pub title: String,
112    pub artist: String,
113    pub album_artist: String,
114    pub album: String,
115    pub year: Option<String>,
116    pub codec: Option<String>,
117    pub track_number: Option<i64>,
118    pub disc: Option<i64>,
119    pub duration_ms: Option<u64>,
120    pub load_state: LoadState,
121}
122
123/// The playlist — one flat array, one cursor. Everything else derived.
124#[derive(Debug, Clone, Default)]
125pub struct Playlist {
126    pub items: Vec<PlaylistItem>,
127    pub cursor: Option<QueueItemId>,
128}
129
130// --- UI view types (kept for TUI compat) ---
131
132/// Status of a track in the queue — for UI display.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum QueueEntryStatus {
135    Queued,
136    Playing,
137    Played,
138    Downloading,
139    /// User double-clicked — this track is priority, will play when ready.
140    PriorityPending,
141    Failed,
142}
143
144/// A single entry in the UI-visible queue snapshot.
145#[derive(Debug, Clone)]
146pub struct QueueEntry {
147    pub id: QueueItemId,
148    /// Database track ID — set for tracks loaded from DB, used for downloads.
149    pub db_id: Option<i64>,
150    /// The playlist row this came from — see `PlaylistItem::playlist_entry_id`.
151    pub playlist_entry_id: Option<i64>,
152    pub path: PathBuf,
153    pub title: String,
154    pub artist: String,
155    pub album_artist: String,
156    pub album: String,
157    pub year: Option<String>,
158    pub codec: Option<String>,
159    pub track_number: Option<i64>,
160    pub disc: Option<i64>,
161    pub duration_ms: Option<u64>,
162    pub status: QueueEntryStatus,
163    pub download_progress: Option<(u64, u64)>,
164    /// Why this entry cannot play, when `status` is `Failed`.
165    pub error: Option<String>,
166}
167
168/// Pre-built visible queue — single atomic snapshot for the UI.
169#[derive(Debug, Clone, Default)]
170pub struct VisibleQueueSnapshot {
171    pub entries: Vec<QueueEntry>,
172    pub finished_count: usize,
173    pub has_playing: bool,
174    pub queue_count: usize,
175}
176
177/// Shared player state — atomics for lock-free reads from UI thread.
178///
179/// The engine writes these, the UI reads them. No mutexes in the hot path.
180#[derive(Debug)]
181pub struct SharedPlayerState {
182    state: AtomicU8,
183    position_ms: AtomicU64,
184    track_info: parking_lot::RwLock<Option<TrackInfo>>,
185
186    /// THE playlist + cursor — one lock, one truth.
187    playlist: parking_lot::RwLock<Playlist>,
188
189    /// Bumped on every playlist mutation so UI can skip redundant redraws.
190    playlist_version: AtomicU64,
191
192    /// Set by external signals (e.g. souvlaki Quit event) to request clean shutdown.
193    quit_requested: AtomicBool,
194
195    /// Set when metadata has been refreshed (e.g. download completed while streaming).
196    /// The UI loop checks this to force a souvlaki/cover-art update without a track change.
197    metadata_refresh_pending: AtomicBool,
198
199    /// The rate the output device settled at for the current track, or 0 when
200    /// nothing has played yet. Compared against the source rate, it is the one
201    /// thing koan can say for certain about the path to the DAC: whether it
202    /// handed the device the samples as they are, or something had to resample
203    /// to reach it. Everything past that — other clients, the volume stage — is
204    /// the system's, and not ours to claim.
205    output_sample_rate: AtomicU64,
206
207    /// Radio mode — automatically queue similar tracks when the queue runs low.
208    /// Shared so GQL/MCP can toggle it without going through the TUI.
209    radio_mode: AtomicBool,
210}
211
212impl SharedPlayerState {
213    pub fn new() -> Arc<Self> {
214        Arc::new(Self {
215            state: AtomicU8::new(PlaybackState::Stopped as u8),
216            position_ms: AtomicU64::new(0),
217            track_info: parking_lot::RwLock::new(None),
218            playlist: parking_lot::RwLock::new(Playlist::default()),
219            playlist_version: AtomicU64::new(0),
220            quit_requested: AtomicBool::new(false),
221            metadata_refresh_pending: AtomicBool::new(false),
222            output_sample_rate: AtomicU64::new(0),
223            radio_mode: AtomicBool::new(false),
224        })
225    }
226
227    // --- Playback state ---
228
229    pub fn playback_state(&self) -> PlaybackState {
230        PlaybackState::from_u8(self.state.load(Ordering::Acquire))
231    }
232
233    pub fn set_playback_state(&self, state: PlaybackState) {
234        self.state.store(state as u8, Ordering::Release);
235    }
236
237    pub fn position_ms(&self) -> u64 {
238        self.position_ms.load(Ordering::Acquire)
239    }
240
241    pub fn set_position_ms(&self, pos: u64) {
242        self.position_ms.store(pos, Ordering::Release);
243    }
244
245    pub fn track_info(&self) -> Option<TrackInfo> {
246        self.track_info.read().clone()
247    }
248
249    pub fn set_track_info(&self, info: Option<TrackInfo>) {
250        *self.track_info.write() = info;
251    }
252
253    /// Download fraction (0.0..1.0) for the currently playing track, if streaming.
254    /// Returns `None` for fully-downloaded or non-playing tracks.
255    pub fn current_download_fraction(&self) -> Option<f64> {
256        // Released before the playlist lock is taken: derive_visible_queue takes
257        // these two in the opposite order, so holding both would close a cycle.
258        let id = self.track_info.read().as_ref()?.id;
259        let pl = self.playlist.read();
260        pl.items
261            .iter()
262            .find(|item| item.id == id)
263            .and_then(|item| match &item.load_state {
264                LoadState::Downloading {
265                    bytes_written,
266                    total,
267                    ..
268                } => {
269                    let written = bytes_written.load(Ordering::Acquire);
270                    if *total > 0 {
271                        Some((written as f64 / *total as f64).min(1.0))
272                    } else {
273                        None
274                    }
275                }
276                _ => None,
277            })
278    }
279
280    // --- Quit ---
281
282    pub fn request_quit(&self) {
283        self.quit_requested.store(true, Ordering::Release);
284    }
285
286    pub fn quit_requested(&self) -> bool {
287        self.quit_requested.load(Ordering::Acquire)
288    }
289
290    // --- Metadata refresh (progressive enhancement) ---
291
292    /// Signal that metadata has been refreshed mid-stream (e.g. download completed).
293    /// The UI loop calls `take_metadata_refresh()` to consume this flag and
294    /// force a souvlaki/cover-art update without waiting for a track change.
295    pub fn signal_metadata_refresh(&self) {
296        self.metadata_refresh_pending.store(true, Ordering::Release);
297    }
298
299    /// Returns true and clears the flag if a metadata refresh is pending.
300    pub fn take_metadata_refresh(&self) -> bool {
301        self.metadata_refresh_pending
302            .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
303            .is_ok()
304    }
305
306    // --- Radio mode ---
307
308    pub fn radio_mode(&self) -> bool {
309        self.radio_mode.load(Ordering::Acquire)
310    }
311
312    pub fn set_radio_mode(&self, enabled: bool) {
313        self.radio_mode.store(enabled, Ordering::Release);
314    }
315
316    // --- Output device rate ---
317
318    /// `None` until a track has started and the device rate is known.
319    pub fn output_sample_rate(&self) -> Option<u32> {
320        match self.output_sample_rate.load(Ordering::Acquire) {
321            0 => None,
322            rate => Some(rate as u32),
323        }
324    }
325
326    pub fn set_output_sample_rate(&self, rate: u32) {
327        self.output_sample_rate
328            .store(u64::from(rate), Ordering::Release);
329    }
330
331    /// Back to "not known yet", for the window where the device is between
332    /// rates. A switch takes as long as the hardware needs to reclock — the
333    /// better part of a second on USB — and the previous track's rate is not
334    /// an answer for this one.
335    pub fn clear_output_sample_rate(&self) {
336        self.output_sample_rate.store(0, Ordering::Release);
337    }
338
339    // --- Playlist version ---
340
341    pub fn playlist_version(&self) -> u64 {
342        self.playlist_version.load(Ordering::Acquire)
343    }
344
345    fn bump_version(&self) {
346        self.playlist_version.fetch_add(1, Ordering::AcqRel);
347    }
348
349    // --- Playlist mutations (called from player thread via commands) ---
350
351    /// Append items to the playlist.
352    pub fn add_items(&self, items: Vec<PlaylistItem>) {
353        let mut pl = self.playlist.write();
354        pl.items.extend(items);
355        drop(pl);
356        self.bump_version();
357    }
358
359    /// Insert items after a specific queue item.
360    pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
361        let mut pl = self.playlist.write();
362        let insert_at = match pl.items.iter().position(|item| item.id == after) {
363            Some(pos) => pos + 1,
364            None => pl.items.len(), // fallback: append
365        };
366        for (i, item) in items.into_iter().enumerate() {
367            pl.items.insert(insert_at + i, item);
368        }
369        drop(pl);
370        self.bump_version();
371    }
372
373    /// Update file paths for playlist items (after organize moves files).
374    pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
375        let mut pl = self.playlist.write();
376        for (id, new_path) in updates {
377            if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
378                item.path = new_path.clone();
379            }
380        }
381        drop(pl);
382        self.bump_version();
383    }
384
385    /// Remove an item by ID.
386    pub fn remove_item(&self, id: QueueItemId) {
387        let mut pl = self.playlist.write();
388        pl.items.retain(|item| item.id != id);
389        // If cursor was on removed item, clear it (caller handles next_track).
390        if pl.cursor == Some(id) {
391            pl.cursor = None;
392        }
393        drop(pl);
394        self.bump_version();
395    }
396
397    /// Move an item relative to another entry.
398    pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
399        let mut pl = self.playlist.write();
400        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
401            return;
402        };
403        let item = pl.items.remove(from);
404        let Some(to) = pl.items.iter().position(|item| item.id == target) else {
405            // Target gone — put it back.
406            let pos = from.min(pl.items.len());
407            pl.items.insert(pos, item);
408            return;
409        };
410        let insert_at = if after { to + 1 } else { to };
411        pl.items.insert(insert_at, item);
412        drop(pl);
413        self.bump_version();
414    }
415
416    /// Batch move: extract items by ID, reinsert them at `target` position.
417    /// Preserves the relative order of the moved items.
418    pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
419        use std::collections::HashSet;
420        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
421
422        let mut pl = self.playlist.write();
423
424        // Partition: extract moved items, keep the rest.
425        let mut remaining = Vec::with_capacity(pl.items.len());
426        let mut moved = Vec::with_capacity(ids.len());
427        for item in pl.items.drain(..) {
428            if id_set.contains(&item.id) {
429                moved.push(item);
430            } else {
431                remaining.push(item);
432            }
433        }
434
435        // Find target in the remaining items.
436        let insert_at = match remaining.iter().position(|item| item.id == target) {
437            Some(pos) => {
438                if after {
439                    pos + 1
440                } else {
441                    pos
442                }
443            }
444            None => remaining.len(),
445        };
446
447        // Splice moved items in at the target position.
448        for (i, item) in moved.into_iter().enumerate() {
449            remaining.insert(insert_at + i, item);
450        }
451
452        pl.items = remaining;
453        drop(pl);
454        self.bump_version();
455    }
456
457    /// Set the cursor (what's playing / should play).
458    pub fn set_cursor(&self, id: Option<QueueItemId>) {
459        let mut pl = self.playlist.write();
460        pl.cursor = id;
461        drop(pl);
462        self.bump_version();
463    }
464
465    /// Get the current cursor ID.
466    pub fn cursor(&self) -> Option<QueueItemId> {
467        self.playlist.read().cursor
468    }
469
470    /// Clear the entire playlist + cursor.
471    pub fn clear_playlist(&self) {
472        let mut pl = self.playlist.write();
473        pl.items.clear();
474        pl.cursor = None;
475        drop(pl);
476        self.bump_version();
477    }
478
479    // --- Called from decode thread (gapless) ---
480
481    /// Move the cursor to the next item that can still play — the first item
482    /// after the cursor that is not `Failed` — and return its ID.
483    ///
484    /// An item that is still downloading parks the cursor rather than being
485    /// skipped, so playback resumes from it when its data lands. Skipping it
486    /// would drop it from the queue for good.
487    ///
488    /// With no cursor set, starts from the top. A cursor pointing at an item
489    /// that is no longer in the playlist yields `None` — restarting from the
490    /// top would silently replay the queue.
491    pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
492        let mut pl = self.playlist.write();
493        let start = match pl.cursor {
494            Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
495            None => 0,
496        };
497
498        let next = pl
499            .items
500            .get(start..)?
501            .iter()
502            .find(|item| !matches!(item.load_state, LoadState::Failed(_)))
503            .map(|item| item.id)?;
504
505        pl.cursor = Some(next);
506        drop(pl);
507        self.bump_version();
508        Some(next)
509    }
510
511    /// Peek at the next Ready item after a given item ID WITHOUT moving the cursor.
512    /// Used by the decode thread for gapless lookahead — the cursor is moved
513    /// later by update_playback_state when playback actually reaches the track.
514    pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
515        let pl = self.playlist.read();
516        // A reference item that has been removed means the lookahead has nothing
517        // to follow; starting from the top would gaplessly replay the queue.
518        let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
519
520        for i in start..pl.items.len() {
521            if matches!(pl.items[i].load_state, LoadState::Ready) {
522                let item = &pl.items[i];
523                return Some((item.id, item.path.clone()));
524            }
525        }
526        None
527    }
528
529    /// Retreat cursor to the previous item. Returns (id, path) if found.
530    /// For prev_track — goes to the item before cursor regardless of load state.
531    pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
532        let mut pl = self.playlist.write();
533        let cursor_pos = match pl.cursor {
534            Some(cid) => pl.items.iter().position(|item| item.id == cid),
535            None => None,
536        };
537
538        let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
539
540        match prev_pos {
541            Some(pos) => {
542                let item = &pl.items[pos];
543                let result = (item.id, item.path.clone());
544                pl.cursor = Some(item.id);
545                drop(pl);
546                self.bump_version();
547                Some(result)
548            }
549            None => None,
550        }
551    }
552
553    // --- Called from resolve thread ---
554
555    /// Update the load state of a playlist item. Safe — just a field update under lock.
556    pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
557        let mut pl = self.playlist.write();
558        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
559            item.load_state = new_state;
560        }
561        drop(pl);
562        self.bump_version();
563    }
564
565    /// Update playlist item metadata after a full download completes.
566    /// Used for progressive enhancement: streaming started with partial Symphonia tags,
567    /// now the full file is available so we can refresh with complete lofty metadata.
568    pub fn update_item_metadata(
569        &self,
570        id: QueueItemId,
571        title: String,
572        artist: String,
573        album_artist: String,
574        album: String,
575        duration_ms: Option<u64>,
576    ) {
577        let mut pl = self.playlist.write();
578        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
579            item.title = title;
580            item.artist = artist;
581            item.album_artist = album_artist;
582            item.album = album;
583            if let Some(dur) = duration_ms {
584                item.duration_ms = Some(dur);
585            }
586        }
587        drop(pl);
588        self.bump_version();
589    }
590
591    /// Get the playback source for an item if it's ready to play.
592    /// Returns `None` if not enough data is available yet.
593    pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
594        let pl = self.playlist.read();
595        pl.items
596            .iter()
597            .find(|item| item.id == id)
598            .and_then(|item| match &item.load_state {
599                LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
600                LoadState::Downloading {
601                    total,
602                    bytes_written,
603                    ..
604                } => {
605                    let written = bytes_written.load(Ordering::Acquire);
606                    if written >= STREAM_THRESHOLD {
607                        Some(PlaybackSource::Streaming {
608                            path: item.path.clone(),
609                            bytes_written: bytes_written.clone(),
610                            total: *total,
611                        })
612                    } else {
613                        None
614                    }
615                }
616                _ => None,
617            })
618    }
619
620    /// Get the path of an item if it's Ready (legacy convenience — use item_playback_source for streaming).
621    pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
622        let pl = self.playlist.read();
623        pl.items.iter().find(|item| item.id == id).and_then(|item| {
624            if matches!(item.load_state, LoadState::Ready) {
625                Some(item.path.clone())
626            } else {
627                None
628            }
629        })
630    }
631
632    /// Check if the cursor is on the given item.
633    pub fn is_cursor(&self, id: QueueItemId) -> bool {
634        self.playlist.read().cursor == Some(id)
635    }
636
637    /// Get QueueItemIds of all playlist items sharing the same album as the given item.
638    /// Matches on both album name and album artist to avoid false positives
639    /// (e.g. two different "Greatest Hits" by different artists).
640    pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
641        let pl = self.playlist.read();
642        let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
643            return vec![];
644        };
645        let album = cursor.album.clone();
646        let album_artist = cursor.album_artist.clone();
647        pl.items
648            .iter()
649            .filter(|item| {
650                item.id != id && item.album == album && item.album_artist == album_artist
651            })
652            .map(|item| item.id)
653            .collect()
654    }
655
656    /// Get all playlist items that are Pending and have a db_id.
657    /// Returns `(db_id, QueueItemId)` pairs suitable for the download queue.
658    pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
659        let pl = self.playlist.read();
660        pl.items
661            .iter()
662            .filter(|item| matches!(item.load_state, LoadState::Pending))
663            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
664            .collect()
665    }
666
667    /// Every item mid-transfer, with the bytes it has and the bytes it expects.
668    ///
669    /// Progress moves without the playlist version moving — the download thread
670    /// writes the byte counter directly — so anything following the version
671    /// alone shows a frozen bar. This is how a watcher sees it move.
672    pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
673        let pl = self.playlist.read();
674        pl.items
675            .iter()
676            .filter_map(|item| match &item.load_state {
677                LoadState::Downloading {
678                    total,
679                    bytes_written,
680                } => Some((item.id, bytes_written.load(Ordering::Relaxed), *total)),
681                _ => None,
682            })
683            .collect()
684    }
685
686    /// Get the db_id for a specific playlist item.
687    pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
688        let pl = self.playlist.read();
689        pl.items
690            .iter()
691            .find(|item| item.id == id)
692            .and_then(|item| item.db_id)
693    }
694
695    /// Get the load state of a specific playlist item.
696    pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
697        let pl = self.playlist.read();
698        pl.items
699            .iter()
700            .find(|item| item.id == id)
701            .map(|item| item.load_state.clone())
702    }
703
704    // --- Snapshot helpers for undo ---
705
706    /// Get the full playlist snapshot (items + cursor) for undo of ClearPlaylist.
707    pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
708        let pl = self.playlist.read();
709        (pl.items.clone(), pl.cursor)
710    }
711
712    /// Get an item by ID (for undo of RemoveFromPlaylist).
713    pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
714        let pl = self.playlist.read();
715        pl.items.iter().find(|item| item.id == id).cloned()
716    }
717
718    /// Get the ID of the item immediately before the given ID (None if first).
719    pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
720        let pl = self.playlist.read();
721        let pos = pl.items.iter().position(|item| item.id == id)?;
722        if pos == 0 {
723            None
724        } else {
725            Some(pl.items[pos - 1].id)
726        }
727    }
728
729    /// For each ID, the ID of the item before it (or None if first), returned in
730    /// playlist order regardless of the order `ids` arrives in.
731    ///
732    /// Undo replays these left to right, so an item whose recorded predecessor is
733    /// also in `ids` must come after it — otherwise the predecessor is missing at
734    /// replay time and the item lands at the end of the playlist instead.
735    /// Put the items in exactly this order.
736    ///
737    /// Items not named keep their relative order and follow at the end, so a
738    /// stale order cannot lose anything. The items themselves are moved, not
739    /// rebuilt: their ids, load states and download progress are what the rest
740    /// of the player is holding on to.
741    pub fn reorder_to(&self, order: &[QueueItemId]) {
742        let mut pl = self.playlist.write();
743        let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
744        let mut sorted = Vec::with_capacity(taken.len());
745        for id in order {
746            if let Some(slot) = taken
747                .iter_mut()
748                .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
749                && let Some(item) = slot.take()
750            {
751                sorted.push(item);
752            }
753        }
754        sorted.extend(taken.into_iter().flatten());
755        pl.items = sorted;
756        drop(pl);
757        self.bump_version();
758    }
759
760    pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
761        use std::collections::HashSet;
762        let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
763        let pl = self.playlist.read();
764        pl.items
765            .iter()
766            .enumerate()
767            .filter(|(_, item)| wanted.contains(&item.id))
768            .map(|(pos, item)| {
769                let before = if pos == 0 {
770                    None
771                } else {
772                    Some(pl.items[pos - 1].id)
773                };
774                (item.id, before)
775            })
776            .collect()
777    }
778
779    /// The nearest item before `id` that is not itself being removed — where
780    /// playback resumes from after a batch delete that takes out the cursor.
781    /// `None` means resume from the top of what survives.
782    pub fn surviving_item_before(
783        &self,
784        id: QueueItemId,
785        removed: &[QueueItemId],
786    ) -> Option<QueueItemId> {
787        use std::collections::HashSet;
788        let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
789        let pl = self.playlist.read();
790        let pos = pl.items.iter().position(|item| item.id == id)?;
791        pl.items[..pos]
792            .iter()
793            .rev()
794            .find(|item| !removed.contains(&item.id))
795            .map(|item| item.id)
796    }
797
798    /// Restore a full playlist from snapshot (for redo of ClearPlaylist undo).
799    pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
800        let mut pl = self.playlist.write();
801        pl.items = items;
802        pl.cursor = cursor;
803        drop(pl);
804        self.bump_version();
805    }
806
807    /// Remove multiple items by IDs.
808    pub fn remove_items(&self, ids: &[QueueItemId]) {
809        use std::collections::HashSet;
810        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
811        let mut pl = self.playlist.write();
812        pl.items.retain(|item| !id_set.contains(&item.id));
813        if let Some(cursor) = pl.cursor
814            && id_set.contains(&cursor)
815        {
816            pl.cursor = None;
817        }
818        drop(pl);
819        self.bump_version();
820    }
821
822    /// Insert a single item after a given ID (or at front if None).
823    pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
824        let mut pl = self.playlist.write();
825        let insert_at = match after {
826            Some(after_id) => {
827                match pl.items.iter().position(|i| i.id == after_id) {
828                    Some(pos) => pos + 1,
829                    None => pl.items.len(), // fallback
830                }
831            }
832            None => 0,
833        };
834        pl.items.insert(insert_at, item);
835        drop(pl);
836        self.bump_version();
837    }
838
839    /// Move a single item to after `after` (or to front if None).
840    pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
841        let mut pl = self.playlist.write();
842        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
843            return;
844        };
845        let item = pl.items.remove(from);
846        let insert_at = match after {
847            Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
848                Some(pos) => pos + 1,
849                None => pl.items.len(),
850            },
851            None => 0,
852        };
853        pl.items.insert(insert_at, item);
854        drop(pl);
855        self.bump_version();
856    }
857
858    /// Batch move: reposition each item to after its given predecessor.
859    /// Processes in order so earlier insertions don't corrupt later positions.
860    pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
861        for &(id, after) in entries {
862            self.move_item_to(id, after);
863        }
864    }
865
866    // --- Called from UI thread (read lock) ---
867
868    /// Derive the visible queue from the playlist + cursor. O(n).
869    /// Called once per UI tick.
870    pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
871        // Read before the playlist lock — see current_download_fraction.
872        let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
873        let pl = self.playlist.read();
874
875        let cursor_pos = match pl.cursor {
876            Some(cid) => pl.items.iter().position(|item| item.id == cid),
877            None => None,
878        };
879
880        let mut entries = Vec::with_capacity(pl.items.len());
881        let mut finished_count = 0;
882        let mut has_playing = false;
883        let mut queue_count = 0;
884
885        for (i, item) in pl.items.iter().enumerate() {
886            let is_cursor = cursor_pos == Some(i);
887            let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
888
889            // Byte count comes from the shared atomic, not from the load
890            // state's own copy: the download thread writes it per chunk
891            // without taking the playlist lock, which is what keeps a
892            // transfer from bumping the playlist version a thousand times.
893            let dl_progress = match &item.load_state {
894                LoadState::Downloading {
895                    total,
896                    bytes_written,
897                    ..
898                } => Some((bytes_written.load(Ordering::Relaxed), *total)),
899                _ => None,
900            };
901
902            let status = if is_cursor {
903                has_playing = true;
904                match &item.load_state {
905                    LoadState::Ready => QueueEntryStatus::Playing,
906                    LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
907                    LoadState::Pending => QueueEntryStatus::PriorityPending,
908                    LoadState::Failed(_) => QueueEntryStatus::Failed,
909                }
910            } else if is_before_cursor {
911                finished_count += 1;
912                match &item.load_state {
913                    LoadState::Ready => QueueEntryStatus::Played,
914                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
915                    LoadState::Pending => QueueEntryStatus::Downloading,
916                    LoadState::Failed(_) => QueueEntryStatus::Failed,
917                }
918            } else {
919                queue_count += 1;
920                match &item.load_state {
921                    LoadState::Ready => QueueEntryStatus::Queued,
922                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
923                    LoadState::Pending => QueueEntryStatus::Downloading,
924                    LoadState::Failed(_) => QueueEntryStatus::Failed,
925                }
926            };
927
928            // Override duration from TrackInfo if we have it and this is playing.
929            let duration_ms =
930                if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
931                {
932                    playing_duration_ms
933                } else {
934                    item.duration_ms
935                };
936
937            entries.push(QueueEntry {
938                id: item.id,
939                db_id: item.db_id,
940                playlist_entry_id: item.playlist_entry_id,
941                path: item.path.clone(),
942                title: item.title.clone(),
943                artist: item.artist.clone(),
944                album_artist: item.album_artist.clone(),
945                album: item.album.clone(),
946                year: item.year.clone(),
947                codec: item.codec.clone(),
948                track_number: item.track_number,
949                disc: item.disc,
950                duration_ms,
951                status,
952                download_progress: dl_progress,
953                error: match &item.load_state {
954                    LoadState::Failed(reason) => Some(reason.clone()),
955                    _ => None,
956                },
957            });
958        }
959
960        VisibleQueueSnapshot {
961            entries,
962            finished_count,
963            has_playing,
964            queue_count,
965        }
966    }
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    // --- helpers ---
974
975    fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
976        PlaylistItem {
977            playlist_entry_id: None,
978            id: QueueItemId::new(),
979            db_id: None,
980            path: PathBuf::from(format!("/music/{title}.flac")),
981            title: title.to_string(),
982            artist: "Artist".to_string(),
983            album_artist: "Artist".to_string(),
984            album: "Album".to_string(),
985            year: None,
986            codec: Some("FLAC".to_string()),
987            track_number: None,
988            disc: None,
989            duration_ms: Some(200_000),
990            load_state,
991        }
992    }
993
994    fn ready_item(title: &str) -> PlaylistItem {
995        make_item(title, LoadState::Ready)
996    }
997
998    fn pending_item(title: &str) -> PlaylistItem {
999        make_item(title, LoadState::Pending)
1000    }
1001
1002    fn failed_item(title: &str) -> PlaylistItem {
1003        make_item(title, LoadState::Failed("nope".into()))
1004    }
1005
1006    // --- advance_cursor_loadable ---
1007
1008    #[test]
1009    fn advance_parks_on_a_still_downloading_track() {
1010        // A track that has not arrived yet must hold the cursor, not be skipped:
1011        // skipping it drops it from the queue for good, and it is the item whose
1012        // TrackReady has to resume playback.
1013        let state = SharedPlayerState::new();
1014        let item0 = ready_item("track-0");
1015        let item1 = pending_item("track-1");
1016        let item2 = ready_item("track-2");
1017        let (id0, id1) = (item0.id, item1.id);
1018
1019        state.add_items(vec![item0, item1, item2]);
1020
1021        assert_eq!(state.advance_cursor_loadable(), Some(id0));
1022        assert_eq!(state.advance_cursor_loadable(), Some(id1));
1023        assert_eq!(state.cursor(), Some(id1));
1024    }
1025
1026    #[test]
1027    fn advance_skips_failed_items() {
1028        let state = SharedPlayerState::new();
1029        let item0 = ready_item("track-0");
1030        let item1 = failed_item("track-1");
1031        let item2 = ready_item("track-2");
1032        let (id0, id2) = (item0.id, item2.id);
1033
1034        state.add_items(vec![item0, item1, item2]);
1035        state.set_cursor(Some(id0));
1036
1037        assert_eq!(state.advance_cursor_loadable(), Some(id2));
1038    }
1039
1040    #[test]
1041    fn advance_stops_at_end_of_playlist() {
1042        let state = SharedPlayerState::new();
1043        let item0 = ready_item("track-0");
1044        let item1 = ready_item("track-1");
1045        let id1 = item1.id;
1046
1047        state.add_items(vec![item0, item1]);
1048        state.set_cursor(Some(id1));
1049
1050        assert_eq!(state.advance_cursor_loadable(), None);
1051        assert_eq!(
1052            state.cursor(),
1053            Some(id1),
1054            "cursor unchanged on a failed advance"
1055        );
1056    }
1057
1058    #[test]
1059    fn advance_with_only_failed_items_returns_none() {
1060        let state = SharedPlayerState::new();
1061        state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1062
1063        assert_eq!(state.advance_cursor_loadable(), None);
1064    }
1065
1066    #[test]
1067    fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1068        let state = SharedPlayerState::new();
1069        let item0 = ready_item("track-0");
1070        let item1 = ready_item("track-1");
1071        let id0 = item0.id;
1072
1073        state.add_items(vec![item0, item1]);
1074        let ghost = QueueItemId::new();
1075        state.set_cursor(Some(ghost));
1076
1077        assert_eq!(state.advance_cursor_loadable(), None);
1078        assert_ne!(state.cursor(), Some(id0));
1079    }
1080
1081    // --- peek_next_ready_after ---
1082
1083    #[test]
1084    fn peek_after_a_removed_item_returns_none() {
1085        // The decode thread's lookahead runs seconds ahead of what is audible.
1086        // Removing the track it is pre-decoding must end the lookahead, not send
1087        // it back to the top of the queue.
1088        let state = SharedPlayerState::new();
1089        let item0 = ready_item("track-0");
1090        let item1 = ready_item("track-1");
1091        let item2 = ready_item("track-2");
1092        let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1093
1094        state.add_items(vec![item0, item1, item2]);
1095        assert_eq!(
1096            state.peek_next_ready_after(id1).map(|(id, _)| id),
1097            Some(id2)
1098        );
1099
1100        state.remove_item(id2);
1101        assert!(
1102            state.peek_next_ready_after(id2).is_none(),
1103            "a vanished reference must not resolve to the head of the queue"
1104        );
1105        assert_ne!(
1106            state.peek_next_ready_after(id2).map(|(id, _)| id),
1107            Some(id0)
1108        );
1109    }
1110
1111    // --- surviving_item_before ---
1112
1113    #[test]
1114    fn surviving_predecessor_skips_items_being_removed() {
1115        let state = SharedPlayerState::new();
1116        let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1117        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1118        state.add_items(items);
1119
1120        // Deleting 1..=3 leaves 0 as the resume point for a cursor on 3.
1121        assert_eq!(
1122            state.surviving_item_before(ids[3], &ids[1..4]),
1123            Some(ids[0])
1124        );
1125        // Deleting everything from the top leaves nothing to resume after.
1126        assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1127    }
1128
1129    // --- retreat_cursor ---
1130
1131    #[test]
1132    fn test_retreat_cursor_goes_to_previous_item() {
1133        let state = SharedPlayerState::new();
1134        let item0 = ready_item("track-0");
1135        let item1 = ready_item("track-1");
1136        let id0 = item0.id;
1137        let id1 = item1.id;
1138
1139        state.add_items(vec![item0, item1]);
1140        state.set_cursor(Some(id1));
1141
1142        let result = state.retreat_cursor();
1143        assert!(result.is_some(), "expected to retreat to previous item");
1144        assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1145        assert_eq!(state.cursor(), Some(id0));
1146    }
1147
1148    #[test]
1149    fn test_retreat_cursor_returns_none_when_at_first_item() {
1150        let state = SharedPlayerState::new();
1151        let item0 = ready_item("only-track");
1152        let id0 = item0.id;
1153
1154        state.add_items(vec![item0]);
1155        state.set_cursor(Some(id0));
1156
1157        let result = state.retreat_cursor();
1158        assert!(result.is_none(), "cannot retreat before the first item");
1159        // Cursor stays on the first item.
1160        assert_eq!(state.cursor(), Some(id0));
1161    }
1162
1163    #[test]
1164    fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1165        let state = SharedPlayerState::new();
1166        state.add_items(vec![ready_item("track-0")]);
1167
1168        let result = state.retreat_cursor();
1169        assert!(
1170            result.is_none(),
1171            "retreat with no cursor should return None"
1172        );
1173    }
1174
1175    // --- derive_visible_queue ---
1176
1177    #[test]
1178    fn test_derive_visible_queue_statuses() {
1179        // playlist: [played, playing, queued]
1180        let state = SharedPlayerState::new();
1181        let item0 = ready_item("played-track");
1182        let item1 = ready_item("playing-track");
1183        let item2 = ready_item("queued-track");
1184        let id1 = item1.id;
1185
1186        state.add_items(vec![item0, item1, item2]);
1187        state.set_cursor(Some(id1));
1188
1189        let snap = state.derive_visible_queue();
1190
1191        assert_eq!(snap.entries.len(), 3);
1192        assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1193        assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1194        assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1195        assert!(snap.has_playing);
1196        assert_eq!(snap.finished_count, 1);
1197        assert_eq!(snap.queue_count, 1);
1198    }
1199
1200    #[test]
1201    fn test_derive_visible_queue_downloading_statuses() {
1202        // A Downloading item at cursor → PriorityPending; after cursor → Downloading.
1203        let state = SharedPlayerState::new();
1204        let bytes_cursor = Arc::new(AtomicU64::new(0));
1205        let bytes_queued = Arc::new(AtomicU64::new(0));
1206        let dl_cursor = make_item(
1207            "downloading-at-cursor",
1208            LoadState::Downloading {
1209                total: 1_000_000,
1210                bytes_written: bytes_cursor.clone(),
1211            },
1212        );
1213        let dl_queued = make_item(
1214            "downloading-queued",
1215            LoadState::Downloading {
1216                total: 500_000,
1217                bytes_written: bytes_queued.clone(),
1218            },
1219        );
1220        let id_cursor = dl_cursor.id;
1221
1222        state.add_items(vec![dl_cursor, dl_queued]);
1223        state.set_cursor(Some(id_cursor));
1224
1225        let snap = state.derive_visible_queue();
1226
1227        assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1228        assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1229    }
1230
1231    #[test]
1232    fn progress_follows_the_counter_without_touching_the_playlist() {
1233        // The download thread writes bytes and nothing else. A queue derived
1234        // afterwards must see them — the version has not moved, and the load
1235        // state it was given is the one it still holds.
1236        let state = SharedPlayerState::new();
1237        let bytes = Arc::new(AtomicU64::new(0));
1238        let item = make_item(
1239            "downloading",
1240            LoadState::Downloading {
1241                total: 1_000,
1242                bytes_written: bytes.clone(),
1243            },
1244        );
1245        state.add_items(vec![item]);
1246
1247        let version = state.playlist_version();
1248        bytes.store(250, Ordering::Release);
1249
1250        let snap = state.derive_visible_queue();
1251        assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1252        assert_eq!(
1253            state.playlist_version(),
1254            version,
1255            "progress must not read as a queue mutation"
1256        );
1257        assert_eq!(
1258            state.downloads_in_flight(),
1259            vec![(snap.entries[0].id, 250, 1_000)]
1260        );
1261    }
1262
1263    #[test]
1264    fn test_derive_visible_queue_no_cursor_all_queued() {
1265        let state = SharedPlayerState::new();
1266        state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1267
1268        let snap = state.derive_visible_queue();
1269
1270        assert_eq!(snap.entries.len(), 3);
1271        for entry in &snap.entries {
1272            assert_eq!(entry.status, QueueEntryStatus::Queued);
1273        }
1274        assert!(!snap.has_playing);
1275        assert_eq!(snap.finished_count, 0);
1276        assert_eq!(snap.queue_count, 3);
1277    }
1278
1279    // --- same_album_item_ids ---
1280
1281    fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1282        PlaylistItem {
1283            playlist_entry_id: None,
1284            id: QueueItemId::new(),
1285            db_id: None,
1286            path: PathBuf::from(format!("/music/{title}.flac")),
1287            title: title.to_string(),
1288            artist: "Artist".to_string(),
1289            album_artist: album_artist.to_string(),
1290            album: album.to_string(),
1291            year: None,
1292            codec: Some("FLAC".to_string()),
1293            track_number: None,
1294            disc: None,
1295            duration_ms: Some(200_000),
1296            load_state: LoadState::Ready,
1297        }
1298    }
1299
1300    #[test]
1301    fn test_same_album_item_ids_returns_album_mates() {
1302        let state = SharedPlayerState::new();
1303        let a1 = make_album_item("A1", "Album A", "Artist A");
1304        let a2 = make_album_item("A2", "Album A", "Artist A");
1305        let b1 = make_album_item("B1", "Album B", "Artist B");
1306        let a3 = make_album_item("A3", "Album A", "Artist A");
1307
1308        let id_a1 = a1.id;
1309        let id_a2 = a2.id;
1310        let id_a3 = a3.id;
1311
1312        state.add_items(vec![a1, a2, b1, a3]);
1313
1314        let mates = state.same_album_item_ids(id_a1);
1315        assert_eq!(mates.len(), 2);
1316        assert!(mates.contains(&id_a2));
1317        assert!(mates.contains(&id_a3));
1318    }
1319
1320    #[test]
1321    fn test_same_album_item_ids_distinguishes_album_artists() {
1322        // Two albums named the same but by different artists — should NOT match.
1323        let state = SharedPlayerState::new();
1324        let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1325        let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1326
1327        let id_a1 = a1.id;
1328
1329        state.add_items(vec![a1, b1]);
1330
1331        let mates = state.same_album_item_ids(id_a1);
1332        assert!(mates.is_empty(), "different album_artist should not match");
1333    }
1334
1335    #[test]
1336    fn test_same_album_item_ids_unknown_id_returns_empty() {
1337        let state = SharedPlayerState::new();
1338        state.add_items(vec![ready_item("track-0")]);
1339
1340        let bogus = QueueItemId::new();
1341        let mates = state.same_album_item_ids(bogus);
1342        assert!(mates.is_empty());
1343    }
1344
1345    // --- move_item_to ---
1346
1347    #[test]
1348    fn test_move_item_to_reorders_playlist() {
1349        // Start: [A, B, C]. Move C to after A → [A, C, B].
1350        let state = SharedPlayerState::new();
1351        let item_a = ready_item("A");
1352        let item_b = ready_item("B");
1353        let item_c = ready_item("C");
1354        let id_a = item_a.id;
1355        let id_b = item_b.id;
1356        let id_c = item_c.id;
1357
1358        state.add_items(vec![item_a, item_b, item_c]);
1359        state.move_item_to(id_c, Some(id_a));
1360
1361        let (items, _) = state.snapshot_playlist();
1362        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1363        assert_eq!(titles, vec!["A", "C", "B"]);
1364        assert_eq!(items[0].id, id_a);
1365        assert_eq!(items[1].id, id_c);
1366        assert_eq!(items[2].id, id_b);
1367    }
1368
1369    #[test]
1370    fn test_move_item_to_front_when_after_is_none() {
1371        // Start: [A, B, C]. Move C to front (after=None) → [C, A, B].
1372        let state = SharedPlayerState::new();
1373        let item_a = ready_item("A");
1374        let item_b = ready_item("B");
1375        let item_c = ready_item("C");
1376        let id_c = item_c.id;
1377
1378        state.add_items(vec![item_a, item_b, item_c]);
1379        state.move_item_to(id_c, None);
1380
1381        let (items, _) = state.snapshot_playlist();
1382        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1383        assert_eq!(titles, vec!["C", "A", "B"]);
1384    }
1385
1386    // --- move_items (batch) ---
1387
1388    #[test]
1389    fn test_move_items_batch_preserves_relative_order() {
1390        // Start: [A, B, C, D]. Move [A, C] after D → [B, D, A, C].
1391        let state = SharedPlayerState::new();
1392        let item_a = ready_item("A");
1393        let item_b = ready_item("B");
1394        let item_c = ready_item("C");
1395        let item_d = ready_item("D");
1396        let id_a = item_a.id;
1397        let id_b = item_b.id;
1398        let id_c = item_c.id;
1399        let id_d = item_d.id;
1400
1401        state.add_items(vec![item_a, item_b, item_c, item_d]);
1402        state.move_items(&[id_a, id_c], id_d, true);
1403
1404        let (items, _) = state.snapshot_playlist();
1405        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1406        assert_eq!(titles, vec!["B", "D", "A", "C"]);
1407        assert_eq!(items[0].id, id_b);
1408        assert_eq!(items[1].id, id_d);
1409        assert_eq!(items[2].id, id_a);
1410        assert_eq!(items[3].id, id_c);
1411    }
1412
1413    // --- pending_downloads ---
1414
1415    #[test]
1416    fn test_pending_downloads_collects_pending_with_db_id() {
1417        let state = SharedPlayerState::new();
1418        let mut item_a = ready_item("local");
1419        item_a.db_id = None;
1420
1421        let mut item_b = pending_item("remote-1");
1422        item_b.db_id = Some(10);
1423        let id_b = item_b.id;
1424
1425        let mut item_c = ready_item("cached");
1426        item_c.db_id = Some(20);
1427
1428        let mut item_d = pending_item("remote-2");
1429        item_d.db_id = Some(30);
1430        let id_d = item_d.id;
1431
1432        // Pending without db_id — should NOT appear (no way to download).
1433        let item_e = pending_item("orphan");
1434
1435        state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1436
1437        let pending = state.pending_downloads();
1438        assert_eq!(pending.len(), 2);
1439        assert_eq!(pending[0], (10, id_b));
1440        assert_eq!(pending[1], (30, id_d));
1441    }
1442
1443    #[test]
1444    fn test_item_db_id_and_load_state() {
1445        let state = SharedPlayerState::new();
1446        let mut item = pending_item("track");
1447        item.db_id = Some(42);
1448        let id = item.id;
1449        state.add_items(vec![item]);
1450
1451        assert_eq!(state.item_db_id(id), Some(42));
1452        assert!(matches!(
1453            state.item_load_state(id),
1454            Some(LoadState::Pending)
1455        ));
1456
1457        state.update_load_state(id, LoadState::Ready);
1458        assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1459    }
1460}