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
This commit is contained in:
1 parent
3c8fc35fcb
commit
ad7333d426
3 files changed
+813
-15
No files matched your search
@@ -21,6 +21,9 @@ log = { workspace = true }
|
||||
# Error handling
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Random (for shuffle)
|
||||
rand = "0.8"
|
||||
|
||||
# Audio decoding
|
||||
symphonia = { workspace = true }
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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<QueueTrack>,
|
||||
/// Current playback index
|
||||
current_index: Option<usize>,
|
||||
/// Shuffle mode enabled
|
||||
shuffle: bool,
|
||||
/// Shuffled indices (when shuffle is on)
|
||||
shuffle_order: Vec<usize>,
|
||||
/// Position in shuffle order
|
||||
shuffle_position: usize,
|
||||
/// Repeat mode
|
||||
repeat: RepeatMode,
|
||||
/// History of played track indices (for going back)
|
||||
history: VecDeque<usize>,
|
||||
}
|
||||
|
||||
/// Queue manager for handling playback queue
|
||||
pub struct QueueManager {
|
||||
state: Mutex<InternalState>,
|
||||
}
|
||||
|
||||
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<QueueTrack>) {
|
||||
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<QueueTrack>, start_index: Option<usize>) {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> {
|
||||
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<QueueTrack> = 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<QueueTrack> = 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<usize> = (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<u64>>(),
|
||||
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<u64>>(),
|
||||
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<u64>>(),
|
||||
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<u64>>(),
|
||||
vec![5, 2, 3, 4]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user