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; pub const SEEK_SAFETY_MS: u64 = 2_000;
74
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub enum ItemState {
84 #[default]
86 Pending,
87 Ready,
89 Failed(String),
93}
94
95#[derive(Debug, Clone)]
101pub enum LoadState {
102 Pending,
103 Downloading {
104 path: PathBuf,
107 total: u64,
109 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
112 },
113 Ready,
114 Failed(String),
115}
116
117impl LoadState {
118 pub fn of(item: &PlaylistItem) -> Self {
124 use crate::remote::downloads::{DownloadState, store};
125
126 if let Some(transfer) = store().get(item.id) {
127 match transfer.state {
128 DownloadState::Queued | DownloadState::Running => {
129 return Self::Downloading {
130 path: transfer.source,
131 total: transfer.total,
132 bytes_written: transfer.written,
133 };
134 }
135 DownloadState::Failed(reason) if item.state == ItemState::Pending => {
139 return Self::Failed(reason);
140 }
141 DownloadState::Failed(_) | DownloadState::Done => {}
142 }
143 }
144
145 match &item.state {
146 ItemState::Pending => Self::Pending,
147 ItemState::Ready => Self::Ready,
148 ItemState::Failed(reason) => Self::Failed(reason.clone()),
149 }
150 }
151}
152
153pub enum PlaybackSource {
155 Ready(PathBuf),
157 Streaming {
159 path: PathBuf,
160 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
161 total: u64,
162 },
163}
164
165#[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.changed();
306 self.state.store(state as u8, Ordering::Release);
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.changed();
328 *self.track_info.write() = info;
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 }
464
465 pub fn take_metadata_refresh(&self) -> bool {
467 self.metadata_refresh_pending
468 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
469 .is_ok()
470 }
471
472 pub fn radio_mode(&self) -> bool {
475 self.radio_mode.load(Ordering::Acquire)
476 }
477
478 pub fn set_radio_mode(&self, enabled: bool) {
479 self.changed();
480 self.radio_mode.store(enabled, Ordering::Release);
481 }
482
483 pub fn output_sample_rate(&self) -> Option<u32> {
487 match self.output_sample_rate.load(Ordering::Acquire) {
488 0 => None,
489 rate => Some(rate as u32),
490 }
491 }
492
493 pub fn set_output_sample_rate(&self, rate: u32) {
494 self.changed();
495 self.output_sample_rate
496 .store(u64::from(rate), Ordering::Release);
497 }
498
499 pub fn clear_output_sample_rate(&self) {
504 self.output_sample_rate.store(0, Ordering::Release);
505 }
506
507 pub fn playlist_version(&self) -> u64 {
510 self.playlist_version.load(Ordering::Acquire)
511 }
512
513 fn bump_version(&self) {
514 self.playlist_version.fetch_add(1, Ordering::AcqRel);
515 self.changed();
516 }
517
518 pub fn changed(&self) {
524 crate::signal::engine_changed().bump();
525 }
526
527 pub fn add_items(&self, items: Vec<PlaylistItem>) {
531 let mut pl = self.playlist.write();
532 pl.items.extend(items);
533 drop(pl);
534 self.bump_version();
535 }
536
537 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
539 let mut pl = self.playlist.write();
540 let insert_at = match pl.items.iter().position(|item| item.id == after) {
541 Some(pos) => pos + 1,
542 None => pl.items.len(), };
544 for (i, item) in items.into_iter().enumerate() {
545 pl.items.insert(insert_at + i, item);
546 }
547 drop(pl);
548 self.bump_version();
549 }
550
551 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
553 let mut pl = self.playlist.write();
554 for (id, new_path) in updates {
555 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
556 item.path = new_path.clone();
557 }
558 }
559 drop(pl);
560 self.bump_version();
561 }
562
563 pub fn remove_item(&self, id: QueueItemId) {
565 let mut pl = self.playlist.write();
566 pl.items.retain(|item| item.id != id);
567 if pl.cursor == Some(id) {
569 pl.cursor = None;
570 }
571 drop(pl);
572 self.bump_version();
573 }
574
575 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
577 let mut pl = self.playlist.write();
578 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
579 return;
580 };
581 let item = pl.items.remove(from);
582 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
583 let pos = from.min(pl.items.len());
585 pl.items.insert(pos, item);
586 return;
587 };
588 let insert_at = if after { to + 1 } else { to };
589 pl.items.insert(insert_at, item);
590 drop(pl);
591 self.bump_version();
592 }
593
594 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
597 use std::collections::HashSet;
598 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
599
600 let mut pl = self.playlist.write();
601
602 let mut remaining = Vec::with_capacity(pl.items.len());
604 let mut moved = Vec::with_capacity(ids.len());
605 for item in pl.items.drain(..) {
606 if id_set.contains(&item.id) {
607 moved.push(item);
608 } else {
609 remaining.push(item);
610 }
611 }
612
613 let insert_at = match remaining.iter().position(|item| item.id == target) {
615 Some(pos) => {
616 if after {
617 pos + 1
618 } else {
619 pos
620 }
621 }
622 None => remaining.len(),
623 };
624
625 for (i, item) in moved.into_iter().enumerate() {
627 remaining.insert(insert_at + i, item);
628 }
629
630 pl.items = remaining;
631 drop(pl);
632 self.bump_version();
633 }
634
635 pub fn set_cursor(&self, id: Option<QueueItemId>) {
637 let mut pl = self.playlist.write();
638 pl.cursor = id;
639 drop(pl);
640 self.bump_version();
641 }
642
643 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 {
846 self.playlist.read().cursor == Some(id)
847 }
848
849 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
853 let pl = self.playlist.read();
854 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
855 return vec![];
856 };
857 let album = cursor.album.clone();
858 let album_artist = cursor.album_artist.clone();
859 pl.items
860 .iter()
861 .filter(|item| {
862 item.id != id && item.album == album && item.album_artist == album_artist
863 })
864 .map(|item| item.id)
865 .collect()
866 }
867
868 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
871 let pl = self.playlist.read();
872 pl.items
873 .iter()
874 .filter(|item| matches!(item.state, ItemState::Pending))
875 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
876 .collect()
877 }
878
879 pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
885 crate::remote::downloads::store()
886 .all()
887 .iter()
888 .filter(|d| !d.state.is_settled())
889 .map(|d| (d.id, d.bytes_written(), d.total))
890 .collect()
891 }
892
893 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
895 let pl = self.playlist.read();
896 pl.items
897 .iter()
898 .find(|item| item.id == id)
899 .and_then(|item| item.db_id)
900 }
901
902 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
904 let pl = self.playlist.read();
905 pl.items
906 .iter()
907 .find(|item| item.id == id)
908 .map(LoadState::of)
909 }
910
911 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
915 let pl = self.playlist.read();
916 (pl.items.clone(), pl.cursor)
917 }
918
919 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
921 let pl = self.playlist.read();
922 pl.items.iter().find(|item| item.id == id).cloned()
923 }
924
925 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
927 let pl = self.playlist.read();
928 let pos = pl.items.iter().position(|item| item.id == id)?;
929 if pos == 0 {
930 None
931 } else {
932 Some(pl.items[pos - 1].id)
933 }
934 }
935
936 pub fn reorder_to(&self, order: &[QueueItemId]) {
949 let mut pl = self.playlist.write();
950 let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
951 let mut sorted = Vec::with_capacity(taken.len());
952 for id in order {
953 if let Some(slot) = taken
954 .iter_mut()
955 .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
956 && let Some(item) = slot.take()
957 {
958 sorted.push(item);
959 }
960 }
961 sorted.extend(taken.into_iter().flatten());
962 pl.items = sorted;
963 drop(pl);
964 self.bump_version();
965 }
966
967 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
968 use std::collections::HashSet;
969 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
970 let pl = self.playlist.read();
971 pl.items
972 .iter()
973 .enumerate()
974 .filter(|(_, item)| wanted.contains(&item.id))
975 .map(|(pos, item)| {
976 let before = if pos == 0 {
977 None
978 } else {
979 Some(pl.items[pos - 1].id)
980 };
981 (item.id, before)
982 })
983 .collect()
984 }
985
986 pub fn surviving_item_before(
990 &self,
991 id: QueueItemId,
992 removed: &[QueueItemId],
993 ) -> Option<QueueItemId> {
994 use std::collections::HashSet;
995 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
996 let pl = self.playlist.read();
997 let pos = pl.items.iter().position(|item| item.id == id)?;
998 pl.items[..pos]
999 .iter()
1000 .rev()
1001 .find(|item| !removed.contains(&item.id))
1002 .map(|item| item.id)
1003 }
1004
1005 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
1007 let mut pl = self.playlist.write();
1008 pl.items = items;
1009 pl.cursor = cursor;
1010 drop(pl);
1011 self.bump_version();
1012 }
1013
1014 pub fn remove_items(&self, ids: &[QueueItemId]) {
1016 use std::collections::HashSet;
1017 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
1018 let mut pl = self.playlist.write();
1019 pl.items.retain(|item| !id_set.contains(&item.id));
1020 if let Some(cursor) = pl.cursor
1021 && id_set.contains(&cursor)
1022 {
1023 pl.cursor = None;
1024 }
1025 drop(pl);
1026 self.bump_version();
1027 }
1028
1029 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1031 let mut pl = self.playlist.write();
1032 let insert_at = match after {
1033 Some(after_id) => {
1034 match pl.items.iter().position(|i| i.id == after_id) {
1035 Some(pos) => pos + 1,
1036 None => pl.items.len(), }
1038 }
1039 None => 0,
1040 };
1041 pl.items.insert(insert_at, item);
1042 drop(pl);
1043 self.bump_version();
1044 }
1045
1046 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1048 let mut pl = self.playlist.write();
1049 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1050 return;
1051 };
1052 let item = pl.items.remove(from);
1053 let insert_at = match after {
1054 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1055 Some(pos) => pos + 1,
1056 None => pl.items.len(),
1057 },
1058 None => 0,
1059 };
1060 pl.items.insert(insert_at, item);
1061 drop(pl);
1062 self.bump_version();
1063 }
1064
1065 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1068 for &(id, after) in entries {
1069 self.move_item_to(id, after);
1070 }
1071 }
1072
1073 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1078 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1080 let pl = self.playlist.read();
1081
1082 let cursor_pos = match pl.cursor {
1083 Some(cid) => pl.items.iter().position(|item| item.id == cid),
1084 None => None,
1085 };
1086
1087 let mut entries = Vec::with_capacity(pl.items.len());
1088 let mut finished_count = 0;
1089 let mut has_playing = false;
1090 let mut queue_count = 0;
1091
1092 for (i, item) in pl.items.iter().enumerate() {
1093 let is_cursor = cursor_pos == Some(i);
1094 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1095
1096 let load_state = LoadState::of(item);
1103
1104 let dl_progress = match &load_state {
1105 LoadState::Downloading {
1106 total,
1107 bytes_written,
1108 ..
1109 } => Some((bytes_written.load(Ordering::Relaxed), *total)),
1110 _ => None,
1111 };
1112
1113 let status = if is_cursor {
1114 has_playing = true;
1115 match &load_state {
1116 LoadState::Ready => QueueEntryStatus::Playing,
1117 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
1118 LoadState::Pending => QueueEntryStatus::PriorityPending,
1119 LoadState::Failed(_) => QueueEntryStatus::Failed,
1120 }
1121 } else if is_before_cursor {
1122 finished_count += 1;
1123 match &load_state {
1124 LoadState::Ready => QueueEntryStatus::Played,
1125 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1126 LoadState::Pending => QueueEntryStatus::Downloading,
1127 LoadState::Failed(_) => QueueEntryStatus::Failed,
1128 }
1129 } else {
1130 queue_count += 1;
1131 match &load_state {
1132 LoadState::Ready => QueueEntryStatus::Queued,
1133 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1134 LoadState::Pending => QueueEntryStatus::Downloading,
1135 LoadState::Failed(_) => QueueEntryStatus::Failed,
1136 }
1137 };
1138
1139 let duration_ms =
1141 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1142 {
1143 playing_duration_ms
1144 } else {
1145 item.duration_ms
1146 };
1147
1148 entries.push(QueueEntry {
1149 id: item.id,
1150 db_id: item.db_id,
1151 playlist_entry_id: item.playlist_entry_id,
1152 path: item.path.clone(),
1153 title: item.title.clone(),
1154 artist: item.artist.clone(),
1155 album_artist: item.album_artist.clone(),
1156 album: item.album.clone(),
1157 year: item.year.clone(),
1158 codec: item.codec.clone(),
1159 track_number: item.track_number,
1160 disc: item.disc,
1161 duration_ms,
1162 status,
1163 download_progress: dl_progress,
1164 error: match &load_state {
1165 LoadState::Failed(reason) => Some(reason.clone()),
1166 _ => None,
1167 },
1168 });
1169 }
1170
1171 VisibleQueueSnapshot {
1172 entries,
1173 finished_count,
1174 has_playing,
1175 queue_count,
1176 }
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183
1184 fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1187 PlaylistItem {
1188 playlist_entry_id: None,
1189 id: QueueItemId::new(),
1190 db_id: None,
1191 path: PathBuf::from(format!("/music/{title}.flac")),
1192 title: title.to_string(),
1193 artist: "Artist".to_string(),
1194 album_artist: "Artist".to_string(),
1195 album: "Album".to_string(),
1196 year: None,
1197 codec: Some("FLAC".to_string()),
1198 track_number: None,
1199 disc: None,
1200 duration_ms: Some(200_000),
1201 state,
1202 }
1203 }
1204
1205 fn downloading_item(
1208 title: &str,
1209 total: u64,
1210 written: Arc<crate::remote::downloads::ByteFeed>,
1211 ) -> PlaylistItem {
1212 let item = make_item(title, ItemState::Pending);
1213 let store = crate::remote::downloads::store();
1214 store.queued(crate::remote::downloads::Download {
1215 id: item.id,
1216 track_id: 1,
1217 title: title.into(),
1218 artist: String::new(),
1219 source: PathBuf::from(format!("/cache/{title}.flac.part")),
1220 dest: PathBuf::from(format!("/cache/{title}.flac")),
1221 total,
1222 written: written.clone(),
1223 state: crate::remote::downloads::DownloadState::Queued,
1224 bytes_per_second: 0,
1225 });
1226 store.started(item.id, total, written);
1227 item
1228 }
1229
1230 fn ready_item(title: &str) -> PlaylistItem {
1231 make_item(title, ItemState::Ready)
1232 }
1233
1234 fn pending_item(title: &str) -> PlaylistItem {
1235 make_item(title, ItemState::Pending)
1236 }
1237
1238 fn failed_item(title: &str) -> PlaylistItem {
1239 make_item(title, ItemState::Failed("nope".into()))
1240 }
1241
1242 const DURATION_MS: u64 = 32_523_787;
1243
1244 fn streaming_state(
1247 downloaded: u64,
1248 total: u64,
1249 bitrate_kbps: Option<u32>,
1250 ) -> Arc<SharedPlayerState> {
1251 streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1252 }
1253
1254 fn streaming_state_with_duration(
1257 downloaded: u64,
1258 total: u64,
1259 bitrate_kbps: Option<u32>,
1260 container_duration_ms: u64,
1261 ) -> Arc<SharedPlayerState> {
1262 let written = crate::remote::downloads::ByteFeed::new();
1263 written.set(downloaded);
1264 let item = make_item("train", ItemState::Pending);
1265 let id = item.id;
1266 let path = item.path.clone();
1267
1268 let store = crate::remote::downloads::store();
1271 store.queued(crate::remote::downloads::Download {
1272 id,
1273 track_id: 1,
1274 title: "train".into(),
1275 artist: String::new(),
1276 source: PathBuf::from("/cache/train.opus.part"),
1277 dest: PathBuf::from("/cache/train.opus"),
1278 total,
1279 written: written.clone(),
1280 state: crate::remote::downloads::DownloadState::Queued,
1281 bytes_per_second: 0,
1282 });
1283 store.started(id, total, written);
1284
1285 let state = SharedPlayerState::new();
1286 state.add_items(vec![item]);
1287 state.set_cursor(Some(id));
1288 state.set_track_info(Some(TrackInfo {
1289 id,
1290 path,
1291 codec: "Opus".into(),
1292 sample_rate: 48_000,
1293 bit_depth: None,
1294 bitrate_kbps,
1295 channels: 2,
1296 duration_ms: container_duration_ms,
1297 }));
1298 state
1299 }
1300
1301 #[test]
1304 fn a_track_on_disk_is_seekable_end_to_end() {
1305 let item = ready_item("done");
1306 let id = item.id;
1307 let path = item.path.clone();
1308 let state = SharedPlayerState::new();
1309 state.add_items(vec![item]);
1310 state.set_cursor(Some(id));
1311 state.set_track_info(Some(TrackInfo {
1312 id,
1313 path,
1314 codec: "FLAC".into(),
1315 sample_rate: 44_100,
1316 bit_depth: Some(16),
1317 bitrate_kbps: None,
1318 channels: 2,
1319 duration_ms: 200_000,
1320 }));
1321
1322 assert_eq!(state.seekable_ms(), 200_000);
1323 assert_eq!(state.seek_ceiling_ms(), None);
1325 }
1326
1327 #[test]
1328 fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1329 let state = streaming_state(100, 400, None);
1332 assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1333 assert_eq!(
1334 state.seek_ceiling_ms(),
1335 Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1336 );
1337 }
1338
1339 #[test]
1340 fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1341 let state = streaming_state(1024 * 1024, 0, Some(128));
1344 assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1345 }
1346
1347 #[test]
1348 fn nothing_to_estimate_from_allows_no_forward_seek() {
1349 let state = streaming_state(1024 * 1024, 0, None);
1352 state.set_position_ms(12_000);
1353 assert_eq!(state.seekable_ms(), 12_000);
1354 }
1355
1356 #[test]
1357 fn the_seekable_extent_never_exceeds_the_track() {
1358 let state = streaming_state(500, 400, None);
1361 assert_eq!(state.seekable_ms(), 32_523_787);
1362 }
1363
1364 #[test]
1365 fn nothing_playing_is_seekable_nowhere() {
1366 assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1367 assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1368 }
1369
1370 #[test]
1371 fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1372 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1376 assert_eq!(state.seekable_ms(), 0);
1377 }
1378
1379 #[test]
1380 fn the_library_duration_stands_in_for_a_silent_container() {
1381 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1384 assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1385 assert_eq!(state.seekable_ms(), 0);
1387 assert_eq!(state.seek_ceiling_ms(), Some(0));
1388 }
1389
1390 #[test]
1391 fn the_container_duration_wins_where_there_is_one() {
1392 let state = streaming_state(200, 400, None);
1393 assert_eq!(state.duration_ms(), DURATION_MS);
1394 }
1395
1396 #[test]
1397 fn the_download_landing_restores_seeking() {
1398 let state = streaming_state_with_duration(400, 400, Some(128), 0);
1402 assert_eq!(state.seekable_ms(), 0);
1403
1404 let id = state.cursor().expect("cursor");
1408 crate::remote::downloads::store().finished(id);
1409 state.update_item_state(id, ItemState::Ready);
1410 let info = state.track_info().expect("track info");
1411 state.set_track_info(Some(TrackInfo {
1412 duration_ms: DURATION_MS,
1413 ..info
1414 }));
1415
1416 assert_eq!(state.seekable_ms(), DURATION_MS);
1417 assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1418 }
1419
1420 #[test]
1423 fn advance_parks_on_a_still_downloading_track() {
1424 let state = SharedPlayerState::new();
1428 let item0 = ready_item("track-0");
1429 let item1 = pending_item("track-1");
1430 let item2 = ready_item("track-2");
1431 let (id0, id1) = (item0.id, item1.id);
1432
1433 state.add_items(vec![item0, item1, item2]);
1434
1435 assert_eq!(state.advance_cursor_loadable(), Some(id0));
1436 assert_eq!(state.advance_cursor_loadable(), Some(id1));
1437 assert_eq!(state.cursor(), Some(id1));
1438 }
1439
1440 #[test]
1441 fn advance_skips_failed_items() {
1442 let state = SharedPlayerState::new();
1443 let item0 = ready_item("track-0");
1444 let item1 = failed_item("track-1");
1445 let item2 = ready_item("track-2");
1446 let (id0, id2) = (item0.id, item2.id);
1447
1448 state.add_items(vec![item0, item1, item2]);
1449 state.set_cursor(Some(id0));
1450
1451 assert_eq!(state.advance_cursor_loadable(), Some(id2));
1452 }
1453
1454 #[test]
1455 fn advance_stops_at_end_of_playlist() {
1456 let state = SharedPlayerState::new();
1457 let item0 = ready_item("track-0");
1458 let item1 = ready_item("track-1");
1459 let id1 = item1.id;
1460
1461 state.add_items(vec![item0, item1]);
1462 state.set_cursor(Some(id1));
1463
1464 assert_eq!(state.advance_cursor_loadable(), None);
1465 assert_eq!(
1466 state.cursor(),
1467 Some(id1),
1468 "cursor unchanged on a failed advance"
1469 );
1470 }
1471
1472 #[test]
1473 fn advance_with_only_failed_items_returns_none() {
1474 let state = SharedPlayerState::new();
1475 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1476
1477 assert_eq!(state.advance_cursor_loadable(), None);
1478 }
1479
1480 #[test]
1481 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1482 let state = SharedPlayerState::new();
1483 let item0 = ready_item("track-0");
1484 let item1 = ready_item("track-1");
1485 let id0 = item0.id;
1486
1487 state.add_items(vec![item0, item1]);
1488 let ghost = QueueItemId::new();
1489 state.set_cursor(Some(ghost));
1490
1491 assert_eq!(state.advance_cursor_loadable(), None);
1492 assert_ne!(state.cursor(), Some(id0));
1493 }
1494
1495 #[test]
1498 fn peek_after_a_removed_item_returns_none() {
1499 let state = SharedPlayerState::new();
1503 let item0 = ready_item("track-0");
1504 let item1 = ready_item("track-1");
1505 let item2 = ready_item("track-2");
1506 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1507
1508 state.add_items(vec![item0, item1, item2]);
1509 assert_eq!(
1510 state.peek_next_ready_after(id1).map(|(id, _)| id),
1511 Some(id2)
1512 );
1513
1514 state.remove_item(id2);
1515 assert!(
1516 state.peek_next_ready_after(id2).is_none(),
1517 "a vanished reference must not resolve to the head of the queue"
1518 );
1519 assert_ne!(
1520 state.peek_next_ready_after(id2).map(|(id, _)| id),
1521 Some(id0)
1522 );
1523 }
1524
1525 #[test]
1528 fn surviving_predecessor_skips_items_being_removed() {
1529 let state = SharedPlayerState::new();
1530 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1531 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1532 state.add_items(items);
1533
1534 assert_eq!(
1536 state.surviving_item_before(ids[3], &ids[1..4]),
1537 Some(ids[0])
1538 );
1539 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1541 }
1542
1543 #[test]
1546 fn test_retreat_cursor_goes_to_previous_item() {
1547 let state = SharedPlayerState::new();
1548 let item0 = ready_item("track-0");
1549 let item1 = ready_item("track-1");
1550 let id0 = item0.id;
1551 let id1 = item1.id;
1552
1553 state.add_items(vec![item0, item1]);
1554 state.set_cursor(Some(id1));
1555
1556 let result = state.retreat_cursor();
1557 assert!(result.is_some(), "expected to retreat to previous item");
1558 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1559 assert_eq!(state.cursor(), Some(id0));
1560 }
1561
1562 #[test]
1563 fn test_retreat_cursor_returns_none_when_at_first_item() {
1564 let state = SharedPlayerState::new();
1565 let item0 = ready_item("only-track");
1566 let id0 = item0.id;
1567
1568 state.add_items(vec![item0]);
1569 state.set_cursor(Some(id0));
1570
1571 let result = state.retreat_cursor();
1572 assert!(result.is_none(), "cannot retreat before the first item");
1573 assert_eq!(state.cursor(), Some(id0));
1575 }
1576
1577 #[test]
1578 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1579 let state = SharedPlayerState::new();
1580 state.add_items(vec![ready_item("track-0")]);
1581
1582 let result = state.retreat_cursor();
1583 assert!(
1584 result.is_none(),
1585 "retreat with no cursor should return None"
1586 );
1587 }
1588
1589 #[test]
1592 fn test_derive_visible_queue_statuses() {
1593 let state = SharedPlayerState::new();
1595 let item0 = ready_item("played-track");
1596 let item1 = ready_item("playing-track");
1597 let item2 = ready_item("queued-track");
1598 let id1 = item1.id;
1599
1600 state.add_items(vec![item0, item1, item2]);
1601 state.set_cursor(Some(id1));
1602
1603 let snap = state.derive_visible_queue();
1604
1605 assert_eq!(snap.entries.len(), 3);
1606 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1607 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1608 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1609 assert!(snap.has_playing);
1610 assert_eq!(snap.finished_count, 1);
1611 assert_eq!(snap.queue_count, 1);
1612 }
1613
1614 #[test]
1615 fn test_derive_visible_queue_downloading_statuses() {
1616 let state = SharedPlayerState::new();
1618 let bytes_cursor = crate::remote::downloads::ByteFeed::new();
1619 let bytes_queued = crate::remote::downloads::ByteFeed::new();
1620 let dl_cursor = downloading_item("downloading-at-cursor", 1_000_000, bytes_cursor.clone());
1621 let dl_queued = downloading_item("downloading-queued", 500_000, bytes_queued.clone());
1622 let id_cursor = dl_cursor.id;
1623
1624 state.add_items(vec![dl_cursor, dl_queued]);
1625 state.set_cursor(Some(id_cursor));
1626
1627 let snap = state.derive_visible_queue();
1628
1629 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1630 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1631 }
1632
1633 #[test]
1634 fn progress_follows_the_counter_without_touching_the_playlist() {
1635 let state = SharedPlayerState::new();
1639 let bytes = crate::remote::downloads::ByteFeed::new();
1640 let item = downloading_item("downloading", 1_000, bytes.clone());
1641 state.add_items(vec![item]);
1642
1643 let version = state.playlist_version();
1644 bytes.set(250);
1645
1646 let snap = state.derive_visible_queue();
1647 assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1648 assert_eq!(
1649 state.playlist_version(),
1650 version,
1651 "progress must not read as a queue mutation"
1652 );
1653 assert!(
1658 state
1659 .downloads_in_flight()
1660 .contains(&(snap.entries[0].id, 250, 1_000)),
1661 "the counter should be visible through the store"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_derive_visible_queue_no_cursor_all_queued() {
1667 let state = SharedPlayerState::new();
1668 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1669
1670 let snap = state.derive_visible_queue();
1671
1672 assert_eq!(snap.entries.len(), 3);
1673 for entry in &snap.entries {
1674 assert_eq!(entry.status, QueueEntryStatus::Queued);
1675 }
1676 assert!(!snap.has_playing);
1677 assert_eq!(snap.finished_count, 0);
1678 assert_eq!(snap.queue_count, 3);
1679 }
1680
1681 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1684 PlaylistItem {
1685 playlist_entry_id: None,
1686 id: QueueItemId::new(),
1687 db_id: None,
1688 path: PathBuf::from(format!("/music/{title}.flac")),
1689 title: title.to_string(),
1690 artist: "Artist".to_string(),
1691 album_artist: album_artist.to_string(),
1692 album: album.to_string(),
1693 year: None,
1694 codec: Some("FLAC".to_string()),
1695 track_number: None,
1696 disc: None,
1697 duration_ms: Some(200_000),
1698 state: ItemState::Ready,
1699 }
1700 }
1701
1702 #[test]
1703 fn test_same_album_item_ids_returns_album_mates() {
1704 let state = SharedPlayerState::new();
1705 let a1 = make_album_item("A1", "Album A", "Artist A");
1706 let a2 = make_album_item("A2", "Album A", "Artist A");
1707 let b1 = make_album_item("B1", "Album B", "Artist B");
1708 let a3 = make_album_item("A3", "Album A", "Artist A");
1709
1710 let id_a1 = a1.id;
1711 let id_a2 = a2.id;
1712 let id_a3 = a3.id;
1713
1714 state.add_items(vec![a1, a2, b1, a3]);
1715
1716 let mates = state.same_album_item_ids(id_a1);
1717 assert_eq!(mates.len(), 2);
1718 assert!(mates.contains(&id_a2));
1719 assert!(mates.contains(&id_a3));
1720 }
1721
1722 #[test]
1723 fn test_same_album_item_ids_distinguishes_album_artists() {
1724 let state = SharedPlayerState::new();
1726 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1727 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1728
1729 let id_a1 = a1.id;
1730
1731 state.add_items(vec![a1, b1]);
1732
1733 let mates = state.same_album_item_ids(id_a1);
1734 assert!(mates.is_empty(), "different album_artist should not match");
1735 }
1736
1737 #[test]
1738 fn test_same_album_item_ids_unknown_id_returns_empty() {
1739 let state = SharedPlayerState::new();
1740 state.add_items(vec![ready_item("track-0")]);
1741
1742 let bogus = QueueItemId::new();
1743 let mates = state.same_album_item_ids(bogus);
1744 assert!(mates.is_empty());
1745 }
1746
1747 #[test]
1750 fn test_update_item_metadata_leaves_library_tags_alone() {
1751 let state = SharedPlayerState::new();
1752 let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
1753 item.db_id = Some(29615);
1754 let id = item.id;
1755 state.add_items(vec![item]);
1756
1757 state.update_item_metadata(
1758 id,
1759 "[unknown]".into(),
1760 "Soulwax".into(),
1761 "Soulwax".into(),
1762 "Nite Versions".into(),
1763 Some(54_000),
1764 );
1765
1766 let pl = state.playlist.read();
1767 assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
1768 assert_eq!(pl.items[0].title, "A1");
1769 assert_eq!(pl.items[0].duration_ms, Some(54_000));
1770 }
1771
1772 #[test]
1773 fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
1774 let state = SharedPlayerState::new();
1775 let item = make_album_item("A1", "", "");
1776 let id = item.id;
1777 state.add_items(vec![item]);
1778
1779 state.update_item_metadata(
1780 id,
1781 "Teachers".into(),
1782 "Soulwax".into(),
1783 "Soulwax".into(),
1784 "Nite Versions".into(),
1785 Some(148_000),
1786 );
1787
1788 let pl = state.playlist.read();
1789 assert_eq!(pl.items[0].title, "Teachers");
1790 assert_eq!(pl.items[0].album, "Nite Versions");
1791 assert_eq!(pl.items[0].duration_ms, Some(148_000));
1792 }
1793
1794 #[test]
1797 fn test_move_item_to_reorders_playlist() {
1798 let state = SharedPlayerState::new();
1800 let item_a = ready_item("A");
1801 let item_b = ready_item("B");
1802 let item_c = ready_item("C");
1803 let id_a = item_a.id;
1804 let id_b = item_b.id;
1805 let id_c = item_c.id;
1806
1807 state.add_items(vec![item_a, item_b, item_c]);
1808 state.move_item_to(id_c, Some(id_a));
1809
1810 let (items, _) = state.snapshot_playlist();
1811 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1812 assert_eq!(titles, vec!["A", "C", "B"]);
1813 assert_eq!(items[0].id, id_a);
1814 assert_eq!(items[1].id, id_c);
1815 assert_eq!(items[2].id, id_b);
1816 }
1817
1818 #[test]
1819 fn test_move_item_to_front_when_after_is_none() {
1820 let state = SharedPlayerState::new();
1822 let item_a = ready_item("A");
1823 let item_b = ready_item("B");
1824 let item_c = ready_item("C");
1825 let id_c = item_c.id;
1826
1827 state.add_items(vec![item_a, item_b, item_c]);
1828 state.move_item_to(id_c, None);
1829
1830 let (items, _) = state.snapshot_playlist();
1831 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1832 assert_eq!(titles, vec!["C", "A", "B"]);
1833 }
1834
1835 #[test]
1838 fn test_move_items_batch_preserves_relative_order() {
1839 let state = SharedPlayerState::new();
1841 let item_a = ready_item("A");
1842 let item_b = ready_item("B");
1843 let item_c = ready_item("C");
1844 let item_d = ready_item("D");
1845 let id_a = item_a.id;
1846 let id_b = item_b.id;
1847 let id_c = item_c.id;
1848 let id_d = item_d.id;
1849
1850 state.add_items(vec![item_a, item_b, item_c, item_d]);
1851 state.move_items(&[id_a, id_c], id_d, true);
1852
1853 let (items, _) = state.snapshot_playlist();
1854 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1855 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1856 assert_eq!(items[0].id, id_b);
1857 assert_eq!(items[1].id, id_d);
1858 assert_eq!(items[2].id, id_a);
1859 assert_eq!(items[3].id, id_c);
1860 }
1861
1862 #[test]
1865 fn test_pending_downloads_collects_pending_with_db_id() {
1866 let state = SharedPlayerState::new();
1867 let mut item_a = ready_item("local");
1868 item_a.db_id = None;
1869
1870 let mut item_b = pending_item("remote-1");
1871 item_b.db_id = Some(10);
1872 let id_b = item_b.id;
1873
1874 let mut item_c = ready_item("cached");
1875 item_c.db_id = Some(20);
1876
1877 let mut item_d = pending_item("remote-2");
1878 item_d.db_id = Some(30);
1879 let id_d = item_d.id;
1880
1881 let item_e = pending_item("orphan");
1883
1884 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1885
1886 let pending = state.pending_downloads();
1887 assert_eq!(pending.len(), 2);
1888 assert_eq!(pending[0], (10, id_b));
1889 assert_eq!(pending[1], (30, id_d));
1890 }
1891
1892 #[test]
1893 fn test_item_db_id_and_load_state() {
1894 let state = SharedPlayerState::new();
1895 let mut item = pending_item("track");
1896 item.db_id = Some(42);
1897 let id = item.id;
1898 state.add_items(vec![item]);
1899
1900 assert_eq!(state.item_db_id(id), Some(42));
1901 assert!(matches!(
1902 state.item_load_state(id),
1903 Some(LoadState::Pending)
1904 ));
1905
1906 state.update_item_state(id, ItemState::Ready);
1907 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1908 }
1909}