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,
257 timeline: std::sync::OnceLock<Arc<crate::audio::buffer::PlaybackTimeline>>,
258 track_info: parking_lot::RwLock<Option<TrackInfo>>,
259
260 playlist: parking_lot::RwLock<Playlist>,
262
263 playlist_version: AtomicU64,
265
266 content_version: AtomicU64,
270
271 quit_requested: AtomicBool,
273
274 metadata_refresh_pending: AtomicBool,
277
278 output_sample_rate: AtomicU64,
285}
286
287impl SharedPlayerState {
288 pub fn new() -> Arc<Self> {
289 Arc::new(Self {
290 state: AtomicU8::new(PlaybackState::Stopped as u8),
291 position_ms: AtomicU64::new(0),
292 timeline: std::sync::OnceLock::new(),
293 track_info: parking_lot::RwLock::new(None),
294 playlist: parking_lot::RwLock::new(Playlist::default()),
295 playlist_version: AtomicU64::new(0),
296 content_version: AtomicU64::new(0),
297 quit_requested: AtomicBool::new(false),
298 metadata_refresh_pending: AtomicBool::new(false),
299 output_sample_rate: AtomicU64::new(0),
300 })
301 }
302
303 pub fn playback_state(&self) -> PlaybackState {
306 PlaybackState::from_u8(self.state.load(Ordering::Acquire))
307 }
308
309 pub fn set_playback_state(&self, state: PlaybackState) {
310 self.state.store(state as u8, Ordering::Release);
311 self.changed();
312 }
313
314 pub fn position_ms(&self) -> u64 {
317 if self.playback_state() != PlaybackState::Stopped
318 && let Some((_, at)) = self.timeline.get().and_then(|t| t.playhead())
319 {
320 return at;
321 }
322 self.position_ms.load(Ordering::Acquire)
323 }
324
325 pub(crate) fn attach_timeline(&self, timeline: Arc<crate::audio::buffer::PlaybackTimeline>) {
327 let _ = self.timeline.set(timeline);
328 }
329
330 pub fn set_position_ms(&self, pos: u64) {
331 self.position_ms.store(pos, Ordering::Release);
332 }
338
339 pub fn track_info(&self) -> Option<TrackInfo> {
340 self.track_info.read().clone()
341 }
342
343 pub fn set_track_info(&self, info: Option<TrackInfo>) {
344 *self.track_info.write() = info;
345 self.changed();
346 }
347
348 pub fn seekable_ms(&self) -> u64 {
359 let Some(info) = self.track_info.read().clone() else {
360 return 0;
361 };
362
363 let pl = self.playlist.read();
366 let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
367 return info.duration_ms;
368 };
369
370 let LoadState::Downloading {
371 total,
372 bytes_written,
373 ..
374 } = LoadState::of(item)
375 else {
376 return info.duration_ms;
377 };
378
379 if info.duration_ms == 0 {
384 return 0;
385 }
386
387 let written = bytes_written.load(Ordering::Acquire);
388 let reached = if total > 0 && info.duration_ms > 0 {
389 ((written as f64 / total as f64) * info.duration_ms as f64) as u64
390 } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
391 written.saturating_mul(8) / kbps as u64
395 } else {
396 return self.position_ms();
399 };
400
401 reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
402 }
403
404 pub fn duration_ms(&self) -> u64 {
412 let Some(info) = self.track_info.read().clone() else {
413 return 0;
414 };
415 if info.duration_ms > 0 {
416 return info.duration_ms;
417 }
418 self.playlist
420 .read()
421 .items
422 .iter()
423 .find(|item| item.id == info.id)
424 .and_then(|item| item.duration_ms)
425 .unwrap_or(0)
426 }
427
428 pub fn seek_ceiling_ms(&self) -> Option<u64> {
432 let duration = self.duration_ms();
433 if duration == 0 {
434 return None;
435 }
436 let seekable = self.seekable_ms();
437 (seekable < duration).then_some(seekable)
438 }
439
440 pub fn current_download_fraction(&self) -> Option<f64> {
443 let id = self.track_info.read().as_ref()?.id;
446 let pl = self.playlist.read();
447 pl.items
448 .iter()
449 .find(|item| item.id == id)
450 .and_then(|item| match LoadState::of(item) {
451 LoadState::Downloading {
452 bytes_written,
453 total,
454 ..
455 } => {
456 let written = bytes_written.load(Ordering::Acquire);
457 (total > 0).then(|| (written as f64 / total as f64).min(1.0))
458 }
459 _ => None,
460 })
461 }
462
463 pub fn request_quit(&self) {
466 self.quit_requested.store(true, Ordering::Release);
467 }
468
469 pub fn quit_requested(&self) -> bool {
470 self.quit_requested.load(Ordering::Acquire)
471 }
472
473 pub fn signal_metadata_refresh(&self) {
479 self.metadata_refresh_pending.store(true, Ordering::Release);
480 self.changed();
481 }
482
483 pub fn take_metadata_refresh(&self) -> bool {
485 self.metadata_refresh_pending
486 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
487 .is_ok()
488 }
489
490 pub fn output_sample_rate(&self) -> Option<u32> {
494 match self.output_sample_rate.load(Ordering::Acquire) {
495 0 => None,
496 rate => Some(rate as u32),
497 }
498 }
499
500 pub fn set_output_sample_rate(&self, rate: u32) {
501 self.output_sample_rate
502 .store(u64::from(rate), Ordering::Release);
503 self.changed();
504 }
505
506 pub fn clear_output_sample_rate(&self) {
511 self.output_sample_rate.store(0, Ordering::Release);
512 }
513
514 pub fn playlist_version(&self) -> u64 {
517 self.playlist_version.load(Ordering::Acquire)
518 }
519
520 fn bump_version(&self) {
521 self.playlist_version.fetch_add(1, Ordering::AcqRel);
522 self.changed();
523 }
524
525 pub fn content_version(&self) -> u64 {
526 self.content_version.load(Ordering::Acquire)
527 }
528
529 fn bump_content(&self) {
531 self.content_version.fetch_add(1, Ordering::AcqRel);
532 self.bump_version();
533 }
534
535 pub fn changed(&self) {
541 crate::signal::engine_changed().bump();
542 }
543
544 pub fn add_items(&self, items: Vec<PlaylistItem>) {
548 let mut pl = self.playlist.write();
549 pl.items.extend(items);
550 drop(pl);
551 self.bump_content();
552 }
553
554 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
556 let mut pl = self.playlist.write();
557 let insert_at = match pl.items.iter().position(|item| item.id == after) {
558 Some(pos) => pos + 1,
559 None => pl.items.len(), };
561 for (i, item) in items.into_iter().enumerate() {
562 pl.items.insert(insert_at + i, item);
563 }
564 drop(pl);
565 self.bump_content();
566 }
567
568 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
570 let mut pl = self.playlist.write();
571 for (id, new_path) in updates {
572 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
573 item.path = new_path.clone();
574 }
575 }
576 drop(pl);
577 self.bump_content();
578 }
579
580 pub fn remove_item(&self, id: QueueItemId) {
582 let mut pl = self.playlist.write();
583 pl.items.retain(|item| item.id != id);
584 if pl.cursor == Some(id) {
586 pl.cursor = None;
587 }
588 drop(pl);
589 self.bump_content();
590 }
591
592 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
594 let mut pl = self.playlist.write();
595 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
596 return;
597 };
598 let item = pl.items.remove(from);
599 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
600 let pos = from.min(pl.items.len());
602 pl.items.insert(pos, item);
603 return;
604 };
605 let insert_at = if after { to + 1 } else { to };
606 pl.items.insert(insert_at, item);
607 drop(pl);
608 self.bump_content();
609 }
610
611 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
614 use std::collections::HashSet;
615 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
616
617 let mut pl = self.playlist.write();
618
619 let mut remaining = Vec::with_capacity(pl.items.len());
621 let mut moved = Vec::with_capacity(ids.len());
622 for item in pl.items.drain(..) {
623 if id_set.contains(&item.id) {
624 moved.push(item);
625 } else {
626 remaining.push(item);
627 }
628 }
629
630 let insert_at = match remaining.iter().position(|item| item.id == target) {
632 Some(pos) => {
633 if after {
634 pos + 1
635 } else {
636 pos
637 }
638 }
639 None => remaining.len(),
640 };
641
642 for (i, item) in moved.into_iter().enumerate() {
644 remaining.insert(insert_at + i, item);
645 }
646
647 pl.items = remaining;
648 drop(pl);
649 self.bump_content();
650 }
651
652 pub fn set_cursor(&self, id: Option<QueueItemId>) {
654 let mut pl = self.playlist.write();
655 pl.cursor = id;
656 drop(pl);
657 self.bump_version();
658 }
659
660 pub fn cursor(&self) -> Option<QueueItemId> {
661 self.playlist.read().cursor
662 }
663
664 pub fn cursor_path(&self) -> Option<PathBuf> {
666 let pl = self.playlist.read();
667 let cursor = pl.cursor?;
668 pl.items
669 .iter()
670 .find(|item| item.id == cursor)
671 .map(|item| item.path.clone())
672 }
673
674 pub fn clear_playlist(&self) {
676 let mut pl = self.playlist.write();
677 pl.items.clear();
678 pl.cursor = None;
679 drop(pl);
680 self.bump_content();
681 }
682
683 pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
696 let mut pl = self.playlist.write();
697 let start = match pl.cursor {
698 Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
699 None => 0,
700 };
701
702 let next = pl
703 .items
704 .get(start..)?
705 .iter()
706 .find(|item| !matches!(item.state, ItemState::Failed(_)))
707 .map(|item| item.id)?;
708
709 pl.cursor = Some(next);
710 drop(pl);
711 self.bump_version();
712 Some(next)
713 }
714
715 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
719 let pl = self.playlist.read();
720 let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
723
724 for i in start..pl.items.len() {
725 if matches!(pl.items[i].state, ItemState::Ready) {
726 let item = &pl.items[i];
727 return Some((item.id, item.path.clone()));
728 }
729 }
730 None
731 }
732
733 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
736 let mut pl = self.playlist.write();
737 let cursor_pos = match pl.cursor {
738 Some(cid) => pl.items.iter().position(|item| item.id == cid),
739 None => None,
740 };
741
742 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
743
744 match prev_pos {
745 Some(pos) => {
746 let item = &pl.items[pos];
747 let result = (item.id, item.path.clone());
748 pl.cursor = Some(item.id);
749 drop(pl);
750 self.bump_version();
751 Some(result)
752 }
753 None => None,
754 }
755 }
756
757 pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
761 let mut pl = self.playlist.write();
762 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
763 item.state = new_state;
764 }
765 drop(pl);
766 self.bump_version();
767 }
768
769 pub fn update_item_metadata(
779 &self,
780 id: QueueItemId,
781 title: String,
782 artist: String,
783 album_artist: String,
784 album: String,
785 duration_ms: Option<u64>,
786 ) {
787 let mut pl = self.playlist.write();
788 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
789 if item.db_id.is_none() {
790 item.title = title;
791 item.artist = artist;
792 item.album_artist = album_artist;
793 item.album = album;
794 }
795 if let Some(dur) = duration_ms {
796 item.duration_ms = Some(dur);
797 }
798 }
799 drop(pl);
800 self.bump_content();
801 }
802
803 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
806 let pl = self.playlist.read();
807 pl.items
808 .iter()
809 .find(|item| item.id == id)
810 .and_then(|item| match LoadState::of(item) {
811 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
812 LoadState::Downloading {
813 path,
814 total,
815 bytes_written,
816 } => {
817 let written = bytes_written.load(Ordering::Acquire);
818 (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
819 path,
820 bytes_written,
821 total,
822 })
823 }
824 _ => None,
825 })
826 }
827
828 pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
838 let mut pl = self.playlist.write();
839 let mut reset = Vec::new();
840 for item in pl.items.iter_mut() {
841 let Some(db_id) = item.db_id else { continue };
842 if !matches!(item.state, ItemState::Ready) {
843 continue;
844 }
845 if item.path.exists() {
846 continue;
847 }
848 item.state = ItemState::Pending;
849 reset.push((db_id, item.id));
850 }
851 drop(pl);
852 if !reset.is_empty() {
853 self.bump_version();
854 }
855 reset
856 }
857
858 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
860 let pl = self.playlist.read();
861 pl.items.iter().find(|item| item.id == id).and_then(|item| {
862 if matches!(item.state, ItemState::Ready) {
863 Some(item.path.clone())
864 } else {
865 None
866 }
867 })
868 }
869
870 pub fn is_cursor(&self, id: QueueItemId) -> bool {
871 self.playlist.read().cursor == Some(id)
872 }
873
874 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
878 let pl = self.playlist.read();
879 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
880 return vec![];
881 };
882 let album = cursor.album.clone();
883 let album_artist = cursor.album_artist.clone();
884 pl.items
885 .iter()
886 .filter(|item| {
887 item.id != id && item.album == album && item.album_artist == album_artist
888 })
889 .map(|item| item.id)
890 .collect()
891 }
892
893 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
896 let pl = self.playlist.read();
897 pl.items
898 .iter()
899 .filter(|item| matches!(item.state, ItemState::Pending))
900 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
901 .collect()
902 }
903
904 pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
910 crate::remote::downloads::store()
911 .all()
912 .iter()
913 .filter(|d| !d.state.is_settled())
914 .map(|d| (d.id, d.bytes_written(), d.total))
915 .collect()
916 }
917
918 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
920 let pl = self.playlist.read();
921 pl.items
922 .iter()
923 .find(|item| item.id == id)
924 .and_then(|item| item.db_id)
925 }
926
927 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
929 let pl = self.playlist.read();
930 pl.items
931 .iter()
932 .find(|item| item.id == id)
933 .map(LoadState::of)
934 }
935
936 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
940 let pl = self.playlist.read();
941 (pl.items.clone(), pl.cursor)
942 }
943
944 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
946 let pl = self.playlist.read();
947 pl.items.iter().find(|item| item.id == id).cloned()
948 }
949
950 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
952 let pl = self.playlist.read();
953 let pos = pl.items.iter().position(|item| item.id == id)?;
954 if pos == 0 {
955 None
956 } else {
957 Some(pl.items[pos - 1].id)
958 }
959 }
960
961 pub fn reorder_to(&self, order: &[QueueItemId]) {
968 let mut pl = self.playlist.write();
969 let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
970 let mut sorted = Vec::with_capacity(taken.len());
971 for id in order {
972 if let Some(slot) = taken
973 .iter_mut()
974 .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
975 && let Some(item) = slot.take()
976 {
977 sorted.push(item);
978 }
979 }
980 sorted.extend(taken.into_iter().flatten());
981 pl.items = sorted;
982 drop(pl);
983 self.bump_content();
984 }
985
986 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
993 use std::collections::HashSet;
994 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
995 let pl = self.playlist.read();
996 pl.items
997 .iter()
998 .enumerate()
999 .filter(|(_, item)| wanted.contains(&item.id))
1000 .map(|(pos, item)| {
1001 let before = if pos == 0 {
1002 None
1003 } else {
1004 Some(pl.items[pos - 1].id)
1005 };
1006 (item.id, before)
1007 })
1008 .collect()
1009 }
1010
1011 pub fn surviving_item_before(
1015 &self,
1016 id: QueueItemId,
1017 removed: &[QueueItemId],
1018 ) -> Option<QueueItemId> {
1019 use std::collections::HashSet;
1020 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
1021 let pl = self.playlist.read();
1022 let pos = pl.items.iter().position(|item| item.id == id)?;
1023 pl.items[..pos]
1024 .iter()
1025 .rev()
1026 .find(|item| !removed.contains(&item.id))
1027 .map(|item| item.id)
1028 }
1029
1030 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
1032 let mut pl = self.playlist.write();
1033 pl.items = items;
1034 pl.cursor = cursor;
1035 drop(pl);
1036 self.bump_content();
1037 }
1038
1039 pub fn remove_items(&self, ids: &[QueueItemId]) {
1041 use std::collections::HashSet;
1042 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
1043 let mut pl = self.playlist.write();
1044 pl.items.retain(|item| !id_set.contains(&item.id));
1045 if let Some(cursor) = pl.cursor
1046 && id_set.contains(&cursor)
1047 {
1048 pl.cursor = None;
1049 }
1050 drop(pl);
1051 self.bump_content();
1052 }
1053
1054 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1056 let mut pl = self.playlist.write();
1057 let insert_at = match after {
1058 Some(after_id) => {
1059 match pl.items.iter().position(|i| i.id == after_id) {
1060 Some(pos) => pos + 1,
1061 None => pl.items.len(), }
1063 }
1064 None => 0,
1065 };
1066 pl.items.insert(insert_at, item);
1067 drop(pl);
1068 self.bump_content();
1069 }
1070
1071 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1073 let mut pl = self.playlist.write();
1074 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1075 return;
1076 };
1077 let item = pl.items.remove(from);
1078 let insert_at = match after {
1079 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1080 Some(pos) => pos + 1,
1081 None => pl.items.len(),
1082 },
1083 None => 0,
1084 };
1085 pl.items.insert(insert_at, item);
1086 drop(pl);
1087 self.bump_content();
1088 }
1089
1090 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1093 for &(id, after) in entries {
1094 self.move_item_to(id, after);
1095 }
1096 }
1097
1098 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1103 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1105 let pl = self.playlist.read();
1106
1107 let cursor_pos = match pl.cursor {
1108 Some(cid) => pl.items.iter().position(|item| item.id == cid),
1109 None => None,
1110 };
1111
1112 let mut entries = Vec::with_capacity(pl.items.len());
1113 let mut finished_count = 0;
1114 let mut has_playing = false;
1115 let mut queue_count = 0;
1116
1117 for (i, item) in pl.items.iter().enumerate() {
1118 let is_cursor = cursor_pos == Some(i);
1119 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1120
1121 let load_state = LoadState::of(item);
1126
1127 let dl_progress = match &load_state {
1128 LoadState::Downloading {
1129 total,
1130 bytes_written,
1131 ..
1132 } => Some((bytes_written.load(Ordering::Relaxed), *total)),
1133 _ => None,
1134 };
1135
1136 let status = if is_cursor {
1137 has_playing = true;
1138 match &load_state {
1139 LoadState::Ready => QueueEntryStatus::Playing,
1140 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
1141 LoadState::Pending => QueueEntryStatus::PriorityPending,
1142 LoadState::Failed(_) => QueueEntryStatus::Failed,
1143 }
1144 } else if is_before_cursor {
1145 finished_count += 1;
1146 match &load_state {
1147 LoadState::Ready => QueueEntryStatus::Played,
1148 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1149 LoadState::Pending => QueueEntryStatus::Downloading,
1150 LoadState::Failed(_) => QueueEntryStatus::Failed,
1151 }
1152 } else {
1153 queue_count += 1;
1154 match &load_state {
1155 LoadState::Ready => QueueEntryStatus::Queued,
1156 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1157 LoadState::Pending => QueueEntryStatus::Queued,
1160 LoadState::Failed(_) => QueueEntryStatus::Failed,
1161 }
1162 };
1163
1164 let duration_ms =
1166 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1167 {
1168 playing_duration_ms
1169 } else {
1170 item.duration_ms
1171 };
1172
1173 entries.push(QueueEntry {
1174 id: item.id,
1175 db_id: item.db_id,
1176 playlist_entry_id: item.playlist_entry_id,
1177 path: item.path.clone(),
1178 title: item.title.clone(),
1179 artist: item.artist.clone(),
1180 album_artist: item.album_artist.clone(),
1181 album: item.album.clone(),
1182 year: item.year.clone(),
1183 codec: item.codec.clone(),
1184 track_number: item.track_number,
1185 disc: item.disc,
1186 duration_ms,
1187 status,
1188 download_progress: dl_progress,
1189 error: match &load_state {
1190 LoadState::Failed(reason) => Some(reason.clone()),
1191 _ => None,
1192 },
1193 });
1194 }
1195
1196 VisibleQueueSnapshot {
1197 entries,
1198 finished_count,
1199 has_playing,
1200 queue_count,
1201 }
1202 }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208
1209 fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1212 PlaylistItem {
1213 playlist_entry_id: None,
1214 id: QueueItemId::new(),
1215 db_id: None,
1216 path: PathBuf::from(format!("/music/{title}.flac")),
1217 title: title.to_string(),
1218 artist: "Artist".to_string(),
1219 album_artist: "Artist".to_string(),
1220 album: "Album".to_string(),
1221 year: None,
1222 codec: Some("FLAC".to_string()),
1223 track_number: None,
1224 disc: None,
1225 duration_ms: Some(200_000),
1226 state,
1227 }
1228 }
1229
1230 fn downloading_item(
1233 title: &str,
1234 total: u64,
1235 written: Arc<crate::remote::downloads::ByteFeed>,
1236 ) -> PlaylistItem {
1237 let item = make_item(title, ItemState::Pending);
1238 let store = crate::remote::downloads::store();
1239 store.queued(crate::remote::downloads::Download {
1240 id: item.id,
1241 track_id: 1,
1242 title: title.into(),
1243 artist: String::new(),
1244 source: PathBuf::from(format!("/cache/{title}.flac.part")),
1245 dest: PathBuf::from(format!("/cache/{title}.flac")),
1246 total,
1247 written: written.clone(),
1248 state: crate::remote::downloads::DownloadState::Queued,
1249 bytes_per_second: 0,
1250 });
1251 store.started(item.id, total, written);
1252 item
1253 }
1254
1255 fn ready_item(title: &str) -> PlaylistItem {
1256 make_item(title, ItemState::Ready)
1257 }
1258
1259 fn pending_item(title: &str) -> PlaylistItem {
1260 make_item(title, ItemState::Pending)
1261 }
1262
1263 fn failed_item(title: &str) -> PlaylistItem {
1264 make_item(title, ItemState::Failed("nope".into()))
1265 }
1266
1267 const DURATION_MS: u64 = 32_523_787;
1268
1269 fn streaming_state(
1272 downloaded: u64,
1273 total: u64,
1274 bitrate_kbps: Option<u32>,
1275 ) -> Arc<SharedPlayerState> {
1276 streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1277 }
1278
1279 fn streaming_state_with_duration(
1282 downloaded: u64,
1283 total: u64,
1284 bitrate_kbps: Option<u32>,
1285 container_duration_ms: u64,
1286 ) -> Arc<SharedPlayerState> {
1287 let written = crate::remote::downloads::ByteFeed::new();
1288 written.set(downloaded);
1289 let item = make_item("train", ItemState::Pending);
1290 let id = item.id;
1291 let path = item.path.clone();
1292
1293 let store = crate::remote::downloads::store();
1296 store.queued(crate::remote::downloads::Download {
1297 id,
1298 track_id: 1,
1299 title: "train".into(),
1300 artist: String::new(),
1301 source: PathBuf::from("/cache/train.opus.part"),
1302 dest: PathBuf::from("/cache/train.opus"),
1303 total,
1304 written: written.clone(),
1305 state: crate::remote::downloads::DownloadState::Queued,
1306 bytes_per_second: 0,
1307 });
1308 store.started(id, total, written);
1309
1310 let state = SharedPlayerState::new();
1311 state.add_items(vec![item]);
1312 state.set_cursor(Some(id));
1313 state.set_track_info(Some(TrackInfo {
1314 id,
1315 path,
1316 codec: "Opus".into(),
1317 sample_rate: 48_000,
1318 bit_depth: None,
1319 bitrate_kbps,
1320 channels: 2,
1321 duration_ms: container_duration_ms,
1322 }));
1323 state
1324 }
1325
1326 #[test]
1329 fn a_track_on_disk_is_seekable_end_to_end() {
1330 let item = ready_item("done");
1331 let id = item.id;
1332 let path = item.path.clone();
1333 let state = SharedPlayerState::new();
1334 state.add_items(vec![item]);
1335 state.set_cursor(Some(id));
1336 state.set_track_info(Some(TrackInfo {
1337 id,
1338 path,
1339 codec: "FLAC".into(),
1340 sample_rate: 44_100,
1341 bit_depth: Some(16),
1342 bitrate_kbps: None,
1343 channels: 2,
1344 duration_ms: 200_000,
1345 }));
1346
1347 assert_eq!(state.seekable_ms(), 200_000);
1348 assert_eq!(state.seek_ceiling_ms(), None);
1350 }
1351
1352 #[test]
1353 fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1354 let state = streaming_state(100, 400, None);
1357 assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1358 assert_eq!(
1359 state.seek_ceiling_ms(),
1360 Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1361 );
1362 }
1363
1364 #[test]
1365 fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1366 let state = streaming_state(1024 * 1024, 0, Some(128));
1369 assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1370 }
1371
1372 #[test]
1373 fn nothing_to_estimate_from_allows_no_forward_seek() {
1374 let state = streaming_state(1024 * 1024, 0, None);
1377 state.set_position_ms(12_000);
1378 assert_eq!(state.seekable_ms(), 12_000);
1379 }
1380
1381 #[test]
1382 fn the_seekable_extent_never_exceeds_the_track() {
1383 let state = streaming_state(500, 400, None);
1386 assert_eq!(state.seekable_ms(), 32_523_787);
1387 }
1388
1389 #[test]
1390 fn nothing_playing_is_seekable_nowhere() {
1391 assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1392 assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1393 }
1394
1395 #[test]
1396 fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1397 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1401 assert_eq!(state.seekable_ms(), 0);
1402 }
1403
1404 #[test]
1405 fn the_library_duration_stands_in_for_a_silent_container() {
1406 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1409 assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1410 assert_eq!(state.seekable_ms(), 0);
1412 assert_eq!(state.seek_ceiling_ms(), Some(0));
1413 }
1414
1415 #[test]
1416 fn the_container_duration_wins_where_there_is_one() {
1417 let state = streaming_state(200, 400, None);
1418 assert_eq!(state.duration_ms(), DURATION_MS);
1419 }
1420
1421 #[test]
1422 fn the_download_landing_restores_seeking() {
1423 let state = streaming_state_with_duration(400, 400, Some(128), 0);
1427 assert_eq!(state.seekable_ms(), 0);
1428
1429 let id = state.cursor().expect("cursor");
1433 crate::remote::downloads::store().finished(id);
1434 state.update_item_state(id, ItemState::Ready);
1435 let info = state.track_info().expect("track info");
1436 state.set_track_info(Some(TrackInfo {
1437 duration_ms: DURATION_MS,
1438 ..info
1439 }));
1440
1441 assert_eq!(state.seekable_ms(), DURATION_MS);
1442 assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1443 }
1444
1445 #[test]
1448 fn advance_parks_on_a_still_downloading_track() {
1449 let state = SharedPlayerState::new();
1453 let item0 = ready_item("track-0");
1454 let item1 = pending_item("track-1");
1455 let item2 = ready_item("track-2");
1456 let (id0, id1) = (item0.id, item1.id);
1457
1458 state.add_items(vec![item0, item1, item2]);
1459
1460 assert_eq!(state.advance_cursor_loadable(), Some(id0));
1461 assert_eq!(state.advance_cursor_loadable(), Some(id1));
1462 assert_eq!(state.cursor(), Some(id1));
1463 }
1464
1465 #[test]
1466 fn advance_skips_failed_items() {
1467 let state = SharedPlayerState::new();
1468 let item0 = ready_item("track-0");
1469 let item1 = failed_item("track-1");
1470 let item2 = ready_item("track-2");
1471 let (id0, id2) = (item0.id, item2.id);
1472
1473 state.add_items(vec![item0, item1, item2]);
1474 state.set_cursor(Some(id0));
1475
1476 assert_eq!(state.advance_cursor_loadable(), Some(id2));
1477 }
1478
1479 #[test]
1480 fn advance_stops_at_end_of_playlist() {
1481 let state = SharedPlayerState::new();
1482 let item0 = ready_item("track-0");
1483 let item1 = ready_item("track-1");
1484 let id1 = item1.id;
1485
1486 state.add_items(vec![item0, item1]);
1487 state.set_cursor(Some(id1));
1488
1489 assert_eq!(state.advance_cursor_loadable(), None);
1490 assert_eq!(
1491 state.cursor(),
1492 Some(id1),
1493 "cursor unchanged on a failed advance"
1494 );
1495 }
1496
1497 #[test]
1498 fn advance_with_only_failed_items_returns_none() {
1499 let state = SharedPlayerState::new();
1500 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1501
1502 assert_eq!(state.advance_cursor_loadable(), None);
1503 }
1504
1505 #[test]
1506 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1507 let state = SharedPlayerState::new();
1508 let item0 = ready_item("track-0");
1509 let item1 = ready_item("track-1");
1510 let id0 = item0.id;
1511
1512 state.add_items(vec![item0, item1]);
1513 let ghost = QueueItemId::new();
1514 state.set_cursor(Some(ghost));
1515
1516 assert_eq!(state.advance_cursor_loadable(), None);
1517 assert_ne!(state.cursor(), Some(id0));
1518 }
1519
1520 #[test]
1523 fn peek_after_a_removed_item_returns_none() {
1524 let state = SharedPlayerState::new();
1528 let item0 = ready_item("track-0");
1529 let item1 = ready_item("track-1");
1530 let item2 = ready_item("track-2");
1531 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1532
1533 state.add_items(vec![item0, item1, item2]);
1534 assert_eq!(
1535 state.peek_next_ready_after(id1).map(|(id, _)| id),
1536 Some(id2)
1537 );
1538
1539 state.remove_item(id2);
1540 assert!(
1541 state.peek_next_ready_after(id2).is_none(),
1542 "a vanished reference must not resolve to the head of the queue"
1543 );
1544 assert_ne!(
1545 state.peek_next_ready_after(id2).map(|(id, _)| id),
1546 Some(id0)
1547 );
1548 }
1549
1550 #[test]
1553 fn surviving_predecessor_skips_items_being_removed() {
1554 let state = SharedPlayerState::new();
1555 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1556 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1557 state.add_items(items);
1558
1559 assert_eq!(
1561 state.surviving_item_before(ids[3], &ids[1..4]),
1562 Some(ids[0])
1563 );
1564 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1566 }
1567
1568 #[test]
1571 fn test_retreat_cursor_goes_to_previous_item() {
1572 let state = SharedPlayerState::new();
1573 let item0 = ready_item("track-0");
1574 let item1 = ready_item("track-1");
1575 let id0 = item0.id;
1576 let id1 = item1.id;
1577
1578 state.add_items(vec![item0, item1]);
1579 state.set_cursor(Some(id1));
1580
1581 let result = state.retreat_cursor();
1582 assert!(result.is_some(), "expected to retreat to previous item");
1583 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1584 assert_eq!(state.cursor(), Some(id0));
1585 }
1586
1587 #[test]
1588 fn test_retreat_cursor_returns_none_when_at_first_item() {
1589 let state = SharedPlayerState::new();
1590 let item0 = ready_item("only-track");
1591 let id0 = item0.id;
1592
1593 state.add_items(vec![item0]);
1594 state.set_cursor(Some(id0));
1595
1596 let result = state.retreat_cursor();
1597 assert!(result.is_none(), "cannot retreat before the first item");
1598 assert_eq!(state.cursor(), Some(id0));
1600 }
1601
1602 #[test]
1603 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1604 let state = SharedPlayerState::new();
1605 state.add_items(vec![ready_item("track-0")]);
1606
1607 let result = state.retreat_cursor();
1608 assert!(
1609 result.is_none(),
1610 "retreat with no cursor should return None"
1611 );
1612 }
1613
1614 #[test]
1617 fn test_derive_visible_queue_statuses() {
1618 let state = SharedPlayerState::new();
1620 let item0 = ready_item("played-track");
1621 let item1 = ready_item("playing-track");
1622 let item2 = ready_item("queued-track");
1623 let id1 = item1.id;
1624
1625 state.add_items(vec![item0, item1, item2]);
1626 state.set_cursor(Some(id1));
1627
1628 let snap = state.derive_visible_queue();
1629
1630 assert_eq!(snap.entries.len(), 3);
1631 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1632 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1633 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1634 assert!(snap.has_playing);
1635 assert_eq!(snap.finished_count, 1);
1636 assert_eq!(snap.queue_count, 1);
1637 }
1638
1639 #[test]
1640 fn test_derive_visible_queue_downloading_statuses() {
1641 let state = SharedPlayerState::new();
1643 let bytes_cursor = crate::remote::downloads::ByteFeed::new();
1644 let bytes_queued = crate::remote::downloads::ByteFeed::new();
1645 let dl_cursor = downloading_item("downloading-at-cursor", 1_000_000, bytes_cursor.clone());
1646 let dl_queued = downloading_item("downloading-queued", 500_000, bytes_queued.clone());
1647 let id_cursor = dl_cursor.id;
1648
1649 state.add_items(vec![dl_cursor, dl_queued]);
1650 state.set_cursor(Some(id_cursor));
1651
1652 let snap = state.derive_visible_queue();
1653
1654 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1655 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1656 }
1657
1658 #[test]
1661 fn only_a_change_to_what_is_saved_moves_the_content_version() {
1662 let state = SharedPlayerState::new();
1663 let a = make_item("a", ItemState::Pending);
1664 let b = make_item("b", ItemState::Ready);
1665 let (a_id, b_id) = (a.id, b.id);
1666
1667 let start = state.content_version();
1668 state.add_items(vec![a, b]);
1669 let added = state.content_version();
1670 assert_ne!(added, start);
1671
1672 state.set_cursor(Some(a_id));
1673 state.update_item_state(a_id, ItemState::Ready);
1674 state.advance_cursor_loadable();
1675 state.retreat_cursor();
1676 assert_eq!(state.content_version(), added);
1677 assert_eq!(state.cursor_path(), Some(PathBuf::from("/music/a.flac")));
1678
1679 state.move_item_to(b_id, None);
1680 assert_ne!(state.content_version(), added);
1681 }
1682
1683 #[test]
1684 fn progress_follows_the_counter_without_touching_the_playlist() {
1685 let state = SharedPlayerState::new();
1689 let bytes = crate::remote::downloads::ByteFeed::new();
1690 let item = downloading_item("downloading", 1_000, bytes.clone());
1691 state.add_items(vec![item]);
1692
1693 let version = state.playlist_version();
1694 bytes.set(250);
1695
1696 let snap = state.derive_visible_queue();
1697 assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1698 assert_eq!(
1699 state.playlist_version(),
1700 version,
1701 "progress must not read as a queue mutation"
1702 );
1703 assert!(
1708 state
1709 .downloads_in_flight()
1710 .contains(&(snap.entries[0].id, 250, 1_000)),
1711 "the counter should be visible through the store"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_derive_visible_queue_no_cursor_all_queued() {
1717 let state = SharedPlayerState::new();
1718 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1719
1720 let snap = state.derive_visible_queue();
1721
1722 assert_eq!(snap.entries.len(), 3);
1723 for entry in &snap.entries {
1724 assert_eq!(entry.status, QueueEntryStatus::Queued);
1725 }
1726 assert!(!snap.has_playing);
1727 assert_eq!(snap.finished_count, 0);
1728 assert_eq!(snap.queue_count, 3);
1729 }
1730
1731 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1734 PlaylistItem {
1735 playlist_entry_id: None,
1736 id: QueueItemId::new(),
1737 db_id: None,
1738 path: PathBuf::from(format!("/music/{title}.flac")),
1739 title: title.to_string(),
1740 artist: "Artist".to_string(),
1741 album_artist: album_artist.to_string(),
1742 album: album.to_string(),
1743 year: None,
1744 codec: Some("FLAC".to_string()),
1745 track_number: None,
1746 disc: None,
1747 duration_ms: Some(200_000),
1748 state: ItemState::Ready,
1749 }
1750 }
1751
1752 #[test]
1753 fn test_same_album_item_ids_returns_album_mates() {
1754 let state = SharedPlayerState::new();
1755 let a1 = make_album_item("A1", "Album A", "Artist A");
1756 let a2 = make_album_item("A2", "Album A", "Artist A");
1757 let b1 = make_album_item("B1", "Album B", "Artist B");
1758 let a3 = make_album_item("A3", "Album A", "Artist A");
1759
1760 let id_a1 = a1.id;
1761 let id_a2 = a2.id;
1762 let id_a3 = a3.id;
1763
1764 state.add_items(vec![a1, a2, b1, a3]);
1765
1766 let mates = state.same_album_item_ids(id_a1);
1767 assert_eq!(mates.len(), 2);
1768 assert!(mates.contains(&id_a2));
1769 assert!(mates.contains(&id_a3));
1770 }
1771
1772 #[test]
1773 fn test_same_album_item_ids_distinguishes_album_artists() {
1774 let state = SharedPlayerState::new();
1776 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1777 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1778
1779 let id_a1 = a1.id;
1780
1781 state.add_items(vec![a1, b1]);
1782
1783 let mates = state.same_album_item_ids(id_a1);
1784 assert!(mates.is_empty(), "different album_artist should not match");
1785 }
1786
1787 #[test]
1788 fn test_same_album_item_ids_unknown_id_returns_empty() {
1789 let state = SharedPlayerState::new();
1790 state.add_items(vec![ready_item("track-0")]);
1791
1792 let bogus = QueueItemId::new();
1793 let mates = state.same_album_item_ids(bogus);
1794 assert!(mates.is_empty());
1795 }
1796
1797 #[test]
1800 fn test_update_item_metadata_leaves_library_tags_alone() {
1801 let state = SharedPlayerState::new();
1802 let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
1803 item.db_id = Some(29615);
1804 let id = item.id;
1805 state.add_items(vec![item]);
1806
1807 state.update_item_metadata(
1808 id,
1809 "[unknown]".into(),
1810 "Soulwax".into(),
1811 "Soulwax".into(),
1812 "Nite Versions".into(),
1813 Some(54_000),
1814 );
1815
1816 let pl = state.playlist.read();
1817 assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
1818 assert_eq!(pl.items[0].title, "A1");
1819 assert_eq!(pl.items[0].duration_ms, Some(54_000));
1820 }
1821
1822 #[test]
1823 fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
1824 let state = SharedPlayerState::new();
1825 let item = make_album_item("A1", "", "");
1826 let id = item.id;
1827 state.add_items(vec![item]);
1828
1829 state.update_item_metadata(
1830 id,
1831 "Teachers".into(),
1832 "Soulwax".into(),
1833 "Soulwax".into(),
1834 "Nite Versions".into(),
1835 Some(148_000),
1836 );
1837
1838 let pl = state.playlist.read();
1839 assert_eq!(pl.items[0].title, "Teachers");
1840 assert_eq!(pl.items[0].album, "Nite Versions");
1841 assert_eq!(pl.items[0].duration_ms, Some(148_000));
1842 }
1843
1844 #[test]
1847 fn test_move_item_to_reorders_playlist() {
1848 let state = SharedPlayerState::new();
1850 let item_a = ready_item("A");
1851 let item_b = ready_item("B");
1852 let item_c = ready_item("C");
1853 let id_a = item_a.id;
1854 let id_b = item_b.id;
1855 let id_c = item_c.id;
1856
1857 state.add_items(vec![item_a, item_b, item_c]);
1858 state.move_item_to(id_c, Some(id_a));
1859
1860 let (items, _) = state.snapshot_playlist();
1861 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1862 assert_eq!(titles, vec!["A", "C", "B"]);
1863 assert_eq!(items[0].id, id_a);
1864 assert_eq!(items[1].id, id_c);
1865 assert_eq!(items[2].id, id_b);
1866 }
1867
1868 #[test]
1869 fn test_move_item_to_front_when_after_is_none() {
1870 let state = SharedPlayerState::new();
1872 let item_a = ready_item("A");
1873 let item_b = ready_item("B");
1874 let item_c = ready_item("C");
1875 let id_c = item_c.id;
1876
1877 state.add_items(vec![item_a, item_b, item_c]);
1878 state.move_item_to(id_c, None);
1879
1880 let (items, _) = state.snapshot_playlist();
1881 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1882 assert_eq!(titles, vec!["C", "A", "B"]);
1883 }
1884
1885 #[test]
1888 fn test_move_items_batch_preserves_relative_order() {
1889 let state = SharedPlayerState::new();
1891 let item_a = ready_item("A");
1892 let item_b = ready_item("B");
1893 let item_c = ready_item("C");
1894 let item_d = ready_item("D");
1895 let id_a = item_a.id;
1896 let id_b = item_b.id;
1897 let id_c = item_c.id;
1898 let id_d = item_d.id;
1899
1900 state.add_items(vec![item_a, item_b, item_c, item_d]);
1901 state.move_items(&[id_a, id_c], id_d, true);
1902
1903 let (items, _) = state.snapshot_playlist();
1904 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1905 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1906 assert_eq!(items[0].id, id_b);
1907 assert_eq!(items[1].id, id_d);
1908 assert_eq!(items[2].id, id_a);
1909 assert_eq!(items[3].id, id_c);
1910 }
1911
1912 #[test]
1915 fn test_pending_downloads_collects_pending_with_db_id() {
1916 let state = SharedPlayerState::new();
1917 let mut item_a = ready_item("local");
1918 item_a.db_id = None;
1919
1920 let mut item_b = pending_item("remote-1");
1921 item_b.db_id = Some(10);
1922 let id_b = item_b.id;
1923
1924 let mut item_c = ready_item("cached");
1925 item_c.db_id = Some(20);
1926
1927 let mut item_d = pending_item("remote-2");
1928 item_d.db_id = Some(30);
1929 let id_d = item_d.id;
1930
1931 let item_e = pending_item("orphan");
1933
1934 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1935
1936 let pending = state.pending_downloads();
1937 assert_eq!(pending.len(), 2);
1938 assert_eq!(pending[0], (10, id_b));
1939 assert_eq!(pending[1], (30, id_d));
1940 }
1941
1942 #[test]
1943 fn test_item_db_id_and_load_state() {
1944 let state = SharedPlayerState::new();
1945 let mut item = pending_item("track");
1946 item.db_id = Some(42);
1947 let id = item.id;
1948 state.add_items(vec![item]);
1949
1950 assert_eq!(state.item_db_id(id), Some(42));
1951 assert!(matches!(
1952 state.item_load_state(id),
1953 Some(LoadState::Pending)
1954 ));
1955
1956 state.update_item_state(id, ItemState::Ready);
1957 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1958 }
1959}