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: u16,
58 pub channels: u16,
59 pub duration_ms: u64,
60}
61
62pub const STREAM_THRESHOLD: u64 = 256 * 1024; #[derive(Debug, Clone)]
69pub enum LoadState {
70 Pending,
71 Downloading {
72 downloaded: u64,
73 total: u64,
74 bytes_written: Arc<AtomicU64>,
76 },
77 Ready,
78 Failed(String),
79}
80
81pub enum PlaybackSource {
83 Ready(PathBuf),
85 Streaming {
87 path: PathBuf,
88 bytes_written: Arc<AtomicU64>,
89 total: u64,
90 },
91}
92
93#[derive(Debug, Clone)]
96pub struct PlaylistItem {
97 pub id: QueueItemId,
98 pub path: PathBuf,
99 pub title: String,
100 pub artist: String,
101 pub album_artist: String,
102 pub album: String,
103 pub year: Option<String>,
104 pub codec: Option<String>,
105 pub track_number: Option<i64>,
106 pub disc: Option<i64>,
107 pub duration_ms: Option<u64>,
108 pub load_state: LoadState,
109}
110
111#[derive(Debug, Clone, Default)]
113pub struct Playlist {
114 pub items: Vec<PlaylistItem>,
115 pub cursor: Option<QueueItemId>,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum QueueEntryStatus {
123 Queued,
124 Playing,
125 Played,
126 Downloading,
127 PriorityPending,
129 Failed,
130}
131
132#[derive(Debug, Clone)]
134pub struct QueueEntry {
135 pub id: QueueItemId,
136 pub path: PathBuf,
137 pub title: String,
138 pub artist: String,
139 pub album_artist: String,
140 pub album: String,
141 pub year: Option<String>,
142 pub codec: Option<String>,
143 pub track_number: Option<i64>,
144 pub disc: Option<i64>,
145 pub duration_ms: Option<u64>,
146 pub status: QueueEntryStatus,
147 pub download_progress: Option<(u64, u64)>,
148}
149
150#[derive(Debug, Clone, Default)]
152pub struct VisibleQueueSnapshot {
153 pub entries: Vec<QueueEntry>,
154 pub finished_count: usize,
155 pub has_playing: bool,
156 pub queue_count: usize,
157}
158
159#[derive(Debug)]
163pub struct SharedPlayerState {
164 state: AtomicU8,
165 position_ms: AtomicU64,
166 track_info: parking_lot::RwLock<Option<TrackInfo>>,
167
168 playlist: parking_lot::RwLock<Playlist>,
170
171 playlist_version: AtomicU64,
173
174 playback_generation: AtomicU64,
177
178 quit_requested: AtomicBool,
180
181 metadata_refresh_pending: AtomicBool,
184
185 radio_mode: AtomicBool,
188}
189
190impl SharedPlayerState {
191 pub fn new() -> Arc<Self> {
192 Arc::new(Self {
193 state: AtomicU8::new(PlaybackState::Stopped as u8),
194 position_ms: AtomicU64::new(0),
195 track_info: parking_lot::RwLock::new(None),
196 playlist: parking_lot::RwLock::new(Playlist::default()),
197 playlist_version: AtomicU64::new(0),
198 playback_generation: 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 track_info = self.track_info.read();
235 let id = track_info.as_ref()?.id;
236 let pl = self.playlist.read();
237 pl.items
238 .iter()
239 .find(|item| item.id == id)
240 .and_then(|item| match &item.load_state {
241 LoadState::Downloading {
242 bytes_written,
243 total,
244 ..
245 } => {
246 let written = bytes_written.load(Ordering::Acquire);
247 if *total > 0 {
248 Some((written as f64 / *total as f64).min(1.0))
249 } else {
250 None
251 }
252 }
253 _ => None,
254 })
255 }
256
257 pub fn bump_generation(&self) -> u64 {
260 self.playback_generation.fetch_add(1, Ordering::AcqRel) + 1
261 }
262
263 pub fn generation(&self) -> u64 {
264 self.playback_generation.load(Ordering::Acquire)
265 }
266
267 pub fn request_quit(&self) {
270 self.quit_requested.store(true, Ordering::Release);
271 }
272
273 pub fn quit_requested(&self) -> bool {
274 self.quit_requested.load(Ordering::Acquire)
275 }
276
277 pub fn signal_metadata_refresh(&self) {
283 self.metadata_refresh_pending.store(true, Ordering::Release);
284 }
285
286 pub fn take_metadata_refresh(&self) -> bool {
288 self.metadata_refresh_pending
289 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
290 .is_ok()
291 }
292
293 pub fn radio_mode(&self) -> bool {
296 self.radio_mode.load(Ordering::Acquire)
297 }
298
299 pub fn set_radio_mode(&self, enabled: bool) {
300 self.radio_mode.store(enabled, Ordering::Release);
301 }
302
303 pub fn playlist_version(&self) -> u64 {
306 self.playlist_version.load(Ordering::Acquire)
307 }
308
309 fn bump_version(&self) {
310 self.playlist_version.fetch_add(1, Ordering::AcqRel);
311 }
312
313 pub fn add_items(&self, items: Vec<PlaylistItem>) {
317 let mut pl = self.playlist.write();
318 pl.items.extend(items);
319 drop(pl);
320 self.bump_version();
321 }
322
323 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
325 let mut pl = self.playlist.write();
326 let insert_at = match pl.items.iter().position(|item| item.id == after) {
327 Some(pos) => pos + 1,
328 None => pl.items.len(), };
330 for (i, item) in items.into_iter().enumerate() {
331 pl.items.insert(insert_at + i, item);
332 }
333 drop(pl);
334 self.bump_version();
335 }
336
337 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
339 let mut pl = self.playlist.write();
340 for (id, new_path) in updates {
341 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
342 item.path = new_path.clone();
343 }
344 }
345 drop(pl);
346 self.bump_version();
347 }
348
349 pub fn remove_item(&self, id: QueueItemId) {
351 let mut pl = self.playlist.write();
352 pl.items.retain(|item| item.id != id);
353 if pl.cursor == Some(id) {
355 pl.cursor = None;
356 }
357 drop(pl);
358 self.bump_version();
359 }
360
361 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
363 let mut pl = self.playlist.write();
364 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
365 return;
366 };
367 let item = pl.items.remove(from);
368 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
369 let pos = from.min(pl.items.len());
371 pl.items.insert(pos, item);
372 return;
373 };
374 let insert_at = if after { to + 1 } else { to };
375 pl.items.insert(insert_at, item);
376 drop(pl);
377 self.bump_version();
378 }
379
380 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
383 use std::collections::HashSet;
384 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
385
386 let mut pl = self.playlist.write();
387
388 let mut remaining = Vec::with_capacity(pl.items.len());
390 let mut moved = Vec::with_capacity(ids.len());
391 for item in pl.items.drain(..) {
392 if id_set.contains(&item.id) {
393 moved.push(item);
394 } else {
395 remaining.push(item);
396 }
397 }
398
399 let insert_at = match remaining.iter().position(|item| item.id == target) {
401 Some(pos) => {
402 if after {
403 pos + 1
404 } else {
405 pos
406 }
407 }
408 None => remaining.len(),
409 };
410
411 for (i, item) in moved.into_iter().enumerate() {
413 remaining.insert(insert_at + i, item);
414 }
415
416 pl.items = remaining;
417 drop(pl);
418 self.bump_version();
419 }
420
421 pub fn set_cursor(&self, id: Option<QueueItemId>) {
423 let mut pl = self.playlist.write();
424 pl.cursor = id;
425 drop(pl);
426 self.bump_version();
427 }
428
429 pub fn cursor(&self) -> Option<QueueItemId> {
431 self.playlist.read().cursor
432 }
433
434 pub fn clear_playlist(&self) {
436 let mut pl = self.playlist.write();
437 pl.items.clear();
438 pl.cursor = None;
439 drop(pl);
440 self.bump_version();
441 }
442
443 pub fn advance_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
448 let mut pl = self.playlist.write();
449 let cursor_pos = match pl.cursor {
450 Some(cid) => pl.items.iter().position(|item| item.id == cid),
451 None => None,
452 };
453
454 let start = match cursor_pos {
455 Some(pos) => pos + 1,
456 None => 0,
457 };
458
459 for i in start..pl.items.len() {
461 if matches!(pl.items[i].load_state, LoadState::Ready) {
462 let item = &pl.items[i];
463 let result = (item.id, item.path.clone());
464 pl.cursor = Some(item.id);
465 drop(pl);
466 self.bump_version();
467 return Some(result);
468 }
469 }
470 None
471 }
472
473 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
477 let pl = self.playlist.read();
478 let pos = pl.items.iter().position(|item| item.id == after_id);
479
480 let start = match pos {
481 Some(p) => p + 1,
482 None => 0,
483 };
484
485 for i in start..pl.items.len() {
486 if matches!(pl.items[i].load_state, LoadState::Ready) {
487 let item = &pl.items[i];
488 return Some((item.id, item.path.clone()));
489 }
490 }
491 None
492 }
493
494 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
497 let mut pl = self.playlist.write();
498 let cursor_pos = match pl.cursor {
499 Some(cid) => pl.items.iter().position(|item| item.id == cid),
500 None => None,
501 };
502
503 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
504
505 match prev_pos {
506 Some(pos) => {
507 let item = &pl.items[pos];
508 let result = (item.id, item.path.clone());
509 pl.cursor = Some(item.id);
510 drop(pl);
511 self.bump_version();
512 Some(result)
513 }
514 None => None,
515 }
516 }
517
518 pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
522 let mut pl = self.playlist.write();
523 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
524 item.load_state = new_state;
525 }
526 drop(pl);
527 self.bump_version();
528 }
529
530 pub fn update_item_metadata(
534 &self,
535 id: QueueItemId,
536 title: String,
537 artist: String,
538 album_artist: String,
539 album: String,
540 duration_ms: Option<u64>,
541 ) {
542 let mut pl = self.playlist.write();
543 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
544 item.title = title;
545 item.artist = artist;
546 item.album_artist = album_artist;
547 item.album = album;
548 if let Some(dur) = duration_ms {
549 item.duration_ms = Some(dur);
550 }
551 }
552 drop(pl);
553 self.bump_version();
554 }
555
556 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
559 let pl = self.playlist.read();
560 pl.items
561 .iter()
562 .find(|item| item.id == id)
563 .and_then(|item| match &item.load_state {
564 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
565 LoadState::Downloading {
566 total,
567 bytes_written,
568 ..
569 } => {
570 let written = bytes_written.load(Ordering::Acquire);
571 if written >= STREAM_THRESHOLD {
572 Some(PlaybackSource::Streaming {
573 path: item.path.clone(),
574 bytes_written: bytes_written.clone(),
575 total: *total,
576 })
577 } else {
578 None
579 }
580 }
581 _ => None,
582 })
583 }
584
585 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
587 let pl = self.playlist.read();
588 pl.items.iter().find(|item| item.id == id).and_then(|item| {
589 if matches!(item.load_state, LoadState::Ready) {
590 Some(item.path.clone())
591 } else {
592 None
593 }
594 })
595 }
596
597 pub fn is_cursor(&self, id: QueueItemId) -> bool {
599 self.playlist.read().cursor == Some(id)
600 }
601
602 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
606 let pl = self.playlist.read();
607 (pl.items.clone(), pl.cursor)
608 }
609
610 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
612 let pl = self.playlist.read();
613 pl.items.iter().find(|item| item.id == id).cloned()
614 }
615
616 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
618 let pl = self.playlist.read();
619 let pos = pl.items.iter().position(|item| item.id == id)?;
620 if pos == 0 {
621 None
622 } else {
623 Some(pl.items[pos - 1].id)
624 }
625 }
626
627 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
630 let pl = self.playlist.read();
631 ids.iter()
632 .filter_map(|&id| {
633 let pos = pl.items.iter().position(|item| item.id == id)?;
634 let before = if pos == 0 {
635 None
636 } else {
637 Some(pl.items[pos - 1].id)
638 };
639 Some((id, before))
640 })
641 .collect()
642 }
643
644 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
646 let mut pl = self.playlist.write();
647 pl.items = items;
648 pl.cursor = cursor;
649 drop(pl);
650 self.bump_version();
651 }
652
653 pub fn remove_items(&self, ids: &[QueueItemId]) {
655 use std::collections::HashSet;
656 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
657 let mut pl = self.playlist.write();
658 pl.items.retain(|item| !id_set.contains(&item.id));
659 if let Some(cursor) = pl.cursor
660 && id_set.contains(&cursor)
661 {
662 pl.cursor = None;
663 }
664 drop(pl);
665 self.bump_version();
666 }
667
668 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
670 let mut pl = self.playlist.write();
671 let insert_at = match after {
672 Some(after_id) => {
673 match pl.items.iter().position(|i| i.id == after_id) {
674 Some(pos) => pos + 1,
675 None => pl.items.len(), }
677 }
678 None => 0,
679 };
680 pl.items.insert(insert_at, item);
681 drop(pl);
682 self.bump_version();
683 }
684
685 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
687 let mut pl = self.playlist.write();
688 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
689 return;
690 };
691 let item = pl.items.remove(from);
692 let insert_at = match after {
693 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
694 Some(pos) => pos + 1,
695 None => pl.items.len(),
696 },
697 None => 0,
698 };
699 pl.items.insert(insert_at, item);
700 drop(pl);
701 self.bump_version();
702 }
703
704 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
707 for &(id, after) in entries {
708 self.move_item_to(id, after);
709 }
710 }
711
712 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
717 let pl = self.playlist.read();
718 let track_info = self.track_info.read();
719
720 let cursor_pos = match pl.cursor {
721 Some(cid) => pl.items.iter().position(|item| item.id == cid),
722 None => None,
723 };
724
725 let mut entries = Vec::with_capacity(pl.items.len());
726 let mut finished_count = 0;
727 let mut has_playing = false;
728 let mut queue_count = 0;
729
730 for (i, item) in pl.items.iter().enumerate() {
731 let is_cursor = cursor_pos == Some(i);
732 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
733
734 let dl_progress = match &item.load_state {
736 LoadState::Downloading {
737 downloaded, total, ..
738 } => Some((*downloaded, *total)),
739 _ => None,
740 };
741
742 let status = if is_cursor {
743 has_playing = true;
744 match &item.load_state {
745 LoadState::Ready => QueueEntryStatus::Playing,
746 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
747 LoadState::Pending => QueueEntryStatus::PriorityPending,
748 LoadState::Failed(_) => QueueEntryStatus::Failed,
749 }
750 } else if is_before_cursor {
751 finished_count += 1;
752 match &item.load_state {
753 LoadState::Ready => QueueEntryStatus::Played,
754 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
755 LoadState::Pending => QueueEntryStatus::Downloading,
756 LoadState::Failed(_) => QueueEntryStatus::Failed,
757 }
758 } else {
759 queue_count += 1;
760 match &item.load_state {
761 LoadState::Ready => QueueEntryStatus::Queued,
762 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
763 LoadState::Pending => QueueEntryStatus::Downloading,
764 LoadState::Failed(_) => QueueEntryStatus::Failed,
765 }
766 };
767
768 let duration_ms =
770 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
771 {
772 track_info.as_ref().map(|ti| ti.duration_ms)
773 } else {
774 item.duration_ms
775 };
776
777 entries.push(QueueEntry {
778 id: item.id,
779 path: item.path.clone(),
780 title: item.title.clone(),
781 artist: item.artist.clone(),
782 album_artist: item.album_artist.clone(),
783 album: item.album.clone(),
784 year: item.year.clone(),
785 codec: item.codec.clone(),
786 track_number: item.track_number,
787 disc: item.disc,
788 duration_ms,
789 status,
790 download_progress: dl_progress,
791 });
792 }
793
794 VisibleQueueSnapshot {
795 entries,
796 finished_count,
797 has_playing,
798 queue_count,
799 }
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
810 PlaylistItem {
811 id: QueueItemId::new(),
812 path: PathBuf::from(format!("/music/{title}.flac")),
813 title: title.to_string(),
814 artist: "Artist".to_string(),
815 album_artist: "Artist".to_string(),
816 album: "Album".to_string(),
817 year: None,
818 codec: Some("FLAC".to_string()),
819 track_number: None,
820 disc: None,
821 duration_ms: Some(200_000),
822 load_state,
823 }
824 }
825
826 fn ready_item(title: &str) -> PlaylistItem {
827 make_item(title, LoadState::Ready)
828 }
829
830 fn pending_item(title: &str) -> PlaylistItem {
831 make_item(title, LoadState::Pending)
832 }
833
834 #[test]
837 fn test_advance_cursor_skips_non_ready() {
838 let state = SharedPlayerState::new();
842 let item0 = ready_item("track-0");
843 let item1 = pending_item("track-1");
844 let item2 = ready_item("track-2");
845 let id0 = item0.id;
846 let id2 = item2.id;
847
848 state.add_items(vec![item0, item1, item2]);
849
850 let result = state.advance_cursor();
852 assert!(result.is_some(), "expected to find first Ready item");
853 assert_eq!(result.unwrap().0, id0, "should land on first Ready item");
854
855 let result = state.advance_cursor();
857 assert!(
858 result.is_some(),
859 "expected to find next Ready item after skipping Pending"
860 );
861 assert_eq!(
862 result.unwrap().0,
863 id2,
864 "should skip Pending and land on third item"
865 );
866 }
867
868 #[test]
869 fn test_advance_cursor_stops_at_end_of_playlist() {
870 let state = SharedPlayerState::new();
872 let item0 = ready_item("track-0");
873 let item1 = ready_item("track-1");
874 let id1 = item1.id;
875
876 state.add_items(vec![item0, item1]);
877
878 state.set_cursor(Some(id1));
880
881 let result = state.advance_cursor();
882 assert!(
883 result.is_none(),
884 "advance past last item should return None"
885 );
886
887 assert_eq!(state.cursor(), Some(id1));
889 }
890
891 #[test]
892 fn test_advance_cursor_with_no_ready_items_returns_none() {
893 let state = SharedPlayerState::new();
894 state.add_items(vec![pending_item("pending-0"), pending_item("pending-1")]);
895
896 let result = state.advance_cursor();
897 assert!(
898 result.is_none(),
899 "should return None when no Ready items exist"
900 );
901 }
902
903 #[test]
906 fn test_retreat_cursor_goes_to_previous_item() {
907 let state = SharedPlayerState::new();
908 let item0 = ready_item("track-0");
909 let item1 = ready_item("track-1");
910 let id0 = item0.id;
911 let id1 = item1.id;
912
913 state.add_items(vec![item0, item1]);
914 state.set_cursor(Some(id1));
915
916 let result = state.retreat_cursor();
917 assert!(result.is_some(), "expected to retreat to previous item");
918 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
919 assert_eq!(state.cursor(), Some(id0));
920 }
921
922 #[test]
923 fn test_retreat_cursor_returns_none_when_at_first_item() {
924 let state = SharedPlayerState::new();
925 let item0 = ready_item("only-track");
926 let id0 = item0.id;
927
928 state.add_items(vec![item0]);
929 state.set_cursor(Some(id0));
930
931 let result = state.retreat_cursor();
932 assert!(result.is_none(), "cannot retreat before the first item");
933 assert_eq!(state.cursor(), Some(id0));
935 }
936
937 #[test]
938 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
939 let state = SharedPlayerState::new();
940 state.add_items(vec![ready_item("track-0")]);
941
942 let result = state.retreat_cursor();
943 assert!(
944 result.is_none(),
945 "retreat with no cursor should return None"
946 );
947 }
948
949 #[test]
952 fn test_derive_visible_queue_statuses() {
953 let state = SharedPlayerState::new();
955 let item0 = ready_item("played-track");
956 let item1 = ready_item("playing-track");
957 let item2 = ready_item("queued-track");
958 let id1 = item1.id;
959
960 state.add_items(vec![item0, item1, item2]);
961 state.set_cursor(Some(id1));
962
963 let snap = state.derive_visible_queue();
964
965 assert_eq!(snap.entries.len(), 3);
966 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
967 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
968 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
969 assert!(snap.has_playing);
970 assert_eq!(snap.finished_count, 1);
971 assert_eq!(snap.queue_count, 1);
972 }
973
974 #[test]
975 fn test_derive_visible_queue_downloading_statuses() {
976 let state = SharedPlayerState::new();
978 let bytes_cursor = Arc::new(AtomicU64::new(0));
979 let bytes_queued = Arc::new(AtomicU64::new(0));
980 let dl_cursor = make_item(
981 "downloading-at-cursor",
982 LoadState::Downloading {
983 downloaded: 0,
984 total: 1_000_000,
985 bytes_written: bytes_cursor.clone(),
986 },
987 );
988 let dl_queued = make_item(
989 "downloading-queued",
990 LoadState::Downloading {
991 downloaded: 0,
992 total: 500_000,
993 bytes_written: bytes_queued.clone(),
994 },
995 );
996 let id_cursor = dl_cursor.id;
997
998 state.add_items(vec![dl_cursor, dl_queued]);
999 state.set_cursor(Some(id_cursor));
1000
1001 let snap = state.derive_visible_queue();
1002
1003 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1004 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1005 }
1006
1007 #[test]
1008 fn test_derive_visible_queue_no_cursor_all_queued() {
1009 let state = SharedPlayerState::new();
1010 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1011
1012 let snap = state.derive_visible_queue();
1013
1014 assert_eq!(snap.entries.len(), 3);
1015 for entry in &snap.entries {
1016 assert_eq!(entry.status, QueueEntryStatus::Queued);
1017 }
1018 assert!(!snap.has_playing);
1019 assert_eq!(snap.finished_count, 0);
1020 assert_eq!(snap.queue_count, 3);
1021 }
1022
1023 #[test]
1026 fn test_move_item_to_reorders_playlist() {
1027 let state = SharedPlayerState::new();
1029 let item_a = ready_item("A");
1030 let item_b = ready_item("B");
1031 let item_c = ready_item("C");
1032 let id_a = item_a.id;
1033 let id_b = item_b.id;
1034 let id_c = item_c.id;
1035
1036 state.add_items(vec![item_a, item_b, item_c]);
1037 state.move_item_to(id_c, Some(id_a));
1038
1039 let (items, _) = state.snapshot_playlist();
1040 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1041 assert_eq!(titles, vec!["A", "C", "B"]);
1042 assert_eq!(items[0].id, id_a);
1043 assert_eq!(items[1].id, id_c);
1044 assert_eq!(items[2].id, id_b);
1045 }
1046
1047 #[test]
1048 fn test_move_item_to_front_when_after_is_none() {
1049 let state = SharedPlayerState::new();
1051 let item_a = ready_item("A");
1052 let item_b = ready_item("B");
1053 let item_c = ready_item("C");
1054 let id_c = item_c.id;
1055
1056 state.add_items(vec![item_a, item_b, item_c]);
1057 state.move_item_to(id_c, None);
1058
1059 let (items, _) = state.snapshot_playlist();
1060 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1061 assert_eq!(titles, vec!["C", "A", "B"]);
1062 }
1063
1064 #[test]
1067 fn test_move_items_batch_preserves_relative_order() {
1068 let state = SharedPlayerState::new();
1070 let item_a = ready_item("A");
1071 let item_b = ready_item("B");
1072 let item_c = ready_item("C");
1073 let item_d = ready_item("D");
1074 let id_a = item_a.id;
1075 let id_b = item_b.id;
1076 let id_c = item_c.id;
1077 let id_d = item_d.id;
1078
1079 state.add_items(vec![item_a, item_b, item_c, item_d]);
1080 state.move_items(&[id_a, id_c], id_d, true);
1081
1082 let (items, _) = state.snapshot_playlist();
1083 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1084 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1085 assert_eq!(items[0].id, id_b);
1086 assert_eq!(items[1].id, id_d);
1087 assert_eq!(items[2].id, id_a);
1088 assert_eq!(items[3].id, id_c);
1089 }
1090}