From ad7333d426c7477cd3f770d2a62d23962fdba7f4 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:13:25 -0600 Subject: [PATCH] feat(qbz-player): implement Phase 3a - queue management Extracted QueueManager from qbz-nix to qbz-player crate. Features: - Queue manipulation (add, remove, reorder, clear) - Current track tracking - Shuffle mode with Fisher-Yates algorithm - Repeat modes (off, all, one) - Play history for going back - Full test coverage preserved Uses types from qbz-models: - QueueTrack - QueueState - RepeatMode Phase 3b (player module extraction) deferred - requires: - Trait abstraction for API client - Trait abstraction for audio backend - Config system refactoring --- crates/qbz-player/Cargo.toml | 3 + crates/qbz-player/src/lib.rs | 57 ++- crates/qbz-player/src/queue.rs | 768 +++++++++++++++++++++++++++++++++ 3 files changed, 813 insertions(+), 15 deletions(-) create mode 100644 crates/qbz-player/src/queue.rs diff --git a/crates/qbz-player/Cargo.toml b/crates/qbz-player/Cargo.toml index 43cbb7379..f75894a68 100644 --- a/crates/qbz-player/Cargo.toml +++ b/crates/qbz-player/Cargo.toml @@ -21,6 +21,9 @@ log = { workspace = true } # Error handling thiserror = { workspace = true } +# Random (for shuffle) +rand = "0.8" + # Audio decoding symphonia = { workspace = true } diff --git a/crates/qbz-player/src/lib.rs b/crates/qbz-player/src/lib.rs index 7583d87d4..135d7c7ff 100644 --- a/crates/qbz-player/src/lib.rs +++ b/crates/qbz-player/src/lib.rs @@ -1,21 +1,48 @@ //! QBZ Player - Playback engine and queue management //! //! This crate provides: -//! - Player: Main playback control -//! - QueueManager: Track queue management -//! - StreamingSource: HTTP audio streaming -//! - PlaybackCache: In-memory prefetch cache +//! - QueueManager: Track queue management with shuffle/repeat +//! - Player: Main playback control (TODO: Phase 3b) +//! - StreamingSource: HTTP audio streaming (TODO: Phase 3b) //! -//! The vendor/ directory contains modified versions of: -//! - rodio: High-level audio playback -//! - cpal: Cross-platform audio I/O +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ qbz-player (Tier 2) │ +//! │ Queue management, playback engine, streaming │ +//! └─────────────────────────────────────────────────────────────┘ +//! ↑ +//! ┌───────────────┼───────────────┐ +//! │ │ │ +//! ┌────┴────┐ ┌─────┴─────┐ ┌─────┴─────┐ +//! │qbz-audio│ │qbz-models │ │qbz-qobuz │ +//! │ Tier 1 │ │ Tier 0 │ │ Tier 1 │ +//! └─────────┘ └───────────┘ └───────────┘ +//! ``` +//! +//! # Usage +//! +//! ```rust +//! use qbz_player::QueueManager; +//! use qbz_models::QueueTrack; +//! +//! let queue = QueueManager::new(); +//! // queue.add_track(track); +//! // queue.next(); +//! ``` -// TODO: Phase 3 - Copy player modules from qbz-nix -// pub mod player; -// pub mod queue; -// pub mod streaming_source; -// pub mod playback_engine; -// pub mod playback_cache; +pub mod queue; -// pub use player::Player; -// pub use queue::QueueManager; +// Re-export main types +pub use queue::QueueManager; + +// TODO: Phase 3b - Extract player module +// The player module has complex dependencies on: +// - QobuzClient (for streaming URLs) +// - Audio backend (for playback) +// - Config system (for audio settings) +// - Visualizer +// +// These need to be abstracted via traits before extraction. +// For now, the player remains in qbz-nix/src-tauri. diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs new file mode 100644 index 000000000..18aaeeac9 --- /dev/null +++ b/crates/qbz-player/src/queue.rs @@ -0,0 +1,768 @@ +//! Queue management module +//! +//! Handles playback queue with: +//! - Queue manipulation (add, remove, reorder, clear) +//! - Current track tracking +//! - Shuffle mode +//! - Repeat modes (off, all, one) +//! - Play history for going back + +use std::collections::VecDeque; +use std::sync::Mutex; + +use qbz_models::{QueueState, QueueTrack, RepeatMode}; + +#[derive(Debug, PartialEq, Eq)] +enum QueueMoveDirection { + Up, + Down, +} + +/// Internal queue state - all in one struct to avoid deadlocks +struct InternalState { + /// All tracks in the queue (original order) + tracks: Vec, + /// Current playback index + current_index: Option, + /// Shuffle mode enabled + shuffle: bool, + /// Shuffled indices (when shuffle is on) + shuffle_order: Vec, + /// Position in shuffle order + shuffle_position: usize, + /// Repeat mode + repeat: RepeatMode, + /// History of played track indices (for going back) + history: VecDeque, +} + +/// Queue manager for handling playback queue +pub struct QueueManager { + state: Mutex, +} + +impl Default for QueueManager { + fn default() -> Self { + Self::new() + } +} + +impl QueueManager { + pub fn new() -> Self { + Self { + state: Mutex::new(InternalState { + tracks: Vec::new(), + current_index: None, + shuffle: false, + shuffle_order: Vec::new(), + shuffle_position: 0, + repeat: RepeatMode::Off, + history: VecDeque::with_capacity(50), + }), + } + } + + /// Add a track to the end of the queue + pub fn add_track(&self, track: QueueTrack) { + let mut state = self.state.lock().unwrap(); + state.tracks.push(track); + + if state.shuffle { + let new_idx = state.tracks.len() - 1; + state.shuffle_order.push(new_idx); + } + } + + /// Add multiple tracks to the queue + pub fn add_tracks(&self, new_tracks: Vec) { + let mut state = self.state.lock().unwrap(); + let start_idx = state.tracks.len(); + state.tracks.extend(new_tracks); + + if state.shuffle { + for i in start_idx..state.tracks.len() { + state.shuffle_order.push(i); + } + } + } + + /// Add a track to play next (after current index if set) + pub fn add_track_next(&self, track: QueueTrack) { + let mut state = self.state.lock().unwrap(); + let insert_index = state.current_index.map(|idx| idx + 1).unwrap_or(0); + + if insert_index >= state.tracks.len() { + state.tracks.push(track); + } else { + state.tracks.insert(insert_index, track); + } + + if state.shuffle { + for idx in state.shuffle_order.iter_mut() { + if *idx >= insert_index { + *idx += 1; + } + } + + let new_idx = insert_index; + let next_pos = if state.current_index.is_some() { + state.shuffle_position + 1 + } else { + state.shuffle_order.len() + }; + + if next_pos >= state.shuffle_order.len() { + state.shuffle_order.push(new_idx); + } else { + state.shuffle_order.insert(next_pos, new_idx); + } + } + } + + /// Set the entire queue (replaces existing) + pub fn set_queue(&self, new_tracks: Vec, start_index: Option) { + let mut state = self.state.lock().unwrap(); + state.tracks = new_tracks; + state.current_index = start_index; + state.history.clear(); + + // Regenerate shuffle order + Self::regenerate_shuffle_order_internal(&mut state); + + // CRITICAL FIX: When shuffle is enabled and we have a start_index, + // ensure the start_index track is at the BEGINNING of shuffle order + if state.shuffle { + if let Some(start_idx) = start_index { + if start_idx < state.tracks.len() { + if let Some(pos) = state.shuffle_order.iter().position(|&x| x == start_idx) { + state.shuffle_order.swap(0, pos); + state.shuffle_position = 0; + + log::info!( + "Queue: Adjusted shuffle order to start with track index {} (was at position {})", + start_idx, + pos + ); + } + } + } + } + } + + /// Clear the queue + pub fn clear(&self) { + let mut state = self.state.lock().unwrap(); + + // A track is currently playing, keep it as the only item left. + if let Some(_curr_idx) = state.current_index { + state.tracks.truncate(1); + state.current_index = Some(0); + } else { + state.tracks.clear(); + state.current_index = None; + } + + state.shuffle_order.clear(); + state.shuffle_position = 0; + state.history.clear(); + } + + /// Remove a track by index + pub fn remove_track(&self, index: usize) -> Option { + let mut state = self.state.lock().unwrap(); + if index >= state.tracks.len() { + return None; + } + + let removed = state.tracks.remove(index); + + // Adjust current index if needed + if let Some(curr_idx) = state.current_index { + if index < curr_idx { + state.current_index = Some(curr_idx - 1); + } else if index == curr_idx { + if curr_idx >= state.tracks.len() { + state.current_index = if state.tracks.is_empty() { + None + } else { + Some(state.tracks.len() - 1) + }; + } + } + } + + Self::regenerate_shuffle_order_internal(&mut state); + Some(removed) + } + + /// Remove a track by its position in the upcoming list + pub fn remove_upcoming_track(&self, upcoming_index: usize) -> Option { + let mut state = self.state.lock().unwrap(); + + let actual_index = if state.shuffle { + let shuffle_pos = state.shuffle_position + 1 + upcoming_index; + if shuffle_pos >= state.shuffle_order.len() { + return None; + } + state.shuffle_order[shuffle_pos] + } else { + match state.current_index { + Some(curr_idx) => curr_idx + 1 + upcoming_index, + None => upcoming_index, + } + }; + + if actual_index >= state.tracks.len() { + return None; + } + + log::info!( + "remove_upcoming_track: upcoming_index={} -> actual_index={}", + upcoming_index, + actual_index + ); + + let removed = state.tracks.remove(actual_index); + + if let Some(curr_idx) = state.current_index { + if actual_index < curr_idx { + state.current_index = Some(curr_idx - 1); + } else if actual_index == curr_idx { + if curr_idx >= state.tracks.len() { + state.current_index = if state.tracks.is_empty() { + None + } else { + Some(state.tracks.len() - 1) + }; + } + } + } + + Self::regenerate_shuffle_order_internal(&mut state); + Some(removed) + } + + /// Move a track from one position to another + pub fn move_track(&self, from_index: usize, to_index: usize) -> bool { + let mut state = self.state.lock().unwrap(); + + let direction: QueueMoveDirection = if from_index > to_index { + QueueMoveDirection::Up + } else { + QueueMoveDirection::Down + }; + + let mut from_idx = from_index; + let mut to_idx = to_index; + + if let Some(curr_idx) = state.current_index { + from_idx = from_idx + curr_idx + 1; + to_idx = to_idx + curr_idx + 1; + } + + if direction == QueueMoveDirection::Down { + to_idx = to_idx - 1; + } + + log::info!( + "Queue: move_track - {:?} from {} to {} (internal indices:{} -> {}). Tracks in queue: {}", + direction, + from_index, + to_index, + from_idx, + to_idx, + state.tracks.len() + ); + + if from_idx == to_idx { + return true; + } + + if from_idx >= state.tracks.len() || to_idx >= state.tracks.len() { + return false; + } + + let track = state.tracks.remove(from_idx); + state.tracks.insert(to_idx, track); + + if let Some(curr_idx) = state.current_index { + if from_idx == curr_idx { + state.current_index = Some(to_idx); + } else if from_idx < curr_idx && to_idx >= curr_idx { + state.current_index = Some(curr_idx - 1); + } else if from_idx > curr_idx && to_idx <= curr_idx { + state.current_index = Some(curr_idx + 1); + } + } + + Self::regenerate_shuffle_order_internal(&mut state); + true + } + + /// Get current track + pub fn current_track(&self) -> Option { + let state = self.state.lock().unwrap(); + state + .current_index + .and_then(|idx| state.tracks.get(idx).cloned()) + } + + /// Get next track without advancing + pub fn peek_next(&self) -> Option { + let state = self.state.lock().unwrap(); + if state.tracks.is_empty() { + return None; + } + + if state.repeat == RepeatMode::One { + return state + .current_index + .and_then(|idx| state.tracks.get(idx).cloned()); + } + + let next_idx = if state.shuffle { + let next_pos = state.shuffle_position + 1; + if next_pos < state.shuffle_order.len() { + Some(state.shuffle_order[next_pos]) + } else if state.repeat == RepeatMode::All { + state.shuffle_order.first().copied() + } else { + None + } + } else { + let curr_idx = state.current_index.unwrap_or(0); + let next_idx = curr_idx + 1; + if next_idx < state.tracks.len() { + Some(next_idx) + } else if state.repeat == RepeatMode::All { + Some(0) + } else { + None + } + }; + + next_idx.and_then(|idx| state.tracks.get(idx).cloned()) + } + + /// Get multiple upcoming tracks without advancing + pub fn peek_upcoming(&self, count: usize) -> Vec { + let state = self.state.lock().unwrap(); + if state.tracks.is_empty() || count == 0 { + return Vec::new(); + } + + if state.repeat == RepeatMode::One { + return Vec::new(); + } + + let mut result = Vec::with_capacity(count); + + if state.shuffle { + let start_pos = state.shuffle_position + 1; + for i in 0..count { + let pos = start_pos + i; + if pos < state.shuffle_order.len() { + if let Some(track) = state.tracks.get(state.shuffle_order[pos]) { + result.push(track.clone()); + } + } else if state.repeat == RepeatMode::All { + let wrapped_pos = pos % state.shuffle_order.len(); + if let Some(track) = state.tracks.get(state.shuffle_order[wrapped_pos]) { + result.push(track.clone()); + } + } + } + } else { + let start_idx = state.current_index.map(|i| i + 1).unwrap_or(0); + for i in 0..count { + let idx = start_idx + i; + if idx < state.tracks.len() { + result.push(state.tracks[idx].clone()); + } else if state.repeat == RepeatMode::All { + let wrapped_idx = idx % state.tracks.len(); + result.push(state.tracks[wrapped_idx].clone()); + } + } + } + + result + } + + /// Advance to next track and return it + pub fn next(&self) -> Option { + let mut state = self.state.lock().unwrap(); + if state.tracks.is_empty() { + return None; + } + + // Save current to history before moving + if let Some(curr_idx) = state.current_index { + state.history.push_back(curr_idx); + while state.history.len() > 50 { + state.history.pop_front(); + } + } + + if state.repeat == RepeatMode::One { + return state + .current_index + .and_then(|idx| state.tracks.get(idx).cloned()); + } + + let next_idx = if state.shuffle { + state.shuffle_position += 1; + if state.shuffle_position < state.shuffle_order.len() { + Some(state.shuffle_order[state.shuffle_position]) + } else if state.repeat == RepeatMode::All { + state.shuffle_position = 0; + state.shuffle_order.first().copied() + } else { + None + } + } else { + let curr_idx = state.current_index.unwrap_or(0); + let next_idx = curr_idx + 1; + if next_idx < state.tracks.len() { + Some(next_idx) + } else if state.repeat == RepeatMode::All { + Some(0) + } else { + None + } + }; + + state.current_index = next_idx; + next_idx.and_then(|idx| state.tracks.get(idx).cloned()) + } + + /// Go to previous track and return it + pub fn previous(&self) -> Option { + let mut state = self.state.lock().unwrap(); + if state.tracks.is_empty() { + return None; + } + + // Try to get from history first + if let Some(prev_idx) = state.history.pop_back() { + state.current_index = Some(prev_idx); + + if state.shuffle { + if let Some(pos) = state.shuffle_order.iter().position(|&x| x == prev_idx) { + state.shuffle_position = pos; + } + } + + return state.tracks.get(prev_idx).cloned(); + } + + // No history, go to previous in order + let prev_idx = if state.shuffle { + if state.shuffle_position > 0 { + state.shuffle_position -= 1; + Some(state.shuffle_order[state.shuffle_position]) + } else if state.repeat == RepeatMode::All { + state.shuffle_position = state.shuffle_order.len().saturating_sub(1); + state.shuffle_order.last().copied() + } else { + state.shuffle_order.first().copied() + } + } else { + let curr_idx = state.current_index.unwrap_or(0); + if curr_idx > 0 { + Some(curr_idx - 1) + } else if state.repeat == RepeatMode::All { + Some(state.tracks.len().saturating_sub(1)) + } else { + Some(0) + } + }; + + state.current_index = prev_idx; + prev_idx.and_then(|idx| state.tracks.get(idx).cloned()) + } + + /// Jump to a specific track by index + pub fn play_index(&self, index: usize) -> Option { + let mut state = self.state.lock().unwrap(); + if index >= state.tracks.len() { + return None; + } + + // Save current to history + if let Some(curr_idx) = state.current_index { + state.history.push_back(curr_idx); + while state.history.len() > 50 { + state.history.pop_front(); + } + } + + state.current_index = Some(index); + + if state.shuffle { + if let Some(pos) = state.shuffle_order.iter().position(|&x| x == index) { + state.shuffle_position = pos; + } + } + + state.tracks.get(index).cloned() + } + + /// Toggle shuffle mode + pub fn set_shuffle(&self, enabled: bool) { + let mut state = self.state.lock().unwrap(); + if state.shuffle == enabled { + return; + } + state.shuffle = enabled; + + if enabled { + Self::regenerate_shuffle_order_internal(&mut state); + } + } + + /// Get shuffle status + pub fn is_shuffle(&self) -> bool { + self.state.lock().unwrap().shuffle + } + + /// Set repeat mode + pub fn set_repeat(&self, mode: RepeatMode) { + self.state.lock().unwrap().repeat = mode; + } + + /// Get repeat mode + pub fn get_repeat(&self) -> RepeatMode { + self.state.lock().unwrap().repeat + } + + /// Get queue state for frontend + pub fn get_state(&self) -> QueueState { + let state = self.state.lock().unwrap(); + + let current_track = state + .current_index + .and_then(|idx| state.tracks.get(idx).cloned()); + + // Get upcoming tracks (after current) + let upcoming: Vec = if let Some(curr_idx) = state.current_index { + if state.shuffle { + state + .shuffle_order + .iter() + .skip(state.shuffle_position + 1) + .take(20) + .filter_map(|&idx| state.tracks.get(idx).cloned()) + .collect() + } else { + state + .tracks + .iter() + .skip(curr_idx + 1) + .take(20) + .cloned() + .collect() + } + } else { + state.tracks.iter().take(20).cloned().collect() + }; + + // Get history tracks (recent first) + let history_tracks: Vec = state + .history + .iter() + .rev() + .take(10) + .filter_map(|&idx| state.tracks.get(idx).cloned()) + .collect(); + + QueueState { + current_track, + current_index: state.current_index, + upcoming, + history: history_tracks, + shuffle: state.shuffle, + repeat: state.repeat, + total_tracks: state.tracks.len(), + } + } + + /// Regenerate shuffle order (internal, must be called with lock held) + fn regenerate_shuffle_order_internal(state: &mut InternalState) { + let mut order: Vec = (0..state.tracks.len()).collect(); + + // Fisher-Yates shuffle with proper PRNG + use rand::{Rng, SeedableRng}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + + for i in (1..order.len()).rev() { + let j = rng.gen_range(0..=i); + order.swap(i, j); + } + + state.shuffle_order = order; + + if let Some(curr_idx) = state.current_index { + if let Some(pos) = state.shuffle_order.iter().position(|&x| x == curr_idx) { + state.shuffle_position = pos; + } else { + state.shuffle_position = 0; + } + } else { + state.shuffle_position = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_track(id: u64) -> QueueTrack { + QueueTrack { + id, + title: format!("Track {}", id), + artist: "Artist".to_string(), + album: "Album".to_string(), + duration_secs: 180, + artwork_url: None, + hires: false, + bit_depth: None, + sample_rate: None, + is_local: false, + album_id: None, + artist_id: None, + streamable: true, + source: Some("test".to_string()), + } + } + + #[test] + fn test_clear_without_current_track() { + let queue = QueueManager::new(); + + queue.add_track(create_test_track(123)); + queue.add_track(create_test_track(124)); + queue.add_track(create_test_track(125)); + + queue.clear(); + + let state = queue.get_state(); + assert!(state.current_track.is_none()); + assert!(state.upcoming.is_empty()); + assert_eq!(state.total_tracks, 0); + } + + #[test] + fn test_clear_keeps_current_track() { + let queue = QueueManager::new(); + + queue.add_track(create_test_track(123)); + queue.add_track(create_test_track(124)); + queue.add_track(create_test_track(125)); + queue.play_index(0); + + queue.clear(); + + let state = queue.get_state(); + assert!(state.current_track.is_some()); + assert_eq!(state.current_track.unwrap().id, 123); + assert!(state.upcoming.is_empty()); + assert_eq!(state.total_tracks, 1); + } + + #[test] + fn test_move_track_down_without_current_track() { + let queue = QueueManager::new(); + + for i in 1..=5 { + queue.add_track(create_test_track(i)); + } + + let result = queue.move_track(0, 3); + + assert!(result, "move_track should succeed"); + assert_eq!( + queue + .get_state() + .upcoming + .iter() + .map(|track| track.id) + .collect::>(), + vec![2, 3, 1, 4, 5] + ); + } + + #[test] + fn test_move_track_down_with_current_track() { + let queue = QueueManager::new(); + + for i in 1..=5 { + queue.add_track(create_test_track(i)); + } + queue.play_index(0); + + let result = queue.move_track(0, 3); + + assert!(result, "move_track should succeed"); + assert_eq!( + queue + .get_state() + .upcoming + .iter() + .map(|track| track.id) + .collect::>(), + vec![3, 4, 2, 5] + ); + } + + #[test] + fn test_move_track_up_without_current_track() { + let queue = QueueManager::new(); + + for i in 1..=5 { + queue.add_track(create_test_track(i)); + } + + let result = queue.move_track(3, 0); + + assert!(result, "move_track should succeed"); + assert_eq!( + queue + .get_state() + .upcoming + .iter() + .map(|track| track.id) + .collect::>(), + vec![4, 1, 2, 3, 5] + ); + } + + #[test] + fn test_move_track_up_with_current_track() { + let queue = QueueManager::new(); + + for i in 1..=5 { + queue.add_track(create_test_track(i)); + } + queue.play_index(0); + + let result = queue.move_track(3, 0); + + assert!(result, "move_track should succeed"); + assert_eq!( + queue + .get_state() + .upcoming + .iter() + .map(|track| track.id) + .collect::>(), + vec![5, 2, 3, 4] + ); + } +}