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: Option<u16>,
58 pub bitrate_kbps: Option<u32>,
59 pub channels: u16,
60 pub duration_ms: u64,
61}
62
63pub const STREAM_THRESHOLD: u64 = 256 * 1024; #[derive(Debug, Clone)]
70pub enum LoadState {
71 Pending,
72 Downloading {
73 downloaded: u64,
74 total: u64,
75 bytes_written: Arc<AtomicU64>,
77 },
78 Ready,
79 Failed(String),
80}
81
82pub enum PlaybackSource {
84 Ready(PathBuf),
86 Streaming {
88 path: PathBuf,
89 bytes_written: Arc<AtomicU64>,
90 total: u64,
91 },
92}
93
94#[derive(Debug, Clone)]
97pub struct PlaylistItem {
98 pub id: QueueItemId,
99 pub db_id: Option<i64>,
101 pub path: PathBuf,
102 pub title: String,
103 pub artist: String,
104 pub album_artist: String,
105 pub album: String,
106 pub year: Option<String>,
107 pub codec: Option<String>,
108 pub track_number: Option<i64>,
109 pub disc: Option<i64>,
110 pub duration_ms: Option<u64>,
111 pub load_state: LoadState,
112}
113
114#[derive(Debug, Clone, Default)]
116pub struct Playlist {
117 pub items: Vec<PlaylistItem>,
118 pub cursor: Option<QueueItemId>,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum QueueEntryStatus {
126 Queued,
127 Playing,
128 Played,
129 Downloading,
130 PriorityPending,
132 Failed,
133}
134
135#[derive(Debug, Clone)]
137pub struct QueueEntry {
138 pub id: QueueItemId,
139 pub db_id: Option<i64>,
141 pub path: PathBuf,
142 pub title: String,
143 pub artist: String,
144 pub album_artist: String,
145 pub album: String,
146 pub year: Option<String>,
147 pub codec: Option<String>,
148 pub track_number: Option<i64>,
149 pub disc: Option<i64>,
150 pub duration_ms: Option<u64>,
151 pub status: QueueEntryStatus,
152 pub download_progress: Option<(u64, u64)>,
153}
154
155#[derive(Debug, Clone, Default)]
157pub struct VisibleQueueSnapshot {
158 pub entries: Vec<QueueEntry>,
159 pub finished_count: usize,
160 pub has_playing: bool,
161 pub queue_count: usize,
162}
163
164#[derive(Debug)]
168pub struct SharedPlayerState {
169 state: AtomicU8,
170 position_ms: AtomicU64,
171 track_info: parking_lot::RwLock<Option<TrackInfo>>,
172
173 playlist: parking_lot::RwLock<Playlist>,
175
176 playlist_version: AtomicU64,
178
179 playback_generation: AtomicU64,
182
183 quit_requested: AtomicBool,
185
186 metadata_refresh_pending: AtomicBool,
189
190 radio_mode: AtomicBool,
193}
194
195impl SharedPlayerState {
196 pub fn new() -> Arc<Self> {
197 Arc::new(Self {
198 state: AtomicU8::new(PlaybackState::Stopped as u8),
199 position_ms: AtomicU64::new(0),
200 track_info: parking_lot::RwLock::new(None),
201 playlist: parking_lot::RwLock::new(Playlist::default()),
202 playlist_version: AtomicU64::new(0),
203 playback_generation: AtomicU64::new(0),
204 quit_requested: AtomicBool::new(false),
205 metadata_refresh_pending: AtomicBool::new(false),
206 radio_mode: AtomicBool::new(false),
207 })
208 }
209
210 pub fn playback_state(&self) -> PlaybackState {
213 PlaybackState::from_u8(self.state.load(Ordering::Acquire))
214 }
215
216 pub fn set_playback_state(&self, state: PlaybackState) {
217 self.state.store(state as u8, Ordering::Release);
218 }
219
220 pub fn position_ms(&self) -> u64 {
221 self.position_ms.load(Ordering::Acquire)
222 }
223
224 pub fn set_position_ms(&self, pos: u64) {
225 self.position_ms.store(pos, Ordering::Release);
226 }
227
228 pub fn track_info(&self) -> Option<TrackInfo> {
229 self.track_info.read().clone()
230 }
231
232 pub fn set_track_info(&self, info: Option<TrackInfo>) {
233 *self.track_info.write() = info;
234 }
235
236 pub fn current_download_fraction(&self) -> Option<f64> {
239 let track_info = self.track_info.read();
240 let id = track_info.as_ref()?.id;
241 let pl = self.playlist.read();
242 pl.items
243 .iter()
244 .find(|item| item.id == id)
245 .and_then(|item| match &item.load_state {
246 LoadState::Downloading {
247 bytes_written,
248 total,
249 ..
250 } => {
251 let written = bytes_written.load(Ordering::Acquire);
252 if *total > 0 {
253 Some((written as f64 / *total as f64).min(1.0))
254 } else {
255 None
256 }
257 }
258 _ => None,
259 })
260 }
261
262 pub fn bump_generation(&self) -> u64 {
265 self.playback_generation.fetch_add(1, Ordering::AcqRel) + 1
266 }
267
268 pub fn generation(&self) -> u64 {
269 self.playback_generation.load(Ordering::Acquire)
270 }
271
272 pub fn request_quit(&self) {
275 self.quit_requested.store(true, Ordering::Release);
276 }
277
278 pub fn quit_requested(&self) -> bool {
279 self.quit_requested.load(Ordering::Acquire)
280 }
281
282 pub fn signal_metadata_refresh(&self) {
288 self.metadata_refresh_pending.store(true, Ordering::Release);
289 }
290
291 pub fn take_metadata_refresh(&self) -> bool {
293 self.metadata_refresh_pending
294 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
295 .is_ok()
296 }
297
298 pub fn radio_mode(&self) -> bool {
301 self.radio_mode.load(Ordering::Acquire)
302 }
303
304 pub fn set_radio_mode(&self, enabled: bool) {
305 self.radio_mode.store(enabled, Ordering::Release);
306 }
307
308 pub fn playlist_version(&self) -> u64 {
311 self.playlist_version.load(Ordering::Acquire)
312 }
313
314 fn bump_version(&self) {
315 self.playlist_version.fetch_add(1, Ordering::AcqRel);
316 }
317
318 pub fn add_items(&self, items: Vec<PlaylistItem>) {
322 let mut pl = self.playlist.write();
323 pl.items.extend(items);
324 drop(pl);
325 self.bump_version();
326 }
327
328 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
330 let mut pl = self.playlist.write();
331 let insert_at = match pl.items.iter().position(|item| item.id == after) {
332 Some(pos) => pos + 1,
333 None => pl.items.len(), };
335 for (i, item) in items.into_iter().enumerate() {
336 pl.items.insert(insert_at + i, item);
337 }
338 drop(pl);
339 self.bump_version();
340 }
341
342 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
344 let mut pl = self.playlist.write();
345 for (id, new_path) in updates {
346 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
347 item.path = new_path.clone();
348 }
349 }
350 drop(pl);
351 self.bump_version();
352 }
353
354 pub fn remove_item(&self, id: QueueItemId) {
356 let mut pl = self.playlist.write();
357 pl.items.retain(|item| item.id != id);
358 if pl.cursor == Some(id) {
360 pl.cursor = None;
361 }
362 drop(pl);
363 self.bump_version();
364 }
365
366 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
368 let mut pl = self.playlist.write();
369 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
370 return;
371 };
372 let item = pl.items.remove(from);
373 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
374 let pos = from.min(pl.items.len());
376 pl.items.insert(pos, item);
377 return;
378 };
379 let insert_at = if after { to + 1 } else { to };
380 pl.items.insert(insert_at, item);
381 drop(pl);
382 self.bump_version();
383 }
384
385 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
388 use std::collections::HashSet;
389 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
390
391 let mut pl = self.playlist.write();
392
393 let mut remaining = Vec::with_capacity(pl.items.len());
395 let mut moved = Vec::with_capacity(ids.len());
396 for item in pl.items.drain(..) {
397 if id_set.contains(&item.id) {
398 moved.push(item);
399 } else {
400 remaining.push(item);
401 }
402 }
403
404 let insert_at = match remaining.iter().position(|item| item.id == target) {
406 Some(pos) => {
407 if after {
408 pos + 1
409 } else {
410 pos
411 }
412 }
413 None => remaining.len(),
414 };
415
416 for (i, item) in moved.into_iter().enumerate() {
418 remaining.insert(insert_at + i, item);
419 }
420
421 pl.items = remaining;
422 drop(pl);
423 self.bump_version();
424 }
425
426 pub fn set_cursor(&self, id: Option<QueueItemId>) {
428 let mut pl = self.playlist.write();
429 pl.cursor = id;
430 drop(pl);
431 self.bump_version();
432 }
433
434 pub fn cursor(&self) -> Option<QueueItemId> {
436 self.playlist.read().cursor
437 }
438
439 pub fn clear_playlist(&self) {
441 let mut pl = self.playlist.write();
442 pl.items.clear();
443 pl.cursor = None;
444 drop(pl);
445 self.bump_version();
446 }
447
448 pub fn advance_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
453 let mut pl = self.playlist.write();
454 let cursor_pos = match pl.cursor {
455 Some(cid) => pl.items.iter().position(|item| item.id == cid),
456 None => None,
457 };
458
459 let start = match cursor_pos {
460 Some(pos) => pos + 1,
461 None => 0,
462 };
463
464 for i in start..pl.items.len() {
466 if matches!(pl.items[i].load_state, LoadState::Ready) {
467 let item = &pl.items[i];
468 let result = (item.id, item.path.clone());
469 pl.cursor = Some(item.id);
470 drop(pl);
471 self.bump_version();
472 return Some(result);
473 }
474 }
475 None
476 }
477
478 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
482 let pl = self.playlist.read();
483 let pos = pl.items.iter().position(|item| item.id == after_id);
484
485 let start = match pos {
486 Some(p) => p + 1,
487 None => 0,
488 };
489
490 for i in start..pl.items.len() {
491 if matches!(pl.items[i].load_state, LoadState::Ready) {
492 let item = &pl.items[i];
493 return Some((item.id, item.path.clone()));
494 }
495 }
496 None
497 }
498
499 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
502 let mut pl = self.playlist.write();
503 let cursor_pos = match pl.cursor {
504 Some(cid) => pl.items.iter().position(|item| item.id == cid),
505 None => None,
506 };
507
508 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
509
510 match prev_pos {
511 Some(pos) => {
512 let item = &pl.items[pos];
513 let result = (item.id, item.path.clone());
514 pl.cursor = Some(item.id);
515 drop(pl);
516 self.bump_version();
517 Some(result)
518 }
519 None => None,
520 }
521 }
522
523 pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
527 let mut pl = self.playlist.write();
528 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
529 item.load_state = new_state;
530 }
531 drop(pl);
532 self.bump_version();
533 }
534
535 pub fn update_item_metadata(
539 &self,
540 id: QueueItemId,
541 title: String,
542 artist: String,
543 album_artist: String,
544 album: String,
545 duration_ms: Option<u64>,
546 ) {
547 let mut pl = self.playlist.write();
548 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
549 item.title = title;
550 item.artist = artist;
551 item.album_artist = album_artist;
552 item.album = album;
553 if let Some(dur) = duration_ms {
554 item.duration_ms = Some(dur);
555 }
556 }
557 drop(pl);
558 self.bump_version();
559 }
560
561 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
564 let pl = self.playlist.read();
565 pl.items
566 .iter()
567 .find(|item| item.id == id)
568 .and_then(|item| match &item.load_state {
569 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
570 LoadState::Downloading {
571 total,
572 bytes_written,
573 ..
574 } => {
575 let written = bytes_written.load(Ordering::Acquire);
576 if written >= STREAM_THRESHOLD {
577 Some(PlaybackSource::Streaming {
578 path: item.path.clone(),
579 bytes_written: bytes_written.clone(),
580 total: *total,
581 })
582 } else {
583 None
584 }
585 }
586 _ => None,
587 })
588 }
589
590 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
592 let pl = self.playlist.read();
593 pl.items.iter().find(|item| item.id == id).and_then(|item| {
594 if matches!(item.load_state, LoadState::Ready) {
595 Some(item.path.clone())
596 } else {
597 None
598 }
599 })
600 }
601
602 pub fn is_cursor(&self, id: QueueItemId) -> bool {
604 self.playlist.read().cursor == Some(id)
605 }
606
607 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
611 let pl = self.playlist.read();
612 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
613 return vec![];
614 };
615 let album = cursor.album.clone();
616 let album_artist = cursor.album_artist.clone();
617 pl.items
618 .iter()
619 .filter(|item| {
620 item.id != id && item.album == album && item.album_artist == album_artist
621 })
622 .map(|item| item.id)
623 .collect()
624 }
625
626 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
629 let pl = self.playlist.read();
630 pl.items
631 .iter()
632 .filter(|item| matches!(item.load_state, LoadState::Pending))
633 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
634 .collect()
635 }
636
637 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
639 let pl = self.playlist.read();
640 pl.items
641 .iter()
642 .find(|item| item.id == id)
643 .and_then(|item| item.db_id)
644 }
645
646 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
648 let pl = self.playlist.read();
649 pl.items
650 .iter()
651 .find(|item| item.id == id)
652 .map(|item| item.load_state.clone())
653 }
654
655 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
659 let pl = self.playlist.read();
660 (pl.items.clone(), pl.cursor)
661 }
662
663 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
665 let pl = self.playlist.read();
666 pl.items.iter().find(|item| item.id == id).cloned()
667 }
668
669 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
671 let pl = self.playlist.read();
672 let pos = pl.items.iter().position(|item| item.id == id)?;
673 if pos == 0 {
674 None
675 } else {
676 Some(pl.items[pos - 1].id)
677 }
678 }
679
680 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
683 let pl = self.playlist.read();
684 ids.iter()
685 .filter_map(|&id| {
686 let pos = pl.items.iter().position(|item| item.id == id)?;
687 let before = if pos == 0 {
688 None
689 } else {
690 Some(pl.items[pos - 1].id)
691 };
692 Some((id, before))
693 })
694 .collect()
695 }
696
697 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
699 let mut pl = self.playlist.write();
700 pl.items = items;
701 pl.cursor = cursor;
702 drop(pl);
703 self.bump_version();
704 }
705
706 pub fn remove_items(&self, ids: &[QueueItemId]) {
708 use std::collections::HashSet;
709 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
710 let mut pl = self.playlist.write();
711 pl.items.retain(|item| !id_set.contains(&item.id));
712 if let Some(cursor) = pl.cursor
713 && id_set.contains(&cursor)
714 {
715 pl.cursor = None;
716 }
717 drop(pl);
718 self.bump_version();
719 }
720
721 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
723 let mut pl = self.playlist.write();
724 let insert_at = match after {
725 Some(after_id) => {
726 match pl.items.iter().position(|i| i.id == after_id) {
727 Some(pos) => pos + 1,
728 None => pl.items.len(), }
730 }
731 None => 0,
732 };
733 pl.items.insert(insert_at, item);
734 drop(pl);
735 self.bump_version();
736 }
737
738 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
740 let mut pl = self.playlist.write();
741 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
742 return;
743 };
744 let item = pl.items.remove(from);
745 let insert_at = match after {
746 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
747 Some(pos) => pos + 1,
748 None => pl.items.len(),
749 },
750 None => 0,
751 };
752 pl.items.insert(insert_at, item);
753 drop(pl);
754 self.bump_version();
755 }
756
757 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
760 for &(id, after) in entries {
761 self.move_item_to(id, after);
762 }
763 }
764
765 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
770 let pl = self.playlist.read();
771 let track_info = self.track_info.read();
772
773 let cursor_pos = match pl.cursor {
774 Some(cid) => pl.items.iter().position(|item| item.id == cid),
775 None => None,
776 };
777
778 let mut entries = Vec::with_capacity(pl.items.len());
779 let mut finished_count = 0;
780 let mut has_playing = false;
781 let mut queue_count = 0;
782
783 for (i, item) in pl.items.iter().enumerate() {
784 let is_cursor = cursor_pos == Some(i);
785 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
786
787 let dl_progress = match &item.load_state {
789 LoadState::Downloading {
790 downloaded, total, ..
791 } => Some((*downloaded, *total)),
792 _ => None,
793 };
794
795 let status = if is_cursor {
796 has_playing = true;
797 match &item.load_state {
798 LoadState::Ready => QueueEntryStatus::Playing,
799 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
800 LoadState::Pending => QueueEntryStatus::PriorityPending,
801 LoadState::Failed(_) => QueueEntryStatus::Failed,
802 }
803 } else if is_before_cursor {
804 finished_count += 1;
805 match &item.load_state {
806 LoadState::Ready => QueueEntryStatus::Played,
807 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
808 LoadState::Pending => QueueEntryStatus::Downloading,
809 LoadState::Failed(_) => QueueEntryStatus::Failed,
810 }
811 } else {
812 queue_count += 1;
813 match &item.load_state {
814 LoadState::Ready => QueueEntryStatus::Queued,
815 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
816 LoadState::Pending => QueueEntryStatus::Downloading,
817 LoadState::Failed(_) => QueueEntryStatus::Failed,
818 }
819 };
820
821 let duration_ms =
823 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
824 {
825 track_info.as_ref().map(|ti| ti.duration_ms)
826 } else {
827 item.duration_ms
828 };
829
830 entries.push(QueueEntry {
831 id: item.id,
832 db_id: item.db_id,
833 path: item.path.clone(),
834 title: item.title.clone(),
835 artist: item.artist.clone(),
836 album_artist: item.album_artist.clone(),
837 album: item.album.clone(),
838 year: item.year.clone(),
839 codec: item.codec.clone(),
840 track_number: item.track_number,
841 disc: item.disc,
842 duration_ms,
843 status,
844 download_progress: dl_progress,
845 });
846 }
847
848 VisibleQueueSnapshot {
849 entries,
850 finished_count,
851 has_playing,
852 queue_count,
853 }
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
864 PlaylistItem {
865 id: QueueItemId::new(),
866 db_id: None,
867 path: PathBuf::from(format!("/music/{title}.flac")),
868 title: title.to_string(),
869 artist: "Artist".to_string(),
870 album_artist: "Artist".to_string(),
871 album: "Album".to_string(),
872 year: None,
873 codec: Some("FLAC".to_string()),
874 track_number: None,
875 disc: None,
876 duration_ms: Some(200_000),
877 load_state,
878 }
879 }
880
881 fn ready_item(title: &str) -> PlaylistItem {
882 make_item(title, LoadState::Ready)
883 }
884
885 fn pending_item(title: &str) -> PlaylistItem {
886 make_item(title, LoadState::Pending)
887 }
888
889 #[test]
892 fn test_advance_cursor_skips_non_ready() {
893 let state = SharedPlayerState::new();
897 let item0 = ready_item("track-0");
898 let item1 = pending_item("track-1");
899 let item2 = ready_item("track-2");
900 let id0 = item0.id;
901 let id2 = item2.id;
902
903 state.add_items(vec![item0, item1, item2]);
904
905 let result = state.advance_cursor();
907 assert!(result.is_some(), "expected to find first Ready item");
908 assert_eq!(result.unwrap().0, id0, "should land on first Ready item");
909
910 let result = state.advance_cursor();
912 assert!(
913 result.is_some(),
914 "expected to find next Ready item after skipping Pending"
915 );
916 assert_eq!(
917 result.unwrap().0,
918 id2,
919 "should skip Pending and land on third item"
920 );
921 }
922
923 #[test]
924 fn test_advance_cursor_stops_at_end_of_playlist() {
925 let state = SharedPlayerState::new();
927 let item0 = ready_item("track-0");
928 let item1 = ready_item("track-1");
929 let id1 = item1.id;
930
931 state.add_items(vec![item0, item1]);
932
933 state.set_cursor(Some(id1));
935
936 let result = state.advance_cursor();
937 assert!(
938 result.is_none(),
939 "advance past last item should return None"
940 );
941
942 assert_eq!(state.cursor(), Some(id1));
944 }
945
946 #[test]
947 fn test_advance_cursor_with_no_ready_items_returns_none() {
948 let state = SharedPlayerState::new();
949 state.add_items(vec![pending_item("pending-0"), pending_item("pending-1")]);
950
951 let result = state.advance_cursor();
952 assert!(
953 result.is_none(),
954 "should return None when no Ready items exist"
955 );
956 }
957
958 #[test]
961 fn test_retreat_cursor_goes_to_previous_item() {
962 let state = SharedPlayerState::new();
963 let item0 = ready_item("track-0");
964 let item1 = ready_item("track-1");
965 let id0 = item0.id;
966 let id1 = item1.id;
967
968 state.add_items(vec![item0, item1]);
969 state.set_cursor(Some(id1));
970
971 let result = state.retreat_cursor();
972 assert!(result.is_some(), "expected to retreat to previous item");
973 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
974 assert_eq!(state.cursor(), Some(id0));
975 }
976
977 #[test]
978 fn test_retreat_cursor_returns_none_when_at_first_item() {
979 let state = SharedPlayerState::new();
980 let item0 = ready_item("only-track");
981 let id0 = item0.id;
982
983 state.add_items(vec![item0]);
984 state.set_cursor(Some(id0));
985
986 let result = state.retreat_cursor();
987 assert!(result.is_none(), "cannot retreat before the first item");
988 assert_eq!(state.cursor(), Some(id0));
990 }
991
992 #[test]
993 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
994 let state = SharedPlayerState::new();
995 state.add_items(vec![ready_item("track-0")]);
996
997 let result = state.retreat_cursor();
998 assert!(
999 result.is_none(),
1000 "retreat with no cursor should return None"
1001 );
1002 }
1003
1004 #[test]
1007 fn test_derive_visible_queue_statuses() {
1008 let state = SharedPlayerState::new();
1010 let item0 = ready_item("played-track");
1011 let item1 = ready_item("playing-track");
1012 let item2 = ready_item("queued-track");
1013 let id1 = item1.id;
1014
1015 state.add_items(vec![item0, item1, item2]);
1016 state.set_cursor(Some(id1));
1017
1018 let snap = state.derive_visible_queue();
1019
1020 assert_eq!(snap.entries.len(), 3);
1021 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1022 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1023 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1024 assert!(snap.has_playing);
1025 assert_eq!(snap.finished_count, 1);
1026 assert_eq!(snap.queue_count, 1);
1027 }
1028
1029 #[test]
1030 fn test_derive_visible_queue_downloading_statuses() {
1031 let state = SharedPlayerState::new();
1033 let bytes_cursor = Arc::new(AtomicU64::new(0));
1034 let bytes_queued = Arc::new(AtomicU64::new(0));
1035 let dl_cursor = make_item(
1036 "downloading-at-cursor",
1037 LoadState::Downloading {
1038 downloaded: 0,
1039 total: 1_000_000,
1040 bytes_written: bytes_cursor.clone(),
1041 },
1042 );
1043 let dl_queued = make_item(
1044 "downloading-queued",
1045 LoadState::Downloading {
1046 downloaded: 0,
1047 total: 500_000,
1048 bytes_written: bytes_queued.clone(),
1049 },
1050 );
1051 let id_cursor = dl_cursor.id;
1052
1053 state.add_items(vec![dl_cursor, dl_queued]);
1054 state.set_cursor(Some(id_cursor));
1055
1056 let snap = state.derive_visible_queue();
1057
1058 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1059 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1060 }
1061
1062 #[test]
1063 fn test_derive_visible_queue_no_cursor_all_queued() {
1064 let state = SharedPlayerState::new();
1065 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1066
1067 let snap = state.derive_visible_queue();
1068
1069 assert_eq!(snap.entries.len(), 3);
1070 for entry in &snap.entries {
1071 assert_eq!(entry.status, QueueEntryStatus::Queued);
1072 }
1073 assert!(!snap.has_playing);
1074 assert_eq!(snap.finished_count, 0);
1075 assert_eq!(snap.queue_count, 3);
1076 }
1077
1078 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1081 PlaylistItem {
1082 id: QueueItemId::new(),
1083 db_id: None,
1084 path: PathBuf::from(format!("/music/{title}.flac")),
1085 title: title.to_string(),
1086 artist: "Artist".to_string(),
1087 album_artist: album_artist.to_string(),
1088 album: album.to_string(),
1089 year: None,
1090 codec: Some("FLAC".to_string()),
1091 track_number: None,
1092 disc: None,
1093 duration_ms: Some(200_000),
1094 load_state: LoadState::Ready,
1095 }
1096 }
1097
1098 #[test]
1099 fn test_same_album_item_ids_returns_album_mates() {
1100 let state = SharedPlayerState::new();
1101 let a1 = make_album_item("A1", "Album A", "Artist A");
1102 let a2 = make_album_item("A2", "Album A", "Artist A");
1103 let b1 = make_album_item("B1", "Album B", "Artist B");
1104 let a3 = make_album_item("A3", "Album A", "Artist A");
1105
1106 let id_a1 = a1.id;
1107 let id_a2 = a2.id;
1108 let id_a3 = a3.id;
1109
1110 state.add_items(vec![a1, a2, b1, a3]);
1111
1112 let mates = state.same_album_item_ids(id_a1);
1113 assert_eq!(mates.len(), 2);
1114 assert!(mates.contains(&id_a2));
1115 assert!(mates.contains(&id_a3));
1116 }
1117
1118 #[test]
1119 fn test_same_album_item_ids_distinguishes_album_artists() {
1120 let state = SharedPlayerState::new();
1122 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1123 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1124
1125 let id_a1 = a1.id;
1126
1127 state.add_items(vec![a1, b1]);
1128
1129 let mates = state.same_album_item_ids(id_a1);
1130 assert!(mates.is_empty(), "different album_artist should not match");
1131 }
1132
1133 #[test]
1134 fn test_same_album_item_ids_unknown_id_returns_empty() {
1135 let state = SharedPlayerState::new();
1136 state.add_items(vec![ready_item("track-0")]);
1137
1138 let bogus = QueueItemId::new();
1139 let mates = state.same_album_item_ids(bogus);
1140 assert!(mates.is_empty());
1141 }
1142
1143 #[test]
1146 fn test_move_item_to_reorders_playlist() {
1147 let state = SharedPlayerState::new();
1149 let item_a = ready_item("A");
1150 let item_b = ready_item("B");
1151 let item_c = ready_item("C");
1152 let id_a = item_a.id;
1153 let id_b = item_b.id;
1154 let id_c = item_c.id;
1155
1156 state.add_items(vec![item_a, item_b, item_c]);
1157 state.move_item_to(id_c, Some(id_a));
1158
1159 let (items, _) = state.snapshot_playlist();
1160 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1161 assert_eq!(titles, vec!["A", "C", "B"]);
1162 assert_eq!(items[0].id, id_a);
1163 assert_eq!(items[1].id, id_c);
1164 assert_eq!(items[2].id, id_b);
1165 }
1166
1167 #[test]
1168 fn test_move_item_to_front_when_after_is_none() {
1169 let state = SharedPlayerState::new();
1171 let item_a = ready_item("A");
1172 let item_b = ready_item("B");
1173 let item_c = ready_item("C");
1174 let id_c = item_c.id;
1175
1176 state.add_items(vec![item_a, item_b, item_c]);
1177 state.move_item_to(id_c, None);
1178
1179 let (items, _) = state.snapshot_playlist();
1180 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1181 assert_eq!(titles, vec!["C", "A", "B"]);
1182 }
1183
1184 #[test]
1187 fn test_move_items_batch_preserves_relative_order() {
1188 let state = SharedPlayerState::new();
1190 let item_a = ready_item("A");
1191 let item_b = ready_item("B");
1192 let item_c = ready_item("C");
1193 let item_d = ready_item("D");
1194 let id_a = item_a.id;
1195 let id_b = item_b.id;
1196 let id_c = item_c.id;
1197 let id_d = item_d.id;
1198
1199 state.add_items(vec![item_a, item_b, item_c, item_d]);
1200 state.move_items(&[id_a, id_c], id_d, true);
1201
1202 let (items, _) = state.snapshot_playlist();
1203 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1204 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1205 assert_eq!(items[0].id, id_b);
1206 assert_eq!(items[1].id, id_d);
1207 assert_eq!(items[2].id, id_a);
1208 assert_eq!(items[3].id, id_c);
1209 }
1210
1211 #[test]
1214 fn test_pending_downloads_collects_pending_with_db_id() {
1215 let state = SharedPlayerState::new();
1216 let mut item_a = ready_item("local");
1217 item_a.db_id = None;
1218
1219 let mut item_b = pending_item("remote-1");
1220 item_b.db_id = Some(10);
1221 let id_b = item_b.id;
1222
1223 let mut item_c = ready_item("cached");
1224 item_c.db_id = Some(20);
1225
1226 let mut item_d = pending_item("remote-2");
1227 item_d.db_id = Some(30);
1228 let id_d = item_d.id;
1229
1230 let item_e = pending_item("orphan");
1232
1233 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1234
1235 let pending = state.pending_downloads();
1236 assert_eq!(pending.len(), 2);
1237 assert_eq!(pending[0], (10, id_b));
1238 assert_eq!(pending[1], (30, id_d));
1239 }
1240
1241 #[test]
1242 fn test_item_db_id_and_load_state() {
1243 let state = SharedPlayerState::new();
1244 let mut item = pending_item("track");
1245 item.db_id = Some(42);
1246 let id = item.id;
1247 state.add_items(vec![item]);
1248
1249 assert_eq!(state.item_db_id(id), Some(42));
1250 assert!(matches!(
1251 state.item_load_state(id),
1252 Some(LoadState::Pending)
1253 ));
1254
1255 state.update_load_state(id, LoadState::Ready);
1256 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1257 }
1258}