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 pub error: Option<String>,
155}
156
157#[derive(Debug, Clone, Default)]
159pub struct VisibleQueueSnapshot {
160 pub entries: Vec<QueueEntry>,
161 pub finished_count: usize,
162 pub has_playing: bool,
163 pub queue_count: usize,
164}
165
166#[derive(Debug)]
170pub struct SharedPlayerState {
171 state: AtomicU8,
172 position_ms: AtomicU64,
173 track_info: parking_lot::RwLock<Option<TrackInfo>>,
174
175 playlist: parking_lot::RwLock<Playlist>,
177
178 playlist_version: AtomicU64,
180
181 quit_requested: AtomicBool,
183
184 metadata_refresh_pending: AtomicBool,
187
188 radio_mode: AtomicBool,
191}
192
193impl SharedPlayerState {
194 pub fn new() -> Arc<Self> {
195 Arc::new(Self {
196 state: AtomicU8::new(PlaybackState::Stopped as u8),
197 position_ms: AtomicU64::new(0),
198 track_info: parking_lot::RwLock::new(None),
199 playlist: parking_lot::RwLock::new(Playlist::default()),
200 playlist_version: AtomicU64::new(0),
201 quit_requested: AtomicBool::new(false),
202 metadata_refresh_pending: AtomicBool::new(false),
203 radio_mode: AtomicBool::new(false),
204 })
205 }
206
207 pub fn playback_state(&self) -> PlaybackState {
210 PlaybackState::from_u8(self.state.load(Ordering::Acquire))
211 }
212
213 pub fn set_playback_state(&self, state: PlaybackState) {
214 self.state.store(state as u8, Ordering::Release);
215 }
216
217 pub fn position_ms(&self) -> u64 {
218 self.position_ms.load(Ordering::Acquire)
219 }
220
221 pub fn set_position_ms(&self, pos: u64) {
222 self.position_ms.store(pos, Ordering::Release);
223 }
224
225 pub fn track_info(&self) -> Option<TrackInfo> {
226 self.track_info.read().clone()
227 }
228
229 pub fn set_track_info(&self, info: Option<TrackInfo>) {
230 *self.track_info.write() = info;
231 }
232
233 pub fn current_download_fraction(&self) -> Option<f64> {
236 let id = self.track_info.read().as_ref()?.id;
239 let pl = self.playlist.read();
240 pl.items
241 .iter()
242 .find(|item| item.id == id)
243 .and_then(|item| match &item.load_state {
244 LoadState::Downloading {
245 bytes_written,
246 total,
247 ..
248 } => {
249 let written = bytes_written.load(Ordering::Acquire);
250 if *total > 0 {
251 Some((written as f64 / *total as f64).min(1.0))
252 } else {
253 None
254 }
255 }
256 _ => None,
257 })
258 }
259
260 pub fn request_quit(&self) {
263 self.quit_requested.store(true, Ordering::Release);
264 }
265
266 pub fn quit_requested(&self) -> bool {
267 self.quit_requested.load(Ordering::Acquire)
268 }
269
270 pub fn signal_metadata_refresh(&self) {
276 self.metadata_refresh_pending.store(true, Ordering::Release);
277 }
278
279 pub fn take_metadata_refresh(&self) -> bool {
281 self.metadata_refresh_pending
282 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
283 .is_ok()
284 }
285
286 pub fn radio_mode(&self) -> bool {
289 self.radio_mode.load(Ordering::Acquire)
290 }
291
292 pub fn set_radio_mode(&self, enabled: bool) {
293 self.radio_mode.store(enabled, Ordering::Release);
294 }
295
296 pub fn playlist_version(&self) -> u64 {
299 self.playlist_version.load(Ordering::Acquire)
300 }
301
302 fn bump_version(&self) {
303 self.playlist_version.fetch_add(1, Ordering::AcqRel);
304 }
305
306 pub fn add_items(&self, items: Vec<PlaylistItem>) {
310 let mut pl = self.playlist.write();
311 pl.items.extend(items);
312 drop(pl);
313 self.bump_version();
314 }
315
316 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
318 let mut pl = self.playlist.write();
319 let insert_at = match pl.items.iter().position(|item| item.id == after) {
320 Some(pos) => pos + 1,
321 None => pl.items.len(), };
323 for (i, item) in items.into_iter().enumerate() {
324 pl.items.insert(insert_at + i, item);
325 }
326 drop(pl);
327 self.bump_version();
328 }
329
330 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
332 let mut pl = self.playlist.write();
333 for (id, new_path) in updates {
334 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
335 item.path = new_path.clone();
336 }
337 }
338 drop(pl);
339 self.bump_version();
340 }
341
342 pub fn remove_item(&self, id: QueueItemId) {
344 let mut pl = self.playlist.write();
345 pl.items.retain(|item| item.id != id);
346 if pl.cursor == Some(id) {
348 pl.cursor = None;
349 }
350 drop(pl);
351 self.bump_version();
352 }
353
354 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
356 let mut pl = self.playlist.write();
357 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
358 return;
359 };
360 let item = pl.items.remove(from);
361 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
362 let pos = from.min(pl.items.len());
364 pl.items.insert(pos, item);
365 return;
366 };
367 let insert_at = if after { to + 1 } else { to };
368 pl.items.insert(insert_at, item);
369 drop(pl);
370 self.bump_version();
371 }
372
373 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
376 use std::collections::HashSet;
377 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
378
379 let mut pl = self.playlist.write();
380
381 let mut remaining = Vec::with_capacity(pl.items.len());
383 let mut moved = Vec::with_capacity(ids.len());
384 for item in pl.items.drain(..) {
385 if id_set.contains(&item.id) {
386 moved.push(item);
387 } else {
388 remaining.push(item);
389 }
390 }
391
392 let insert_at = match remaining.iter().position(|item| item.id == target) {
394 Some(pos) => {
395 if after {
396 pos + 1
397 } else {
398 pos
399 }
400 }
401 None => remaining.len(),
402 };
403
404 for (i, item) in moved.into_iter().enumerate() {
406 remaining.insert(insert_at + i, item);
407 }
408
409 pl.items = remaining;
410 drop(pl);
411 self.bump_version();
412 }
413
414 pub fn set_cursor(&self, id: Option<QueueItemId>) {
416 let mut pl = self.playlist.write();
417 pl.cursor = id;
418 drop(pl);
419 self.bump_version();
420 }
421
422 pub fn cursor(&self) -> Option<QueueItemId> {
424 self.playlist.read().cursor
425 }
426
427 pub fn clear_playlist(&self) {
429 let mut pl = self.playlist.write();
430 pl.items.clear();
431 pl.cursor = None;
432 drop(pl);
433 self.bump_version();
434 }
435
436 pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
449 let mut pl = self.playlist.write();
450 let start = match pl.cursor {
451 Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
452 None => 0,
453 };
454
455 let next = pl
456 .items
457 .get(start..)?
458 .iter()
459 .find(|item| !matches!(item.load_state, LoadState::Failed(_)))
460 .map(|item| item.id)?;
461
462 pl.cursor = Some(next);
463 drop(pl);
464 self.bump_version();
465 Some(next)
466 }
467
468 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
472 let pl = self.playlist.read();
473 let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
476
477 for i in start..pl.items.len() {
478 if matches!(pl.items[i].load_state, LoadState::Ready) {
479 let item = &pl.items[i];
480 return Some((item.id, item.path.clone()));
481 }
482 }
483 None
484 }
485
486 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
489 let mut pl = self.playlist.write();
490 let cursor_pos = match pl.cursor {
491 Some(cid) => pl.items.iter().position(|item| item.id == cid),
492 None => None,
493 };
494
495 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
496
497 match prev_pos {
498 Some(pos) => {
499 let item = &pl.items[pos];
500 let result = (item.id, item.path.clone());
501 pl.cursor = Some(item.id);
502 drop(pl);
503 self.bump_version();
504 Some(result)
505 }
506 None => None,
507 }
508 }
509
510 pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
514 let mut pl = self.playlist.write();
515 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
516 item.load_state = new_state;
517 }
518 drop(pl);
519 self.bump_version();
520 }
521
522 pub fn update_item_metadata(
526 &self,
527 id: QueueItemId,
528 title: String,
529 artist: String,
530 album_artist: String,
531 album: String,
532 duration_ms: Option<u64>,
533 ) {
534 let mut pl = self.playlist.write();
535 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
536 item.title = title;
537 item.artist = artist;
538 item.album_artist = album_artist;
539 item.album = album;
540 if let Some(dur) = duration_ms {
541 item.duration_ms = Some(dur);
542 }
543 }
544 drop(pl);
545 self.bump_version();
546 }
547
548 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
551 let pl = self.playlist.read();
552 pl.items
553 .iter()
554 .find(|item| item.id == id)
555 .and_then(|item| match &item.load_state {
556 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
557 LoadState::Downloading {
558 total,
559 bytes_written,
560 ..
561 } => {
562 let written = bytes_written.load(Ordering::Acquire);
563 if written >= STREAM_THRESHOLD {
564 Some(PlaybackSource::Streaming {
565 path: item.path.clone(),
566 bytes_written: bytes_written.clone(),
567 total: *total,
568 })
569 } else {
570 None
571 }
572 }
573 _ => None,
574 })
575 }
576
577 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
579 let pl = self.playlist.read();
580 pl.items.iter().find(|item| item.id == id).and_then(|item| {
581 if matches!(item.load_state, LoadState::Ready) {
582 Some(item.path.clone())
583 } else {
584 None
585 }
586 })
587 }
588
589 pub fn is_cursor(&self, id: QueueItemId) -> bool {
591 self.playlist.read().cursor == Some(id)
592 }
593
594 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
598 let pl = self.playlist.read();
599 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
600 return vec![];
601 };
602 let album = cursor.album.clone();
603 let album_artist = cursor.album_artist.clone();
604 pl.items
605 .iter()
606 .filter(|item| {
607 item.id != id && item.album == album && item.album_artist == album_artist
608 })
609 .map(|item| item.id)
610 .collect()
611 }
612
613 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
616 let pl = self.playlist.read();
617 pl.items
618 .iter()
619 .filter(|item| matches!(item.load_state, LoadState::Pending))
620 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
621 .collect()
622 }
623
624 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
626 let pl = self.playlist.read();
627 pl.items
628 .iter()
629 .find(|item| item.id == id)
630 .and_then(|item| item.db_id)
631 }
632
633 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
635 let pl = self.playlist.read();
636 pl.items
637 .iter()
638 .find(|item| item.id == id)
639 .map(|item| item.load_state.clone())
640 }
641
642 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
646 let pl = self.playlist.read();
647 (pl.items.clone(), pl.cursor)
648 }
649
650 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
652 let pl = self.playlist.read();
653 pl.items.iter().find(|item| item.id == id).cloned()
654 }
655
656 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
658 let pl = self.playlist.read();
659 let pos = pl.items.iter().position(|item| item.id == id)?;
660 if pos == 0 {
661 None
662 } else {
663 Some(pl.items[pos - 1].id)
664 }
665 }
666
667 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
674 use std::collections::HashSet;
675 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
676 let pl = self.playlist.read();
677 pl.items
678 .iter()
679 .enumerate()
680 .filter(|(_, item)| wanted.contains(&item.id))
681 .map(|(pos, item)| {
682 let before = if pos == 0 {
683 None
684 } else {
685 Some(pl.items[pos - 1].id)
686 };
687 (item.id, before)
688 })
689 .collect()
690 }
691
692 pub fn surviving_item_before(
696 &self,
697 id: QueueItemId,
698 removed: &[QueueItemId],
699 ) -> Option<QueueItemId> {
700 use std::collections::HashSet;
701 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
702 let pl = self.playlist.read();
703 let pos = pl.items.iter().position(|item| item.id == id)?;
704 pl.items[..pos]
705 .iter()
706 .rev()
707 .find(|item| !removed.contains(&item.id))
708 .map(|item| item.id)
709 }
710
711 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
713 let mut pl = self.playlist.write();
714 pl.items = items;
715 pl.cursor = cursor;
716 drop(pl);
717 self.bump_version();
718 }
719
720 pub fn remove_items(&self, ids: &[QueueItemId]) {
722 use std::collections::HashSet;
723 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
724 let mut pl = self.playlist.write();
725 pl.items.retain(|item| !id_set.contains(&item.id));
726 if let Some(cursor) = pl.cursor
727 && id_set.contains(&cursor)
728 {
729 pl.cursor = None;
730 }
731 drop(pl);
732 self.bump_version();
733 }
734
735 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
737 let mut pl = self.playlist.write();
738 let insert_at = match after {
739 Some(after_id) => {
740 match pl.items.iter().position(|i| i.id == after_id) {
741 Some(pos) => pos + 1,
742 None => pl.items.len(), }
744 }
745 None => 0,
746 };
747 pl.items.insert(insert_at, item);
748 drop(pl);
749 self.bump_version();
750 }
751
752 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
754 let mut pl = self.playlist.write();
755 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
756 return;
757 };
758 let item = pl.items.remove(from);
759 let insert_at = match after {
760 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
761 Some(pos) => pos + 1,
762 None => pl.items.len(),
763 },
764 None => 0,
765 };
766 pl.items.insert(insert_at, item);
767 drop(pl);
768 self.bump_version();
769 }
770
771 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
774 for &(id, after) in entries {
775 self.move_item_to(id, after);
776 }
777 }
778
779 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
784 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
786 let pl = self.playlist.read();
787
788 let cursor_pos = match pl.cursor {
789 Some(cid) => pl.items.iter().position(|item| item.id == cid),
790 None => None,
791 };
792
793 let mut entries = Vec::with_capacity(pl.items.len());
794 let mut finished_count = 0;
795 let mut has_playing = false;
796 let mut queue_count = 0;
797
798 for (i, item) in pl.items.iter().enumerate() {
799 let is_cursor = cursor_pos == Some(i);
800 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
801
802 let dl_progress = match &item.load_state {
804 LoadState::Downloading {
805 downloaded, total, ..
806 } => Some((*downloaded, *total)),
807 _ => None,
808 };
809
810 let status = if is_cursor {
811 has_playing = true;
812 match &item.load_state {
813 LoadState::Ready => QueueEntryStatus::Playing,
814 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
815 LoadState::Pending => QueueEntryStatus::PriorityPending,
816 LoadState::Failed(_) => QueueEntryStatus::Failed,
817 }
818 } else if is_before_cursor {
819 finished_count += 1;
820 match &item.load_state {
821 LoadState::Ready => QueueEntryStatus::Played,
822 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
823 LoadState::Pending => QueueEntryStatus::Downloading,
824 LoadState::Failed(_) => QueueEntryStatus::Failed,
825 }
826 } else {
827 queue_count += 1;
828 match &item.load_state {
829 LoadState::Ready => QueueEntryStatus::Queued,
830 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
831 LoadState::Pending => QueueEntryStatus::Downloading,
832 LoadState::Failed(_) => QueueEntryStatus::Failed,
833 }
834 };
835
836 let duration_ms =
838 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
839 {
840 playing_duration_ms
841 } else {
842 item.duration_ms
843 };
844
845 entries.push(QueueEntry {
846 id: item.id,
847 db_id: item.db_id,
848 path: item.path.clone(),
849 title: item.title.clone(),
850 artist: item.artist.clone(),
851 album_artist: item.album_artist.clone(),
852 album: item.album.clone(),
853 year: item.year.clone(),
854 codec: item.codec.clone(),
855 track_number: item.track_number,
856 disc: item.disc,
857 duration_ms,
858 status,
859 download_progress: dl_progress,
860 error: match &item.load_state {
861 LoadState::Failed(reason) => Some(reason.clone()),
862 _ => None,
863 },
864 });
865 }
866
867 VisibleQueueSnapshot {
868 entries,
869 finished_count,
870 has_playing,
871 queue_count,
872 }
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
883 PlaylistItem {
884 id: QueueItemId::new(),
885 db_id: None,
886 path: PathBuf::from(format!("/music/{title}.flac")),
887 title: title.to_string(),
888 artist: "Artist".to_string(),
889 album_artist: "Artist".to_string(),
890 album: "Album".to_string(),
891 year: None,
892 codec: Some("FLAC".to_string()),
893 track_number: None,
894 disc: None,
895 duration_ms: Some(200_000),
896 load_state,
897 }
898 }
899
900 fn ready_item(title: &str) -> PlaylistItem {
901 make_item(title, LoadState::Ready)
902 }
903
904 fn pending_item(title: &str) -> PlaylistItem {
905 make_item(title, LoadState::Pending)
906 }
907
908 fn failed_item(title: &str) -> PlaylistItem {
909 make_item(title, LoadState::Failed("nope".into()))
910 }
911
912 #[test]
915 fn advance_parks_on_a_still_downloading_track() {
916 let state = SharedPlayerState::new();
920 let item0 = ready_item("track-0");
921 let item1 = pending_item("track-1");
922 let item2 = ready_item("track-2");
923 let (id0, id1) = (item0.id, item1.id);
924
925 state.add_items(vec![item0, item1, item2]);
926
927 assert_eq!(state.advance_cursor_loadable(), Some(id0));
928 assert_eq!(state.advance_cursor_loadable(), Some(id1));
929 assert_eq!(state.cursor(), Some(id1));
930 }
931
932 #[test]
933 fn advance_skips_failed_items() {
934 let state = SharedPlayerState::new();
935 let item0 = ready_item("track-0");
936 let item1 = failed_item("track-1");
937 let item2 = ready_item("track-2");
938 let (id0, id2) = (item0.id, item2.id);
939
940 state.add_items(vec![item0, item1, item2]);
941 state.set_cursor(Some(id0));
942
943 assert_eq!(state.advance_cursor_loadable(), Some(id2));
944 }
945
946 #[test]
947 fn advance_stops_at_end_of_playlist() {
948 let state = SharedPlayerState::new();
949 let item0 = ready_item("track-0");
950 let item1 = ready_item("track-1");
951 let id1 = item1.id;
952
953 state.add_items(vec![item0, item1]);
954 state.set_cursor(Some(id1));
955
956 assert_eq!(state.advance_cursor_loadable(), None);
957 assert_eq!(
958 state.cursor(),
959 Some(id1),
960 "cursor unchanged on a failed advance"
961 );
962 }
963
964 #[test]
965 fn advance_with_only_failed_items_returns_none() {
966 let state = SharedPlayerState::new();
967 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
968
969 assert_eq!(state.advance_cursor_loadable(), None);
970 }
971
972 #[test]
973 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
974 let state = SharedPlayerState::new();
975 let item0 = ready_item("track-0");
976 let item1 = ready_item("track-1");
977 let id0 = item0.id;
978
979 state.add_items(vec![item0, item1]);
980 let ghost = QueueItemId::new();
981 state.set_cursor(Some(ghost));
982
983 assert_eq!(state.advance_cursor_loadable(), None);
984 assert_ne!(state.cursor(), Some(id0));
985 }
986
987 #[test]
990 fn peek_after_a_removed_item_returns_none() {
991 let state = SharedPlayerState::new();
995 let item0 = ready_item("track-0");
996 let item1 = ready_item("track-1");
997 let item2 = ready_item("track-2");
998 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
999
1000 state.add_items(vec![item0, item1, item2]);
1001 assert_eq!(
1002 state.peek_next_ready_after(id1).map(|(id, _)| id),
1003 Some(id2)
1004 );
1005
1006 state.remove_item(id2);
1007 assert!(
1008 state.peek_next_ready_after(id2).is_none(),
1009 "a vanished reference must not resolve to the head of the queue"
1010 );
1011 assert_ne!(
1012 state.peek_next_ready_after(id2).map(|(id, _)| id),
1013 Some(id0)
1014 );
1015 }
1016
1017 #[test]
1020 fn surviving_predecessor_skips_items_being_removed() {
1021 let state = SharedPlayerState::new();
1022 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1023 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1024 state.add_items(items);
1025
1026 assert_eq!(
1028 state.surviving_item_before(ids[3], &ids[1..4]),
1029 Some(ids[0])
1030 );
1031 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1033 }
1034
1035 #[test]
1038 fn test_retreat_cursor_goes_to_previous_item() {
1039 let state = SharedPlayerState::new();
1040 let item0 = ready_item("track-0");
1041 let item1 = ready_item("track-1");
1042 let id0 = item0.id;
1043 let id1 = item1.id;
1044
1045 state.add_items(vec![item0, item1]);
1046 state.set_cursor(Some(id1));
1047
1048 let result = state.retreat_cursor();
1049 assert!(result.is_some(), "expected to retreat to previous item");
1050 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1051 assert_eq!(state.cursor(), Some(id0));
1052 }
1053
1054 #[test]
1055 fn test_retreat_cursor_returns_none_when_at_first_item() {
1056 let state = SharedPlayerState::new();
1057 let item0 = ready_item("only-track");
1058 let id0 = item0.id;
1059
1060 state.add_items(vec![item0]);
1061 state.set_cursor(Some(id0));
1062
1063 let result = state.retreat_cursor();
1064 assert!(result.is_none(), "cannot retreat before the first item");
1065 assert_eq!(state.cursor(), Some(id0));
1067 }
1068
1069 #[test]
1070 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1071 let state = SharedPlayerState::new();
1072 state.add_items(vec![ready_item("track-0")]);
1073
1074 let result = state.retreat_cursor();
1075 assert!(
1076 result.is_none(),
1077 "retreat with no cursor should return None"
1078 );
1079 }
1080
1081 #[test]
1084 fn test_derive_visible_queue_statuses() {
1085 let state = SharedPlayerState::new();
1087 let item0 = ready_item("played-track");
1088 let item1 = ready_item("playing-track");
1089 let item2 = ready_item("queued-track");
1090 let id1 = item1.id;
1091
1092 state.add_items(vec![item0, item1, item2]);
1093 state.set_cursor(Some(id1));
1094
1095 let snap = state.derive_visible_queue();
1096
1097 assert_eq!(snap.entries.len(), 3);
1098 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1099 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1100 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1101 assert!(snap.has_playing);
1102 assert_eq!(snap.finished_count, 1);
1103 assert_eq!(snap.queue_count, 1);
1104 }
1105
1106 #[test]
1107 fn test_derive_visible_queue_downloading_statuses() {
1108 let state = SharedPlayerState::new();
1110 let bytes_cursor = Arc::new(AtomicU64::new(0));
1111 let bytes_queued = Arc::new(AtomicU64::new(0));
1112 let dl_cursor = make_item(
1113 "downloading-at-cursor",
1114 LoadState::Downloading {
1115 downloaded: 0,
1116 total: 1_000_000,
1117 bytes_written: bytes_cursor.clone(),
1118 },
1119 );
1120 let dl_queued = make_item(
1121 "downloading-queued",
1122 LoadState::Downloading {
1123 downloaded: 0,
1124 total: 500_000,
1125 bytes_written: bytes_queued.clone(),
1126 },
1127 );
1128 let id_cursor = dl_cursor.id;
1129
1130 state.add_items(vec![dl_cursor, dl_queued]);
1131 state.set_cursor(Some(id_cursor));
1132
1133 let snap = state.derive_visible_queue();
1134
1135 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1136 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1137 }
1138
1139 #[test]
1140 fn test_derive_visible_queue_no_cursor_all_queued() {
1141 let state = SharedPlayerState::new();
1142 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1143
1144 let snap = state.derive_visible_queue();
1145
1146 assert_eq!(snap.entries.len(), 3);
1147 for entry in &snap.entries {
1148 assert_eq!(entry.status, QueueEntryStatus::Queued);
1149 }
1150 assert!(!snap.has_playing);
1151 assert_eq!(snap.finished_count, 0);
1152 assert_eq!(snap.queue_count, 3);
1153 }
1154
1155 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1158 PlaylistItem {
1159 id: QueueItemId::new(),
1160 db_id: None,
1161 path: PathBuf::from(format!("/music/{title}.flac")),
1162 title: title.to_string(),
1163 artist: "Artist".to_string(),
1164 album_artist: album_artist.to_string(),
1165 album: album.to_string(),
1166 year: None,
1167 codec: Some("FLAC".to_string()),
1168 track_number: None,
1169 disc: None,
1170 duration_ms: Some(200_000),
1171 load_state: LoadState::Ready,
1172 }
1173 }
1174
1175 #[test]
1176 fn test_same_album_item_ids_returns_album_mates() {
1177 let state = SharedPlayerState::new();
1178 let a1 = make_album_item("A1", "Album A", "Artist A");
1179 let a2 = make_album_item("A2", "Album A", "Artist A");
1180 let b1 = make_album_item("B1", "Album B", "Artist B");
1181 let a3 = make_album_item("A3", "Album A", "Artist A");
1182
1183 let id_a1 = a1.id;
1184 let id_a2 = a2.id;
1185 let id_a3 = a3.id;
1186
1187 state.add_items(vec![a1, a2, b1, a3]);
1188
1189 let mates = state.same_album_item_ids(id_a1);
1190 assert_eq!(mates.len(), 2);
1191 assert!(mates.contains(&id_a2));
1192 assert!(mates.contains(&id_a3));
1193 }
1194
1195 #[test]
1196 fn test_same_album_item_ids_distinguishes_album_artists() {
1197 let state = SharedPlayerState::new();
1199 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1200 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1201
1202 let id_a1 = a1.id;
1203
1204 state.add_items(vec![a1, b1]);
1205
1206 let mates = state.same_album_item_ids(id_a1);
1207 assert!(mates.is_empty(), "different album_artist should not match");
1208 }
1209
1210 #[test]
1211 fn test_same_album_item_ids_unknown_id_returns_empty() {
1212 let state = SharedPlayerState::new();
1213 state.add_items(vec![ready_item("track-0")]);
1214
1215 let bogus = QueueItemId::new();
1216 let mates = state.same_album_item_ids(bogus);
1217 assert!(mates.is_empty());
1218 }
1219
1220 #[test]
1223 fn test_move_item_to_reorders_playlist() {
1224 let state = SharedPlayerState::new();
1226 let item_a = ready_item("A");
1227 let item_b = ready_item("B");
1228 let item_c = ready_item("C");
1229 let id_a = item_a.id;
1230 let id_b = item_b.id;
1231 let id_c = item_c.id;
1232
1233 state.add_items(vec![item_a, item_b, item_c]);
1234 state.move_item_to(id_c, Some(id_a));
1235
1236 let (items, _) = state.snapshot_playlist();
1237 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1238 assert_eq!(titles, vec!["A", "C", "B"]);
1239 assert_eq!(items[0].id, id_a);
1240 assert_eq!(items[1].id, id_c);
1241 assert_eq!(items[2].id, id_b);
1242 }
1243
1244 #[test]
1245 fn test_move_item_to_front_when_after_is_none() {
1246 let state = SharedPlayerState::new();
1248 let item_a = ready_item("A");
1249 let item_b = ready_item("B");
1250 let item_c = ready_item("C");
1251 let id_c = item_c.id;
1252
1253 state.add_items(vec![item_a, item_b, item_c]);
1254 state.move_item_to(id_c, None);
1255
1256 let (items, _) = state.snapshot_playlist();
1257 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1258 assert_eq!(titles, vec!["C", "A", "B"]);
1259 }
1260
1261 #[test]
1264 fn test_move_items_batch_preserves_relative_order() {
1265 let state = SharedPlayerState::new();
1267 let item_a = ready_item("A");
1268 let item_b = ready_item("B");
1269 let item_c = ready_item("C");
1270 let item_d = ready_item("D");
1271 let id_a = item_a.id;
1272 let id_b = item_b.id;
1273 let id_c = item_c.id;
1274 let id_d = item_d.id;
1275
1276 state.add_items(vec![item_a, item_b, item_c, item_d]);
1277 state.move_items(&[id_a, id_c], id_d, true);
1278
1279 let (items, _) = state.snapshot_playlist();
1280 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1281 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1282 assert_eq!(items[0].id, id_b);
1283 assert_eq!(items[1].id, id_d);
1284 assert_eq!(items[2].id, id_a);
1285 assert_eq!(items[3].id, id_c);
1286 }
1287
1288 #[test]
1291 fn test_pending_downloads_collects_pending_with_db_id() {
1292 let state = SharedPlayerState::new();
1293 let mut item_a = ready_item("local");
1294 item_a.db_id = None;
1295
1296 let mut item_b = pending_item("remote-1");
1297 item_b.db_id = Some(10);
1298 let id_b = item_b.id;
1299
1300 let mut item_c = ready_item("cached");
1301 item_c.db_id = Some(20);
1302
1303 let mut item_d = pending_item("remote-2");
1304 item_d.db_id = Some(30);
1305 let id_d = item_d.id;
1306
1307 let item_e = pending_item("orphan");
1309
1310 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1311
1312 let pending = state.pending_downloads();
1313 assert_eq!(pending.len(), 2);
1314 assert_eq!(pending[0], (10, id_b));
1315 assert_eq!(pending[1], (30, id_d));
1316 }
1317
1318 #[test]
1319 fn test_item_db_id_and_load_state() {
1320 let state = SharedPlayerState::new();
1321 let mut item = pending_item("track");
1322 item.db_id = Some(42);
1323 let id = item.id;
1324 state.add_items(vec![item]);
1325
1326 assert_eq!(state.item_db_id(id), Some(42));
1327 assert!(matches!(
1328 state.item_load_state(id),
1329 Some(LoadState::Pending)
1330 ));
1331
1332 state.update_load_state(id, LoadState::Ready);
1333 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1334 }
1335}