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 total: u64,
75 bytes_written: Arc<AtomicU64>,
79 },
80 Ready,
81 Failed(String),
82}
83
84pub enum PlaybackSource {
86 Ready(PathBuf),
88 Streaming {
90 path: PathBuf,
91 bytes_written: Arc<AtomicU64>,
92 total: u64,
93 },
94}
95
96#[derive(Debug, Clone)]
99pub struct PlaylistItem {
100 pub id: QueueItemId,
101 pub db_id: Option<i64>,
103 pub playlist_entry_id: Option<i64>,
110 pub path: PathBuf,
111 pub title: String,
112 pub artist: String,
113 pub album_artist: String,
114 pub album: String,
115 pub year: Option<String>,
116 pub codec: Option<String>,
117 pub track_number: Option<i64>,
118 pub disc: Option<i64>,
119 pub duration_ms: Option<u64>,
120 pub load_state: LoadState,
121}
122
123#[derive(Debug, Clone, Default)]
125pub struct Playlist {
126 pub items: Vec<PlaylistItem>,
127 pub cursor: Option<QueueItemId>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum QueueEntryStatus {
135 Queued,
136 Playing,
137 Played,
138 Downloading,
139 PriorityPending,
141 Failed,
142}
143
144#[derive(Debug, Clone)]
146pub struct QueueEntry {
147 pub id: QueueItemId,
148 pub db_id: Option<i64>,
150 pub playlist_entry_id: Option<i64>,
152 pub path: PathBuf,
153 pub title: String,
154 pub artist: String,
155 pub album_artist: String,
156 pub album: String,
157 pub year: Option<String>,
158 pub codec: Option<String>,
159 pub track_number: Option<i64>,
160 pub disc: Option<i64>,
161 pub duration_ms: Option<u64>,
162 pub status: QueueEntryStatus,
163 pub download_progress: Option<(u64, u64)>,
164 pub error: Option<String>,
166}
167
168#[derive(Debug, Clone, Default)]
170pub struct VisibleQueueSnapshot {
171 pub entries: Vec<QueueEntry>,
172 pub finished_count: usize,
173 pub has_playing: bool,
174 pub queue_count: usize,
175}
176
177#[derive(Debug)]
181pub struct SharedPlayerState {
182 state: AtomicU8,
183 position_ms: AtomicU64,
184 track_info: parking_lot::RwLock<Option<TrackInfo>>,
185
186 playlist: parking_lot::RwLock<Playlist>,
188
189 playlist_version: AtomicU64,
191
192 quit_requested: AtomicBool,
194
195 metadata_refresh_pending: AtomicBool,
198
199 output_sample_rate: AtomicU64,
206
207 radio_mode: AtomicBool,
210}
211
212impl SharedPlayerState {
213 pub fn new() -> Arc<Self> {
214 Arc::new(Self {
215 state: AtomicU8::new(PlaybackState::Stopped as u8),
216 position_ms: AtomicU64::new(0),
217 track_info: parking_lot::RwLock::new(None),
218 playlist: parking_lot::RwLock::new(Playlist::default()),
219 playlist_version: AtomicU64::new(0),
220 quit_requested: AtomicBool::new(false),
221 metadata_refresh_pending: AtomicBool::new(false),
222 output_sample_rate: AtomicU64::new(0),
223 radio_mode: AtomicBool::new(false),
224 })
225 }
226
227 pub fn playback_state(&self) -> PlaybackState {
230 PlaybackState::from_u8(self.state.load(Ordering::Acquire))
231 }
232
233 pub fn set_playback_state(&self, state: PlaybackState) {
234 self.state.store(state as u8, Ordering::Release);
235 }
236
237 pub fn position_ms(&self) -> u64 {
238 self.position_ms.load(Ordering::Acquire)
239 }
240
241 pub fn set_position_ms(&self, pos: u64) {
242 self.position_ms.store(pos, Ordering::Release);
243 }
244
245 pub fn track_info(&self) -> Option<TrackInfo> {
246 self.track_info.read().clone()
247 }
248
249 pub fn set_track_info(&self, info: Option<TrackInfo>) {
250 *self.track_info.write() = info;
251 }
252
253 pub fn current_download_fraction(&self) -> Option<f64> {
256 let id = self.track_info.read().as_ref()?.id;
259 let pl = self.playlist.read();
260 pl.items
261 .iter()
262 .find(|item| item.id == id)
263 .and_then(|item| match &item.load_state {
264 LoadState::Downloading {
265 bytes_written,
266 total,
267 ..
268 } => {
269 let written = bytes_written.load(Ordering::Acquire);
270 if *total > 0 {
271 Some((written as f64 / *total as f64).min(1.0))
272 } else {
273 None
274 }
275 }
276 _ => None,
277 })
278 }
279
280 pub fn request_quit(&self) {
283 self.quit_requested.store(true, Ordering::Release);
284 }
285
286 pub fn quit_requested(&self) -> bool {
287 self.quit_requested.load(Ordering::Acquire)
288 }
289
290 pub fn signal_metadata_refresh(&self) {
296 self.metadata_refresh_pending.store(true, Ordering::Release);
297 }
298
299 pub fn take_metadata_refresh(&self) -> bool {
301 self.metadata_refresh_pending
302 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
303 .is_ok()
304 }
305
306 pub fn radio_mode(&self) -> bool {
309 self.radio_mode.load(Ordering::Acquire)
310 }
311
312 pub fn set_radio_mode(&self, enabled: bool) {
313 self.radio_mode.store(enabled, Ordering::Release);
314 }
315
316 pub fn output_sample_rate(&self) -> Option<u32> {
320 match self.output_sample_rate.load(Ordering::Acquire) {
321 0 => None,
322 rate => Some(rate as u32),
323 }
324 }
325
326 pub fn set_output_sample_rate(&self, rate: u32) {
327 self.output_sample_rate
328 .store(u64::from(rate), Ordering::Release);
329 }
330
331 pub fn clear_output_sample_rate(&self) {
336 self.output_sample_rate.store(0, Ordering::Release);
337 }
338
339 pub fn playlist_version(&self) -> u64 {
342 self.playlist_version.load(Ordering::Acquire)
343 }
344
345 fn bump_version(&self) {
346 self.playlist_version.fetch_add(1, Ordering::AcqRel);
347 }
348
349 pub fn add_items(&self, items: Vec<PlaylistItem>) {
353 let mut pl = self.playlist.write();
354 pl.items.extend(items);
355 drop(pl);
356 self.bump_version();
357 }
358
359 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
361 let mut pl = self.playlist.write();
362 let insert_at = match pl.items.iter().position(|item| item.id == after) {
363 Some(pos) => pos + 1,
364 None => pl.items.len(), };
366 for (i, item) in items.into_iter().enumerate() {
367 pl.items.insert(insert_at + i, item);
368 }
369 drop(pl);
370 self.bump_version();
371 }
372
373 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
375 let mut pl = self.playlist.write();
376 for (id, new_path) in updates {
377 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
378 item.path = new_path.clone();
379 }
380 }
381 drop(pl);
382 self.bump_version();
383 }
384
385 pub fn remove_item(&self, id: QueueItemId) {
387 let mut pl = self.playlist.write();
388 pl.items.retain(|item| item.id != id);
389 if pl.cursor == Some(id) {
391 pl.cursor = None;
392 }
393 drop(pl);
394 self.bump_version();
395 }
396
397 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
399 let mut pl = self.playlist.write();
400 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
401 return;
402 };
403 let item = pl.items.remove(from);
404 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
405 let pos = from.min(pl.items.len());
407 pl.items.insert(pos, item);
408 return;
409 };
410 let insert_at = if after { to + 1 } else { to };
411 pl.items.insert(insert_at, item);
412 drop(pl);
413 self.bump_version();
414 }
415
416 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
419 use std::collections::HashSet;
420 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
421
422 let mut pl = self.playlist.write();
423
424 let mut remaining = Vec::with_capacity(pl.items.len());
426 let mut moved = Vec::with_capacity(ids.len());
427 for item in pl.items.drain(..) {
428 if id_set.contains(&item.id) {
429 moved.push(item);
430 } else {
431 remaining.push(item);
432 }
433 }
434
435 let insert_at = match remaining.iter().position(|item| item.id == target) {
437 Some(pos) => {
438 if after {
439 pos + 1
440 } else {
441 pos
442 }
443 }
444 None => remaining.len(),
445 };
446
447 for (i, item) in moved.into_iter().enumerate() {
449 remaining.insert(insert_at + i, item);
450 }
451
452 pl.items = remaining;
453 drop(pl);
454 self.bump_version();
455 }
456
457 pub fn set_cursor(&self, id: Option<QueueItemId>) {
459 let mut pl = self.playlist.write();
460 pl.cursor = id;
461 drop(pl);
462 self.bump_version();
463 }
464
465 pub fn cursor(&self) -> Option<QueueItemId> {
467 self.playlist.read().cursor
468 }
469
470 pub fn clear_playlist(&self) {
472 let mut pl = self.playlist.write();
473 pl.items.clear();
474 pl.cursor = None;
475 drop(pl);
476 self.bump_version();
477 }
478
479 pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
492 let mut pl = self.playlist.write();
493 let start = match pl.cursor {
494 Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
495 None => 0,
496 };
497
498 let next = pl
499 .items
500 .get(start..)?
501 .iter()
502 .find(|item| !matches!(item.load_state, LoadState::Failed(_)))
503 .map(|item| item.id)?;
504
505 pl.cursor = Some(next);
506 drop(pl);
507 self.bump_version();
508 Some(next)
509 }
510
511 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
515 let pl = self.playlist.read();
516 let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
519
520 for i in start..pl.items.len() {
521 if matches!(pl.items[i].load_state, LoadState::Ready) {
522 let item = &pl.items[i];
523 return Some((item.id, item.path.clone()));
524 }
525 }
526 None
527 }
528
529 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
532 let mut pl = self.playlist.write();
533 let cursor_pos = match pl.cursor {
534 Some(cid) => pl.items.iter().position(|item| item.id == cid),
535 None => None,
536 };
537
538 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
539
540 match prev_pos {
541 Some(pos) => {
542 let item = &pl.items[pos];
543 let result = (item.id, item.path.clone());
544 pl.cursor = Some(item.id);
545 drop(pl);
546 self.bump_version();
547 Some(result)
548 }
549 None => None,
550 }
551 }
552
553 pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
557 let mut pl = self.playlist.write();
558 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
559 item.load_state = new_state;
560 }
561 drop(pl);
562 self.bump_version();
563 }
564
565 pub fn update_item_metadata(
569 &self,
570 id: QueueItemId,
571 title: String,
572 artist: String,
573 album_artist: String,
574 album: String,
575 duration_ms: Option<u64>,
576 ) {
577 let mut pl = self.playlist.write();
578 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
579 item.title = title;
580 item.artist = artist;
581 item.album_artist = album_artist;
582 item.album = album;
583 if let Some(dur) = duration_ms {
584 item.duration_ms = Some(dur);
585 }
586 }
587 drop(pl);
588 self.bump_version();
589 }
590
591 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
594 let pl = self.playlist.read();
595 pl.items
596 .iter()
597 .find(|item| item.id == id)
598 .and_then(|item| match &item.load_state {
599 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
600 LoadState::Downloading {
601 total,
602 bytes_written,
603 ..
604 } => {
605 let written = bytes_written.load(Ordering::Acquire);
606 if written >= STREAM_THRESHOLD {
607 Some(PlaybackSource::Streaming {
608 path: item.path.clone(),
609 bytes_written: bytes_written.clone(),
610 total: *total,
611 })
612 } else {
613 None
614 }
615 }
616 _ => None,
617 })
618 }
619
620 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
622 let pl = self.playlist.read();
623 pl.items.iter().find(|item| item.id == id).and_then(|item| {
624 if matches!(item.load_state, LoadState::Ready) {
625 Some(item.path.clone())
626 } else {
627 None
628 }
629 })
630 }
631
632 pub fn is_cursor(&self, id: QueueItemId) -> bool {
634 self.playlist.read().cursor == Some(id)
635 }
636
637 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
641 let pl = self.playlist.read();
642 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
643 return vec![];
644 };
645 let album = cursor.album.clone();
646 let album_artist = cursor.album_artist.clone();
647 pl.items
648 .iter()
649 .filter(|item| {
650 item.id != id && item.album == album && item.album_artist == album_artist
651 })
652 .map(|item| item.id)
653 .collect()
654 }
655
656 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
659 let pl = self.playlist.read();
660 pl.items
661 .iter()
662 .filter(|item| matches!(item.load_state, LoadState::Pending))
663 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
664 .collect()
665 }
666
667 pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
673 let pl = self.playlist.read();
674 pl.items
675 .iter()
676 .filter_map(|item| match &item.load_state {
677 LoadState::Downloading {
678 total,
679 bytes_written,
680 } => Some((item.id, bytes_written.load(Ordering::Relaxed), *total)),
681 _ => None,
682 })
683 .collect()
684 }
685
686 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
688 let pl = self.playlist.read();
689 pl.items
690 .iter()
691 .find(|item| item.id == id)
692 .and_then(|item| item.db_id)
693 }
694
695 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
697 let pl = self.playlist.read();
698 pl.items
699 .iter()
700 .find(|item| item.id == id)
701 .map(|item| item.load_state.clone())
702 }
703
704 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
708 let pl = self.playlist.read();
709 (pl.items.clone(), pl.cursor)
710 }
711
712 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
714 let pl = self.playlist.read();
715 pl.items.iter().find(|item| item.id == id).cloned()
716 }
717
718 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
720 let pl = self.playlist.read();
721 let pos = pl.items.iter().position(|item| item.id == id)?;
722 if pos == 0 {
723 None
724 } else {
725 Some(pl.items[pos - 1].id)
726 }
727 }
728
729 pub fn reorder_to(&self, order: &[QueueItemId]) {
742 let mut pl = self.playlist.write();
743 let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
744 let mut sorted = Vec::with_capacity(taken.len());
745 for id in order {
746 if let Some(slot) = taken
747 .iter_mut()
748 .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
749 && let Some(item) = slot.take()
750 {
751 sorted.push(item);
752 }
753 }
754 sorted.extend(taken.into_iter().flatten());
755 pl.items = sorted;
756 drop(pl);
757 self.bump_version();
758 }
759
760 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
761 use std::collections::HashSet;
762 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
763 let pl = self.playlist.read();
764 pl.items
765 .iter()
766 .enumerate()
767 .filter(|(_, item)| wanted.contains(&item.id))
768 .map(|(pos, item)| {
769 let before = if pos == 0 {
770 None
771 } else {
772 Some(pl.items[pos - 1].id)
773 };
774 (item.id, before)
775 })
776 .collect()
777 }
778
779 pub fn surviving_item_before(
783 &self,
784 id: QueueItemId,
785 removed: &[QueueItemId],
786 ) -> Option<QueueItemId> {
787 use std::collections::HashSet;
788 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
789 let pl = self.playlist.read();
790 let pos = pl.items.iter().position(|item| item.id == id)?;
791 pl.items[..pos]
792 .iter()
793 .rev()
794 .find(|item| !removed.contains(&item.id))
795 .map(|item| item.id)
796 }
797
798 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
800 let mut pl = self.playlist.write();
801 pl.items = items;
802 pl.cursor = cursor;
803 drop(pl);
804 self.bump_version();
805 }
806
807 pub fn remove_items(&self, ids: &[QueueItemId]) {
809 use std::collections::HashSet;
810 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
811 let mut pl = self.playlist.write();
812 pl.items.retain(|item| !id_set.contains(&item.id));
813 if let Some(cursor) = pl.cursor
814 && id_set.contains(&cursor)
815 {
816 pl.cursor = None;
817 }
818 drop(pl);
819 self.bump_version();
820 }
821
822 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
824 let mut pl = self.playlist.write();
825 let insert_at = match after {
826 Some(after_id) => {
827 match pl.items.iter().position(|i| i.id == after_id) {
828 Some(pos) => pos + 1,
829 None => pl.items.len(), }
831 }
832 None => 0,
833 };
834 pl.items.insert(insert_at, item);
835 drop(pl);
836 self.bump_version();
837 }
838
839 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
841 let mut pl = self.playlist.write();
842 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
843 return;
844 };
845 let item = pl.items.remove(from);
846 let insert_at = match after {
847 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
848 Some(pos) => pos + 1,
849 None => pl.items.len(),
850 },
851 None => 0,
852 };
853 pl.items.insert(insert_at, item);
854 drop(pl);
855 self.bump_version();
856 }
857
858 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
861 for &(id, after) in entries {
862 self.move_item_to(id, after);
863 }
864 }
865
866 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
871 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
873 let pl = self.playlist.read();
874
875 let cursor_pos = match pl.cursor {
876 Some(cid) => pl.items.iter().position(|item| item.id == cid),
877 None => None,
878 };
879
880 let mut entries = Vec::with_capacity(pl.items.len());
881 let mut finished_count = 0;
882 let mut has_playing = false;
883 let mut queue_count = 0;
884
885 for (i, item) in pl.items.iter().enumerate() {
886 let is_cursor = cursor_pos == Some(i);
887 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
888
889 let dl_progress = match &item.load_state {
894 LoadState::Downloading {
895 total,
896 bytes_written,
897 ..
898 } => Some((bytes_written.load(Ordering::Relaxed), *total)),
899 _ => None,
900 };
901
902 let status = if is_cursor {
903 has_playing = true;
904 match &item.load_state {
905 LoadState::Ready => QueueEntryStatus::Playing,
906 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
907 LoadState::Pending => QueueEntryStatus::PriorityPending,
908 LoadState::Failed(_) => QueueEntryStatus::Failed,
909 }
910 } else if is_before_cursor {
911 finished_count += 1;
912 match &item.load_state {
913 LoadState::Ready => QueueEntryStatus::Played,
914 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
915 LoadState::Pending => QueueEntryStatus::Downloading,
916 LoadState::Failed(_) => QueueEntryStatus::Failed,
917 }
918 } else {
919 queue_count += 1;
920 match &item.load_state {
921 LoadState::Ready => QueueEntryStatus::Queued,
922 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
923 LoadState::Pending => QueueEntryStatus::Downloading,
924 LoadState::Failed(_) => QueueEntryStatus::Failed,
925 }
926 };
927
928 let duration_ms =
930 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
931 {
932 playing_duration_ms
933 } else {
934 item.duration_ms
935 };
936
937 entries.push(QueueEntry {
938 id: item.id,
939 db_id: item.db_id,
940 playlist_entry_id: item.playlist_entry_id,
941 path: item.path.clone(),
942 title: item.title.clone(),
943 artist: item.artist.clone(),
944 album_artist: item.album_artist.clone(),
945 album: item.album.clone(),
946 year: item.year.clone(),
947 codec: item.codec.clone(),
948 track_number: item.track_number,
949 disc: item.disc,
950 duration_ms,
951 status,
952 download_progress: dl_progress,
953 error: match &item.load_state {
954 LoadState::Failed(reason) => Some(reason.clone()),
955 _ => None,
956 },
957 });
958 }
959
960 VisibleQueueSnapshot {
961 entries,
962 finished_count,
963 has_playing,
964 queue_count,
965 }
966 }
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972
973 fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
976 PlaylistItem {
977 playlist_entry_id: None,
978 id: QueueItemId::new(),
979 db_id: None,
980 path: PathBuf::from(format!("/music/{title}.flac")),
981 title: title.to_string(),
982 artist: "Artist".to_string(),
983 album_artist: "Artist".to_string(),
984 album: "Album".to_string(),
985 year: None,
986 codec: Some("FLAC".to_string()),
987 track_number: None,
988 disc: None,
989 duration_ms: Some(200_000),
990 load_state,
991 }
992 }
993
994 fn ready_item(title: &str) -> PlaylistItem {
995 make_item(title, LoadState::Ready)
996 }
997
998 fn pending_item(title: &str) -> PlaylistItem {
999 make_item(title, LoadState::Pending)
1000 }
1001
1002 fn failed_item(title: &str) -> PlaylistItem {
1003 make_item(title, LoadState::Failed("nope".into()))
1004 }
1005
1006 #[test]
1009 fn advance_parks_on_a_still_downloading_track() {
1010 let state = SharedPlayerState::new();
1014 let item0 = ready_item("track-0");
1015 let item1 = pending_item("track-1");
1016 let item2 = ready_item("track-2");
1017 let (id0, id1) = (item0.id, item1.id);
1018
1019 state.add_items(vec![item0, item1, item2]);
1020
1021 assert_eq!(state.advance_cursor_loadable(), Some(id0));
1022 assert_eq!(state.advance_cursor_loadable(), Some(id1));
1023 assert_eq!(state.cursor(), Some(id1));
1024 }
1025
1026 #[test]
1027 fn advance_skips_failed_items() {
1028 let state = SharedPlayerState::new();
1029 let item0 = ready_item("track-0");
1030 let item1 = failed_item("track-1");
1031 let item2 = ready_item("track-2");
1032 let (id0, id2) = (item0.id, item2.id);
1033
1034 state.add_items(vec![item0, item1, item2]);
1035 state.set_cursor(Some(id0));
1036
1037 assert_eq!(state.advance_cursor_loadable(), Some(id2));
1038 }
1039
1040 #[test]
1041 fn advance_stops_at_end_of_playlist() {
1042 let state = SharedPlayerState::new();
1043 let item0 = ready_item("track-0");
1044 let item1 = ready_item("track-1");
1045 let id1 = item1.id;
1046
1047 state.add_items(vec![item0, item1]);
1048 state.set_cursor(Some(id1));
1049
1050 assert_eq!(state.advance_cursor_loadable(), None);
1051 assert_eq!(
1052 state.cursor(),
1053 Some(id1),
1054 "cursor unchanged on a failed advance"
1055 );
1056 }
1057
1058 #[test]
1059 fn advance_with_only_failed_items_returns_none() {
1060 let state = SharedPlayerState::new();
1061 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1062
1063 assert_eq!(state.advance_cursor_loadable(), None);
1064 }
1065
1066 #[test]
1067 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1068 let state = SharedPlayerState::new();
1069 let item0 = ready_item("track-0");
1070 let item1 = ready_item("track-1");
1071 let id0 = item0.id;
1072
1073 state.add_items(vec![item0, item1]);
1074 let ghost = QueueItemId::new();
1075 state.set_cursor(Some(ghost));
1076
1077 assert_eq!(state.advance_cursor_loadable(), None);
1078 assert_ne!(state.cursor(), Some(id0));
1079 }
1080
1081 #[test]
1084 fn peek_after_a_removed_item_returns_none() {
1085 let state = SharedPlayerState::new();
1089 let item0 = ready_item("track-0");
1090 let item1 = ready_item("track-1");
1091 let item2 = ready_item("track-2");
1092 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1093
1094 state.add_items(vec![item0, item1, item2]);
1095 assert_eq!(
1096 state.peek_next_ready_after(id1).map(|(id, _)| id),
1097 Some(id2)
1098 );
1099
1100 state.remove_item(id2);
1101 assert!(
1102 state.peek_next_ready_after(id2).is_none(),
1103 "a vanished reference must not resolve to the head of the queue"
1104 );
1105 assert_ne!(
1106 state.peek_next_ready_after(id2).map(|(id, _)| id),
1107 Some(id0)
1108 );
1109 }
1110
1111 #[test]
1114 fn surviving_predecessor_skips_items_being_removed() {
1115 let state = SharedPlayerState::new();
1116 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1117 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1118 state.add_items(items);
1119
1120 assert_eq!(
1122 state.surviving_item_before(ids[3], &ids[1..4]),
1123 Some(ids[0])
1124 );
1125 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1127 }
1128
1129 #[test]
1132 fn test_retreat_cursor_goes_to_previous_item() {
1133 let state = SharedPlayerState::new();
1134 let item0 = ready_item("track-0");
1135 let item1 = ready_item("track-1");
1136 let id0 = item0.id;
1137 let id1 = item1.id;
1138
1139 state.add_items(vec![item0, item1]);
1140 state.set_cursor(Some(id1));
1141
1142 let result = state.retreat_cursor();
1143 assert!(result.is_some(), "expected to retreat to previous item");
1144 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1145 assert_eq!(state.cursor(), Some(id0));
1146 }
1147
1148 #[test]
1149 fn test_retreat_cursor_returns_none_when_at_first_item() {
1150 let state = SharedPlayerState::new();
1151 let item0 = ready_item("only-track");
1152 let id0 = item0.id;
1153
1154 state.add_items(vec![item0]);
1155 state.set_cursor(Some(id0));
1156
1157 let result = state.retreat_cursor();
1158 assert!(result.is_none(), "cannot retreat before the first item");
1159 assert_eq!(state.cursor(), Some(id0));
1161 }
1162
1163 #[test]
1164 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1165 let state = SharedPlayerState::new();
1166 state.add_items(vec![ready_item("track-0")]);
1167
1168 let result = state.retreat_cursor();
1169 assert!(
1170 result.is_none(),
1171 "retreat with no cursor should return None"
1172 );
1173 }
1174
1175 #[test]
1178 fn test_derive_visible_queue_statuses() {
1179 let state = SharedPlayerState::new();
1181 let item0 = ready_item("played-track");
1182 let item1 = ready_item("playing-track");
1183 let item2 = ready_item("queued-track");
1184 let id1 = item1.id;
1185
1186 state.add_items(vec![item0, item1, item2]);
1187 state.set_cursor(Some(id1));
1188
1189 let snap = state.derive_visible_queue();
1190
1191 assert_eq!(snap.entries.len(), 3);
1192 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1193 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1194 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1195 assert!(snap.has_playing);
1196 assert_eq!(snap.finished_count, 1);
1197 assert_eq!(snap.queue_count, 1);
1198 }
1199
1200 #[test]
1201 fn test_derive_visible_queue_downloading_statuses() {
1202 let state = SharedPlayerState::new();
1204 let bytes_cursor = Arc::new(AtomicU64::new(0));
1205 let bytes_queued = Arc::new(AtomicU64::new(0));
1206 let dl_cursor = make_item(
1207 "downloading-at-cursor",
1208 LoadState::Downloading {
1209 total: 1_000_000,
1210 bytes_written: bytes_cursor.clone(),
1211 },
1212 );
1213 let dl_queued = make_item(
1214 "downloading-queued",
1215 LoadState::Downloading {
1216 total: 500_000,
1217 bytes_written: bytes_queued.clone(),
1218 },
1219 );
1220 let id_cursor = dl_cursor.id;
1221
1222 state.add_items(vec![dl_cursor, dl_queued]);
1223 state.set_cursor(Some(id_cursor));
1224
1225 let snap = state.derive_visible_queue();
1226
1227 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1228 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1229 }
1230
1231 #[test]
1232 fn progress_follows_the_counter_without_touching_the_playlist() {
1233 let state = SharedPlayerState::new();
1237 let bytes = Arc::new(AtomicU64::new(0));
1238 let item = make_item(
1239 "downloading",
1240 LoadState::Downloading {
1241 total: 1_000,
1242 bytes_written: bytes.clone(),
1243 },
1244 );
1245 state.add_items(vec![item]);
1246
1247 let version = state.playlist_version();
1248 bytes.store(250, Ordering::Release);
1249
1250 let snap = state.derive_visible_queue();
1251 assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1252 assert_eq!(
1253 state.playlist_version(),
1254 version,
1255 "progress must not read as a queue mutation"
1256 );
1257 assert_eq!(
1258 state.downloads_in_flight(),
1259 vec![(snap.entries[0].id, 250, 1_000)]
1260 );
1261 }
1262
1263 #[test]
1264 fn test_derive_visible_queue_no_cursor_all_queued() {
1265 let state = SharedPlayerState::new();
1266 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1267
1268 let snap = state.derive_visible_queue();
1269
1270 assert_eq!(snap.entries.len(), 3);
1271 for entry in &snap.entries {
1272 assert_eq!(entry.status, QueueEntryStatus::Queued);
1273 }
1274 assert!(!snap.has_playing);
1275 assert_eq!(snap.finished_count, 0);
1276 assert_eq!(snap.queue_count, 3);
1277 }
1278
1279 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1282 PlaylistItem {
1283 playlist_entry_id: None,
1284 id: QueueItemId::new(),
1285 db_id: None,
1286 path: PathBuf::from(format!("/music/{title}.flac")),
1287 title: title.to_string(),
1288 artist: "Artist".to_string(),
1289 album_artist: album_artist.to_string(),
1290 album: album.to_string(),
1291 year: None,
1292 codec: Some("FLAC".to_string()),
1293 track_number: None,
1294 disc: None,
1295 duration_ms: Some(200_000),
1296 load_state: LoadState::Ready,
1297 }
1298 }
1299
1300 #[test]
1301 fn test_same_album_item_ids_returns_album_mates() {
1302 let state = SharedPlayerState::new();
1303 let a1 = make_album_item("A1", "Album A", "Artist A");
1304 let a2 = make_album_item("A2", "Album A", "Artist A");
1305 let b1 = make_album_item("B1", "Album B", "Artist B");
1306 let a3 = make_album_item("A3", "Album A", "Artist A");
1307
1308 let id_a1 = a1.id;
1309 let id_a2 = a2.id;
1310 let id_a3 = a3.id;
1311
1312 state.add_items(vec![a1, a2, b1, a3]);
1313
1314 let mates = state.same_album_item_ids(id_a1);
1315 assert_eq!(mates.len(), 2);
1316 assert!(mates.contains(&id_a2));
1317 assert!(mates.contains(&id_a3));
1318 }
1319
1320 #[test]
1321 fn test_same_album_item_ids_distinguishes_album_artists() {
1322 let state = SharedPlayerState::new();
1324 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1325 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1326
1327 let id_a1 = a1.id;
1328
1329 state.add_items(vec![a1, b1]);
1330
1331 let mates = state.same_album_item_ids(id_a1);
1332 assert!(mates.is_empty(), "different album_artist should not match");
1333 }
1334
1335 #[test]
1336 fn test_same_album_item_ids_unknown_id_returns_empty() {
1337 let state = SharedPlayerState::new();
1338 state.add_items(vec![ready_item("track-0")]);
1339
1340 let bogus = QueueItemId::new();
1341 let mates = state.same_album_item_ids(bogus);
1342 assert!(mates.is_empty());
1343 }
1344
1345 #[test]
1348 fn test_move_item_to_reorders_playlist() {
1349 let state = SharedPlayerState::new();
1351 let item_a = ready_item("A");
1352 let item_b = ready_item("B");
1353 let item_c = ready_item("C");
1354 let id_a = item_a.id;
1355 let id_b = item_b.id;
1356 let id_c = item_c.id;
1357
1358 state.add_items(vec![item_a, item_b, item_c]);
1359 state.move_item_to(id_c, Some(id_a));
1360
1361 let (items, _) = state.snapshot_playlist();
1362 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1363 assert_eq!(titles, vec!["A", "C", "B"]);
1364 assert_eq!(items[0].id, id_a);
1365 assert_eq!(items[1].id, id_c);
1366 assert_eq!(items[2].id, id_b);
1367 }
1368
1369 #[test]
1370 fn test_move_item_to_front_when_after_is_none() {
1371 let state = SharedPlayerState::new();
1373 let item_a = ready_item("A");
1374 let item_b = ready_item("B");
1375 let item_c = ready_item("C");
1376 let id_c = item_c.id;
1377
1378 state.add_items(vec![item_a, item_b, item_c]);
1379 state.move_item_to(id_c, None);
1380
1381 let (items, _) = state.snapshot_playlist();
1382 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1383 assert_eq!(titles, vec!["C", "A", "B"]);
1384 }
1385
1386 #[test]
1389 fn test_move_items_batch_preserves_relative_order() {
1390 let state = SharedPlayerState::new();
1392 let item_a = ready_item("A");
1393 let item_b = ready_item("B");
1394 let item_c = ready_item("C");
1395 let item_d = ready_item("D");
1396 let id_a = item_a.id;
1397 let id_b = item_b.id;
1398 let id_c = item_c.id;
1399 let id_d = item_d.id;
1400
1401 state.add_items(vec![item_a, item_b, item_c, item_d]);
1402 state.move_items(&[id_a, id_c], id_d, true);
1403
1404 let (items, _) = state.snapshot_playlist();
1405 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1406 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1407 assert_eq!(items[0].id, id_b);
1408 assert_eq!(items[1].id, id_d);
1409 assert_eq!(items[2].id, id_a);
1410 assert_eq!(items[3].id, id_c);
1411 }
1412
1413 #[test]
1416 fn test_pending_downloads_collects_pending_with_db_id() {
1417 let state = SharedPlayerState::new();
1418 let mut item_a = ready_item("local");
1419 item_a.db_id = None;
1420
1421 let mut item_b = pending_item("remote-1");
1422 item_b.db_id = Some(10);
1423 let id_b = item_b.id;
1424
1425 let mut item_c = ready_item("cached");
1426 item_c.db_id = Some(20);
1427
1428 let mut item_d = pending_item("remote-2");
1429 item_d.db_id = Some(30);
1430 let id_d = item_d.id;
1431
1432 let item_e = pending_item("orphan");
1434
1435 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1436
1437 let pending = state.pending_downloads();
1438 assert_eq!(pending.len(), 2);
1439 assert_eq!(pending[0], (10, id_b));
1440 assert_eq!(pending[1], (30, id_d));
1441 }
1442
1443 #[test]
1444 fn test_item_db_id_and_load_state() {
1445 let state = SharedPlayerState::new();
1446 let mut item = pending_item("track");
1447 item.db_id = Some(42);
1448 let id = item.id;
1449 state.add_items(vec![item]);
1450
1451 assert_eq!(state.item_db_id(id), Some(42));
1452 assert!(matches!(
1453 state.item_load_state(id),
1454 Some(LoadState::Pending)
1455 ));
1456
1457 state.update_load_state(id, LoadState::Ready);
1458 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1459 }
1460}