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; pub 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)]
169pub struct PlaylistItem {
170 pub id: QueueItemId,
171 pub db_id: Option<i64>,
173 pub playlist_entry_id: Option<i64>,
180 pub path: PathBuf,
181 pub title: String,
182 pub artist: String,
183 pub album_artist: String,
184 pub album: String,
185 pub year: Option<String>,
186 pub codec: Option<String>,
187 pub track_number: Option<i64>,
188 pub disc: Option<i64>,
189 pub duration_ms: Option<u64>,
190 pub state: ItemState,
193}
194
195#[derive(Debug, Clone, Default)]
197pub struct Playlist {
198 pub items: Vec<PlaylistItem>,
199 pub cursor: Option<QueueItemId>,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum QueueEntryStatus {
207 Queued,
208 Playing,
209 Played,
210 Downloading,
211 PriorityPending,
213 Failed,
214}
215
216#[derive(Debug, Clone)]
218pub struct QueueEntry {
219 pub id: QueueItemId,
220 pub db_id: Option<i64>,
222 pub playlist_entry_id: Option<i64>,
224 pub path: PathBuf,
225 pub title: String,
226 pub artist: String,
227 pub album_artist: String,
228 pub album: String,
229 pub year: Option<String>,
230 pub codec: Option<String>,
231 pub track_number: Option<i64>,
232 pub disc: Option<i64>,
233 pub duration_ms: Option<u64>,
234 pub status: QueueEntryStatus,
235 pub download_progress: Option<(u64, u64)>,
236 pub error: Option<String>,
238}
239
240#[derive(Debug, Clone, Default)]
242pub struct VisibleQueueSnapshot {
243 pub entries: Vec<QueueEntry>,
244 pub finished_count: usize,
245 pub has_playing: bool,
246 pub queue_count: usize,
247}
248
249#[derive(Debug)]
253pub struct SharedPlayerState {
254 state: AtomicU8,
255 position_ms: AtomicU64,
256 track_info: parking_lot::RwLock<Option<TrackInfo>>,
257
258 playlist: parking_lot::RwLock<Playlist>,
260
261 playlist_version: AtomicU64,
263
264 quit_requested: AtomicBool,
266
267 metadata_refresh_pending: AtomicBool,
270
271 output_sample_rate: AtomicU64,
278
279 radio_mode: AtomicBool,
282}
283
284impl SharedPlayerState {
285 pub fn new() -> Arc<Self> {
286 Arc::new(Self {
287 state: AtomicU8::new(PlaybackState::Stopped as u8),
288 position_ms: AtomicU64::new(0),
289 track_info: parking_lot::RwLock::new(None),
290 playlist: parking_lot::RwLock::new(Playlist::default()),
291 playlist_version: AtomicU64::new(0),
292 quit_requested: AtomicBool::new(false),
293 metadata_refresh_pending: AtomicBool::new(false),
294 output_sample_rate: AtomicU64::new(0),
295 radio_mode: AtomicBool::new(false),
296 })
297 }
298
299 pub fn playback_state(&self) -> PlaybackState {
302 PlaybackState::from_u8(self.state.load(Ordering::Acquire))
303 }
304
305 pub fn set_playback_state(&self, state: PlaybackState) {
306 self.state.store(state as u8, Ordering::Release);
307 self.changed();
308 }
309
310 pub fn position_ms(&self) -> u64 {
311 self.position_ms.load(Ordering::Acquire)
312 }
313
314 pub fn set_position_ms(&self, pos: u64) {
315 self.position_ms.store(pos, Ordering::Release);
316 }
322
323 pub fn track_info(&self) -> Option<TrackInfo> {
324 self.track_info.read().clone()
325 }
326
327 pub fn set_track_info(&self, info: Option<TrackInfo>) {
328 *self.track_info.write() = info;
329 self.changed();
330 }
331
332 pub fn seekable_ms(&self) -> u64 {
343 let Some(info) = self.track_info.read().clone() else {
344 return 0;
345 };
346
347 let pl = self.playlist.read();
350 let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
351 return info.duration_ms;
352 };
353
354 let LoadState::Downloading {
355 total,
356 bytes_written,
357 ..
358 } = LoadState::of(item)
359 else {
360 return info.duration_ms;
361 };
362
363 if info.duration_ms == 0 {
368 return 0;
369 }
370
371 let written = bytes_written.load(Ordering::Acquire);
372 let reached = if total > 0 && info.duration_ms > 0 {
373 ((written as f64 / total as f64) * info.duration_ms as f64) as u64
374 } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
375 written.saturating_mul(8) / kbps as u64
379 } else {
380 return self.position_ms();
383 };
384
385 reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
386 }
387
388 pub fn duration_ms(&self) -> u64 {
396 let Some(info) = self.track_info.read().clone() else {
397 return 0;
398 };
399 if info.duration_ms > 0 {
400 return info.duration_ms;
401 }
402 self.playlist
404 .read()
405 .items
406 .iter()
407 .find(|item| item.id == info.id)
408 .and_then(|item| item.duration_ms)
409 .unwrap_or(0)
410 }
411
412 pub fn seek_ceiling_ms(&self) -> Option<u64> {
416 let duration = self.duration_ms();
417 if duration == 0 {
418 return None;
419 }
420 let seekable = self.seekable_ms();
421 (seekable < duration).then_some(seekable)
422 }
423
424 pub fn current_download_fraction(&self) -> Option<f64> {
427 let id = self.track_info.read().as_ref()?.id;
430 let pl = self.playlist.read();
431 pl.items
432 .iter()
433 .find(|item| item.id == id)
434 .and_then(|item| match LoadState::of(item) {
435 LoadState::Downloading {
436 bytes_written,
437 total,
438 ..
439 } => {
440 let written = bytes_written.load(Ordering::Acquire);
441 (total > 0).then(|| (written as f64 / total as f64).min(1.0))
442 }
443 _ => None,
444 })
445 }
446
447 pub fn request_quit(&self) {
450 self.quit_requested.store(true, Ordering::Release);
451 }
452
453 pub fn quit_requested(&self) -> bool {
454 self.quit_requested.load(Ordering::Acquire)
455 }
456
457 pub fn signal_metadata_refresh(&self) {
463 self.metadata_refresh_pending.store(true, Ordering::Release);
464 self.changed();
465 }
466
467 pub fn take_metadata_refresh(&self) -> bool {
469 self.metadata_refresh_pending
470 .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
471 .is_ok()
472 }
473
474 pub fn radio_mode(&self) -> bool {
477 self.radio_mode.load(Ordering::Acquire)
478 }
479
480 pub fn set_radio_mode(&self, enabled: bool) {
481 self.radio_mode.store(enabled, Ordering::Release);
482 self.changed();
483 }
484
485 pub fn output_sample_rate(&self) -> Option<u32> {
489 match self.output_sample_rate.load(Ordering::Acquire) {
490 0 => None,
491 rate => Some(rate as u32),
492 }
493 }
494
495 pub fn set_output_sample_rate(&self, rate: u32) {
496 self.output_sample_rate
497 .store(u64::from(rate), Ordering::Release);
498 self.changed();
499 }
500
501 pub fn clear_output_sample_rate(&self) {
506 self.output_sample_rate.store(0, Ordering::Release);
507 }
508
509 pub fn playlist_version(&self) -> u64 {
512 self.playlist_version.load(Ordering::Acquire)
513 }
514
515 fn bump_version(&self) {
516 self.playlist_version.fetch_add(1, Ordering::AcqRel);
517 self.changed();
518 }
519
520 pub fn changed(&self) {
526 crate::signal::engine_changed().bump();
527 }
528
529 pub fn add_items(&self, items: Vec<PlaylistItem>) {
533 let mut pl = self.playlist.write();
534 pl.items.extend(items);
535 drop(pl);
536 self.bump_version();
537 }
538
539 pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
541 let mut pl = self.playlist.write();
542 let insert_at = match pl.items.iter().position(|item| item.id == after) {
543 Some(pos) => pos + 1,
544 None => pl.items.len(), };
546 for (i, item) in items.into_iter().enumerate() {
547 pl.items.insert(insert_at + i, item);
548 }
549 drop(pl);
550 self.bump_version();
551 }
552
553 pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
555 let mut pl = self.playlist.write();
556 for (id, new_path) in updates {
557 if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
558 item.path = new_path.clone();
559 }
560 }
561 drop(pl);
562 self.bump_version();
563 }
564
565 pub fn remove_item(&self, id: QueueItemId) {
567 let mut pl = self.playlist.write();
568 pl.items.retain(|item| item.id != id);
569 if pl.cursor == Some(id) {
571 pl.cursor = None;
572 }
573 drop(pl);
574 self.bump_version();
575 }
576
577 pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
579 let mut pl = self.playlist.write();
580 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
581 return;
582 };
583 let item = pl.items.remove(from);
584 let Some(to) = pl.items.iter().position(|item| item.id == target) else {
585 let pos = from.min(pl.items.len());
587 pl.items.insert(pos, item);
588 return;
589 };
590 let insert_at = if after { to + 1 } else { to };
591 pl.items.insert(insert_at, item);
592 drop(pl);
593 self.bump_version();
594 }
595
596 pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
599 use std::collections::HashSet;
600 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
601
602 let mut pl = self.playlist.write();
603
604 let mut remaining = Vec::with_capacity(pl.items.len());
606 let mut moved = Vec::with_capacity(ids.len());
607 for item in pl.items.drain(..) {
608 if id_set.contains(&item.id) {
609 moved.push(item);
610 } else {
611 remaining.push(item);
612 }
613 }
614
615 let insert_at = match remaining.iter().position(|item| item.id == target) {
617 Some(pos) => {
618 if after {
619 pos + 1
620 } else {
621 pos
622 }
623 }
624 None => remaining.len(),
625 };
626
627 for (i, item) in moved.into_iter().enumerate() {
629 remaining.insert(insert_at + i, item);
630 }
631
632 pl.items = remaining;
633 drop(pl);
634 self.bump_version();
635 }
636
637 pub fn set_cursor(&self, id: Option<QueueItemId>) {
639 let mut pl = self.playlist.write();
640 pl.cursor = id;
641 drop(pl);
642 self.bump_version();
643 }
644
645 pub fn cursor(&self) -> Option<QueueItemId> {
647 self.playlist.read().cursor
648 }
649
650 pub fn clear_playlist(&self) {
652 let mut pl = self.playlist.write();
653 pl.items.clear();
654 pl.cursor = None;
655 drop(pl);
656 self.bump_version();
657 }
658
659 pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
672 let mut pl = self.playlist.write();
673 let start = match pl.cursor {
674 Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
675 None => 0,
676 };
677
678 let next = pl
679 .items
680 .get(start..)?
681 .iter()
682 .find(|item| !matches!(item.state, ItemState::Failed(_)))
683 .map(|item| item.id)?;
684
685 pl.cursor = Some(next);
686 drop(pl);
687 self.bump_version();
688 Some(next)
689 }
690
691 pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
695 let pl = self.playlist.read();
696 let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
699
700 for i in start..pl.items.len() {
701 if matches!(pl.items[i].state, ItemState::Ready) {
702 let item = &pl.items[i];
703 return Some((item.id, item.path.clone()));
704 }
705 }
706 None
707 }
708
709 pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
712 let mut pl = self.playlist.write();
713 let cursor_pos = match pl.cursor {
714 Some(cid) => pl.items.iter().position(|item| item.id == cid),
715 None => None,
716 };
717
718 let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
719
720 match prev_pos {
721 Some(pos) => {
722 let item = &pl.items[pos];
723 let result = (item.id, item.path.clone());
724 pl.cursor = Some(item.id);
725 drop(pl);
726 self.bump_version();
727 Some(result)
728 }
729 None => None,
730 }
731 }
732
733 pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
737 let mut pl = self.playlist.write();
738 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
739 item.state = new_state;
740 }
741 drop(pl);
742 self.bump_version();
743 }
744
745 pub fn update_item_metadata(
755 &self,
756 id: QueueItemId,
757 title: String,
758 artist: String,
759 album_artist: String,
760 album: String,
761 duration_ms: Option<u64>,
762 ) {
763 let mut pl = self.playlist.write();
764 if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
765 if item.db_id.is_none() {
766 item.title = title;
767 item.artist = artist;
768 item.album_artist = album_artist;
769 item.album = album;
770 }
771 if let Some(dur) = duration_ms {
772 item.duration_ms = Some(dur);
773 }
774 }
775 drop(pl);
776 self.bump_version();
777 }
778
779 pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
782 let pl = self.playlist.read();
783 pl.items
784 .iter()
785 .find(|item| item.id == id)
786 .and_then(|item| match LoadState::of(item) {
787 LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
788 LoadState::Downloading {
789 path,
790 total,
791 bytes_written,
792 } => {
793 let written = bytes_written.load(Ordering::Acquire);
794 (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
795 path,
796 bytes_written,
797 total,
798 })
799 }
800 _ => None,
801 })
802 }
803
804 pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
814 let mut pl = self.playlist.write();
815 let mut reset = Vec::new();
816 for item in pl.items.iter_mut() {
817 let Some(db_id) = item.db_id else { continue };
818 if !matches!(item.state, ItemState::Ready) {
819 continue;
820 }
821 if item.path.exists() {
822 continue;
823 }
824 item.state = ItemState::Pending;
825 reset.push((db_id, item.id));
826 }
827 drop(pl);
828 if !reset.is_empty() {
829 self.bump_version();
830 }
831 reset
832 }
833
834 pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
836 let pl = self.playlist.read();
837 pl.items.iter().find(|item| item.id == id).and_then(|item| {
838 if matches!(item.state, ItemState::Ready) {
839 Some(item.path.clone())
840 } else {
841 None
842 }
843 })
844 }
845
846 pub fn is_cursor(&self, id: QueueItemId) -> bool {
848 self.playlist.read().cursor == Some(id)
849 }
850
851 pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
855 let pl = self.playlist.read();
856 let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
857 return vec![];
858 };
859 let album = cursor.album.clone();
860 let album_artist = cursor.album_artist.clone();
861 pl.items
862 .iter()
863 .filter(|item| {
864 item.id != id && item.album == album && item.album_artist == album_artist
865 })
866 .map(|item| item.id)
867 .collect()
868 }
869
870 pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
873 let pl = self.playlist.read();
874 pl.items
875 .iter()
876 .filter(|item| matches!(item.state, ItemState::Pending))
877 .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
878 .collect()
879 }
880
881 pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
887 crate::remote::downloads::store()
888 .all()
889 .iter()
890 .filter(|d| !d.state.is_settled())
891 .map(|d| (d.id, d.bytes_written(), d.total))
892 .collect()
893 }
894
895 pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
897 let pl = self.playlist.read();
898 pl.items
899 .iter()
900 .find(|item| item.id == id)
901 .and_then(|item| item.db_id)
902 }
903
904 pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
906 let pl = self.playlist.read();
907 pl.items
908 .iter()
909 .find(|item| item.id == id)
910 .map(LoadState::of)
911 }
912
913 pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
917 let pl = self.playlist.read();
918 (pl.items.clone(), pl.cursor)
919 }
920
921 pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
923 let pl = self.playlist.read();
924 pl.items.iter().find(|item| item.id == id).cloned()
925 }
926
927 pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
929 let pl = self.playlist.read();
930 let pos = pl.items.iter().position(|item| item.id == id)?;
931 if pos == 0 {
932 None
933 } else {
934 Some(pl.items[pos - 1].id)
935 }
936 }
937
938 pub fn reorder_to(&self, order: &[QueueItemId]) {
951 let mut pl = self.playlist.write();
952 let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
953 let mut sorted = Vec::with_capacity(taken.len());
954 for id in order {
955 if let Some(slot) = taken
956 .iter_mut()
957 .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
958 && let Some(item) = slot.take()
959 {
960 sorted.push(item);
961 }
962 }
963 sorted.extend(taken.into_iter().flatten());
964 pl.items = sorted;
965 drop(pl);
966 self.bump_version();
967 }
968
969 pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
970 use std::collections::HashSet;
971 let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
972 let pl = self.playlist.read();
973 pl.items
974 .iter()
975 .enumerate()
976 .filter(|(_, item)| wanted.contains(&item.id))
977 .map(|(pos, item)| {
978 let before = if pos == 0 {
979 None
980 } else {
981 Some(pl.items[pos - 1].id)
982 };
983 (item.id, before)
984 })
985 .collect()
986 }
987
988 pub fn surviving_item_before(
992 &self,
993 id: QueueItemId,
994 removed: &[QueueItemId],
995 ) -> Option<QueueItemId> {
996 use std::collections::HashSet;
997 let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
998 let pl = self.playlist.read();
999 let pos = pl.items.iter().position(|item| item.id == id)?;
1000 pl.items[..pos]
1001 .iter()
1002 .rev()
1003 .find(|item| !removed.contains(&item.id))
1004 .map(|item| item.id)
1005 }
1006
1007 pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
1009 let mut pl = self.playlist.write();
1010 pl.items = items;
1011 pl.cursor = cursor;
1012 drop(pl);
1013 self.bump_version();
1014 }
1015
1016 pub fn remove_items(&self, ids: &[QueueItemId]) {
1018 use std::collections::HashSet;
1019 let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
1020 let mut pl = self.playlist.write();
1021 pl.items.retain(|item| !id_set.contains(&item.id));
1022 if let Some(cursor) = pl.cursor
1023 && id_set.contains(&cursor)
1024 {
1025 pl.cursor = None;
1026 }
1027 drop(pl);
1028 self.bump_version();
1029 }
1030
1031 pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1033 let mut pl = self.playlist.write();
1034 let insert_at = match after {
1035 Some(after_id) => {
1036 match pl.items.iter().position(|i| i.id == after_id) {
1037 Some(pos) => pos + 1,
1038 None => pl.items.len(), }
1040 }
1041 None => 0,
1042 };
1043 pl.items.insert(insert_at, item);
1044 drop(pl);
1045 self.bump_version();
1046 }
1047
1048 pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1050 let mut pl = self.playlist.write();
1051 let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1052 return;
1053 };
1054 let item = pl.items.remove(from);
1055 let insert_at = match after {
1056 Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1057 Some(pos) => pos + 1,
1058 None => pl.items.len(),
1059 },
1060 None => 0,
1061 };
1062 pl.items.insert(insert_at, item);
1063 drop(pl);
1064 self.bump_version();
1065 }
1066
1067 pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1070 for &(id, after) in entries {
1071 self.move_item_to(id, after);
1072 }
1073 }
1074
1075 pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1080 let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1082 let pl = self.playlist.read();
1083
1084 let cursor_pos = match pl.cursor {
1085 Some(cid) => pl.items.iter().position(|item| item.id == cid),
1086 None => None,
1087 };
1088
1089 let mut entries = Vec::with_capacity(pl.items.len());
1090 let mut finished_count = 0;
1091 let mut has_playing = false;
1092 let mut queue_count = 0;
1093
1094 for (i, item) in pl.items.iter().enumerate() {
1095 let is_cursor = cursor_pos == Some(i);
1096 let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1097
1098 let load_state = LoadState::of(item);
1105
1106 let dl_progress = match &load_state {
1107 LoadState::Downloading {
1108 total,
1109 bytes_written,
1110 ..
1111 } => Some((bytes_written.load(Ordering::Relaxed), *total)),
1112 _ => None,
1113 };
1114
1115 let status = if is_cursor {
1116 has_playing = true;
1117 match &load_state {
1118 LoadState::Ready => QueueEntryStatus::Playing,
1119 LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
1120 LoadState::Pending => QueueEntryStatus::PriorityPending,
1121 LoadState::Failed(_) => QueueEntryStatus::Failed,
1122 }
1123 } else if is_before_cursor {
1124 finished_count += 1;
1125 match &load_state {
1126 LoadState::Ready => QueueEntryStatus::Played,
1127 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1128 LoadState::Pending => QueueEntryStatus::Downloading,
1129 LoadState::Failed(_) => QueueEntryStatus::Failed,
1130 }
1131 } else {
1132 queue_count += 1;
1133 match &load_state {
1134 LoadState::Ready => QueueEntryStatus::Queued,
1135 LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1136 LoadState::Pending => QueueEntryStatus::Downloading,
1137 LoadState::Failed(_) => QueueEntryStatus::Failed,
1138 }
1139 };
1140
1141 let duration_ms =
1143 if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1144 {
1145 playing_duration_ms
1146 } else {
1147 item.duration_ms
1148 };
1149
1150 entries.push(QueueEntry {
1151 id: item.id,
1152 db_id: item.db_id,
1153 playlist_entry_id: item.playlist_entry_id,
1154 path: item.path.clone(),
1155 title: item.title.clone(),
1156 artist: item.artist.clone(),
1157 album_artist: item.album_artist.clone(),
1158 album: item.album.clone(),
1159 year: item.year.clone(),
1160 codec: item.codec.clone(),
1161 track_number: item.track_number,
1162 disc: item.disc,
1163 duration_ms,
1164 status,
1165 download_progress: dl_progress,
1166 error: match &load_state {
1167 LoadState::Failed(reason) => Some(reason.clone()),
1168 _ => None,
1169 },
1170 });
1171 }
1172
1173 VisibleQueueSnapshot {
1174 entries,
1175 finished_count,
1176 has_playing,
1177 queue_count,
1178 }
1179 }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185
1186 fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1189 PlaylistItem {
1190 playlist_entry_id: None,
1191 id: QueueItemId::new(),
1192 db_id: None,
1193 path: PathBuf::from(format!("/music/{title}.flac")),
1194 title: title.to_string(),
1195 artist: "Artist".to_string(),
1196 album_artist: "Artist".to_string(),
1197 album: "Album".to_string(),
1198 year: None,
1199 codec: Some("FLAC".to_string()),
1200 track_number: None,
1201 disc: None,
1202 duration_ms: Some(200_000),
1203 state,
1204 }
1205 }
1206
1207 fn downloading_item(
1210 title: &str,
1211 total: u64,
1212 written: Arc<crate::remote::downloads::ByteFeed>,
1213 ) -> PlaylistItem {
1214 let item = make_item(title, ItemState::Pending);
1215 let store = crate::remote::downloads::store();
1216 store.queued(crate::remote::downloads::Download {
1217 id: item.id,
1218 track_id: 1,
1219 title: title.into(),
1220 artist: String::new(),
1221 source: PathBuf::from(format!("/cache/{title}.flac.part")),
1222 dest: PathBuf::from(format!("/cache/{title}.flac")),
1223 total,
1224 written: written.clone(),
1225 state: crate::remote::downloads::DownloadState::Queued,
1226 bytes_per_second: 0,
1227 });
1228 store.started(item.id, total, written);
1229 item
1230 }
1231
1232 fn ready_item(title: &str) -> PlaylistItem {
1233 make_item(title, ItemState::Ready)
1234 }
1235
1236 fn pending_item(title: &str) -> PlaylistItem {
1237 make_item(title, ItemState::Pending)
1238 }
1239
1240 fn failed_item(title: &str) -> PlaylistItem {
1241 make_item(title, ItemState::Failed("nope".into()))
1242 }
1243
1244 const DURATION_MS: u64 = 32_523_787;
1245
1246 fn streaming_state(
1249 downloaded: u64,
1250 total: u64,
1251 bitrate_kbps: Option<u32>,
1252 ) -> Arc<SharedPlayerState> {
1253 streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1254 }
1255
1256 fn streaming_state_with_duration(
1259 downloaded: u64,
1260 total: u64,
1261 bitrate_kbps: Option<u32>,
1262 container_duration_ms: u64,
1263 ) -> Arc<SharedPlayerState> {
1264 let written = crate::remote::downloads::ByteFeed::new();
1265 written.set(downloaded);
1266 let item = make_item("train", ItemState::Pending);
1267 let id = item.id;
1268 let path = item.path.clone();
1269
1270 let store = crate::remote::downloads::store();
1273 store.queued(crate::remote::downloads::Download {
1274 id,
1275 track_id: 1,
1276 title: "train".into(),
1277 artist: String::new(),
1278 source: PathBuf::from("/cache/train.opus.part"),
1279 dest: PathBuf::from("/cache/train.opus"),
1280 total,
1281 written: written.clone(),
1282 state: crate::remote::downloads::DownloadState::Queued,
1283 bytes_per_second: 0,
1284 });
1285 store.started(id, total, written);
1286
1287 let state = SharedPlayerState::new();
1288 state.add_items(vec![item]);
1289 state.set_cursor(Some(id));
1290 state.set_track_info(Some(TrackInfo {
1291 id,
1292 path,
1293 codec: "Opus".into(),
1294 sample_rate: 48_000,
1295 bit_depth: None,
1296 bitrate_kbps,
1297 channels: 2,
1298 duration_ms: container_duration_ms,
1299 }));
1300 state
1301 }
1302
1303 #[test]
1306 fn a_track_on_disk_is_seekable_end_to_end() {
1307 let item = ready_item("done");
1308 let id = item.id;
1309 let path = item.path.clone();
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: "FLAC".into(),
1317 sample_rate: 44_100,
1318 bit_depth: Some(16),
1319 bitrate_kbps: None,
1320 channels: 2,
1321 duration_ms: 200_000,
1322 }));
1323
1324 assert_eq!(state.seekable_ms(), 200_000);
1325 assert_eq!(state.seek_ceiling_ms(), None);
1327 }
1328
1329 #[test]
1330 fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1331 let state = streaming_state(100, 400, None);
1334 assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1335 assert_eq!(
1336 state.seek_ceiling_ms(),
1337 Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1338 );
1339 }
1340
1341 #[test]
1342 fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1343 let state = streaming_state(1024 * 1024, 0, Some(128));
1346 assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1347 }
1348
1349 #[test]
1350 fn nothing_to_estimate_from_allows_no_forward_seek() {
1351 let state = streaming_state(1024 * 1024, 0, None);
1354 state.set_position_ms(12_000);
1355 assert_eq!(state.seekable_ms(), 12_000);
1356 }
1357
1358 #[test]
1359 fn the_seekable_extent_never_exceeds_the_track() {
1360 let state = streaming_state(500, 400, None);
1363 assert_eq!(state.seekable_ms(), 32_523_787);
1364 }
1365
1366 #[test]
1367 fn nothing_playing_is_seekable_nowhere() {
1368 assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1369 assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1370 }
1371
1372 #[test]
1373 fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1374 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1378 assert_eq!(state.seekable_ms(), 0);
1379 }
1380
1381 #[test]
1382 fn the_library_duration_stands_in_for_a_silent_container() {
1383 let state = streaming_state_with_duration(200, 400, Some(128), 0);
1386 assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1387 assert_eq!(state.seekable_ms(), 0);
1389 assert_eq!(state.seek_ceiling_ms(), Some(0));
1390 }
1391
1392 #[test]
1393 fn the_container_duration_wins_where_there_is_one() {
1394 let state = streaming_state(200, 400, None);
1395 assert_eq!(state.duration_ms(), DURATION_MS);
1396 }
1397
1398 #[test]
1399 fn the_download_landing_restores_seeking() {
1400 let state = streaming_state_with_duration(400, 400, Some(128), 0);
1404 assert_eq!(state.seekable_ms(), 0);
1405
1406 let id = state.cursor().expect("cursor");
1410 crate::remote::downloads::store().finished(id);
1411 state.update_item_state(id, ItemState::Ready);
1412 let info = state.track_info().expect("track info");
1413 state.set_track_info(Some(TrackInfo {
1414 duration_ms: DURATION_MS,
1415 ..info
1416 }));
1417
1418 assert_eq!(state.seekable_ms(), DURATION_MS);
1419 assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1420 }
1421
1422 #[test]
1425 fn advance_parks_on_a_still_downloading_track() {
1426 let state = SharedPlayerState::new();
1430 let item0 = ready_item("track-0");
1431 let item1 = pending_item("track-1");
1432 let item2 = ready_item("track-2");
1433 let (id0, id1) = (item0.id, item1.id);
1434
1435 state.add_items(vec![item0, item1, item2]);
1436
1437 assert_eq!(state.advance_cursor_loadable(), Some(id0));
1438 assert_eq!(state.advance_cursor_loadable(), Some(id1));
1439 assert_eq!(state.cursor(), Some(id1));
1440 }
1441
1442 #[test]
1443 fn advance_skips_failed_items() {
1444 let state = SharedPlayerState::new();
1445 let item0 = ready_item("track-0");
1446 let item1 = failed_item("track-1");
1447 let item2 = ready_item("track-2");
1448 let (id0, id2) = (item0.id, item2.id);
1449
1450 state.add_items(vec![item0, item1, item2]);
1451 state.set_cursor(Some(id0));
1452
1453 assert_eq!(state.advance_cursor_loadable(), Some(id2));
1454 }
1455
1456 #[test]
1457 fn advance_stops_at_end_of_playlist() {
1458 let state = SharedPlayerState::new();
1459 let item0 = ready_item("track-0");
1460 let item1 = ready_item("track-1");
1461 let id1 = item1.id;
1462
1463 state.add_items(vec![item0, item1]);
1464 state.set_cursor(Some(id1));
1465
1466 assert_eq!(state.advance_cursor_loadable(), None);
1467 assert_eq!(
1468 state.cursor(),
1469 Some(id1),
1470 "cursor unchanged on a failed advance"
1471 );
1472 }
1473
1474 #[test]
1475 fn advance_with_only_failed_items_returns_none() {
1476 let state = SharedPlayerState::new();
1477 state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1478
1479 assert_eq!(state.advance_cursor_loadable(), None);
1480 }
1481
1482 #[test]
1483 fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1484 let state = SharedPlayerState::new();
1485 let item0 = ready_item("track-0");
1486 let item1 = ready_item("track-1");
1487 let id0 = item0.id;
1488
1489 state.add_items(vec![item0, item1]);
1490 let ghost = QueueItemId::new();
1491 state.set_cursor(Some(ghost));
1492
1493 assert_eq!(state.advance_cursor_loadable(), None);
1494 assert_ne!(state.cursor(), Some(id0));
1495 }
1496
1497 #[test]
1500 fn peek_after_a_removed_item_returns_none() {
1501 let state = SharedPlayerState::new();
1505 let item0 = ready_item("track-0");
1506 let item1 = ready_item("track-1");
1507 let item2 = ready_item("track-2");
1508 let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1509
1510 state.add_items(vec![item0, item1, item2]);
1511 assert_eq!(
1512 state.peek_next_ready_after(id1).map(|(id, _)| id),
1513 Some(id2)
1514 );
1515
1516 state.remove_item(id2);
1517 assert!(
1518 state.peek_next_ready_after(id2).is_none(),
1519 "a vanished reference must not resolve to the head of the queue"
1520 );
1521 assert_ne!(
1522 state.peek_next_ready_after(id2).map(|(id, _)| id),
1523 Some(id0)
1524 );
1525 }
1526
1527 #[test]
1530 fn surviving_predecessor_skips_items_being_removed() {
1531 let state = SharedPlayerState::new();
1532 let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1533 let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1534 state.add_items(items);
1535
1536 assert_eq!(
1538 state.surviving_item_before(ids[3], &ids[1..4]),
1539 Some(ids[0])
1540 );
1541 assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1543 }
1544
1545 #[test]
1548 fn test_retreat_cursor_goes_to_previous_item() {
1549 let state = SharedPlayerState::new();
1550 let item0 = ready_item("track-0");
1551 let item1 = ready_item("track-1");
1552 let id0 = item0.id;
1553 let id1 = item1.id;
1554
1555 state.add_items(vec![item0, item1]);
1556 state.set_cursor(Some(id1));
1557
1558 let result = state.retreat_cursor();
1559 assert!(result.is_some(), "expected to retreat to previous item");
1560 assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1561 assert_eq!(state.cursor(), Some(id0));
1562 }
1563
1564 #[test]
1565 fn test_retreat_cursor_returns_none_when_at_first_item() {
1566 let state = SharedPlayerState::new();
1567 let item0 = ready_item("only-track");
1568 let id0 = item0.id;
1569
1570 state.add_items(vec![item0]);
1571 state.set_cursor(Some(id0));
1572
1573 let result = state.retreat_cursor();
1574 assert!(result.is_none(), "cannot retreat before the first item");
1575 assert_eq!(state.cursor(), Some(id0));
1577 }
1578
1579 #[test]
1580 fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1581 let state = SharedPlayerState::new();
1582 state.add_items(vec![ready_item("track-0")]);
1583
1584 let result = state.retreat_cursor();
1585 assert!(
1586 result.is_none(),
1587 "retreat with no cursor should return None"
1588 );
1589 }
1590
1591 #[test]
1594 fn test_derive_visible_queue_statuses() {
1595 let state = SharedPlayerState::new();
1597 let item0 = ready_item("played-track");
1598 let item1 = ready_item("playing-track");
1599 let item2 = ready_item("queued-track");
1600 let id1 = item1.id;
1601
1602 state.add_items(vec![item0, item1, item2]);
1603 state.set_cursor(Some(id1));
1604
1605 let snap = state.derive_visible_queue();
1606
1607 assert_eq!(snap.entries.len(), 3);
1608 assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1609 assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1610 assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1611 assert!(snap.has_playing);
1612 assert_eq!(snap.finished_count, 1);
1613 assert_eq!(snap.queue_count, 1);
1614 }
1615
1616 #[test]
1617 fn test_derive_visible_queue_downloading_statuses() {
1618 let state = SharedPlayerState::new();
1620 let bytes_cursor = crate::remote::downloads::ByteFeed::new();
1621 let bytes_queued = crate::remote::downloads::ByteFeed::new();
1622 let dl_cursor = downloading_item("downloading-at-cursor", 1_000_000, bytes_cursor.clone());
1623 let dl_queued = downloading_item("downloading-queued", 500_000, bytes_queued.clone());
1624 let id_cursor = dl_cursor.id;
1625
1626 state.add_items(vec![dl_cursor, dl_queued]);
1627 state.set_cursor(Some(id_cursor));
1628
1629 let snap = state.derive_visible_queue();
1630
1631 assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1632 assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1633 }
1634
1635 #[test]
1636 fn progress_follows_the_counter_without_touching_the_playlist() {
1637 let state = SharedPlayerState::new();
1641 let bytes = crate::remote::downloads::ByteFeed::new();
1642 let item = downloading_item("downloading", 1_000, bytes.clone());
1643 state.add_items(vec![item]);
1644
1645 let version = state.playlist_version();
1646 bytes.set(250);
1647
1648 let snap = state.derive_visible_queue();
1649 assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1650 assert_eq!(
1651 state.playlist_version(),
1652 version,
1653 "progress must not read as a queue mutation"
1654 );
1655 assert!(
1660 state
1661 .downloads_in_flight()
1662 .contains(&(snap.entries[0].id, 250, 1_000)),
1663 "the counter should be visible through the store"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_derive_visible_queue_no_cursor_all_queued() {
1669 let state = SharedPlayerState::new();
1670 state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1671
1672 let snap = state.derive_visible_queue();
1673
1674 assert_eq!(snap.entries.len(), 3);
1675 for entry in &snap.entries {
1676 assert_eq!(entry.status, QueueEntryStatus::Queued);
1677 }
1678 assert!(!snap.has_playing);
1679 assert_eq!(snap.finished_count, 0);
1680 assert_eq!(snap.queue_count, 3);
1681 }
1682
1683 fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1686 PlaylistItem {
1687 playlist_entry_id: None,
1688 id: QueueItemId::new(),
1689 db_id: None,
1690 path: PathBuf::from(format!("/music/{title}.flac")),
1691 title: title.to_string(),
1692 artist: "Artist".to_string(),
1693 album_artist: album_artist.to_string(),
1694 album: album.to_string(),
1695 year: None,
1696 codec: Some("FLAC".to_string()),
1697 track_number: None,
1698 disc: None,
1699 duration_ms: Some(200_000),
1700 state: ItemState::Ready,
1701 }
1702 }
1703
1704 #[test]
1705 fn test_same_album_item_ids_returns_album_mates() {
1706 let state = SharedPlayerState::new();
1707 let a1 = make_album_item("A1", "Album A", "Artist A");
1708 let a2 = make_album_item("A2", "Album A", "Artist A");
1709 let b1 = make_album_item("B1", "Album B", "Artist B");
1710 let a3 = make_album_item("A3", "Album A", "Artist A");
1711
1712 let id_a1 = a1.id;
1713 let id_a2 = a2.id;
1714 let id_a3 = a3.id;
1715
1716 state.add_items(vec![a1, a2, b1, a3]);
1717
1718 let mates = state.same_album_item_ids(id_a1);
1719 assert_eq!(mates.len(), 2);
1720 assert!(mates.contains(&id_a2));
1721 assert!(mates.contains(&id_a3));
1722 }
1723
1724 #[test]
1725 fn test_same_album_item_ids_distinguishes_album_artists() {
1726 let state = SharedPlayerState::new();
1728 let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1729 let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1730
1731 let id_a1 = a1.id;
1732
1733 state.add_items(vec![a1, b1]);
1734
1735 let mates = state.same_album_item_ids(id_a1);
1736 assert!(mates.is_empty(), "different album_artist should not match");
1737 }
1738
1739 #[test]
1740 fn test_same_album_item_ids_unknown_id_returns_empty() {
1741 let state = SharedPlayerState::new();
1742 state.add_items(vec![ready_item("track-0")]);
1743
1744 let bogus = QueueItemId::new();
1745 let mates = state.same_album_item_ids(bogus);
1746 assert!(mates.is_empty());
1747 }
1748
1749 #[test]
1752 fn test_update_item_metadata_leaves_library_tags_alone() {
1753 let state = SharedPlayerState::new();
1754 let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
1755 item.db_id = Some(29615);
1756 let id = item.id;
1757 state.add_items(vec![item]);
1758
1759 state.update_item_metadata(
1760 id,
1761 "[unknown]".into(),
1762 "Soulwax".into(),
1763 "Soulwax".into(),
1764 "Nite Versions".into(),
1765 Some(54_000),
1766 );
1767
1768 let pl = state.playlist.read();
1769 assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
1770 assert_eq!(pl.items[0].title, "A1");
1771 assert_eq!(pl.items[0].duration_ms, Some(54_000));
1772 }
1773
1774 #[test]
1775 fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
1776 let state = SharedPlayerState::new();
1777 let item = make_album_item("A1", "", "");
1778 let id = item.id;
1779 state.add_items(vec![item]);
1780
1781 state.update_item_metadata(
1782 id,
1783 "Teachers".into(),
1784 "Soulwax".into(),
1785 "Soulwax".into(),
1786 "Nite Versions".into(),
1787 Some(148_000),
1788 );
1789
1790 let pl = state.playlist.read();
1791 assert_eq!(pl.items[0].title, "Teachers");
1792 assert_eq!(pl.items[0].album, "Nite Versions");
1793 assert_eq!(pl.items[0].duration_ms, Some(148_000));
1794 }
1795
1796 #[test]
1799 fn test_move_item_to_reorders_playlist() {
1800 let state = SharedPlayerState::new();
1802 let item_a = ready_item("A");
1803 let item_b = ready_item("B");
1804 let item_c = ready_item("C");
1805 let id_a = item_a.id;
1806 let id_b = item_b.id;
1807 let id_c = item_c.id;
1808
1809 state.add_items(vec![item_a, item_b, item_c]);
1810 state.move_item_to(id_c, Some(id_a));
1811
1812 let (items, _) = state.snapshot_playlist();
1813 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1814 assert_eq!(titles, vec!["A", "C", "B"]);
1815 assert_eq!(items[0].id, id_a);
1816 assert_eq!(items[1].id, id_c);
1817 assert_eq!(items[2].id, id_b);
1818 }
1819
1820 #[test]
1821 fn test_move_item_to_front_when_after_is_none() {
1822 let state = SharedPlayerState::new();
1824 let item_a = ready_item("A");
1825 let item_b = ready_item("B");
1826 let item_c = ready_item("C");
1827 let id_c = item_c.id;
1828
1829 state.add_items(vec![item_a, item_b, item_c]);
1830 state.move_item_to(id_c, None);
1831
1832 let (items, _) = state.snapshot_playlist();
1833 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1834 assert_eq!(titles, vec!["C", "A", "B"]);
1835 }
1836
1837 #[test]
1840 fn test_move_items_batch_preserves_relative_order() {
1841 let state = SharedPlayerState::new();
1843 let item_a = ready_item("A");
1844 let item_b = ready_item("B");
1845 let item_c = ready_item("C");
1846 let item_d = ready_item("D");
1847 let id_a = item_a.id;
1848 let id_b = item_b.id;
1849 let id_c = item_c.id;
1850 let id_d = item_d.id;
1851
1852 state.add_items(vec![item_a, item_b, item_c, item_d]);
1853 state.move_items(&[id_a, id_c], id_d, true);
1854
1855 let (items, _) = state.snapshot_playlist();
1856 let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1857 assert_eq!(titles, vec!["B", "D", "A", "C"]);
1858 assert_eq!(items[0].id, id_b);
1859 assert_eq!(items[1].id, id_d);
1860 assert_eq!(items[2].id, id_a);
1861 assert_eq!(items[3].id, id_c);
1862 }
1863
1864 #[test]
1867 fn test_pending_downloads_collects_pending_with_db_id() {
1868 let state = SharedPlayerState::new();
1869 let mut item_a = ready_item("local");
1870 item_a.db_id = None;
1871
1872 let mut item_b = pending_item("remote-1");
1873 item_b.db_id = Some(10);
1874 let id_b = item_b.id;
1875
1876 let mut item_c = ready_item("cached");
1877 item_c.db_id = Some(20);
1878
1879 let mut item_d = pending_item("remote-2");
1880 item_d.db_id = Some(30);
1881 let id_d = item_d.id;
1882
1883 let item_e = pending_item("orphan");
1885
1886 state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1887
1888 let pending = state.pending_downloads();
1889 assert_eq!(pending.len(), 2);
1890 assert_eq!(pending[0], (10, id_b));
1891 assert_eq!(pending[1], (30, id_d));
1892 }
1893
1894 #[test]
1895 fn test_item_db_id_and_load_state() {
1896 let state = SharedPlayerState::new();
1897 let mut item = pending_item("track");
1898 item.db_id = Some(42);
1899 let id = item.id;
1900 state.add_items(vec![item]);
1901
1902 assert_eq!(state.item_db_id(id), Some(42));
1903 assert!(matches!(
1904 state.item_load_state(id),
1905 Some(LoadState::Pending)
1906 ));
1907
1908 state.update_item_state(id, ItemState::Ready);
1909 assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1910 }
1911}