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