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