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