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#[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 write!(f, "QId({})", &self.0.to_string()[..8])
28 }
29}
30
31#[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#[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
62pub const STREAM_THRESHOLD: u64 = 256 * 1024; #[derive(Debug, Clone)]
69pub enum LoadState {
70 Pending,
71 Downloading {
72 downloaded: u64,
73 total: u64,
74 bytes_written: Arc<AtomicU64>,
76 },
77 Ready,
78 Failed(String),
79}
80
81pub enum PlaybackSource {
83 Ready(PathBuf),
85 Streaming {
87 path: PathBuf,
88 bytes_written: Arc<AtomicU64>,
89 total: u64,
90 },
91}
92
93#[derive(Debug, Clone)]
96pub struct PlaylistItem {
97 pub id: QueueItemId,
98 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#[derive(Debug, Clone, Default)]
115pub struct Playlist {
116 pub items: Vec<PlaylistItem>,
117 pub cursor: Option<QueueItemId>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum QueueEntryStatus {
125 Queued,
126 Playing,
127 Played,
128 Downloading,
129 PriorityPending,
131 Failed,
132}
133
134#[derive(Debug, Clone)]
136pub struct QueueEntry {
137 pub id: QueueItemId,
138 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#[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#[derive(Debug)]
167pub struct SharedPlayerState {
168 state: AtomicU8,
169 position_ms: AtomicU64,
170 track_info: parking_lot::RwLock<Option<TrackInfo>>,
171
172 playlist: parking_lot::RwLock<Playlist>,
174
175 playlist_version: AtomicU64,
177
178 playback_generation: AtomicU64,
181
182 quit_requested: AtomicBool,
184
185 metadata_refresh_pending: AtomicBool,
188
189 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 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 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 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 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 pub fn signal_metadata_refresh(&self) {
287 self.metadata_refresh_pending.store(true, Ordering::Release);
288 }
289
290 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 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 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 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 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(), };
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 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 pub fn remove_item(&self, id: QueueItemId) {
355 let mut pl = self.playlist.write();
356 pl.items.retain(|item| item.id != id);
357 if pl.cursor == Some(id) {
359 pl.cursor = None;
360 }
361 drop(pl);
362 self.bump_version();
363 }
364
365 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 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 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 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 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 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 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 pub fn cursor(&self) -> Option<QueueItemId> {
435 self.playlist.read().cursor
436 }
437
438 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 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 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 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 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 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 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 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 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 pub fn is_cursor(&self, id: QueueItemId) -> bool {
603 self.playlist.read().cursor == Some(id)
604 }
605
606 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 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 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 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 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
658 let pl = self.playlist.read();
659 (pl.items.clone(), pl.cursor)
660 }
661
662 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 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 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 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 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 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(), }
729 }
730 None => 0,
731 };
732 pl.items.insert(insert_at, item);
733 drop(pl);
734 self.bump_version();
735 }
736
737 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 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 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 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 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 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 #[test]
891 fn test_advance_cursor_skips_non_ready() {
892 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 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 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 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 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 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 #[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 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 #[test]
1006 fn test_derive_visible_queue_statuses() {
1007 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 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 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 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 #[test]
1145 fn test_move_item_to_reorders_playlist() {
1146 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 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 #[test]
1186 fn test_move_items_batch_preserves_relative_order() {
1187 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 #[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 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}