use crabidy_core::proto::crabidy::{Queue, Track}; use rand::{rng, seq::SliceRandom}; use std::sync::{atomic::AtomicBool, Arc}; use std::time::SystemTime; use tracing::{debug, error}; /// How a pending queue operation places its resolved chunks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ResolveKind { /// First chunk replaces the whole queue, later chunks append. Replace, /// Every chunk appends at the end. Append, /// Chunks insert after the given position, each advancing the cursor so /// the resolved collection stays contiguous and in order. `Queue` /// (play-after-current) is an `InsertAfter` at the current position. InsertAfter(u32), } /// The playback loop's bookkeeping for one in-flight resolve operation. /// /// Created when a `Replace`/`Queue`/`Append`/`Insert` command arrives, /// dropped when its forwarder reports completion or a `Replace`/`Clear` /// cancels it. Chunk application happens exclusively on the playback loop, /// which keeps the loop the single writer of queue state. #[derive(Debug)] pub struct PendingResolve { kind: ResolveKind, /// Tracks applied so far; an op finishing at zero is worth a warning. applied: usize, /// Shared with the op's forwarder task: set on cancellation so the /// forwarder drops the chunk receiver, which stops the provider fetch. cancelled: Arc, } impl PendingResolve { pub fn new(kind: ResolveKind) -> Self { Self { kind, applied: 0, cancelled: Arc::new(AtomicBool::new(false)), } } /// The cancellation flag to hand to this op's forwarder task. pub fn cancel_flag(&self) -> Arc { Arc::clone(&self.cancelled) } /// Marks the op cancelled so its forwarder stops feeding chunks. pub fn cancel(&self) { self.cancelled .store(true, std::sync::atomic::Ordering::Relaxed); } /// Total tracks applied by this op so far. pub fn applied(&self) -> usize { self.applied } /// Applies one resolved chunk to the queue and advances this op's /// cursor. Returns the track that should start playing, if this chunk /// made one current (first chunk of a replace, or any chunk landing in /// an empty queue) — later chunks of the same op never restart playback. pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option { self.applied += tracks.len(); match self.kind { ResolveKind::Replace => { // Only the first chunk replaces; the rest of this op // extends the fresh queue. self.kind = ResolveKind::Append; queue.replace_with_tracks(tracks) } ResolveKind::Append => queue.append_tracks(tracks), ResolveKind::InsertAfter(position) => { // Advance the cursor so this op's next chunk lands right // behind this one, keeping the collection contiguous. // `insert_tracks` clamps positions past the end. self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32); queue.insert_tracks(position, tracks) } } } } #[derive(Clone, Debug)] pub struct QueueManager { created_at: SystemTime, current_offset: usize, play_order: Vec, tracks: Vec, pub repeat: bool, pub shuffle: bool, } impl From for Queue { fn from(queue_manager: QueueManager) -> Self { Self { // A clock step backwards must not panic the playback loop. timestamp: queue_manager .created_at .elapsed() .unwrap_or_default() .as_secs(), current_position: queue_manager.current_position() as u32, tracks: queue_manager.tracks, // The manager cannot know about in-flight resolves; the // playback loop's broadcast path sets this from its pending-op // map (see `Playback::broadcast_queue`). resolving: false, } } } impl Default for QueueManager { fn default() -> Self { Self::new() } } impl QueueManager { pub fn new() -> Self { Self { created_at: SystemTime::now(), current_offset: 0, play_order: Vec::new(), tracks: Vec::new(), repeat: false, shuffle: false, } } pub fn current_position(&self) -> usize { if self.current_offset < self.play_order.len() { self.play_order[self.current_offset] } else { 0 } } pub fn is_last_track(&self) -> bool { !self.tracks.is_empty() && self.current_position() == self.tracks.len() - 1 } pub fn shuffle_on(&mut self) { self.shuffle = true; self.shuffle_before(self.current_offset); self.shuffle_behind(self.current_offset); } pub fn shuffle_off(&mut self) { self.shuffle = false; let pos = self.current_position(); self.current_offset = pos; self.play_order = (0..self.tracks.len()).collect(); } pub fn shuffle_all(&mut self) { self.play_order.shuffle(&mut rng()); } pub fn shuffle_before(&mut self, pos: usize) { if let Some(slice) = self.play_order.get_mut(..pos) { slice.shuffle(&mut rng()); } } pub fn shuffle_behind(&mut self, pos: usize) { if let Some(slice) = self.play_order.get_mut(pos + 1..) { slice.shuffle(&mut rng()); } } pub fn current_track(&self) -> Option { if self.current_position() < self.tracks.len() { Some(self.tracks[self.current_position()].clone()) } else { None } } pub fn next_track(&mut self) -> Option { let len = self.tracks.len(); if len == 0 { return None; }; if self.current_offset < len - 1 { self.current_offset += 1; let current_pos = self.current_position(); if current_pos < len { Some(self.tracks[current_pos].clone()) } else { None } } else { debug!("no more tracks"); if self.repeat { debug!("repeat"); self.current_offset = 0; if self.shuffle { self.shuffle_all(); } return self.current_track(); } None } } pub fn prev_track(&mut self) -> Option { if 0 < self.current_offset { self.current_offset -= 1; self.current_track() } else { None } } pub fn set_current_position(&mut self, current_position: u32) -> bool { if current_position < self.tracks.len() as u32 { if self.shuffle { self.shuffle_all(); } let Some(current_offset) = self .play_order .iter() .position(|&i| i == current_position as usize) else { error!("invalid current position"); error!("queue: {:#?}", self); return false; }; if self.shuffle { self.play_order.swap(0, current_offset); self.current_offset = 0; } else { self.current_offset = current_offset; } true } else { false } } pub fn replace_with_tracks(&mut self, tracks: &[Track]) -> Option { self.current_offset = 0; self.tracks = tracks.to_vec(); self.play_order = (0..self.tracks.len()).collect(); if self.shuffle { self.shuffle_all(); } if 0 < self.tracks.len() as u32 { Some(self.tracks[self.current_position()].clone()) } else { None } } pub fn append_tracks(&mut self, tracks: &[Track]) -> Option { let len = self.tracks.len(); let is_empty = len == 0; let order_additions: Vec = (len..len + tracks.len()).collect(); self.play_order.extend(order_additions); self.tracks.extend(tracks.iter().cloned()); if self.shuffle { self.shuffle_behind(self.current_offset); } if is_empty { self.current_track() } else { None } } pub fn remove_tracks(&mut self, positions: &[u32]) -> Option { let mut play_next = false; // Remove highest positions first so earlier removals don't shift the // positions that are still to be removed. let mut positions: Vec = positions.iter().map(|p| *p as usize).collect(); positions.sort_unstable_by(|a, b| b.cmp(a)); positions.dedup(); for pos in positions { if pos >= self.tracks.len() { debug!(pos, len = self.tracks.len(), "ignoring out-of-range remove"); continue; } if pos == self.current_position() { play_next = true; } let Some(offset) = self.play_order.iter().position(|&i| i == pos) else { error!(pos, "track position missing from play order, rebuilding"); self.rebuild_play_order(); return None; }; if offset < self.current_offset { self.current_offset -= 1; } self.tracks.remove(pos); self.play_order.remove(offset); self.play_order .iter_mut() .filter(|i| pos < **i) .for_each(|i| *i -= 1); } if self.current_offset >= self.play_order.len() { self.current_offset = 0; } if play_next { self.current_track() } else { None } } pub fn insert_tracks(&mut self, position: u32, tracks: &[Track]) -> Option { let len = self.tracks.len(); if len == 0 { return self.replace_with_tracks(tracks); } let inserted = tracks.len(); let position = (position as usize).min(len - 1); let order_additions: Vec = (len..len + inserted).collect(); self.play_order.extend(order_additions); let tail: Vec = self .tracks .splice(position + 1.., tracks.to_vec()) .collect(); self.tracks.extend(tail); let mut changed: Vec = Vec::new(); // In shuffle mode we may already have played positions that are // behind the insertion point; those shift by the number of inserted // tracks. for i in self .play_order .iter_mut() .take(self.current_offset) .filter(|i| position < **i) { *i += inserted; changed.push(*i); } // The freshly appended order entries need to swap with the shifted // ones so every index stays unique. self.play_order .iter_mut() .skip(self.current_offset) .for_each(|i| { if changed.contains(i) { *i -= inserted; } }); if self.shuffle { self.shuffle_behind(self.current_offset); } None } pub fn queue_tracks(&mut self, tracks: &[Track]) -> Option { let pos = self.current_position(); self.insert_tracks(pos as u32, tracks) } pub fn clear(&mut self, exclude_current: bool) -> bool { let current_track = self.current_track(); self.current_offset = 0; self.tracks.clear(); self.play_order.clear(); if exclude_current { if let Some(track) = current_track { self.tracks.push(track); self.play_order.push(0); } } !exclude_current } /// Restores play_order to a consistent state after an inconsistency was /// detected. Loses shuffle history but keeps the queue playable. fn rebuild_play_order(&mut self) { self.play_order = (0..self.tracks.len()).collect(); self.current_offset = 0; } } #[cfg(test)] mod tests { use super::*; fn track(id: usize) -> Track { Track { path: format!("/tidal/playlists/p/{id}"), artist: "artist".to_string(), title: format!("track {id}"), duration: None, album: None, } } fn queue_with(n: usize) -> QueueManager { let mut q = QueueManager::new(); let tracks: Vec = (0..n).map(track).collect(); q.replace_with_tracks(&tracks); q } #[test] fn empty_queue_operations_do_not_panic() { let mut q = QueueManager::new(); assert!(!q.is_last_track()); assert!(q.current_track().is_none()); assert!(q.next_track().is_none()); assert!(q.prev_track().is_none()); assert!(q.remove_tracks(&[0]).is_none()); q.shuffle_on(); q.shuffle_off(); q.clear(true); q.clear(false); } #[test] fn remove_out_of_range_is_ignored() { let mut q = queue_with(2); assert!(q.remove_tracks(&[5]).is_none()); assert_eq!(q.tracks.len(), 2); // pos == len used to panic via Vec::remove assert!(q.remove_tracks(&[2]).is_none()); assert_eq!(q.tracks.len(), 2); } #[test] fn remove_multiple_positions() { let mut q = queue_with(4); q.remove_tracks(&[1, 3]); assert_eq!(q.tracks.len(), 2); assert_eq!(q.play_order.len(), 2); assert_eq!(q.current_track().unwrap().title, "track 0"); } #[test] fn remove_current_returns_successor() { let mut q = queue_with(3); let next = q.remove_tracks(&[0]); assert_eq!(next.unwrap().title, "track 1"); assert_eq!(q.tracks.len(), 2); } #[test] fn clear_keeps_play_order_consistent() { let mut q = queue_with(3); q.next_track(); q.clear(true); assert_eq!(q.tracks.len(), 1); assert_eq!(q.play_order.len(), 1); assert!(q.current_track().is_some()); assert!(q.next_track().is_none()); } #[test] fn next_track_advances_and_repeats() { let mut q = queue_with(2); assert_eq!(q.next_track().unwrap().title, "track 1"); assert!(q.next_track().is_none()); q.repeat = true; assert_eq!(q.next_track().unwrap().title, "track 0"); } #[test] fn insert_past_end_appends() { let mut q = queue_with(2); q.insert_tracks(99, &[track(2)]); assert_eq!(q.tracks.len(), 3); assert_eq!(q.play_order.len(), 3); assert_eq!(q.tracks.last().unwrap().title, "track 2"); } fn titles(q: &QueueManager) -> Vec { q.tracks.iter().map(|t| t.title.clone()).collect() } #[test] fn replace_op_replaces_first_then_appends_and_plays_once() { let mut q = queue_with(2); let mut op = PendingResolve::new(ResolveKind::Replace); let first = op.apply_chunk(&mut q, &[track(10), track(11)]); // The first chunk resets the queue and names the track to start. assert_eq!(first.unwrap().title, "track 10"); assert_eq!(titles(&q), vec!["track 10", "track 11"]); let second = op.apply_chunk(&mut q, &[track(12)]); // Later chunks extend the same replace without restarting playback. assert!(second.is_none()); assert_eq!(titles(&q), vec!["track 10", "track 11", "track 12"]); assert_eq!(op.applied(), 3); } #[test] fn insert_after_op_keeps_chunks_contiguous_and_in_order() { let mut q = queue_with(3); // playing track 0 let mut op = PendingResolve::new(ResolveKind::InsertAfter(0)); assert!(op.apply_chunk(&mut q, &[track(10), track(11)]).is_none()); assert!(op.apply_chunk(&mut q, &[track(12)]).is_none()); // Both chunks sit as one contiguous run right after the current // track, in arrival order — not interleaved with the old tail. assert_eq!( titles(&q), vec!["track 0", "track 10", "track 11", "track 12", "track 1", "track 2"] ); } #[test] fn insert_after_op_clamps_past_the_end() { let mut q = queue_with(1); let mut op = PendingResolve::new(ResolveKind::InsertAfter(99)); assert!(op.apply_chunk(&mut q, &[track(10)]).is_none()); assert!(op.apply_chunk(&mut q, &[track(11)]).is_none()); assert_eq!(titles(&q), vec!["track 0", "track 10", "track 11"]); } #[test] fn append_op_starts_playback_only_into_an_empty_queue() { let mut q = QueueManager::new(); let mut op = PendingResolve::new(ResolveKind::Append); let first = op.apply_chunk(&mut q, &[track(10)]); // Landing in an empty queue makes the track current: play it. assert_eq!(first.unwrap().title, "track 10"); assert!(op.apply_chunk(&mut q, &[track(11)]).is_none()); assert_eq!(titles(&q), vec!["track 10", "track 11"]); assert_eq!(op.applied(), 2); } #[test] fn cancel_flag_is_shared_with_the_forwarder() { let op = PendingResolve::new(ResolveKind::Append); let flag = op.cancel_flag(); assert!(!flag.load(std::sync::atomic::Ordering::Relaxed)); op.cancel(); assert!(flag.load(std::sync::atomic::Ordering::Relaxed)); } #[test] fn shuffle_insert_keeps_order_unique() { let mut q = queue_with(5); q.shuffle_on(); q.next_track(); q.next_track(); q.queue_tracks(&[track(5), track(6)]); let mut order = q.play_order.clone(); order.sort_unstable(); assert_eq!(order, (0..7).collect::>()); } }