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