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