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<AtomicU64>,
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<AtomicU64>,
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.state.store(state as u8, Ordering::Release);
306 }
307
308 pub fn position_ms(&self) -> u64 {
309 self.position_ms.load(Ordering::Acquire)
310 }
311
312 pub fn set_position_ms(&self, pos: u64) {
313 self.position_ms.store(pos, Ordering::Release);
314 }
315
316 pub fn track_info(&self) -> Option<TrackInfo> {
317 self.track_info.read().clone()
318 }
319
320 pub fn set_track_info(&self, info: Option<TrackInfo>) {
321 *self.track_info.write() = info;
322 }
323
324 pub fn seekable_ms(&self) -> u64 {
335 let Some(info) = self.track_info.read().clone() else {
336 return 0;
337 };
338
339 let pl = self.playlist.read();
342 let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
343 return info.duration_ms;
344 };
345
346 let LoadState::Downloading {
347 total,
348 bytes_written,
349 ..
350 } = LoadState::of(item)
351 else {
352 return info.duration_ms;
353 };
354
355 if info.duration_ms == 0 {
360 return 0;
361 }
362
363 let written = bytes_written.load(Ordering::Acquire);
364 let reached = if total > 0 && info.duration_ms > 0 {
365 ((written as f64 / total as f64) * info.duration_ms as f64) as u64
366 } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
367 written.saturating_mul(8) / kbps as u64
371 } else {
372 return self.position_ms();
375 };
376
377 reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
378 }
379
380 pub fn duration_ms(&self) -> u64 {
388 let Some(info) = self.track_info.read().clone() else {
389 return 0;
390 };
391 if info.duration_ms > 0 {
392 return info.duration_ms;
393 }
394 self.playlist
396 .read()
397 .items
398 .iter()
399 .find(|item| item.id == info.id)
400 .and_then(|item| item.duration_ms)
401 .unwrap_or(0)
402 }
403
404 pub fn seek_ceiling_ms(&self) -> Option<u64> {
408 let duration = self.duration_ms();
409 if duration == 0 {
410 return None;
411 }
412 let seekable = self.seekable_ms();
413 (seekable < duration).then_some(seekable)
414 }
415
416 pub fn current_download_fraction(&self) -> Option<f64> {
419 let id = self.track_info.read().as_ref()?.id;
422 let pl = self.playlist.read();
423 pl.items
424 .iter()
425 .find(|item| item.id == id)
426 .and_then(|item| match LoadState::of(item) {
427 LoadState::Downloading {
428 bytes_written,
429 total,
430 ..
431 } => {
432 let written = bytes_written.load(Ordering::Acquire);
433 (total > 0).then(|| (written as f64 / total as f64).min(1.0))
434 }
435 _ => None,
436 })
437 }
438
439 pub fn request_quit(&self) {
442 self.quit_requested.store(true, Ordering::Release);
443 }
444
445 pub fn quit_requested(&self) -> bool {
446 self.quit_requested.load(Ordering::Acquire)
447 }
448
449 pub fn signal_metadata_refresh(&self) {
455 self.metadata_refresh_pending.store(true, Ordering::Release);
456 }
457
458 pub fn take_metadata_refresh(&self) -> bool {
460 self.metadata_refresh_pending
461 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
462 .is_ok()
463 }
464
465 pub fn radio_mode(&self) -> bool {
468 self.radio_mode.load(Ordering::Acquire)
469 }
470
471 pub fn set_radio_mode(&self, enabled: bool) {
472 self.radio_mode.store(enabled, Ordering::Release);
473 }
474
475 pub fn output_sample_rate(&self) -> Option<u32> {
479 match self.output_sample_rate.load(Ordering::Acquire) {
480 0 => None,
481 rate => Some(rate as u32),
482 }
483 }
484
485 pub fn set_output_sample_rate(&self, rate: u32) {
486 self.output_sample_rate
487 .store(u64::from(rate), Ordering::Release);
488 }
489
490 pub fn clear_output_sample_rate(&self) {
495 self.output_sample_rate.store(0, Ordering::Release);
496 }
497
498 pub fn playlist_version(&self) -> u64 {
501 self.playlist_version.load(Ordering::Acquire)
502 }
503
504 fn bump_version(&self) {
505 self.playlist_version.fetch_add(1, Ordering::AcqRel);
506 }
507
508 pub fn add_items(&self, items: Vec<PlaylistItem>) {
512 let mut pl = self.playlist.write();
513 pl.items.extend(items);
514 drop(pl);
515 self.bump_version();
516 }
517
518 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
520 let mut pl = self.playlist.write();
521 let insert_at = match pl.items.iter().position(|item| item.id == after) {
522 Some(pos) => pos + 1,
523 None => pl.items.len(), };
525 for (i, item) in items.into_iter().enumerate() {
526 pl.items.insert(insert_at + i, item);
527 }
528 drop(pl);
529 self.bump_version();
530 }
531
532 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
534 let mut pl = self.playlist.write();
535 for (id, new_path) in updates {
536 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
537 item.path = new_path.clone();
538 }
539 }
540 drop(pl);
541 self.bump_version();
542 }
543
544 pub fn remove_item(&self, id: QueueItemId) {
546 let mut pl = self.playlist.write();
547 pl.items.retain(|item| item.id != id);
548 if pl.cursor == Some(id) {
550 pl.cursor = None;
551 }
552 drop(pl);
553 self.bump_version();
554 }
555
556 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
558 let mut pl = self.playlist.write();
559 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
560 return;
561 };
562 let item = pl.items.remove(from);
563 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
564 let pos = from.min(pl.items.len());
566 pl.items.insert(pos, item);
567 return;
568 };
569 let insert_at = if after { to + 1 } else { to };
570 pl.items.insert(insert_at, item);
571 drop(pl);
572 self.bump_version();
573 }
574
575 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
578 use std::collections::HashSet;
579 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
580
581 let mut pl = self.playlist.write();
582
583 let mut remaining = Vec::with_capacity(pl.items.len());
585 let mut moved = Vec::with_capacity(ids.len());
586 for item in pl.items.drain(..) {
587 if id_set.contains(&item.id) {
588 moved.push(item);
589 } else {
590 remaining.push(item);
591 }
592 }
593
594 let insert_at = match remaining.iter().position(|item| item.id == target) {
596 Some(pos) => {
597 if after {
598 pos + 1
599 } else {
600 pos
601 }
602 }
603 None => remaining.len(),
604 };
605
606 for (i, item) in moved.into_iter().enumerate() {
608 remaining.insert(insert_at + i, item);
609 }
610
611 pl.items = remaining;
612 drop(pl);
613 self.bump_version();
614 }
615
616 pub fn set_cursor(&self, id: Option<QueueItemId>) {
618 let mut pl = self.playlist.write();
619 pl.cursor = id;
620 drop(pl);
621 self.bump_version();
622 }
623
624 pub fn cursor(&self) -> Option<QueueItemId> {
626 self.playlist.read().cursor
627 }
628
629 pub fn clear_playlist(&self) {
631 let mut pl = self.playlist.write();
632 pl.items.clear();
633 pl.cursor = None;
634 drop(pl);
635 self.bump_version();
636 }
637
638 pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
651 let mut pl = self.playlist.write();
652 let start = match pl.cursor {
653 Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
654 None => 0,
655 };
656
657 let next = pl
658 .items
659 .get(start..)?
660 .iter()
661 .find(|item| !matches!(item.state, ItemState::Failed(_)))
662 .map(|item| item.id)?;
663
664 pl.cursor = Some(next);
665 drop(pl);
666 self.bump_version();
667 Some(next)
668 }
669
670 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
674 let pl = self.playlist.read();
675 let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
678
679 for i in start..pl.items.len() {
680 if matches!(pl.items[i].state, ItemState::Ready) {
681 let item = &pl.items[i];
682 return Some((item.id, item.path.clone()));
683 }
684 }
685 None
686 }
687
688 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
691 let mut pl = self.playlist.write();
692 let cursor_pos = match pl.cursor {
693 Some(cid) => pl.items.iter().position(|item| item.id == cid),
694 None => None,
695 };
696
697 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
698
699 match prev_pos {
700 Some(pos) => {
701 let item = &pl.items[pos];
702 let result = (item.id, item.path.clone());
703 pl.cursor = Some(item.id);
704 drop(pl);
705 self.bump_version();
706 Some(result)
707 }
708 None => None,
709 }
710 }
711
712 pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
716 let mut pl = self.playlist.write();
717 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
718 item.state = new_state;
719 }
720 drop(pl);
721 self.bump_version();
722 }
723
724 pub fn update_item_metadata(
734 &self,
735 id: QueueItemId,
736 title: String,
737 artist: String,
738 album_artist: String,
739 album: String,
740 duration_ms: Option<u64>,
741 ) {
742 let mut pl = self.playlist.write();
743 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
744 if item.db_id.is_none() {
745 item.title = title;
746 item.artist = artist;
747 item.album_artist = album_artist;
748 item.album = album;
749 }
750 if let Some(dur) = duration_ms {
751 item.duration_ms = Some(dur);
752 }
753 }
754 drop(pl);
755 self.bump_version();
756 }
757
758 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
761 let pl = self.playlist.read();
762 pl.items
763 .iter()
764 .find(|item| item.id == id)
765 .and_then(|item| match LoadState::of(item) {
766 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
767 LoadState::Downloading {
768 path,
769 total,
770 bytes_written,
771 } => {
772 let written = bytes_written.load(Ordering::Acquire);
773 (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
774 path,
775 bytes_written,
776 total,
777 })
778 }
779 _ => None,
780 })
781 }
782
783 pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
793 let mut pl = self.playlist.write();
794 let mut reset = Vec::new();
795 for item in pl.items.iter_mut() {
796 let Some(db_id) = item.db_id else { continue };
797 if !matches!(item.state, ItemState::Ready) {
798 continue;
799 }
800 if item.path.exists() {
801 continue;
802 }
803 item.state = ItemState::Pending;
804 reset.push((db_id, item.id));
805 }
806 drop(pl);
807 if !reset.is_empty() {
808 self.bump_version();
809 }
810 reset
811 }
812
813 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
815 let pl = self.playlist.read();
816 pl.items.iter().find(|item| item.id == id).and_then(|item| {
817 if matches!(item.state, ItemState::Ready) {
818 Some(item.path.clone())
819 } else {
820 None
821 }
822 })
823 }
824
825 pub fn is_cursor(&self, id: QueueItemId) -> bool {
827 self.playlist.read().cursor == Some(id)
828 }
829
830 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
834 let pl = self.playlist.read();
835 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
836 return vec![];
837 };
838 let album = cursor.album.clone();
839 let album_artist = cursor.album_artist.clone();
840 pl.items
841 .iter()
842 .filter(|item| {
843 item.id != id && item.album == album && item.album_artist == album_artist
844 })
845 .map(|item| item.id)
846 .collect()
847 }
848
849 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
852 let pl = self.playlist.read();
853 pl.items
854 .iter()
855 .filter(|item| matches!(item.state, ItemState::Pending))
856 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
857 .collect()
858 }
859
860 pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
866 crate::remote::downloads::store()
867 .all()
868 .iter()
869 .filter(|d| !d.state.is_settled())
870 .map(|d| (d.id, d.bytes_written(), d.total))
871 .collect()
872 }
873
874 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
876 let pl = self.playlist.read();
877 pl.items
878 .iter()
879 .find(|item| item.id == id)
880 .and_then(|item| item.db_id)
881 }
882
883 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
885 let pl = self.playlist.read();
886 pl.items
887 .iter()
888 .find(|item| item.id == id)
889 .map(LoadState::of)
890 }
891
892 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
896 let pl = self.playlist.read();
897 (pl.items.clone(), pl.cursor)
898 }
899
900 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
902 let pl = self.playlist.read();
903 pl.items.iter().find(|item| item.id == id).cloned()
904 }
905
906 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
908 let pl = self.playlist.read();
909 let pos = pl.items.iter().position(|item| item.id == id)?;
910 if pos == 0 {
911 None
912 } else {
913 Some(pl.items[pos - 1].id)
914 }
915 }
916
917 pub fn reorder_to(&self, order: &[QueueItemId]) {
930 let mut pl = self.playlist.write();
931 let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
932 let mut sorted = Vec::with_capacity(taken.len());
933 for id in order {
934 if let Some(slot) = taken
935 .iter_mut()
936 .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
937 && let Some(item) = slot.take()
938 {
939 sorted.push(item);
940 }
941 }
942 sorted.extend(taken.into_iter().flatten());
943 pl.items = sorted;
944 drop(pl);
945 self.bump_version();
946 }
947
948 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
949 use std::collections::HashSet;
950 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
951 let pl = self.playlist.read();
952 pl.items
953 .iter()
954 .enumerate()
955 .filter(|(_, item)| wanted.contains(&item.id))
956 .map(|(pos, item)| {
957 let before = if pos == 0 {
958 None
959 } else {
960 Some(pl.items[pos - 1].id)
961 };
962 (item.id, before)
963 })
964 .collect()
965 }
966
967 pub fn surviving_item_before(
971 &self,
972 id: QueueItemId,
973 removed: &[QueueItemId],
974 ) -> Option<QueueItemId> {
975 use std::collections::HashSet;
976 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
977 let pl = self.playlist.read();
978 let pos = pl.items.iter().position(|item| item.id == id)?;
979 pl.items[..pos]
980 .iter()
981 .rev()
982 .find(|item| !removed.contains(&item.id))
983 .map(|item| item.id)
984 }
985
986 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
988 let mut pl = self.playlist.write();
989 pl.items = items;
990 pl.cursor = cursor;
991 drop(pl);
992 self.bump_version();
993 }
994
995 pub fn remove_items(&self, ids: &[QueueItemId]) {
997 use std::collections::HashSet;
998 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
999 let mut pl = self.playlist.write();
1000 pl.items.retain(|item| !id_set.contains(&item.id));
1001 if let Some(cursor) = pl.cursor
1002 && id_set.contains(&cursor)
1003 {
1004 pl.cursor = None;
1005 }
1006 drop(pl);
1007 self.bump_version();
1008 }
1009
1010 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1012 let mut pl = self.playlist.write();
1013 let insert_at = match after {
1014 Some(after_id) => {
1015 match pl.items.iter().position(|i| i.id == after_id) {
1016 Some(pos) => pos + 1,
1017 None => pl.items.len(), }
1019 }
1020 None => 0,
1021 };
1022 pl.items.insert(insert_at, item);
1023 drop(pl);
1024 self.bump_version();
1025 }
1026
1027 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1029 let mut pl = self.playlist.write();
1030 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1031 return;
1032 };
1033 let item = pl.items.remove(from);
1034 let insert_at = match after {
1035 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1036 Some(pos) => pos + 1,
1037 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_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1049 for &(id, after) in entries {
1050 self.move_item_to(id, after);
1051 }
1052 }
1053
1054 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1059 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1061 let pl = self.playlist.read();
1062
1063 let cursor_pos = match pl.cursor {
1064 Some(cid) => pl.items.iter().position(|item| item.id == cid),
1065 None => None,
1066 };
1067
1068 let mut entries = Vec::with_capacity(pl.items.len());
1069 let mut finished_count = 0;
1070 let mut has_playing = false;
1071 let mut queue_count = 0;
1072
1073 for (i, item) in pl.items.iter().enumerate() {
1074 let is_cursor = cursor_pos == Some(i);
1075 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1076
1077 let load_state = LoadState::of(item);
1084
1085 let dl_progress = match &load_state {
1086 LoadState::Downloading {
1087 total,
1088 bytes_written,
1089 ..
1090 } => Some((bytes_written.load(Ordering::Relaxed), *total)),
1091 _ => None,
1092 };
1093
1094 let status = if is_cursor {
1095 has_playing = true;
1096 match &load_state {
1097 LoadState::Ready => QueueEntryStatus::Playing,
1098 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
1099 LoadState::Pending => QueueEntryStatus::PriorityPending,
1100 LoadState::Failed(_) => QueueEntryStatus::Failed,
1101 }
1102 } else if is_before_cursor {
1103 finished_count += 1;
1104 match &load_state {
1105 LoadState::Ready => QueueEntryStatus::Played,
1106 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1107 LoadState::Pending => QueueEntryStatus::Downloading,
1108 LoadState::Failed(_) => QueueEntryStatus::Failed,
1109 }
1110 } else {
1111 queue_count += 1;
1112 match &load_state {
1113 LoadState::Ready => QueueEntryStatus::Queued,
1114 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1115 LoadState::Pending => QueueEntryStatus::Downloading,
1116 LoadState::Failed(_) => QueueEntryStatus::Failed,
1117 }
1118 };
1119
1120 let duration_ms =
1122 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1123 {
1124 playing_duration_ms
1125 } else {
1126 item.duration_ms
1127 };
1128
1129 entries.push(QueueEntry {
1130 id: item.id,
1131 db_id: item.db_id,
1132 playlist_entry_id: item.playlist_entry_id,
1133 path: item.path.clone(),
1134 title: item.title.clone(),
1135 artist: item.artist.clone(),
1136 album_artist: item.album_artist.clone(),
1137 album: item.album.clone(),
1138 year: item.year.clone(),
1139 codec: item.codec.clone(),
1140 track_number: item.track_number,
1141 disc: item.disc,
1142 duration_ms,
1143 status,
1144 download_progress: dl_progress,
1145 error: match &load_state {
1146 LoadState::Failed(reason) => Some(reason.clone()),
1147 _ => None,
1148 },
1149 });
1150 }
1151
1152 VisibleQueueSnapshot {
1153 entries,
1154 finished_count,
1155 has_playing,
1156 queue_count,
1157 }
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164
1165 fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1168 PlaylistItem {
1169 playlist_entry_id: None,
1170 id: QueueItemId::new(),
1171 db_id: None,
1172 path: PathBuf::from(format!("/music/{title}.flac")),
1173 title: title.to_string(),
1174 artist: "Artist".to_string(),
1175 album_artist: "Artist".to_string(),
1176 album: "Album".to_string(),
1177 year: None,
1178 codec: Some("FLAC".to_string()),
1179 track_number: None,
1180 disc: None,
1181 duration_ms: Some(200_000),
1182 state,
1183 }
1184 }
1185
1186 fn downloading_item(title: &str, total: u64, written: Arc<AtomicU64>) -> PlaylistItem {
1189 let item = make_item(title, ItemState::Pending);
1190 let store = crate::remote::downloads::store();
1191 store.queued(crate::remote::downloads::Download {
1192 id: item.id,
1193 track_id: 1,
1194 title: title.into(),
1195 artist: String::new(),
1196 source: PathBuf::from(format!("/cache/{title}.flac.part")),
1197 dest: PathBuf::from(format!("/cache/{title}.flac")),
1198 total,
1199 written: written.clone(),
1200 state: crate::remote::downloads::DownloadState::Queued,
1201 bytes_per_second: 0,
1202 });
1203 store.started(item.id, total, written);
1204 item
1205 }
1206
1207 fn ready_item(title: &str) -> PlaylistItem {
1208 make_item(title, ItemState::Ready)
1209 }
1210
1211 fn pending_item(title: &str) -> PlaylistItem {
1212 make_item(title, ItemState::Pending)
1213 }
1214
1215 fn failed_item(title: &str) -> PlaylistItem {
1216 make_item(title, ItemState::Failed("nope".into()))
1217 }
1218
1219 const DURATION_MS: u64 = 32_523_787;
1220
1221 fn streaming_state(
1224 downloaded: u64,
1225 total: u64,
1226 bitrate_kbps: Option<u32>,
1227 ) -> Arc<SharedPlayerState> {
1228 streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1229 }
1230
1231 fn streaming_state_with_duration(
1234 downloaded: u64,
1235 total: u64,
1236 bitrate_kbps: Option<u32>,
1237 container_duration_ms: u64,
1238 ) -> Arc<SharedPlayerState> {
1239 let written = Arc::new(AtomicU64::new(downloaded));
1240 let item = make_item("train", ItemState::Pending);
1241 let id = item.id;
1242 let path = item.path.clone();
1243
1244 let store = crate::remote::downloads::store();
1247 store.queued(crate::remote::downloads::Download {
1248 id,
1249 track_id: 1,
1250 title: "train".into(),
1251 artist: String::new(),
1252 source: PathBuf::from("/cache/train.opus.part"),
1253 dest: PathBuf::from("/cache/train.opus"),
1254 total,
1255 written: written.clone(),
1256 state: crate::remote::downloads::DownloadState::Queued,
1257 bytes_per_second: 0,
1258 });
1259 store.started(id, total, written);
1260
1261 let state = SharedPlayerState::new();
1262 state.add_items(vec![item]);
1263 state.set_cursor(Some(id));
1264 state.set_track_info(Some(TrackInfo {
1265 id,
1266 path,
1267 codec: "Opus".into(),
1268 sample_rate: 48_000,
1269 bit_depth: None,
1270 bitrate_kbps,
1271 channels: 2,
1272 duration_ms: container_duration_ms,
1273 }));
1274 state
1275 }
1276
1277 #[test]
1280 fn a_track_on_disk_is_seekable_end_to_end() {
1281 let item = ready_item("done");
1282 let id = item.id;
1283 let path = item.path.clone();
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: "FLAC".into(),
1291 sample_rate: 44_100,
1292 bit_depth: Some(16),
1293 bitrate_kbps: None,
1294 channels: 2,
1295 duration_ms: 200_000,
1296 }));
1297
1298 assert_eq!(state.seekable_ms(), 200_000);
1299 assert_eq!(state.seek_ceiling_ms(), None);
1301 }
1302
1303 #[test]
1304 fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1305 let state = streaming_state(100, 400, None);
1308 assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1309 assert_eq!(
1310 state.seek_ceiling_ms(),
1311 Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1312 );
1313 }
1314
1315 #[test]
1316 fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1317 let state = streaming_state(1024 * 1024, 0, Some(128));
1320 assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1321 }
1322
1323 #[test]
1324 fn nothing_to_estimate_from_allows_no_forward_seek() {
1325 let state = streaming_state(1024 * 1024, 0, None);
1328 state.set_position_ms(12_000);
1329 assert_eq!(state.seekable_ms(), 12_000);
1330 }
1331
1332 #[test]
1333 fn the_seekable_extent_never_exceeds_the_track() {
1334 let state = streaming_state(500, 400, None);
1337 assert_eq!(state.seekable_ms(), 32_523_787);
1338 }
1339
1340 #[test]
1341 fn nothing_playing_is_seekable_nowhere() {
1342 assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1343 assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1344 }
1345
1346 #[test]
1347 fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1348 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1352 assert_eq!(state.seekable_ms(), 0);
1353 }
1354
1355 #[test]
1356 fn the_library_duration_stands_in_for_a_silent_container() {
1357 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1360 assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1361 assert_eq!(state.seekable_ms(), 0);
1363 assert_eq!(state.seek_ceiling_ms(), Some(0));
1364 }
1365
1366 #[test]
1367 fn the_container_duration_wins_where_there_is_one() {
1368 let state = streaming_state(200, 400, None);
1369 assert_eq!(state.duration_ms(), DURATION_MS);
1370 }
1371
1372 #[test]
1373 fn the_download_landing_restores_seeking() {
1374 let state = streaming_state_with_duration(400, 400, Some(128), 0);
1378 assert_eq!(state.seekable_ms(), 0);
1379
1380 let id = state.cursor().expect("cursor");
1384 crate::remote::downloads::store().finished(id);
1385 state.update_item_state(id, ItemState::Ready);
1386 let info = state.track_info().expect("track info");
1387 state.set_track_info(Some(TrackInfo {
1388 duration_ms: DURATION_MS,
1389 ..info
1390 }));
1391
1392 assert_eq!(state.seekable_ms(), DURATION_MS);
1393 assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1394 }
1395
1396 #[test]
1399 fn advance_parks_on_a_still_downloading_track() {
1400 let state = SharedPlayerState::new();
1404 let item0 = ready_item("track-0");
1405 let item1 = pending_item("track-1");
1406 let item2 = ready_item("track-2");
1407 let (id0, id1) = (item0.id, item1.id);
1408
1409 state.add_items(vec![item0, item1, item2]);
1410
1411 assert_eq!(state.advance_cursor_loadable(), Some(id0));
1412 assert_eq!(state.advance_cursor_loadable(), Some(id1));
1413 assert_eq!(state.cursor(), Some(id1));
1414 }
1415
1416 #[test]
1417 fn advance_skips_failed_items() {
1418 let state = SharedPlayerState::new();
1419 let item0 = ready_item("track-0");
1420 let item1 = failed_item("track-1");
1421 let item2 = ready_item("track-2");
1422 let (id0, id2) = (item0.id, item2.id);
1423
1424 state.add_items(vec![item0, item1, item2]);
1425 state.set_cursor(Some(id0));
1426
1427 assert_eq!(state.advance_cursor_loadable(), Some(id2));
1428 }
1429
1430 #[test]
1431 fn advance_stops_at_end_of_playlist() {
1432 let state = SharedPlayerState::new();
1433 let item0 = ready_item("track-0");
1434 let item1 = ready_item("track-1");
1435 let id1 = item1.id;
1436
1437 state.add_items(vec![item0, item1]);
1438 state.set_cursor(Some(id1));
1439
1440 assert_eq!(state.advance_cursor_loadable(), None);
1441 assert_eq!(
1442 state.cursor(),
1443 Some(id1),
1444 "cursor unchanged on a failed advance"
1445 );
1446 }
1447
1448 #[test]
1449 fn advance_with_only_failed_items_returns_none() {
1450 let state = SharedPlayerState::new();
1451 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1452
1453 assert_eq!(state.advance_cursor_loadable(), None);
1454 }
1455
1456 #[test]
1457 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1458 let state = SharedPlayerState::new();
1459 let item0 = ready_item("track-0");
1460 let item1 = ready_item("track-1");
1461 let id0 = item0.id;
1462
1463 state.add_items(vec![item0, item1]);
1464 let ghost = QueueItemId::new();
1465 state.set_cursor(Some(ghost));
1466
1467 assert_eq!(state.advance_cursor_loadable(), None);
1468 assert_ne!(state.cursor(), Some(id0));
1469 }
1470
1471 #[test]
1474 fn peek_after_a_removed_item_returns_none() {
1475 let state = SharedPlayerState::new();
1479 let item0 = ready_item("track-0");
1480 let item1 = ready_item("track-1");
1481 let item2 = ready_item("track-2");
1482 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1483
1484 state.add_items(vec![item0, item1, item2]);
1485 assert_eq!(
1486 state.peek_next_ready_after(id1).map(|(id, _)| id),
1487 Some(id2)
1488 );
1489
1490 state.remove_item(id2);
1491 assert!(
1492 state.peek_next_ready_after(id2).is_none(),
1493 "a vanished reference must not resolve to the head of the queue"
1494 );
1495 assert_ne!(
1496 state.peek_next_ready_after(id2).map(|(id, _)| id),
1497 Some(id0)
1498 );
1499 }
1500
1501 #[test]
1504 fn surviving_predecessor_skips_items_being_removed() {
1505 let state = SharedPlayerState::new();
1506 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1507 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1508 state.add_items(items);
1509
1510 assert_eq!(
1512 state.surviving_item_before(ids[3], &ids[1..4]),
1513 Some(ids[0])
1514 );
1515 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1517 }
1518
1519 #[test]
1522 fn test_retreat_cursor_goes_to_previous_item() {
1523 let state = SharedPlayerState::new();
1524 let item0 = ready_item("track-0");
1525 let item1 = ready_item("track-1");
1526 let id0 = item0.id;
1527 let id1 = item1.id;
1528
1529 state.add_items(vec![item0, item1]);
1530 state.set_cursor(Some(id1));
1531
1532 let result = state.retreat_cursor();
1533 assert!(result.is_some(), "expected to retreat to previous item");
1534 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1535 assert_eq!(state.cursor(), Some(id0));
1536 }
1537
1538 #[test]
1539 fn test_retreat_cursor_returns_none_when_at_first_item() {
1540 let state = SharedPlayerState::new();
1541 let item0 = ready_item("only-track");
1542 let id0 = item0.id;
1543
1544 state.add_items(vec![item0]);
1545 state.set_cursor(Some(id0));
1546
1547 let result = state.retreat_cursor();
1548 assert!(result.is_none(), "cannot retreat before the first item");
1549 assert_eq!(state.cursor(), Some(id0));
1551 }
1552
1553 #[test]
1554 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1555 let state = SharedPlayerState::new();
1556 state.add_items(vec![ready_item("track-0")]);
1557
1558 let result = state.retreat_cursor();
1559 assert!(
1560 result.is_none(),
1561 "retreat with no cursor should return None"
1562 );
1563 }
1564
1565 #[test]
1568 fn test_derive_visible_queue_statuses() {
1569 let state = SharedPlayerState::new();
1571 let item0 = ready_item("played-track");
1572 let item1 = ready_item("playing-track");
1573 let item2 = ready_item("queued-track");
1574 let id1 = item1.id;
1575
1576 state.add_items(vec![item0, item1, item2]);
1577 state.set_cursor(Some(id1));
1578
1579 let snap = state.derive_visible_queue();
1580
1581 assert_eq!(snap.entries.len(), 3);
1582 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1583 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1584 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1585 assert!(snap.has_playing);
1586 assert_eq!(snap.finished_count, 1);
1587 assert_eq!(snap.queue_count, 1);
1588 }
1589
1590 #[test]
1591 fn test_derive_visible_queue_downloading_statuses() {
1592 let state = SharedPlayerState::new();
1594 let bytes_cursor = Arc::new(AtomicU64::new(0));
1595 let bytes_queued = Arc::new(AtomicU64::new(0));
1596 let dl_cursor = downloading_item("downloading-at-cursor", 1_000_000, bytes_cursor.clone());
1597 let dl_queued = downloading_item("downloading-queued", 500_000, bytes_queued.clone());
1598 let id_cursor = dl_cursor.id;
1599
1600 state.add_items(vec![dl_cursor, dl_queued]);
1601 state.set_cursor(Some(id_cursor));
1602
1603 let snap = state.derive_visible_queue();
1604
1605 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1606 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1607 }
1608
1609 #[test]
1610 fn progress_follows_the_counter_without_touching_the_playlist() {
1611 let state = SharedPlayerState::new();
1615 let bytes = Arc::new(AtomicU64::new(0));
1616 let item = downloading_item("downloading", 1_000, bytes.clone());
1617 state.add_items(vec![item]);
1618
1619 let version = state.playlist_version();
1620 bytes.store(250, Ordering::Release);
1621
1622 let snap = state.derive_visible_queue();
1623 assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1624 assert_eq!(
1625 state.playlist_version(),
1626 version,
1627 "progress must not read as a queue mutation"
1628 );
1629 assert!(
1634 state
1635 .downloads_in_flight()
1636 .contains(&(snap.entries[0].id, 250, 1_000)),
1637 "the counter should be visible through the store"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_derive_visible_queue_no_cursor_all_queued() {
1643 let state = SharedPlayerState::new();
1644 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1645
1646 let snap = state.derive_visible_queue();
1647
1648 assert_eq!(snap.entries.len(), 3);
1649 for entry in &snap.entries {
1650 assert_eq!(entry.status, QueueEntryStatus::Queued);
1651 }
1652 assert!(!snap.has_playing);
1653 assert_eq!(snap.finished_count, 0);
1654 assert_eq!(snap.queue_count, 3);
1655 }
1656
1657 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1660 PlaylistItem {
1661 playlist_entry_id: None,
1662 id: QueueItemId::new(),
1663 db_id: None,
1664 path: PathBuf::from(format!("/music/{title}.flac")),
1665 title: title.to_string(),
1666 artist: "Artist".to_string(),
1667 album_artist: album_artist.to_string(),
1668 album: album.to_string(),
1669 year: None,
1670 codec: Some("FLAC".to_string()),
1671 track_number: None,
1672 disc: None,
1673 duration_ms: Some(200_000),
1674 state: ItemState::Ready,
1675 }
1676 }
1677
1678 #[test]
1679 fn test_same_album_item_ids_returns_album_mates() {
1680 let state = SharedPlayerState::new();
1681 let a1 = make_album_item("A1", "Album A", "Artist A");
1682 let a2 = make_album_item("A2", "Album A", "Artist A");
1683 let b1 = make_album_item("B1", "Album B", "Artist B");
1684 let a3 = make_album_item("A3", "Album A", "Artist A");
1685
1686 let id_a1 = a1.id;
1687 let id_a2 = a2.id;
1688 let id_a3 = a3.id;
1689
1690 state.add_items(vec![a1, a2, b1, a3]);
1691
1692 let mates = state.same_album_item_ids(id_a1);
1693 assert_eq!(mates.len(), 2);
1694 assert!(mates.contains(&id_a2));
1695 assert!(mates.contains(&id_a3));
1696 }
1697
1698 #[test]
1699 fn test_same_album_item_ids_distinguishes_album_artists() {
1700 let state = SharedPlayerState::new();
1702 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1703 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1704
1705 let id_a1 = a1.id;
1706
1707 state.add_items(vec![a1, b1]);
1708
1709 let mates = state.same_album_item_ids(id_a1);
1710 assert!(mates.is_empty(), "different album_artist should not match");
1711 }
1712
1713 #[test]
1714 fn test_same_album_item_ids_unknown_id_returns_empty() {
1715 let state = SharedPlayerState::new();
1716 state.add_items(vec![ready_item("track-0")]);
1717
1718 let bogus = QueueItemId::new();
1719 let mates = state.same_album_item_ids(bogus);
1720 assert!(mates.is_empty());
1721 }
1722
1723 #[test]
1726 fn test_update_item_metadata_leaves_library_tags_alone() {
1727 let state = SharedPlayerState::new();
1728 let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
1729 item.db_id = Some(29615);
1730 let id = item.id;
1731 state.add_items(vec![item]);
1732
1733 state.update_item_metadata(
1734 id,
1735 "[unknown]".into(),
1736 "Soulwax".into(),
1737 "Soulwax".into(),
1738 "Nite Versions".into(),
1739 Some(54_000),
1740 );
1741
1742 let pl = state.playlist.read();
1743 assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
1744 assert_eq!(pl.items[0].title, "A1");
1745 assert_eq!(pl.items[0].duration_ms, Some(54_000));
1746 }
1747
1748 #[test]
1749 fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
1750 let state = SharedPlayerState::new();
1751 let item = make_album_item("A1", "", "");
1752 let id = item.id;
1753 state.add_items(vec![item]);
1754
1755 state.update_item_metadata(
1756 id,
1757 "Teachers".into(),
1758 "Soulwax".into(),
1759 "Soulwax".into(),
1760 "Nite Versions".into(),
1761 Some(148_000),
1762 );
1763
1764 let pl = state.playlist.read();
1765 assert_eq!(pl.items[0].title, "Teachers");
1766 assert_eq!(pl.items[0].album, "Nite Versions");
1767 assert_eq!(pl.items[0].duration_ms, Some(148_000));
1768 }
1769
1770 #[test]
1773 fn test_move_item_to_reorders_playlist() {
1774 let state = SharedPlayerState::new();
1776 let item_a = ready_item("A");
1777 let item_b = ready_item("B");
1778 let item_c = ready_item("C");
1779 let id_a = item_a.id;
1780 let id_b = item_b.id;
1781 let id_c = item_c.id;
1782
1783 state.add_items(vec![item_a, item_b, item_c]);
1784 state.move_item_to(id_c, Some(id_a));
1785
1786 let (items, _) = state.snapshot_playlist();
1787 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1788 assert_eq!(titles, vec!["A", "C", "B"]);
1789 assert_eq!(items[0].id, id_a);
1790 assert_eq!(items[1].id, id_c);
1791 assert_eq!(items[2].id, id_b);
1792 }
1793
1794 #[test]
1795 fn test_move_item_to_front_when_after_is_none() {
1796 let state = SharedPlayerState::new();
1798 let item_a = ready_item("A");
1799 let item_b = ready_item("B");
1800 let item_c = ready_item("C");
1801 let id_c = item_c.id;
1802
1803 state.add_items(vec![item_a, item_b, item_c]);
1804 state.move_item_to(id_c, None);
1805
1806 let (items, _) = state.snapshot_playlist();
1807 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1808 assert_eq!(titles, vec!["C", "A", "B"]);
1809 }
1810
1811 #[test]
1814 fn test_move_items_batch_preserves_relative_order() {
1815 let state = SharedPlayerState::new();
1817 let item_a = ready_item("A");
1818 let item_b = ready_item("B");
1819 let item_c = ready_item("C");
1820 let item_d = ready_item("D");
1821 let id_a = item_a.id;
1822 let id_b = item_b.id;
1823 let id_c = item_c.id;
1824 let id_d = item_d.id;
1825
1826 state.add_items(vec![item_a, item_b, item_c, item_d]);
1827 state.move_items(&[id_a, id_c], id_d, true);
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!["B", "D", "A", "C"]);
1832 assert_eq!(items[0].id, id_b);
1833 assert_eq!(items[1].id, id_d);
1834 assert_eq!(items[2].id, id_a);
1835 assert_eq!(items[3].id, id_c);
1836 }
1837
1838 #[test]
1841 fn test_pending_downloads_collects_pending_with_db_id() {
1842 let state = SharedPlayerState::new();
1843 let mut item_a = ready_item("local");
1844 item_a.db_id = None;
1845
1846 let mut item_b = pending_item("remote-1");
1847 item_b.db_id = Some(10);
1848 let id_b = item_b.id;
1849
1850 let mut item_c = ready_item("cached");
1851 item_c.db_id = Some(20);
1852
1853 let mut item_d = pending_item("remote-2");
1854 item_d.db_id = Some(30);
1855 let id_d = item_d.id;
1856
1857 let item_e = pending_item("orphan");
1859
1860 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1861
1862 let pending = state.pending_downloads();
1863 assert_eq!(pending.len(), 2);
1864 assert_eq!(pending[0], (10, id_b));
1865 assert_eq!(pending[1], (30, id_d));
1866 }
1867
1868 #[test]
1869 fn test_item_db_id_and_load_state() {
1870 let state = SharedPlayerState::new();
1871 let mut item = pending_item("track");
1872 item.db_id = Some(42);
1873 let id = item.id;
1874 state.add_items(vec![item]);
1875
1876 assert_eq!(state.item_db_id(id), Some(42));
1877 assert!(matches!(
1878 state.item_load_state(id),
1879 Some(LoadState::Pending)
1880 ));
1881
1882 state.update_item_state(id, ItemState::Ready);
1883 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1884 }
1885}