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