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 let hex = self.0.simple().to_string();
28 write!(f, "QId({})", &hex[hex.len() - 8..])
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[repr(u8)]
35pub enum PlaybackState {
36 Stopped = 0,
37 Playing = 1,
38 Paused = 2,
39}
40
41impl PlaybackState {
42 pub fn from_u8(v: u8) -> Self {
43 match v {
44 1 => Self::Playing,
45 2 => Self::Paused,
46 _ => Self::Stopped,
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct TrackInfo {
54 pub id: QueueItemId,
55 pub path: PathBuf,
56 pub codec: String,
57 pub sample_rate: u32,
58 pub bit_depth: Option<u16>,
59 pub bitrate_kbps: Option<u32>,
60 pub channels: u16,
61 pub duration_ms: u64,
62}
63
64pub const STREAM_THRESHOLD: u64 = 256 * 1024;
68
69pub const SEEK_SAFETY_MS: u64 = 2_000;
75
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub enum ItemState {
85 #[default]
87 Pending,
88 Ready,
90 Failed(String),
94}
95
96#[derive(Debug, Clone)]
102pub enum LoadState {
103 Pending,
104 Downloading {
105 path: PathBuf,
108 total: u64,
110 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
113 },
114 Ready,
115 Failed(String),
116}
117
118impl LoadState {
119 pub fn of(item: &PlaylistItem) -> Self {
125 use crate::remote::downloads::{DownloadState, store};
126
127 if let Some(transfer) = store().get(item.id) {
128 match transfer.state {
129 DownloadState::Queued | DownloadState::Running => {
130 return Self::Downloading {
131 path: transfer.source,
132 total: transfer.total,
133 bytes_written: transfer.written,
134 };
135 }
136 DownloadState::Failed(reason) if item.state == ItemState::Pending => {
140 return Self::Failed(reason);
141 }
142 DownloadState::Failed(_) | DownloadState::Done => {}
143 }
144 }
145
146 match &item.state {
147 ItemState::Pending => Self::Pending,
148 ItemState::Ready => Self::Ready,
149 ItemState::Failed(reason) => Self::Failed(reason.clone()),
150 }
151 }
152}
153
154pub enum PlaybackSource {
156 Ready(PathBuf),
158 Streaming {
160 path: PathBuf,
161 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
162 total: u64,
163 },
164}
165
166#[derive(Debug, Clone)]
168pub struct PlaylistItem {
169 pub id: QueueItemId,
170 pub db_id: Option<i64>,
172 pub playlist_entry_id: Option<i64>,
179 pub path: PathBuf,
180 pub title: String,
181 pub artist: String,
182 pub album_artist: String,
183 pub album: String,
184 pub year: Option<String>,
185 pub codec: Option<String>,
186 pub track_number: Option<i64>,
187 pub disc: Option<i64>,
188 pub duration_ms: Option<u64>,
189 pub state: ItemState,
192}
193
194#[derive(Debug, Clone, Default)]
196pub struct Playlist {
197 pub items: Vec<PlaylistItem>,
198 pub cursor: Option<QueueItemId>,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum QueueEntryStatus {
206 Queued,
207 Playing,
208 Played,
209 Downloading,
210 PriorityPending,
212 Failed,
213}
214
215#[derive(Debug, Clone)]
217pub struct QueueEntry {
218 pub id: QueueItemId,
219 pub db_id: Option<i64>,
221 pub playlist_entry_id: Option<i64>,
223 pub path: PathBuf,
224 pub title: String,
225 pub artist: String,
226 pub album_artist: String,
227 pub album: String,
228 pub year: Option<String>,
229 pub codec: Option<String>,
230 pub track_number: Option<i64>,
231 pub disc: Option<i64>,
232 pub duration_ms: Option<u64>,
233 pub status: QueueEntryStatus,
234 pub download_progress: Option<(u64, u64)>,
235 pub error: Option<String>,
237}
238
239#[derive(Debug, Clone, Default)]
241pub struct VisibleQueueSnapshot {
242 pub entries: Vec<QueueEntry>,
243 pub finished_count: usize,
244 pub has_playing: bool,
245 pub queue_count: usize,
246}
247
248#[derive(Debug)]
252pub struct SharedPlayerState {
253 state: AtomicU8,
254 position_ms: AtomicU64,
255 track_info: parking_lot::RwLock<Option<TrackInfo>>,
256
257 playlist: parking_lot::RwLock<Playlist>,
259
260 playlist_version: AtomicU64,
262
263 quit_requested: AtomicBool,
265
266 metadata_refresh_pending: AtomicBool,
269
270 output_sample_rate: AtomicU64,
277
278 radio_mode: AtomicBool,
281}
282
283impl SharedPlayerState {
284 pub fn new() -> Arc<Self> {
285 Arc::new(Self {
286 state: AtomicU8::new(PlaybackState::Stopped as u8),
287 position_ms: AtomicU64::new(0),
288 track_info: parking_lot::RwLock::new(None),
289 playlist: parking_lot::RwLock::new(Playlist::default()),
290 playlist_version: AtomicU64::new(0),
291 quit_requested: AtomicBool::new(false),
292 metadata_refresh_pending: AtomicBool::new(false),
293 output_sample_rate: AtomicU64::new(0),
294 radio_mode: AtomicBool::new(false),
295 })
296 }
297
298 pub fn playback_state(&self) -> PlaybackState {
301 PlaybackState::from_u8(self.state.load(Ordering::Acquire))
302 }
303
304 pub fn set_playback_state(&self, state: PlaybackState) {
305 self.state.store(state as u8, Ordering::Release);
306 self.changed();
307 }
308
309 pub fn position_ms(&self) -> u64 {
310 self.position_ms.load(Ordering::Acquire)
311 }
312
313 pub fn set_position_ms(&self, pos: u64) {
314 self.position_ms.store(pos, Ordering::Release);
315 }
321
322 pub fn track_info(&self) -> Option<TrackInfo> {
323 self.track_info.read().clone()
324 }
325
326 pub fn set_track_info(&self, info: Option<TrackInfo>) {
327 *self.track_info.write() = info;
328 self.changed();
329 }
330
331 pub fn seekable_ms(&self) -> u64 {
342 let Some(info) = self.track_info.read().clone() else {
343 return 0;
344 };
345
346 let pl = self.playlist.read();
349 let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
350 return info.duration_ms;
351 };
352
353 let LoadState::Downloading {
354 total,
355 bytes_written,
356 ..
357 } = LoadState::of(item)
358 else {
359 return info.duration_ms;
360 };
361
362 if info.duration_ms == 0 {
367 return 0;
368 }
369
370 let written = bytes_written.load(Ordering::Acquire);
371 let reached = if total > 0 && info.duration_ms > 0 {
372 ((written as f64 / total as f64) * info.duration_ms as f64) as u64
373 } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
374 written.saturating_mul(8) / kbps as u64
378 } else {
379 return self.position_ms();
382 };
383
384 reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
385 }
386
387 pub fn duration_ms(&self) -> u64 {
395 let Some(info) = self.track_info.read().clone() else {
396 return 0;
397 };
398 if info.duration_ms > 0 {
399 return info.duration_ms;
400 }
401 self.playlist
403 .read()
404 .items
405 .iter()
406 .find(|item| item.id == info.id)
407 .and_then(|item| item.duration_ms)
408 .unwrap_or(0)
409 }
410
411 pub fn seek_ceiling_ms(&self) -> Option<u64> {
415 let duration = self.duration_ms();
416 if duration == 0 {
417 return None;
418 }
419 let seekable = self.seekable_ms();
420 (seekable < duration).then_some(seekable)
421 }
422
423 pub fn current_download_fraction(&self) -> Option<f64> {
426 let id = self.track_info.read().as_ref()?.id;
429 let pl = self.playlist.read();
430 pl.items
431 .iter()
432 .find(|item| item.id == id)
433 .and_then(|item| match LoadState::of(item) {
434 LoadState::Downloading {
435 bytes_written,
436 total,
437 ..
438 } => {
439 let written = bytes_written.load(Ordering::Acquire);
440 (total > 0).then(|| (written as f64 / total as f64).min(1.0))
441 }
442 _ => None,
443 })
444 }
445
446 pub fn request_quit(&self) {
449 self.quit_requested.store(true, Ordering::Release);
450 }
451
452 pub fn quit_requested(&self) -> bool {
453 self.quit_requested.load(Ordering::Acquire)
454 }
455
456 pub fn signal_metadata_refresh(&self) {
462 self.metadata_refresh_pending.store(true, Ordering::Release);
463 self.changed();
464 }
465
466 pub fn take_metadata_refresh(&self) -> bool {
468 self.metadata_refresh_pending
469 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
470 .is_ok()
471 }
472
473 pub fn radio_mode(&self) -> bool {
476 self.radio_mode.load(Ordering::Acquire)
477 }
478
479 pub fn set_radio_mode(&self, enabled: bool) {
480 self.radio_mode.store(enabled, Ordering::Release);
481 self.changed();
482 }
483
484 pub fn output_sample_rate(&self) -> Option<u32> {
488 match self.output_sample_rate.load(Ordering::Acquire) {
489 0 => None,
490 rate => Some(rate as u32),
491 }
492 }
493
494 pub fn set_output_sample_rate(&self, rate: u32) {
495 self.output_sample_rate
496 .store(u64::from(rate), Ordering::Release);
497 self.changed();
498 }
499
500 pub fn clear_output_sample_rate(&self) {
505 self.output_sample_rate.store(0, Ordering::Release);
506 }
507
508 pub fn playlist_version(&self) -> u64 {
511 self.playlist_version.load(Ordering::Acquire)
512 }
513
514 fn bump_version(&self) {
515 self.playlist_version.fetch_add(1, Ordering::AcqRel);
516 self.changed();
517 }
518
519 pub fn changed(&self) {
525 crate::signal::engine_changed().bump();
526 }
527
528 pub fn add_items(&self, items: Vec<PlaylistItem>) {
532 let mut pl = self.playlist.write();
533 pl.items.extend(items);
534 drop(pl);
535 self.bump_version();
536 }
537
538 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
540 let mut pl = self.playlist.write();
541 let insert_at = match pl.items.iter().position(|item| item.id == after) {
542 Some(pos) => pos + 1,
543 None => pl.items.len(), };
545 for (i, item) in items.into_iter().enumerate() {
546 pl.items.insert(insert_at + i, item);
547 }
548 drop(pl);
549 self.bump_version();
550 }
551
552 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
554 let mut pl = self.playlist.write();
555 for (id, new_path) in updates {
556 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
557 item.path = new_path.clone();
558 }
559 }
560 drop(pl);
561 self.bump_version();
562 }
563
564 pub fn remove_item(&self, id: QueueItemId) {
566 let mut pl = self.playlist.write();
567 pl.items.retain(|item| item.id != id);
568 if pl.cursor == Some(id) {
570 pl.cursor = None;
571 }
572 drop(pl);
573 self.bump_version();
574 }
575
576 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
578 let mut pl = self.playlist.write();
579 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
580 return;
581 };
582 let item = pl.items.remove(from);
583 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
584 let pos = from.min(pl.items.len());
586 pl.items.insert(pos, item);
587 return;
588 };
589 let insert_at = if after { to + 1 } else { to };
590 pl.items.insert(insert_at, item);
591 drop(pl);
592 self.bump_version();
593 }
594
595 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
598 use std::collections::HashSet;
599 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
600
601 let mut pl = self.playlist.write();
602
603 let mut remaining = Vec::with_capacity(pl.items.len());
605 let mut moved = Vec::with_capacity(ids.len());
606 for item in pl.items.drain(..) {
607 if id_set.contains(&item.id) {
608 moved.push(item);
609 } else {
610 remaining.push(item);
611 }
612 }
613
614 let insert_at = match remaining.iter().position(|item| item.id == target) {
616 Some(pos) => {
617 if after {
618 pos + 1
619 } else {
620 pos
621 }
622 }
623 None => remaining.len(),
624 };
625
626 for (i, item) in moved.into_iter().enumerate() {
628 remaining.insert(insert_at + i, item);
629 }
630
631 pl.items = remaining;
632 drop(pl);
633 self.bump_version();
634 }
635
636 pub fn set_cursor(&self, id: Option<QueueItemId>) {
638 let mut pl = self.playlist.write();
639 pl.cursor = id;
640 drop(pl);
641 self.bump_version();
642 }
643
644 pub fn cursor(&self) -> Option<QueueItemId> {
645 self.playlist.read().cursor
646 }
647
648 pub fn clear_playlist(&self) {
650 let mut pl = self.playlist.write();
651 pl.items.clear();
652 pl.cursor = None;
653 drop(pl);
654 self.bump_version();
655 }
656
657 pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
670 let mut pl = self.playlist.write();
671 let start = match pl.cursor {
672 Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
673 None => 0,
674 };
675
676 let next = pl
677 .items
678 .get(start..)?
679 .iter()
680 .find(|item| !matches!(item.state, ItemState::Failed(_)))
681 .map(|item| item.id)?;
682
683 pl.cursor = Some(next);
684 drop(pl);
685 self.bump_version();
686 Some(next)
687 }
688
689 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
693 let pl = self.playlist.read();
694 let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
697
698 for i in start..pl.items.len() {
699 if matches!(pl.items[i].state, ItemState::Ready) {
700 let item = &pl.items[i];
701 return Some((item.id, item.path.clone()));
702 }
703 }
704 None
705 }
706
707 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
710 let mut pl = self.playlist.write();
711 let cursor_pos = match pl.cursor {
712 Some(cid) => pl.items.iter().position(|item| item.id == cid),
713 None => None,
714 };
715
716 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
717
718 match prev_pos {
719 Some(pos) => {
720 let item = &pl.items[pos];
721 let result = (item.id, item.path.clone());
722 pl.cursor = Some(item.id);
723 drop(pl);
724 self.bump_version();
725 Some(result)
726 }
727 None => None,
728 }
729 }
730
731 pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
735 let mut pl = self.playlist.write();
736 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
737 item.state = new_state;
738 }
739 drop(pl);
740 self.bump_version();
741 }
742
743 pub fn update_item_metadata(
753 &self,
754 id: QueueItemId,
755 title: String,
756 artist: String,
757 album_artist: String,
758 album: String,
759 duration_ms: Option<u64>,
760 ) {
761 let mut pl = self.playlist.write();
762 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
763 if item.db_id.is_none() {
764 item.title = title;
765 item.artist = artist;
766 item.album_artist = album_artist;
767 item.album = album;
768 }
769 if let Some(dur) = duration_ms {
770 item.duration_ms = Some(dur);
771 }
772 }
773 drop(pl);
774 self.bump_version();
775 }
776
777 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
780 let pl = self.playlist.read();
781 pl.items
782 .iter()
783 .find(|item| item.id == id)
784 .and_then(|item| match LoadState::of(item) {
785 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
786 LoadState::Downloading {
787 path,
788 total,
789 bytes_written,
790 } => {
791 let written = bytes_written.load(Ordering::Acquire);
792 (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
793 path,
794 bytes_written,
795 total,
796 })
797 }
798 _ => None,
799 })
800 }
801
802 pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
812 let mut pl = self.playlist.write();
813 let mut reset = Vec::new();
814 for item in pl.items.iter_mut() {
815 let Some(db_id) = item.db_id else { continue };
816 if !matches!(item.state, ItemState::Ready) {
817 continue;
818 }
819 if item.path.exists() {
820 continue;
821 }
822 item.state = ItemState::Pending;
823 reset.push((db_id, item.id));
824 }
825 drop(pl);
826 if !reset.is_empty() {
827 self.bump_version();
828 }
829 reset
830 }
831
832 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
834 let pl = self.playlist.read();
835 pl.items.iter().find(|item| item.id == id).and_then(|item| {
836 if matches!(item.state, ItemState::Ready) {
837 Some(item.path.clone())
838 } else {
839 None
840 }
841 })
842 }
843
844 pub fn is_cursor(&self, id: QueueItemId) -> bool {
845 self.playlist.read().cursor == Some(id)
846 }
847
848 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
852 let pl = self.playlist.read();
853 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
854 return vec![];
855 };
856 let album = cursor.album.clone();
857 let album_artist = cursor.album_artist.clone();
858 pl.items
859 .iter()
860 .filter(|item| {
861 item.id != id && item.album == album && item.album_artist == album_artist
862 })
863 .map(|item| item.id)
864 .collect()
865 }
866
867 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
870 let pl = self.playlist.read();
871 pl.items
872 .iter()
873 .filter(|item| matches!(item.state, ItemState::Pending))
874 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
875 .collect()
876 }
877
878 pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
884 crate::remote::downloads::store()
885 .all()
886 .iter()
887 .filter(|d| !d.state.is_settled())
888 .map(|d| (d.id, d.bytes_written(), d.total))
889 .collect()
890 }
891
892 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
894 let pl = self.playlist.read();
895 pl.items
896 .iter()
897 .find(|item| item.id == id)
898 .and_then(|item| item.db_id)
899 }
900
901 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
903 let pl = self.playlist.read();
904 pl.items
905 .iter()
906 .find(|item| item.id == id)
907 .map(LoadState::of)
908 }
909
910 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
914 let pl = self.playlist.read();
915 (pl.items.clone(), pl.cursor)
916 }
917
918 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
920 let pl = self.playlist.read();
921 pl.items.iter().find(|item| item.id == id).cloned()
922 }
923
924 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
926 let pl = self.playlist.read();
927 let pos = pl.items.iter().position(|item| item.id == id)?;
928 if pos == 0 {
929 None
930 } else {
931 Some(pl.items[pos - 1].id)
932 }
933 }
934
935 pub fn reorder_to(&self, order: &[QueueItemId]) {
942 let mut pl = self.playlist.write();
943 let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
944 let mut sorted = Vec::with_capacity(taken.len());
945 for id in order {
946 if let Some(slot) = taken
947 .iter_mut()
948 .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
949 && let Some(item) = slot.take()
950 {
951 sorted.push(item);
952 }
953 }
954 sorted.extend(taken.into_iter().flatten());
955 pl.items = sorted;
956 drop(pl);
957 self.bump_version();
958 }
959
960 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
967 use std::collections::HashSet;
968 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
969 let pl = self.playlist.read();
970 pl.items
971 .iter()
972 .enumerate()
973 .filter(|(_, item)| wanted.contains(&item.id))
974 .map(|(pos, item)| {
975 let before = if pos == 0 {
976 None
977 } else {
978 Some(pl.items[pos - 1].id)
979 };
980 (item.id, before)
981 })
982 .collect()
983 }
984
985 pub fn surviving_item_before(
989 &self,
990 id: QueueItemId,
991 removed: &[QueueItemId],
992 ) -> Option<QueueItemId> {
993 use std::collections::HashSet;
994 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
995 let pl = self.playlist.read();
996 let pos = pl.items.iter().position(|item| item.id == id)?;
997 pl.items[..pos]
998 .iter()
999 .rev()
1000 .find(|item| !removed.contains(&item.id))
1001 .map(|item| item.id)
1002 }
1003
1004 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
1006 let mut pl = self.playlist.write();
1007 pl.items = items;
1008 pl.cursor = cursor;
1009 drop(pl);
1010 self.bump_version();
1011 }
1012
1013 pub fn remove_items(&self, ids: &[QueueItemId]) {
1015 use std::collections::HashSet;
1016 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
1017 let mut pl = self.playlist.write();
1018 pl.items.retain(|item| !id_set.contains(&item.id));
1019 if let Some(cursor) = pl.cursor
1020 && id_set.contains(&cursor)
1021 {
1022 pl.cursor = None;
1023 }
1024 drop(pl);
1025 self.bump_version();
1026 }
1027
1028 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1030 let mut pl = self.playlist.write();
1031 let insert_at = match after {
1032 Some(after_id) => {
1033 match pl.items.iter().position(|i| i.id == after_id) {
1034 Some(pos) => pos + 1,
1035 None => pl.items.len(), }
1037 }
1038 None => 0,
1039 };
1040 pl.items.insert(insert_at, item);
1041 drop(pl);
1042 self.bump_version();
1043 }
1044
1045 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1047 let mut pl = self.playlist.write();
1048 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1049 return;
1050 };
1051 let item = pl.items.remove(from);
1052 let insert_at = match after {
1053 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1054 Some(pos) => pos + 1,
1055 None => pl.items.len(),
1056 },
1057 None => 0,
1058 };
1059 pl.items.insert(insert_at, item);
1060 drop(pl);
1061 self.bump_version();
1062 }
1063
1064 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1067 for &(id, after) in entries {
1068 self.move_item_to(id, after);
1069 }
1070 }
1071
1072 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1077 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1079 let pl = self.playlist.read();
1080
1081 let cursor_pos = match pl.cursor {
1082 Some(cid) => pl.items.iter().position(|item| item.id == cid),
1083 None => None,
1084 };
1085
1086 let mut entries = Vec::with_capacity(pl.items.len());
1087 let mut finished_count = 0;
1088 let mut has_playing = false;
1089 let mut queue_count = 0;
1090
1091 for (i, item) in pl.items.iter().enumerate() {
1092 let is_cursor = cursor_pos == Some(i);
1093 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1094
1095 let load_state = LoadState::of(item);
1100
1101 let dl_progress = match &load_state {
1102 LoadState::Downloading {
1103 total,
1104 bytes_written,
1105 ..
1106 } => Some((bytes_written.load(Ordering::Relaxed), *total)),
1107 _ => None,
1108 };
1109
1110 let status = if is_cursor {
1111 has_playing = true;
1112 match &load_state {
1113 LoadState::Ready => QueueEntryStatus::Playing,
1114 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
1115 LoadState::Pending => QueueEntryStatus::PriorityPending,
1116 LoadState::Failed(_) => QueueEntryStatus::Failed,
1117 }
1118 } else if is_before_cursor {
1119 finished_count += 1;
1120 match &load_state {
1121 LoadState::Ready => QueueEntryStatus::Played,
1122 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1123 LoadState::Pending => QueueEntryStatus::Downloading,
1124 LoadState::Failed(_) => QueueEntryStatus::Failed,
1125 }
1126 } else {
1127 queue_count += 1;
1128 match &load_state {
1129 LoadState::Ready => QueueEntryStatus::Queued,
1130 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1131 LoadState::Pending => QueueEntryStatus::Queued,
1134 LoadState::Failed(_) => QueueEntryStatus::Failed,
1135 }
1136 };
1137
1138 let duration_ms =
1140 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1141 {
1142 playing_duration_ms
1143 } else {
1144 item.duration_ms
1145 };
1146
1147 entries.push(QueueEntry {
1148 id: item.id,
1149 db_id: item.db_id,
1150 playlist_entry_id: item.playlist_entry_id,
1151 path: item.path.clone(),
1152 title: item.title.clone(),
1153 artist: item.artist.clone(),
1154 album_artist: item.album_artist.clone(),
1155 album: item.album.clone(),
1156 year: item.year.clone(),
1157 codec: item.codec.clone(),
1158 track_number: item.track_number,
1159 disc: item.disc,
1160 duration_ms,
1161 status,
1162 download_progress: dl_progress,
1163 error: match &load_state {
1164 LoadState::Failed(reason) => Some(reason.clone()),
1165 _ => None,
1166 },
1167 });
1168 }
1169
1170 VisibleQueueSnapshot {
1171 entries,
1172 finished_count,
1173 has_playing,
1174 queue_count,
1175 }
1176 }
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182
1183 fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1186 PlaylistItem {
1187 playlist_entry_id: None,
1188 id: QueueItemId::new(),
1189 db_id: None,
1190 path: PathBuf::from(format!("/music/{title}.flac")),
1191 title: title.to_string(),
1192 artist: "Artist".to_string(),
1193 album_artist: "Artist".to_string(),
1194 album: "Album".to_string(),
1195 year: None,
1196 codec: Some("FLAC".to_string()),
1197 track_number: None,
1198 disc: None,
1199 duration_ms: Some(200_000),
1200 state,
1201 }
1202 }
1203
1204 fn downloading_item(
1207 title: &str,
1208 total: u64,
1209 written: Arc<crate::remote::downloads::ByteFeed>,
1210 ) -> PlaylistItem {
1211 let item = make_item(title, ItemState::Pending);
1212 let store = crate::remote::downloads::store();
1213 store.queued(crate::remote::downloads::Download {
1214 id: item.id,
1215 track_id: 1,
1216 title: title.into(),
1217 artist: String::new(),
1218 source: PathBuf::from(format!("/cache/{title}.flac.part")),
1219 dest: PathBuf::from(format!("/cache/{title}.flac")),
1220 total,
1221 written: written.clone(),
1222 state: crate::remote::downloads::DownloadState::Queued,
1223 bytes_per_second: 0,
1224 });
1225 store.started(item.id, total, written);
1226 item
1227 }
1228
1229 fn ready_item(title: &str) -> PlaylistItem {
1230 make_item(title, ItemState::Ready)
1231 }
1232
1233 fn pending_item(title: &str) -> PlaylistItem {
1234 make_item(title, ItemState::Pending)
1235 }
1236
1237 fn failed_item(title: &str) -> PlaylistItem {
1238 make_item(title, ItemState::Failed("nope".into()))
1239 }
1240
1241 const DURATION_MS: u64 = 32_523_787;
1242
1243 fn streaming_state(
1246 downloaded: u64,
1247 total: u64,
1248 bitrate_kbps: Option<u32>,
1249 ) -> Arc<SharedPlayerState> {
1250 streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1251 }
1252
1253 fn streaming_state_with_duration(
1256 downloaded: u64,
1257 total: u64,
1258 bitrate_kbps: Option<u32>,
1259 container_duration_ms: u64,
1260 ) -> Arc<SharedPlayerState> {
1261 let written = crate::remote::downloads::ByteFeed::new();
1262 written.set(downloaded);
1263 let item = make_item("train", ItemState::Pending);
1264 let id = item.id;
1265 let path = item.path.clone();
1266
1267 let store = crate::remote::downloads::store();
1270 store.queued(crate::remote::downloads::Download {
1271 id,
1272 track_id: 1,
1273 title: "train".into(),
1274 artist: String::new(),
1275 source: PathBuf::from("/cache/train.opus.part"),
1276 dest: PathBuf::from("/cache/train.opus"),
1277 total,
1278 written: written.clone(),
1279 state: crate::remote::downloads::DownloadState::Queued,
1280 bytes_per_second: 0,
1281 });
1282 store.started(id, total, written);
1283
1284 let state = SharedPlayerState::new();
1285 state.add_items(vec![item]);
1286 state.set_cursor(Some(id));
1287 state.set_track_info(Some(TrackInfo {
1288 id,
1289 path,
1290 codec: "Opus".into(),
1291 sample_rate: 48_000,
1292 bit_depth: None,
1293 bitrate_kbps,
1294 channels: 2,
1295 duration_ms: container_duration_ms,
1296 }));
1297 state
1298 }
1299
1300 #[test]
1303 fn a_track_on_disk_is_seekable_end_to_end() {
1304 let item = ready_item("done");
1305 let id = item.id;
1306 let path = item.path.clone();
1307 let state = SharedPlayerState::new();
1308 state.add_items(vec![item]);
1309 state.set_cursor(Some(id));
1310 state.set_track_info(Some(TrackInfo {
1311 id,
1312 path,
1313 codec: "FLAC".into(),
1314 sample_rate: 44_100,
1315 bit_depth: Some(16),
1316 bitrate_kbps: None,
1317 channels: 2,
1318 duration_ms: 200_000,
1319 }));
1320
1321 assert_eq!(state.seekable_ms(), 200_000);
1322 assert_eq!(state.seek_ceiling_ms(), None);
1324 }
1325
1326 #[test]
1327 fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1328 let state = streaming_state(100, 400, None);
1331 assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1332 assert_eq!(
1333 state.seek_ceiling_ms(),
1334 Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1335 );
1336 }
1337
1338 #[test]
1339 fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1340 let state = streaming_state(1024 * 1024, 0, Some(128));
1343 assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1344 }
1345
1346 #[test]
1347 fn nothing_to_estimate_from_allows_no_forward_seek() {
1348 let state = streaming_state(1024 * 1024, 0, None);
1351 state.set_position_ms(12_000);
1352 assert_eq!(state.seekable_ms(), 12_000);
1353 }
1354
1355 #[test]
1356 fn the_seekable_extent_never_exceeds_the_track() {
1357 let state = streaming_state(500, 400, None);
1360 assert_eq!(state.seekable_ms(), 32_523_787);
1361 }
1362
1363 #[test]
1364 fn nothing_playing_is_seekable_nowhere() {
1365 assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1366 assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1367 }
1368
1369 #[test]
1370 fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1371 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1375 assert_eq!(state.seekable_ms(), 0);
1376 }
1377
1378 #[test]
1379 fn the_library_duration_stands_in_for_a_silent_container() {
1380 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1383 assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1384 assert_eq!(state.seekable_ms(), 0);
1386 assert_eq!(state.seek_ceiling_ms(), Some(0));
1387 }
1388
1389 #[test]
1390 fn the_container_duration_wins_where_there_is_one() {
1391 let state = streaming_state(200, 400, None);
1392 assert_eq!(state.duration_ms(), DURATION_MS);
1393 }
1394
1395 #[test]
1396 fn the_download_landing_restores_seeking() {
1397 let state = streaming_state_with_duration(400, 400, Some(128), 0);
1401 assert_eq!(state.seekable_ms(), 0);
1402
1403 let id = state.cursor().expect("cursor");
1407 crate::remote::downloads::store().finished(id);
1408 state.update_item_state(id, ItemState::Ready);
1409 let info = state.track_info().expect("track info");
1410 state.set_track_info(Some(TrackInfo {
1411 duration_ms: DURATION_MS,
1412 ..info
1413 }));
1414
1415 assert_eq!(state.seekable_ms(), DURATION_MS);
1416 assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1417 }
1418
1419 #[test]
1422 fn advance_parks_on_a_still_downloading_track() {
1423 let state = SharedPlayerState::new();
1427 let item0 = ready_item("track-0");
1428 let item1 = pending_item("track-1");
1429 let item2 = ready_item("track-2");
1430 let (id0, id1) = (item0.id, item1.id);
1431
1432 state.add_items(vec![item0, item1, item2]);
1433
1434 assert_eq!(state.advance_cursor_loadable(), Some(id0));
1435 assert_eq!(state.advance_cursor_loadable(), Some(id1));
1436 assert_eq!(state.cursor(), Some(id1));
1437 }
1438
1439 #[test]
1440 fn advance_skips_failed_items() {
1441 let state = SharedPlayerState::new();
1442 let item0 = ready_item("track-0");
1443 let item1 = failed_item("track-1");
1444 let item2 = ready_item("track-2");
1445 let (id0, id2) = (item0.id, item2.id);
1446
1447 state.add_items(vec![item0, item1, item2]);
1448 state.set_cursor(Some(id0));
1449
1450 assert_eq!(state.advance_cursor_loadable(), Some(id2));
1451 }
1452
1453 #[test]
1454 fn advance_stops_at_end_of_playlist() {
1455 let state = SharedPlayerState::new();
1456 let item0 = ready_item("track-0");
1457 let item1 = ready_item("track-1");
1458 let id1 = item1.id;
1459
1460 state.add_items(vec![item0, item1]);
1461 state.set_cursor(Some(id1));
1462
1463 assert_eq!(state.advance_cursor_loadable(), None);
1464 assert_eq!(
1465 state.cursor(),
1466 Some(id1),
1467 "cursor unchanged on a failed advance"
1468 );
1469 }
1470
1471 #[test]
1472 fn advance_with_only_failed_items_returns_none() {
1473 let state = SharedPlayerState::new();
1474 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1475
1476 assert_eq!(state.advance_cursor_loadable(), None);
1477 }
1478
1479 #[test]
1480 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1481 let state = SharedPlayerState::new();
1482 let item0 = ready_item("track-0");
1483 let item1 = ready_item("track-1");
1484 let id0 = item0.id;
1485
1486 state.add_items(vec![item0, item1]);
1487 let ghost = QueueItemId::new();
1488 state.set_cursor(Some(ghost));
1489
1490 assert_eq!(state.advance_cursor_loadable(), None);
1491 assert_ne!(state.cursor(), Some(id0));
1492 }
1493
1494 #[test]
1497 fn peek_after_a_removed_item_returns_none() {
1498 let state = SharedPlayerState::new();
1502 let item0 = ready_item("track-0");
1503 let item1 = ready_item("track-1");
1504 let item2 = ready_item("track-2");
1505 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1506
1507 state.add_items(vec![item0, item1, item2]);
1508 assert_eq!(
1509 state.peek_next_ready_after(id1).map(|(id, _)| id),
1510 Some(id2)
1511 );
1512
1513 state.remove_item(id2);
1514 assert!(
1515 state.peek_next_ready_after(id2).is_none(),
1516 "a vanished reference must not resolve to the head of the queue"
1517 );
1518 assert_ne!(
1519 state.peek_next_ready_after(id2).map(|(id, _)| id),
1520 Some(id0)
1521 );
1522 }
1523
1524 #[test]
1527 fn surviving_predecessor_skips_items_being_removed() {
1528 let state = SharedPlayerState::new();
1529 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1530 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1531 state.add_items(items);
1532
1533 assert_eq!(
1535 state.surviving_item_before(ids[3], &ids[1..4]),
1536 Some(ids[0])
1537 );
1538 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1540 }
1541
1542 #[test]
1545 fn test_retreat_cursor_goes_to_previous_item() {
1546 let state = SharedPlayerState::new();
1547 let item0 = ready_item("track-0");
1548 let item1 = ready_item("track-1");
1549 let id0 = item0.id;
1550 let id1 = item1.id;
1551
1552 state.add_items(vec![item0, item1]);
1553 state.set_cursor(Some(id1));
1554
1555 let result = state.retreat_cursor();
1556 assert!(result.is_some(), "expected to retreat to previous item");
1557 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1558 assert_eq!(state.cursor(), Some(id0));
1559 }
1560
1561 #[test]
1562 fn test_retreat_cursor_returns_none_when_at_first_item() {
1563 let state = SharedPlayerState::new();
1564 let item0 = ready_item("only-track");
1565 let id0 = item0.id;
1566
1567 state.add_items(vec![item0]);
1568 state.set_cursor(Some(id0));
1569
1570 let result = state.retreat_cursor();
1571 assert!(result.is_none(), "cannot retreat before the first item");
1572 assert_eq!(state.cursor(), Some(id0));
1574 }
1575
1576 #[test]
1577 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1578 let state = SharedPlayerState::new();
1579 state.add_items(vec![ready_item("track-0")]);
1580
1581 let result = state.retreat_cursor();
1582 assert!(
1583 result.is_none(),
1584 "retreat with no cursor should return None"
1585 );
1586 }
1587
1588 #[test]
1591 fn test_derive_visible_queue_statuses() {
1592 let state = SharedPlayerState::new();
1594 let item0 = ready_item("played-track");
1595 let item1 = ready_item("playing-track");
1596 let item2 = ready_item("queued-track");
1597 let id1 = item1.id;
1598
1599 state.add_items(vec![item0, item1, item2]);
1600 state.set_cursor(Some(id1));
1601
1602 let snap = state.derive_visible_queue();
1603
1604 assert_eq!(snap.entries.len(), 3);
1605 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1606 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1607 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1608 assert!(snap.has_playing);
1609 assert_eq!(snap.finished_count, 1);
1610 assert_eq!(snap.queue_count, 1);
1611 }
1612
1613 #[test]
1614 fn test_derive_visible_queue_downloading_statuses() {
1615 let state = SharedPlayerState::new();
1617 let bytes_cursor = crate::remote::downloads::ByteFeed::new();
1618 let bytes_queued = crate::remote::downloads::ByteFeed::new();
1619 let dl_cursor = downloading_item("downloading-at-cursor", 1_000_000, bytes_cursor.clone());
1620 let dl_queued = downloading_item("downloading-queued", 500_000, bytes_queued.clone());
1621 let id_cursor = dl_cursor.id;
1622
1623 state.add_items(vec![dl_cursor, dl_queued]);
1624 state.set_cursor(Some(id_cursor));
1625
1626 let snap = state.derive_visible_queue();
1627
1628 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1629 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1630 }
1631
1632 #[test]
1633 fn progress_follows_the_counter_without_touching_the_playlist() {
1634 let state = SharedPlayerState::new();
1638 let bytes = crate::remote::downloads::ByteFeed::new();
1639 let item = downloading_item("downloading", 1_000, bytes.clone());
1640 state.add_items(vec![item]);
1641
1642 let version = state.playlist_version();
1643 bytes.set(250);
1644
1645 let snap = state.derive_visible_queue();
1646 assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1647 assert_eq!(
1648 state.playlist_version(),
1649 version,
1650 "progress must not read as a queue mutation"
1651 );
1652 assert!(
1657 state
1658 .downloads_in_flight()
1659 .contains(&(snap.entries[0].id, 250, 1_000)),
1660 "the counter should be visible through the store"
1661 );
1662 }
1663
1664 #[test]
1665 fn test_derive_visible_queue_no_cursor_all_queued() {
1666 let state = SharedPlayerState::new();
1667 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1668
1669 let snap = state.derive_visible_queue();
1670
1671 assert_eq!(snap.entries.len(), 3);
1672 for entry in &snap.entries {
1673 assert_eq!(entry.status, QueueEntryStatus::Queued);
1674 }
1675 assert!(!snap.has_playing);
1676 assert_eq!(snap.finished_count, 0);
1677 assert_eq!(snap.queue_count, 3);
1678 }
1679
1680 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1683 PlaylistItem {
1684 playlist_entry_id: None,
1685 id: QueueItemId::new(),
1686 db_id: None,
1687 path: PathBuf::from(format!("/music/{title}.flac")),
1688 title: title.to_string(),
1689 artist: "Artist".to_string(),
1690 album_artist: album_artist.to_string(),
1691 album: album.to_string(),
1692 year: None,
1693 codec: Some("FLAC".to_string()),
1694 track_number: None,
1695 disc: None,
1696 duration_ms: Some(200_000),
1697 state: ItemState::Ready,
1698 }
1699 }
1700
1701 #[test]
1702 fn test_same_album_item_ids_returns_album_mates() {
1703 let state = SharedPlayerState::new();
1704 let a1 = make_album_item("A1", "Album A", "Artist A");
1705 let a2 = make_album_item("A2", "Album A", "Artist A");
1706 let b1 = make_album_item("B1", "Album B", "Artist B");
1707 let a3 = make_album_item("A3", "Album A", "Artist A");
1708
1709 let id_a1 = a1.id;
1710 let id_a2 = a2.id;
1711 let id_a3 = a3.id;
1712
1713 state.add_items(vec![a1, a2, b1, a3]);
1714
1715 let mates = state.same_album_item_ids(id_a1);
1716 assert_eq!(mates.len(), 2);
1717 assert!(mates.contains(&id_a2));
1718 assert!(mates.contains(&id_a3));
1719 }
1720
1721 #[test]
1722 fn test_same_album_item_ids_distinguishes_album_artists() {
1723 let state = SharedPlayerState::new();
1725 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1726 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1727
1728 let id_a1 = a1.id;
1729
1730 state.add_items(vec![a1, b1]);
1731
1732 let mates = state.same_album_item_ids(id_a1);
1733 assert!(mates.is_empty(), "different album_artist should not match");
1734 }
1735
1736 #[test]
1737 fn test_same_album_item_ids_unknown_id_returns_empty() {
1738 let state = SharedPlayerState::new();
1739 state.add_items(vec![ready_item("track-0")]);
1740
1741 let bogus = QueueItemId::new();
1742 let mates = state.same_album_item_ids(bogus);
1743 assert!(mates.is_empty());
1744 }
1745
1746 #[test]
1749 fn test_update_item_metadata_leaves_library_tags_alone() {
1750 let state = SharedPlayerState::new();
1751 let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
1752 item.db_id = Some(29615);
1753 let id = item.id;
1754 state.add_items(vec![item]);
1755
1756 state.update_item_metadata(
1757 id,
1758 "[unknown]".into(),
1759 "Soulwax".into(),
1760 "Soulwax".into(),
1761 "Nite Versions".into(),
1762 Some(54_000),
1763 );
1764
1765 let pl = state.playlist.read();
1766 assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
1767 assert_eq!(pl.items[0].title, "A1");
1768 assert_eq!(pl.items[0].duration_ms, Some(54_000));
1769 }
1770
1771 #[test]
1772 fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
1773 let state = SharedPlayerState::new();
1774 let item = make_album_item("A1", "", "");
1775 let id = item.id;
1776 state.add_items(vec![item]);
1777
1778 state.update_item_metadata(
1779 id,
1780 "Teachers".into(),
1781 "Soulwax".into(),
1782 "Soulwax".into(),
1783 "Nite Versions".into(),
1784 Some(148_000),
1785 );
1786
1787 let pl = state.playlist.read();
1788 assert_eq!(pl.items[0].title, "Teachers");
1789 assert_eq!(pl.items[0].album, "Nite Versions");
1790 assert_eq!(pl.items[0].duration_ms, Some(148_000));
1791 }
1792
1793 #[test]
1796 fn test_move_item_to_reorders_playlist() {
1797 let state = SharedPlayerState::new();
1799 let item_a = ready_item("A");
1800 let item_b = ready_item("B");
1801 let item_c = ready_item("C");
1802 let id_a = item_a.id;
1803 let id_b = item_b.id;
1804 let id_c = item_c.id;
1805
1806 state.add_items(vec![item_a, item_b, item_c]);
1807 state.move_item_to(id_c, Some(id_a));
1808
1809 let (items, _) = state.snapshot_playlist();
1810 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1811 assert_eq!(titles, vec!["A", "C", "B"]);
1812 assert_eq!(items[0].id, id_a);
1813 assert_eq!(items[1].id, id_c);
1814 assert_eq!(items[2].id, id_b);
1815 }
1816
1817 #[test]
1818 fn test_move_item_to_front_when_after_is_none() {
1819 let state = SharedPlayerState::new();
1821 let item_a = ready_item("A");
1822 let item_b = ready_item("B");
1823 let item_c = ready_item("C");
1824 let id_c = item_c.id;
1825
1826 state.add_items(vec![item_a, item_b, item_c]);
1827 state.move_item_to(id_c, None);
1828
1829 let (items, _) = state.snapshot_playlist();
1830 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1831 assert_eq!(titles, vec!["C", "A", "B"]);
1832 }
1833
1834 #[test]
1837 fn test_move_items_batch_preserves_relative_order() {
1838 let state = SharedPlayerState::new();
1840 let item_a = ready_item("A");
1841 let item_b = ready_item("B");
1842 let item_c = ready_item("C");
1843 let item_d = ready_item("D");
1844 let id_a = item_a.id;
1845 let id_b = item_b.id;
1846 let id_c = item_c.id;
1847 let id_d = item_d.id;
1848
1849 state.add_items(vec![item_a, item_b, item_c, item_d]);
1850 state.move_items(&[id_a, id_c], id_d, true);
1851
1852 let (items, _) = state.snapshot_playlist();
1853 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1854 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1855 assert_eq!(items[0].id, id_b);
1856 assert_eq!(items[1].id, id_d);
1857 assert_eq!(items[2].id, id_a);
1858 assert_eq!(items[3].id, id_c);
1859 }
1860
1861 #[test]
1864 fn test_pending_downloads_collects_pending_with_db_id() {
1865 let state = SharedPlayerState::new();
1866 let mut item_a = ready_item("local");
1867 item_a.db_id = None;
1868
1869 let mut item_b = pending_item("remote-1");
1870 item_b.db_id = Some(10);
1871 let id_b = item_b.id;
1872
1873 let mut item_c = ready_item("cached");
1874 item_c.db_id = Some(20);
1875
1876 let mut item_d = pending_item("remote-2");
1877 item_d.db_id = Some(30);
1878 let id_d = item_d.id;
1879
1880 let item_e = pending_item("orphan");
1882
1883 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1884
1885 let pending = state.pending_downloads();
1886 assert_eq!(pending.len(), 2);
1887 assert_eq!(pending[0], (10, id_b));
1888 assert_eq!(pending[1], (30, id_d));
1889 }
1890
1891 #[test]
1892 fn test_item_db_id_and_load_state() {
1893 let state = SharedPlayerState::new();
1894 let mut item = pending_item("track");
1895 item.db_id = Some(42);
1896 let id = item.id;
1897 state.add_items(vec![item]);
1898
1899 assert_eq!(state.item_db_id(id), Some(42));
1900 assert!(matches!(
1901 state.item_load_state(id),
1902 Some(LoadState::Pending)
1903 ));
1904
1905 state.update_item_state(id, ItemState::Ready);
1906 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1907 }
1908}