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 path: PathBuf,
104 pub title: String,
105 pub artist: String,
106 pub album_artist: String,
107 pub album: String,
108 pub year: Option<String>,
109 pub codec: Option<String>,
110 pub track_number: Option<i64>,
111 pub disc: Option<i64>,
112 pub duration_ms: Option<u64>,
113 pub load_state: LoadState,
114}
115
116#[derive(Debug, Clone, Default)]
118pub struct Playlist {
119 pub items: Vec<PlaylistItem>,
120 pub cursor: Option<QueueItemId>,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum QueueEntryStatus {
128 Queued,
129 Playing,
130 Played,
131 Downloading,
132 PriorityPending,
134 Failed,
135}
136
137#[derive(Debug, Clone)]
139pub struct QueueEntry {
140 pub id: QueueItemId,
141 pub db_id: Option<i64>,
143 pub path: PathBuf,
144 pub title: String,
145 pub artist: String,
146 pub album_artist: String,
147 pub album: String,
148 pub year: Option<String>,
149 pub codec: Option<String>,
150 pub track_number: Option<i64>,
151 pub disc: Option<i64>,
152 pub duration_ms: Option<u64>,
153 pub status: QueueEntryStatus,
154 pub download_progress: Option<(u64, u64)>,
155 pub error: Option<String>,
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct VisibleQueueSnapshot {
162 pub entries: Vec<QueueEntry>,
163 pub finished_count: usize,
164 pub has_playing: bool,
165 pub queue_count: usize,
166}
167
168#[derive(Debug)]
172pub struct SharedPlayerState {
173 state: AtomicU8,
174 position_ms: AtomicU64,
175 track_info: parking_lot::RwLock<Option<TrackInfo>>,
176
177 playlist: parking_lot::RwLock<Playlist>,
179
180 playlist_version: AtomicU64,
182
183 quit_requested: AtomicBool,
185
186 metadata_refresh_pending: AtomicBool,
189
190 output_sample_rate: AtomicU64,
197
198 radio_mode: AtomicBool,
201}
202
203impl SharedPlayerState {
204 pub fn new() -> Arc<Self> {
205 Arc::new(Self {
206 state: AtomicU8::new(PlaybackState::Stopped as u8),
207 position_ms: AtomicU64::new(0),
208 track_info: parking_lot::RwLock::new(None),
209 playlist: parking_lot::RwLock::new(Playlist::default()),
210 playlist_version: AtomicU64::new(0),
211 quit_requested: AtomicBool::new(false),
212 metadata_refresh_pending: AtomicBool::new(false),
213 output_sample_rate: AtomicU64::new(0),
214 radio_mode: AtomicBool::new(false),
215 })
216 }
217
218 pub fn playback_state(&self) -> PlaybackState {
221 PlaybackState::from_u8(self.state.load(Ordering::Acquire))
222 }
223
224 pub fn set_playback_state(&self, state: PlaybackState) {
225 self.state.store(state as u8, Ordering::Release);
226 }
227
228 pub fn position_ms(&self) -> u64 {
229 self.position_ms.load(Ordering::Acquire)
230 }
231
232 pub fn set_position_ms(&self, pos: u64) {
233 self.position_ms.store(pos, Ordering::Release);
234 }
235
236 pub fn track_info(&self) -> Option<TrackInfo> {
237 self.track_info.read().clone()
238 }
239
240 pub fn set_track_info(&self, info: Option<TrackInfo>) {
241 *self.track_info.write() = info;
242 }
243
244 pub fn current_download_fraction(&self) -> Option<f64> {
247 let id = self.track_info.read().as_ref()?.id;
250 let pl = self.playlist.read();
251 pl.items
252 .iter()
253 .find(|item| item.id == id)
254 .and_then(|item| match &item.load_state {
255 LoadState::Downloading {
256 bytes_written,
257 total,
258 ..
259 } => {
260 let written = bytes_written.load(Ordering::Acquire);
261 if *total > 0 {
262 Some((written as f64 / *total as f64).min(1.0))
263 } else {
264 None
265 }
266 }
267 _ => None,
268 })
269 }
270
271 pub fn request_quit(&self) {
274 self.quit_requested.store(true, Ordering::Release);
275 }
276
277 pub fn quit_requested(&self) -> bool {
278 self.quit_requested.load(Ordering::Acquire)
279 }
280
281 pub fn signal_metadata_refresh(&self) {
287 self.metadata_refresh_pending.store(true, Ordering::Release);
288 }
289
290 pub fn take_metadata_refresh(&self) -> bool {
292 self.metadata_refresh_pending
293 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
294 .is_ok()
295 }
296
297 pub fn radio_mode(&self) -> bool {
300 self.radio_mode.load(Ordering::Acquire)
301 }
302
303 pub fn set_radio_mode(&self, enabled: bool) {
304 self.radio_mode.store(enabled, Ordering::Release);
305 }
306
307 pub fn output_sample_rate(&self) -> Option<u32> {
311 match self.output_sample_rate.load(Ordering::Acquire) {
312 0 => None,
313 rate => Some(rate as u32),
314 }
315 }
316
317 pub fn set_output_sample_rate(&self, rate: u32) {
318 self.output_sample_rate
319 .store(u64::from(rate), Ordering::Release);
320 }
321
322 pub fn playlist_version(&self) -> u64 {
325 self.playlist_version.load(Ordering::Acquire)
326 }
327
328 fn bump_version(&self) {
329 self.playlist_version.fetch_add(1, Ordering::AcqRel);
330 }
331
332 pub fn add_items(&self, items: Vec<PlaylistItem>) {
336 let mut pl = self.playlist.write();
337 pl.items.extend(items);
338 drop(pl);
339 self.bump_version();
340 }
341
342 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
344 let mut pl = self.playlist.write();
345 let insert_at = match pl.items.iter().position(|item| item.id == after) {
346 Some(pos) => pos + 1,
347 None => pl.items.len(), };
349 for (i, item) in items.into_iter().enumerate() {
350 pl.items.insert(insert_at + i, item);
351 }
352 drop(pl);
353 self.bump_version();
354 }
355
356 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
358 let mut pl = self.playlist.write();
359 for (id, new_path) in updates {
360 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
361 item.path = new_path.clone();
362 }
363 }
364 drop(pl);
365 self.bump_version();
366 }
367
368 pub fn remove_item(&self, id: QueueItemId) {
370 let mut pl = self.playlist.write();
371 pl.items.retain(|item| item.id != id);
372 if pl.cursor == Some(id) {
374 pl.cursor = None;
375 }
376 drop(pl);
377 self.bump_version();
378 }
379
380 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
382 let mut pl = self.playlist.write();
383 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
384 return;
385 };
386 let item = pl.items.remove(from);
387 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
388 let pos = from.min(pl.items.len());
390 pl.items.insert(pos, item);
391 return;
392 };
393 let insert_at = if after { to + 1 } else { to };
394 pl.items.insert(insert_at, item);
395 drop(pl);
396 self.bump_version();
397 }
398
399 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
402 use std::collections::HashSet;
403 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
404
405 let mut pl = self.playlist.write();
406
407 let mut remaining = Vec::with_capacity(pl.items.len());
409 let mut moved = Vec::with_capacity(ids.len());
410 for item in pl.items.drain(..) {
411 if id_set.contains(&item.id) {
412 moved.push(item);
413 } else {
414 remaining.push(item);
415 }
416 }
417
418 let insert_at = match remaining.iter().position(|item| item.id == target) {
420 Some(pos) => {
421 if after {
422 pos + 1
423 } else {
424 pos
425 }
426 }
427 None => remaining.len(),
428 };
429
430 for (i, item) in moved.into_iter().enumerate() {
432 remaining.insert(insert_at + i, item);
433 }
434
435 pl.items = remaining;
436 drop(pl);
437 self.bump_version();
438 }
439
440 pub fn set_cursor(&self, id: Option<QueueItemId>) {
442 let mut pl = self.playlist.write();
443 pl.cursor = id;
444 drop(pl);
445 self.bump_version();
446 }
447
448 pub fn cursor(&self) -> Option<QueueItemId> {
450 self.playlist.read().cursor
451 }
452
453 pub fn clear_playlist(&self) {
455 let mut pl = self.playlist.write();
456 pl.items.clear();
457 pl.cursor = None;
458 drop(pl);
459 self.bump_version();
460 }
461
462 pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
475 let mut pl = self.playlist.write();
476 let start = match pl.cursor {
477 Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
478 None => 0,
479 };
480
481 let next = pl
482 .items
483 .get(start..)?
484 .iter()
485 .find(|item| !matches!(item.load_state, LoadState::Failed(_)))
486 .map(|item| item.id)?;
487
488 pl.cursor = Some(next);
489 drop(pl);
490 self.bump_version();
491 Some(next)
492 }
493
494 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
498 let pl = self.playlist.read();
499 let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
502
503 for i in start..pl.items.len() {
504 if matches!(pl.items[i].load_state, LoadState::Ready) {
505 let item = &pl.items[i];
506 return Some((item.id, item.path.clone()));
507 }
508 }
509 None
510 }
511
512 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
515 let mut pl = self.playlist.write();
516 let cursor_pos = match pl.cursor {
517 Some(cid) => pl.items.iter().position(|item| item.id == cid),
518 None => None,
519 };
520
521 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
522
523 match prev_pos {
524 Some(pos) => {
525 let item = &pl.items[pos];
526 let result = (item.id, item.path.clone());
527 pl.cursor = Some(item.id);
528 drop(pl);
529 self.bump_version();
530 Some(result)
531 }
532 None => None,
533 }
534 }
535
536 pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
540 let mut pl = self.playlist.write();
541 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
542 item.load_state = new_state;
543 }
544 drop(pl);
545 self.bump_version();
546 }
547
548 pub fn update_item_metadata(
552 &self,
553 id: QueueItemId,
554 title: String,
555 artist: String,
556 album_artist: String,
557 album: String,
558 duration_ms: Option<u64>,
559 ) {
560 let mut pl = self.playlist.write();
561 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
562 item.title = title;
563 item.artist = artist;
564 item.album_artist = album_artist;
565 item.album = album;
566 if let Some(dur) = duration_ms {
567 item.duration_ms = Some(dur);
568 }
569 }
570 drop(pl);
571 self.bump_version();
572 }
573
574 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
577 let pl = self.playlist.read();
578 pl.items
579 .iter()
580 .find(|item| item.id == id)
581 .and_then(|item| match &item.load_state {
582 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
583 LoadState::Downloading {
584 total,
585 bytes_written,
586 ..
587 } => {
588 let written = bytes_written.load(Ordering::Acquire);
589 if written >= STREAM_THRESHOLD {
590 Some(PlaybackSource::Streaming {
591 path: item.path.clone(),
592 bytes_written: bytes_written.clone(),
593 total: *total,
594 })
595 } else {
596 None
597 }
598 }
599 _ => None,
600 })
601 }
602
603 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
605 let pl = self.playlist.read();
606 pl.items.iter().find(|item| item.id == id).and_then(|item| {
607 if matches!(item.load_state, LoadState::Ready) {
608 Some(item.path.clone())
609 } else {
610 None
611 }
612 })
613 }
614
615 pub fn is_cursor(&self, id: QueueItemId) -> bool {
617 self.playlist.read().cursor == Some(id)
618 }
619
620 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
624 let pl = self.playlist.read();
625 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
626 return vec![];
627 };
628 let album = cursor.album.clone();
629 let album_artist = cursor.album_artist.clone();
630 pl.items
631 .iter()
632 .filter(|item| {
633 item.id != id && item.album == album && item.album_artist == album_artist
634 })
635 .map(|item| item.id)
636 .collect()
637 }
638
639 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
642 let pl = self.playlist.read();
643 pl.items
644 .iter()
645 .filter(|item| matches!(item.load_state, LoadState::Pending))
646 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
647 .collect()
648 }
649
650 pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
656 let pl = self.playlist.read();
657 pl.items
658 .iter()
659 .filter_map(|item| match &item.load_state {
660 LoadState::Downloading {
661 total,
662 bytes_written,
663 } => Some((item.id, bytes_written.load(Ordering::Relaxed), *total)),
664 _ => None,
665 })
666 .collect()
667 }
668
669 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
671 let pl = self.playlist.read();
672 pl.items
673 .iter()
674 .find(|item| item.id == id)
675 .and_then(|item| item.db_id)
676 }
677
678 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
680 let pl = self.playlist.read();
681 pl.items
682 .iter()
683 .find(|item| item.id == id)
684 .map(|item| item.load_state.clone())
685 }
686
687 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
691 let pl = self.playlist.read();
692 (pl.items.clone(), pl.cursor)
693 }
694
695 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
697 let pl = self.playlist.read();
698 pl.items.iter().find(|item| item.id == id).cloned()
699 }
700
701 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
703 let pl = self.playlist.read();
704 let pos = pl.items.iter().position(|item| item.id == id)?;
705 if pos == 0 {
706 None
707 } else {
708 Some(pl.items[pos - 1].id)
709 }
710 }
711
712 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
719 use std::collections::HashSet;
720 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
721 let pl = self.playlist.read();
722 pl.items
723 .iter()
724 .enumerate()
725 .filter(|(_, item)| wanted.contains(&item.id))
726 .map(|(pos, item)| {
727 let before = if pos == 0 {
728 None
729 } else {
730 Some(pl.items[pos - 1].id)
731 };
732 (item.id, before)
733 })
734 .collect()
735 }
736
737 pub fn surviving_item_before(
741 &self,
742 id: QueueItemId,
743 removed: &[QueueItemId],
744 ) -> Option<QueueItemId> {
745 use std::collections::HashSet;
746 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
747 let pl = self.playlist.read();
748 let pos = pl.items.iter().position(|item| item.id == id)?;
749 pl.items[..pos]
750 .iter()
751 .rev()
752 .find(|item| !removed.contains(&item.id))
753 .map(|item| item.id)
754 }
755
756 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
758 let mut pl = self.playlist.write();
759 pl.items = items;
760 pl.cursor = cursor;
761 drop(pl);
762 self.bump_version();
763 }
764
765 pub fn remove_items(&self, ids: &[QueueItemId]) {
767 use std::collections::HashSet;
768 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
769 let mut pl = self.playlist.write();
770 pl.items.retain(|item| !id_set.contains(&item.id));
771 if let Some(cursor) = pl.cursor
772 && id_set.contains(&cursor)
773 {
774 pl.cursor = None;
775 }
776 drop(pl);
777 self.bump_version();
778 }
779
780 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
782 let mut pl = self.playlist.write();
783 let insert_at = match after {
784 Some(after_id) => {
785 match pl.items.iter().position(|i| i.id == after_id) {
786 Some(pos) => pos + 1,
787 None => pl.items.len(), }
789 }
790 None => 0,
791 };
792 pl.items.insert(insert_at, item);
793 drop(pl);
794 self.bump_version();
795 }
796
797 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
799 let mut pl = self.playlist.write();
800 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
801 return;
802 };
803 let item = pl.items.remove(from);
804 let insert_at = match after {
805 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
806 Some(pos) => pos + 1,
807 None => pl.items.len(),
808 },
809 None => 0,
810 };
811 pl.items.insert(insert_at, item);
812 drop(pl);
813 self.bump_version();
814 }
815
816 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
819 for &(id, after) in entries {
820 self.move_item_to(id, after);
821 }
822 }
823
824 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
829 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
831 let pl = self.playlist.read();
832
833 let cursor_pos = match pl.cursor {
834 Some(cid) => pl.items.iter().position(|item| item.id == cid),
835 None => None,
836 };
837
838 let mut entries = Vec::with_capacity(pl.items.len());
839 let mut finished_count = 0;
840 let mut has_playing = false;
841 let mut queue_count = 0;
842
843 for (i, item) in pl.items.iter().enumerate() {
844 let is_cursor = cursor_pos == Some(i);
845 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
846
847 let dl_progress = match &item.load_state {
852 LoadState::Downloading {
853 total,
854 bytes_written,
855 ..
856 } => Some((bytes_written.load(Ordering::Relaxed), *total)),
857 _ => None,
858 };
859
860 let status = if is_cursor {
861 has_playing = true;
862 match &item.load_state {
863 LoadState::Ready => QueueEntryStatus::Playing,
864 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
865 LoadState::Pending => QueueEntryStatus::PriorityPending,
866 LoadState::Failed(_) => QueueEntryStatus::Failed,
867 }
868 } else if is_before_cursor {
869 finished_count += 1;
870 match &item.load_state {
871 LoadState::Ready => QueueEntryStatus::Played,
872 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
873 LoadState::Pending => QueueEntryStatus::Downloading,
874 LoadState::Failed(_) => QueueEntryStatus::Failed,
875 }
876 } else {
877 queue_count += 1;
878 match &item.load_state {
879 LoadState::Ready => QueueEntryStatus::Queued,
880 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
881 LoadState::Pending => QueueEntryStatus::Downloading,
882 LoadState::Failed(_) => QueueEntryStatus::Failed,
883 }
884 };
885
886 let duration_ms =
888 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
889 {
890 playing_duration_ms
891 } else {
892 item.duration_ms
893 };
894
895 entries.push(QueueEntry {
896 id: item.id,
897 db_id: item.db_id,
898 path: item.path.clone(),
899 title: item.title.clone(),
900 artist: item.artist.clone(),
901 album_artist: item.album_artist.clone(),
902 album: item.album.clone(),
903 year: item.year.clone(),
904 codec: item.codec.clone(),
905 track_number: item.track_number,
906 disc: item.disc,
907 duration_ms,
908 status,
909 download_progress: dl_progress,
910 error: match &item.load_state {
911 LoadState::Failed(reason) => Some(reason.clone()),
912 _ => None,
913 },
914 });
915 }
916
917 VisibleQueueSnapshot {
918 entries,
919 finished_count,
920 has_playing,
921 queue_count,
922 }
923 }
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929
930 fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
933 PlaylistItem {
934 id: QueueItemId::new(),
935 db_id: None,
936 path: PathBuf::from(format!("/music/{title}.flac")),
937 title: title.to_string(),
938 artist: "Artist".to_string(),
939 album_artist: "Artist".to_string(),
940 album: "Album".to_string(),
941 year: None,
942 codec: Some("FLAC".to_string()),
943 track_number: None,
944 disc: None,
945 duration_ms: Some(200_000),
946 load_state,
947 }
948 }
949
950 fn ready_item(title: &str) -> PlaylistItem {
951 make_item(title, LoadState::Ready)
952 }
953
954 fn pending_item(title: &str) -> PlaylistItem {
955 make_item(title, LoadState::Pending)
956 }
957
958 fn failed_item(title: &str) -> PlaylistItem {
959 make_item(title, LoadState::Failed("nope".into()))
960 }
961
962 #[test]
965 fn advance_parks_on_a_still_downloading_track() {
966 let state = SharedPlayerState::new();
970 let item0 = ready_item("track-0");
971 let item1 = pending_item("track-1");
972 let item2 = ready_item("track-2");
973 let (id0, id1) = (item0.id, item1.id);
974
975 state.add_items(vec![item0, item1, item2]);
976
977 assert_eq!(state.advance_cursor_loadable(), Some(id0));
978 assert_eq!(state.advance_cursor_loadable(), Some(id1));
979 assert_eq!(state.cursor(), Some(id1));
980 }
981
982 #[test]
983 fn advance_skips_failed_items() {
984 let state = SharedPlayerState::new();
985 let item0 = ready_item("track-0");
986 let item1 = failed_item("track-1");
987 let item2 = ready_item("track-2");
988 let (id0, id2) = (item0.id, item2.id);
989
990 state.add_items(vec![item0, item1, item2]);
991 state.set_cursor(Some(id0));
992
993 assert_eq!(state.advance_cursor_loadable(), Some(id2));
994 }
995
996 #[test]
997 fn advance_stops_at_end_of_playlist() {
998 let state = SharedPlayerState::new();
999 let item0 = ready_item("track-0");
1000 let item1 = ready_item("track-1");
1001 let id1 = item1.id;
1002
1003 state.add_items(vec![item0, item1]);
1004 state.set_cursor(Some(id1));
1005
1006 assert_eq!(state.advance_cursor_loadable(), None);
1007 assert_eq!(
1008 state.cursor(),
1009 Some(id1),
1010 "cursor unchanged on a failed advance"
1011 );
1012 }
1013
1014 #[test]
1015 fn advance_with_only_failed_items_returns_none() {
1016 let state = SharedPlayerState::new();
1017 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1018
1019 assert_eq!(state.advance_cursor_loadable(), None);
1020 }
1021
1022 #[test]
1023 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1024 let state = SharedPlayerState::new();
1025 let item0 = ready_item("track-0");
1026 let item1 = ready_item("track-1");
1027 let id0 = item0.id;
1028
1029 state.add_items(vec![item0, item1]);
1030 let ghost = QueueItemId::new();
1031 state.set_cursor(Some(ghost));
1032
1033 assert_eq!(state.advance_cursor_loadable(), None);
1034 assert_ne!(state.cursor(), Some(id0));
1035 }
1036
1037 #[test]
1040 fn peek_after_a_removed_item_returns_none() {
1041 let state = SharedPlayerState::new();
1045 let item0 = ready_item("track-0");
1046 let item1 = ready_item("track-1");
1047 let item2 = ready_item("track-2");
1048 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1049
1050 state.add_items(vec![item0, item1, item2]);
1051 assert_eq!(
1052 state.peek_next_ready_after(id1).map(|(id, _)| id),
1053 Some(id2)
1054 );
1055
1056 state.remove_item(id2);
1057 assert!(
1058 state.peek_next_ready_after(id2).is_none(),
1059 "a vanished reference must not resolve to the head of the queue"
1060 );
1061 assert_ne!(
1062 state.peek_next_ready_after(id2).map(|(id, _)| id),
1063 Some(id0)
1064 );
1065 }
1066
1067 #[test]
1070 fn surviving_predecessor_skips_items_being_removed() {
1071 let state = SharedPlayerState::new();
1072 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1073 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1074 state.add_items(items);
1075
1076 assert_eq!(
1078 state.surviving_item_before(ids[3], &ids[1..4]),
1079 Some(ids[0])
1080 );
1081 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1083 }
1084
1085 #[test]
1088 fn test_retreat_cursor_goes_to_previous_item() {
1089 let state = SharedPlayerState::new();
1090 let item0 = ready_item("track-0");
1091 let item1 = ready_item("track-1");
1092 let id0 = item0.id;
1093 let id1 = item1.id;
1094
1095 state.add_items(vec![item0, item1]);
1096 state.set_cursor(Some(id1));
1097
1098 let result = state.retreat_cursor();
1099 assert!(result.is_some(), "expected to retreat to previous item");
1100 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1101 assert_eq!(state.cursor(), Some(id0));
1102 }
1103
1104 #[test]
1105 fn test_retreat_cursor_returns_none_when_at_first_item() {
1106 let state = SharedPlayerState::new();
1107 let item0 = ready_item("only-track");
1108 let id0 = item0.id;
1109
1110 state.add_items(vec![item0]);
1111 state.set_cursor(Some(id0));
1112
1113 let result = state.retreat_cursor();
1114 assert!(result.is_none(), "cannot retreat before the first item");
1115 assert_eq!(state.cursor(), Some(id0));
1117 }
1118
1119 #[test]
1120 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1121 let state = SharedPlayerState::new();
1122 state.add_items(vec![ready_item("track-0")]);
1123
1124 let result = state.retreat_cursor();
1125 assert!(
1126 result.is_none(),
1127 "retreat with no cursor should return None"
1128 );
1129 }
1130
1131 #[test]
1134 fn test_derive_visible_queue_statuses() {
1135 let state = SharedPlayerState::new();
1137 let item0 = ready_item("played-track");
1138 let item1 = ready_item("playing-track");
1139 let item2 = ready_item("queued-track");
1140 let id1 = item1.id;
1141
1142 state.add_items(vec![item0, item1, item2]);
1143 state.set_cursor(Some(id1));
1144
1145 let snap = state.derive_visible_queue();
1146
1147 assert_eq!(snap.entries.len(), 3);
1148 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1149 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1150 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1151 assert!(snap.has_playing);
1152 assert_eq!(snap.finished_count, 1);
1153 assert_eq!(snap.queue_count, 1);
1154 }
1155
1156 #[test]
1157 fn test_derive_visible_queue_downloading_statuses() {
1158 let state = SharedPlayerState::new();
1160 let bytes_cursor = Arc::new(AtomicU64::new(0));
1161 let bytes_queued = Arc::new(AtomicU64::new(0));
1162 let dl_cursor = make_item(
1163 "downloading-at-cursor",
1164 LoadState::Downloading {
1165 total: 1_000_000,
1166 bytes_written: bytes_cursor.clone(),
1167 },
1168 );
1169 let dl_queued = make_item(
1170 "downloading-queued",
1171 LoadState::Downloading {
1172 total: 500_000,
1173 bytes_written: bytes_queued.clone(),
1174 },
1175 );
1176 let id_cursor = dl_cursor.id;
1177
1178 state.add_items(vec![dl_cursor, dl_queued]);
1179 state.set_cursor(Some(id_cursor));
1180
1181 let snap = state.derive_visible_queue();
1182
1183 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1184 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1185 }
1186
1187 #[test]
1188 fn progress_follows_the_counter_without_touching_the_playlist() {
1189 let state = SharedPlayerState::new();
1193 let bytes = Arc::new(AtomicU64::new(0));
1194 let item = make_item(
1195 "downloading",
1196 LoadState::Downloading {
1197 total: 1_000,
1198 bytes_written: bytes.clone(),
1199 },
1200 );
1201 state.add_items(vec![item]);
1202
1203 let version = state.playlist_version();
1204 bytes.store(250, Ordering::Release);
1205
1206 let snap = state.derive_visible_queue();
1207 assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1208 assert_eq!(
1209 state.playlist_version(),
1210 version,
1211 "progress must not read as a queue mutation"
1212 );
1213 assert_eq!(
1214 state.downloads_in_flight(),
1215 vec![(snap.entries[0].id, 250, 1_000)]
1216 );
1217 }
1218
1219 #[test]
1220 fn test_derive_visible_queue_no_cursor_all_queued() {
1221 let state = SharedPlayerState::new();
1222 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1223
1224 let snap = state.derive_visible_queue();
1225
1226 assert_eq!(snap.entries.len(), 3);
1227 for entry in &snap.entries {
1228 assert_eq!(entry.status, QueueEntryStatus::Queued);
1229 }
1230 assert!(!snap.has_playing);
1231 assert_eq!(snap.finished_count, 0);
1232 assert_eq!(snap.queue_count, 3);
1233 }
1234
1235 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1238 PlaylistItem {
1239 id: QueueItemId::new(),
1240 db_id: None,
1241 path: PathBuf::from(format!("/music/{title}.flac")),
1242 title: title.to_string(),
1243 artist: "Artist".to_string(),
1244 album_artist: album_artist.to_string(),
1245 album: album.to_string(),
1246 year: None,
1247 codec: Some("FLAC".to_string()),
1248 track_number: None,
1249 disc: None,
1250 duration_ms: Some(200_000),
1251 load_state: LoadState::Ready,
1252 }
1253 }
1254
1255 #[test]
1256 fn test_same_album_item_ids_returns_album_mates() {
1257 let state = SharedPlayerState::new();
1258 let a1 = make_album_item("A1", "Album A", "Artist A");
1259 let a2 = make_album_item("A2", "Album A", "Artist A");
1260 let b1 = make_album_item("B1", "Album B", "Artist B");
1261 let a3 = make_album_item("A3", "Album A", "Artist A");
1262
1263 let id_a1 = a1.id;
1264 let id_a2 = a2.id;
1265 let id_a3 = a3.id;
1266
1267 state.add_items(vec![a1, a2, b1, a3]);
1268
1269 let mates = state.same_album_item_ids(id_a1);
1270 assert_eq!(mates.len(), 2);
1271 assert!(mates.contains(&id_a2));
1272 assert!(mates.contains(&id_a3));
1273 }
1274
1275 #[test]
1276 fn test_same_album_item_ids_distinguishes_album_artists() {
1277 let state = SharedPlayerState::new();
1279 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1280 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1281
1282 let id_a1 = a1.id;
1283
1284 state.add_items(vec![a1, b1]);
1285
1286 let mates = state.same_album_item_ids(id_a1);
1287 assert!(mates.is_empty(), "different album_artist should not match");
1288 }
1289
1290 #[test]
1291 fn test_same_album_item_ids_unknown_id_returns_empty() {
1292 let state = SharedPlayerState::new();
1293 state.add_items(vec![ready_item("track-0")]);
1294
1295 let bogus = QueueItemId::new();
1296 let mates = state.same_album_item_ids(bogus);
1297 assert!(mates.is_empty());
1298 }
1299
1300 #[test]
1303 fn test_move_item_to_reorders_playlist() {
1304 let state = SharedPlayerState::new();
1306 let item_a = ready_item("A");
1307 let item_b = ready_item("B");
1308 let item_c = ready_item("C");
1309 let id_a = item_a.id;
1310 let id_b = item_b.id;
1311 let id_c = item_c.id;
1312
1313 state.add_items(vec![item_a, item_b, item_c]);
1314 state.move_item_to(id_c, Some(id_a));
1315
1316 let (items, _) = state.snapshot_playlist();
1317 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1318 assert_eq!(titles, vec!["A", "C", "B"]);
1319 assert_eq!(items[0].id, id_a);
1320 assert_eq!(items[1].id, id_c);
1321 assert_eq!(items[2].id, id_b);
1322 }
1323
1324 #[test]
1325 fn test_move_item_to_front_when_after_is_none() {
1326 let state = SharedPlayerState::new();
1328 let item_a = ready_item("A");
1329 let item_b = ready_item("B");
1330 let item_c = ready_item("C");
1331 let id_c = item_c.id;
1332
1333 state.add_items(vec![item_a, item_b, item_c]);
1334 state.move_item_to(id_c, None);
1335
1336 let (items, _) = state.snapshot_playlist();
1337 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1338 assert_eq!(titles, vec!["C", "A", "B"]);
1339 }
1340
1341 #[test]
1344 fn test_move_items_batch_preserves_relative_order() {
1345 let state = SharedPlayerState::new();
1347 let item_a = ready_item("A");
1348 let item_b = ready_item("B");
1349 let item_c = ready_item("C");
1350 let item_d = ready_item("D");
1351 let id_a = item_a.id;
1352 let id_b = item_b.id;
1353 let id_c = item_c.id;
1354 let id_d = item_d.id;
1355
1356 state.add_items(vec![item_a, item_b, item_c, item_d]);
1357 state.move_items(&[id_a, id_c], id_d, true);
1358
1359 let (items, _) = state.snapshot_playlist();
1360 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1361 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1362 assert_eq!(items[0].id, id_b);
1363 assert_eq!(items[1].id, id_d);
1364 assert_eq!(items[2].id, id_a);
1365 assert_eq!(items[3].id, id_c);
1366 }
1367
1368 #[test]
1371 fn test_pending_downloads_collects_pending_with_db_id() {
1372 let state = SharedPlayerState::new();
1373 let mut item_a = ready_item("local");
1374 item_a.db_id = None;
1375
1376 let mut item_b = pending_item("remote-1");
1377 item_b.db_id = Some(10);
1378 let id_b = item_b.id;
1379
1380 let mut item_c = ready_item("cached");
1381 item_c.db_id = Some(20);
1382
1383 let mut item_d = pending_item("remote-2");
1384 item_d.db_id = Some(30);
1385 let id_d = item_d.id;
1386
1387 let item_e = pending_item("orphan");
1389
1390 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1391
1392 let pending = state.pending_downloads();
1393 assert_eq!(pending.len(), 2);
1394 assert_eq!(pending[0], (10, id_b));
1395 assert_eq!(pending[1], (30, id_d));
1396 }
1397
1398 #[test]
1399 fn test_item_db_id_and_load_state() {
1400 let state = SharedPlayerState::new();
1401 let mut item = pending_item("track");
1402 item.db_id = Some(42);
1403 let id = item.id;
1404 state.add_items(vec![item]);
1405
1406 assert_eq!(state.item_db_id(id), Some(42));
1407 assert!(matches!(
1408 state.item_load_state(id),
1409 Some(LoadState::Pending)
1410 ));
1411
1412 state.update_load_state(id, LoadState::Ready);
1413 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1414 }
1415}