- Add #[specta::specta] to all 201 #[tauri::command] functions. - Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/ download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState, ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.). - Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands! in lib.rs (exports bindings.ts in debug builds). Two contract changes required by specta constraints (frontend migration follows): - specta caps command arity at 10 args: download_item_and_start / download_item / download_video now take a single request struct (params bundled, body unchanged via destructuring). - specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these now serialize PascalCase to the frontend). cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
975 lines
32 KiB
Rust
975 lines
32 KiB
Rust
use rand::seq::SliceRandom;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::media::{MediaItem, MediaSource, QueueContext};
|
|
|
|
/// Repeat mode for the queue
|
|
///
|
|
/// TRACES: UR-005 | DR-005
|
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum RepeatMode {
|
|
#[default]
|
|
Off,
|
|
All,
|
|
One,
|
|
}
|
|
|
|
/// Queue manager for playlist functionality
|
|
///
|
|
/// TRACES: UR-005, UR-015 | DR-005, DR-020
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct QueueManager {
|
|
/// All items in the queue
|
|
items: Vec<MediaItem>,
|
|
/// Current item index
|
|
current_index: Option<usize>,
|
|
/// Whether shuffle is enabled
|
|
shuffle: bool,
|
|
/// Current repeat mode
|
|
repeat: RepeatMode,
|
|
/// Shuffled order of indices (used when shuffle is on)
|
|
shuffle_order: Vec<usize>,
|
|
/// History of played indices (for going back with shuffle)
|
|
history: Vec<usize>,
|
|
/// Context for the queue (album, playlist, or custom)
|
|
/// Used for remote playback transfer to maintain album/playlist context
|
|
#[serde(default)]
|
|
context: QueueContext,
|
|
}
|
|
|
|
impl Default for QueueManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl QueueManager {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
items: Vec::new(),
|
|
current_index: None,
|
|
shuffle: false,
|
|
repeat: RepeatMode::Off,
|
|
shuffle_order: Vec::new(),
|
|
history: Vec::new(),
|
|
context: QueueContext::Custom,
|
|
}
|
|
}
|
|
|
|
/// Get all items in the queue
|
|
pub fn items(&self) -> &[MediaItem] {
|
|
&self.items
|
|
}
|
|
|
|
/// Get the current item index
|
|
pub fn current_index(&self) -> Option<usize> {
|
|
self.current_index
|
|
}
|
|
|
|
/// Get the current item
|
|
pub fn current(&self) -> Option<&MediaItem> {
|
|
self.current_index.and_then(|i| self.items.get(i))
|
|
}
|
|
|
|
/// Check if shuffle is enabled
|
|
pub fn is_shuffle(&self) -> bool {
|
|
self.shuffle
|
|
}
|
|
|
|
/// Get the current repeat mode
|
|
pub fn repeat_mode(&self) -> RepeatMode {
|
|
self.repeat
|
|
}
|
|
|
|
/// Get the current queue context (album, playlist, or custom)
|
|
pub fn context(&self) -> &QueueContext {
|
|
&self.context
|
|
}
|
|
|
|
/// Set the queue context
|
|
pub fn set_context(&mut self, context: QueueContext) {
|
|
self.context = context;
|
|
}
|
|
|
|
/// Set the queue with new items (resets context to Custom)
|
|
pub fn set_queue(&mut self, items: Vec<MediaItem>, start_index: usize) {
|
|
self.set_queue_with_context(items, start_index, QueueContext::Custom);
|
|
}
|
|
|
|
/// Set the queue with new items and explicit context
|
|
pub fn set_queue_with_context(
|
|
&mut self,
|
|
items: Vec<MediaItem>,
|
|
start_index: usize,
|
|
context: QueueContext,
|
|
) {
|
|
let start_index = start_index.min(items.len().saturating_sub(1));
|
|
|
|
if self.shuffle && !items.is_empty() {
|
|
self.shuffle_order = self.generate_shuffle_order(items.len(), Some(start_index));
|
|
} else {
|
|
self.shuffle_order.clear();
|
|
}
|
|
|
|
self.items = items;
|
|
self.current_index = if self.items.is_empty() {
|
|
None
|
|
} else {
|
|
Some(start_index)
|
|
};
|
|
self.history.clear();
|
|
self.context = context;
|
|
}
|
|
|
|
/// Add items to the queue
|
|
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
|
|
if items.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let insert_index = match position {
|
|
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
|
|
AddPosition::End => self.items.len(),
|
|
};
|
|
|
|
// Insert items
|
|
for (i, item) in items.into_iter().enumerate() {
|
|
self.items.insert(insert_index + i, item);
|
|
}
|
|
|
|
// Update current index if needed
|
|
if let Some(current) = self.current_index {
|
|
if insert_index <= current {
|
|
self.current_index = Some(current + 1);
|
|
}
|
|
}
|
|
|
|
// Regenerate shuffle order if shuffle is on
|
|
if self.shuffle {
|
|
self.shuffle_order = self.generate_shuffle_order(
|
|
self.items.len(),
|
|
self.current_index,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Remove an item from the queue
|
|
pub fn remove(&mut self, index: usize) -> Option<MediaItem> {
|
|
if index >= self.items.len() {
|
|
return None;
|
|
}
|
|
|
|
let removed = self.items.remove(index);
|
|
|
|
// Update current index
|
|
if let Some(current) = self.current_index {
|
|
if index < current {
|
|
self.current_index = Some(current - 1);
|
|
} else if index == current {
|
|
self.current_index = if self.items.is_empty() {
|
|
None
|
|
} else if index >= self.items.len() {
|
|
Some(self.items.len() - 1)
|
|
} else {
|
|
Some(index)
|
|
};
|
|
}
|
|
}
|
|
|
|
// Update shuffle order
|
|
if self.shuffle {
|
|
self.shuffle_order = self.shuffle_order
|
|
.iter()
|
|
.filter(|&&i| i != index)
|
|
.map(|&i| if i > index { i - 1 } else { i })
|
|
.collect();
|
|
}
|
|
|
|
Some(removed)
|
|
}
|
|
|
|
/// Move to the next item
|
|
pub fn next(&mut self) -> Option<&MediaItem> {
|
|
if self.items.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let current = self.current_index?;
|
|
|
|
let next_index = if self.repeat == RepeatMode::One {
|
|
// Repeat current track
|
|
current
|
|
} else if self.shuffle {
|
|
// Find current position in shuffle order and get next
|
|
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
|
|
if pos + 1 < self.shuffle_order.len() {
|
|
self.shuffle_order[pos + 1]
|
|
} else if self.repeat == RepeatMode::All {
|
|
// Wrap around to beginning of shuffle
|
|
self.shuffle_order[0]
|
|
} else {
|
|
log::debug!("[Queue] next() at end of shuffle order, no next track");
|
|
return None;
|
|
}
|
|
} else {
|
|
// Normal sequential order
|
|
if current + 1 < self.items.len() {
|
|
current + 1
|
|
} else if self.repeat == RepeatMode::All {
|
|
0
|
|
} else {
|
|
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// Only add to history if we're actually changing position
|
|
if next_index != current {
|
|
self.history.push(current);
|
|
log::debug!("[Queue] next() moving: {} -> {}", current, next_index);
|
|
} else {
|
|
log::debug!("[Queue] next() repeat one, staying at index {}", current);
|
|
}
|
|
|
|
self.current_index = Some(next_index);
|
|
self.items.get(next_index)
|
|
}
|
|
|
|
/// Move to the previous item
|
|
pub fn previous(&mut self) -> Option<&MediaItem> {
|
|
if self.items.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let current = self.current_index?;
|
|
|
|
// If we have history, go back in history
|
|
// But validate it to prevent wraparound bugs
|
|
if let Some(prev) = self.history.pop() {
|
|
// Safety check: ensure the history entry is valid
|
|
if prev >= self.items.len() {
|
|
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
|
|
prev, self.items.len());
|
|
self.history.clear();
|
|
return None;
|
|
}
|
|
|
|
// In non-shuffle mode, previous track should be before current (or this is from a skip_to)
|
|
// This prevents going from first track to last track
|
|
if !self.shuffle && prev >= current {
|
|
log::warn!("[Queue] Suspicious history: going from index {} to {} (non-shuffle mode), clearing history",
|
|
current, prev);
|
|
self.history.clear();
|
|
return None;
|
|
}
|
|
|
|
log::debug!("[Queue] previous() using history: {} -> {}", current, prev);
|
|
self.current_index = Some(prev);
|
|
return self.items.get(prev);
|
|
}
|
|
|
|
log::debug!("[Queue] previous() no history, current={}", current);
|
|
|
|
let prev_index = if self.shuffle {
|
|
// In shuffle mode without history, go to previous in shuffle order
|
|
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
|
|
if pos > 0 {
|
|
self.shuffle_order[pos - 1]
|
|
} else {
|
|
log::debug!("[Queue] previous() at start of shuffle order, staying at current");
|
|
return None;
|
|
}
|
|
} else {
|
|
// Normal sequential order
|
|
if current > 0 {
|
|
current - 1
|
|
} else {
|
|
log::debug!("[Queue] previous() at index 0, staying at current");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
log::debug!("[Queue] previous() moving: {} -> {}", current, prev_index);
|
|
self.current_index = Some(prev_index);
|
|
self.items.get(prev_index)
|
|
}
|
|
|
|
/// Skip to a specific index
|
|
pub fn skip_to(&mut self, index: usize) -> Option<&MediaItem> {
|
|
if index >= self.items.len() {
|
|
return None;
|
|
}
|
|
|
|
if let Some(current) = self.current_index {
|
|
self.history.push(current);
|
|
}
|
|
|
|
self.current_index = Some(index);
|
|
self.items.get(index)
|
|
}
|
|
|
|
/// Toggle shuffle mode
|
|
pub fn toggle_shuffle(&mut self) {
|
|
self.shuffle = !self.shuffle;
|
|
|
|
if self.shuffle && !self.items.is_empty() {
|
|
self.shuffle_order = self.generate_shuffle_order(
|
|
self.items.len(),
|
|
self.current_index,
|
|
);
|
|
} else {
|
|
self.shuffle_order.clear();
|
|
}
|
|
}
|
|
|
|
/// Cycle through repeat modes
|
|
pub fn cycle_repeat(&mut self) {
|
|
self.repeat = match self.repeat {
|
|
RepeatMode::Off => RepeatMode::All,
|
|
RepeatMode::All => RepeatMode::One,
|
|
RepeatMode::One => RepeatMode::Off,
|
|
};
|
|
}
|
|
|
|
/// Set repeat mode directly (for testing)
|
|
#[cfg(test)]
|
|
pub fn set_repeat(&mut self, mode: RepeatMode) {
|
|
self.repeat = mode;
|
|
}
|
|
|
|
/// Check if there's a next item available
|
|
pub fn has_next(&self) -> bool {
|
|
if self.items.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
match self.current_index {
|
|
None => false,
|
|
Some(current) => {
|
|
if self.repeat == RepeatMode::All || self.repeat == RepeatMode::One {
|
|
true
|
|
} else if self.shuffle {
|
|
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
|
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
|
|
} else {
|
|
current + 1 < self.items.len()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if there's a previous item available
|
|
pub fn has_previous(&self) -> bool {
|
|
!self.history.is_empty() || {
|
|
match self.current_index {
|
|
None => false,
|
|
Some(current) => {
|
|
if self.shuffle {
|
|
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
|
pos.map(|p| p > 0).unwrap_or(false)
|
|
} else {
|
|
current > 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get the next N upcoming items (for preloading)
|
|
/// Returns items that will play after the current item, respecting shuffle order
|
|
pub fn get_upcoming(&self, count: usize) -> Vec<&MediaItem> {
|
|
if self.items.is_empty() || count == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let current = match self.current_index {
|
|
Some(idx) => idx,
|
|
None => return Vec::new(),
|
|
};
|
|
|
|
let mut upcoming = Vec::with_capacity(count);
|
|
|
|
if self.shuffle {
|
|
// Find current position in shuffle order
|
|
if let Some(pos) = self.shuffle_order.iter().position(|&i| i == current) {
|
|
for i in 1..=count {
|
|
let next_pos = pos + i;
|
|
if next_pos < self.shuffle_order.len() {
|
|
if let Some(item) = self.items.get(self.shuffle_order[next_pos]) {
|
|
upcoming.push(item);
|
|
}
|
|
} else if self.repeat == RepeatMode::All {
|
|
// Wrap around if repeat all is enabled
|
|
let wrapped_pos = (next_pos) % self.shuffle_order.len();
|
|
if let Some(item) = self.items.get(self.shuffle_order[wrapped_pos]) {
|
|
upcoming.push(item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Normal sequential order
|
|
for i in 1..=count {
|
|
let next_idx = current + i;
|
|
if next_idx < self.items.len() {
|
|
upcoming.push(&self.items[next_idx]);
|
|
} else if self.repeat == RepeatMode::All {
|
|
// Wrap around if repeat all is enabled
|
|
let wrapped_idx = next_idx % self.items.len();
|
|
upcoming.push(&self.items[wrapped_idx]);
|
|
}
|
|
}
|
|
}
|
|
|
|
upcoming
|
|
}
|
|
|
|
/// Move an item from one index to another
|
|
pub fn move_item(&mut self, from_index: usize, to_index: usize) -> bool {
|
|
if from_index >= self.items.len() || to_index >= self.items.len() || from_index == to_index
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Remove the item and insert at new position
|
|
let item = self.items.remove(from_index);
|
|
self.items.insert(to_index, item);
|
|
|
|
// Update current_index if affected
|
|
if let Some(current) = self.current_index {
|
|
if current == from_index {
|
|
// The moved item was the current one
|
|
self.current_index = Some(to_index);
|
|
} else if from_index < current && to_index >= current {
|
|
// Item moved from before current to after/at current
|
|
self.current_index = Some(current - 1);
|
|
} else if from_index > current && to_index <= current {
|
|
// Item moved from after current to before/at current
|
|
self.current_index = Some(current + 1);
|
|
}
|
|
}
|
|
|
|
// Update shuffle order if shuffle is on
|
|
if self.shuffle && !self.shuffle_order.is_empty() {
|
|
// Regenerate shuffle order to maintain consistency
|
|
self.shuffle_order =
|
|
self.generate_shuffle_order(self.items.len(), self.current_index);
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// Update the stream URL of the current item (for transcoded seeking)
|
|
/// Returns true if the update was successful
|
|
pub fn update_current_stream_url(&mut self, new_url: String) -> bool {
|
|
if let Some(current_index) = self.current_index {
|
|
if let Some(item) = self.items.get_mut(current_index) {
|
|
// Only update if it's a Remote source
|
|
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
|
|
item.source = MediaSource::Remote {
|
|
stream_url: new_url,
|
|
jellyfin_item_id: jellyfin_item_id.clone(),
|
|
};
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Generate a shuffle order, optionally starting from a specific index
|
|
fn generate_shuffle_order(&self, length: usize, start_index: Option<usize>) -> Vec<usize> {
|
|
let mut indices: Vec<usize> = (0..length).collect();
|
|
let mut rng = rand::thread_rng();
|
|
indices.shuffle(&mut rng);
|
|
|
|
// Move start index to the front if specified
|
|
if let Some(start) = start_index {
|
|
if let Some(pos) = indices.iter().position(|&i| i == start) {
|
|
indices.remove(pos);
|
|
indices.insert(0, start);
|
|
}
|
|
}
|
|
|
|
indices
|
|
}
|
|
}
|
|
|
|
/// Position to add items to the queue
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AddPosition {
|
|
/// Add immediately after current item
|
|
Next,
|
|
/// Add at the end of the queue
|
|
End,
|
|
}
|
|
|
|
// TRACES: UR-005, UR-015 | DR-005 | UT-003, UT-004, UT-005
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::player::media::{MediaSource, MediaType};
|
|
|
|
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
|
(0..count)
|
|
.map(|i| MediaItem {
|
|
id: format!("item_{}", i),
|
|
title: format!("Track {}", i + 1),
|
|
name: Some(format!("Track {}", i + 1)),
|
|
artist: Some("Artist".to_string()),
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: Some(vec!["Artist".to_string()]),
|
|
primary_image_tag: None,
|
|
item_type: Some("Audio".to_string()),
|
|
playlist_id: None,
|
|
duration: Some(180.0),
|
|
artwork_url: None,
|
|
media_type: MediaType::Audio,
|
|
source: MediaSource::DirectUrl {
|
|
url: format!("http://example.com/track_{}.mp3", i),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Test setting up the queue with items
|
|
///
|
|
/// @req-test: UR-015 - View and manage audio queue (add tracks)
|
|
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
|
|
#[test]
|
|
fn test_set_queue() {
|
|
let mut queue = QueueManager::new();
|
|
let items = create_test_items(5);
|
|
|
|
queue.set_queue(items.clone(), 0);
|
|
|
|
assert_eq!(queue.items().len(), 5);
|
|
assert_eq!(queue.current_index(), Some(0));
|
|
assert_eq!(queue.current().unwrap().id, "item_0");
|
|
}
|
|
|
|
/// Test next track navigation
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (skip to next track)
|
|
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
|
|
#[test]
|
|
fn test_next() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 0);
|
|
|
|
assert_eq!(queue.current().unwrap().id, "item_0");
|
|
|
|
queue.next();
|
|
assert_eq!(queue.current().unwrap().id, "item_1");
|
|
|
|
queue.next();
|
|
assert_eq!(queue.current().unwrap().id, "item_2");
|
|
|
|
// No next without repeat
|
|
assert!(queue.next().is_none());
|
|
}
|
|
|
|
/// Test repeat all mode wraps to beginning
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (repeat all mode)
|
|
/// @req-test: DR-005 - Queue manager with repeat
|
|
#[test]
|
|
fn test_repeat_all() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(2), 0);
|
|
queue.set_repeat(RepeatMode::All);
|
|
|
|
queue.next(); // Move to item_1
|
|
let next = queue.next(); // Should wrap to item_0
|
|
|
|
assert!(next.is_some());
|
|
assert_eq!(queue.current().unwrap().id, "item_0");
|
|
}
|
|
|
|
/// Test previous track navigation
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (previous track)
|
|
/// @req-test: DR-005 - Queue manager
|
|
#[test]
|
|
fn test_previous() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 2);
|
|
|
|
queue.previous();
|
|
assert_eq!(queue.current_index(), Some(1));
|
|
}
|
|
|
|
/// Test viewing upcoming tracks in queue
|
|
///
|
|
/// @req-test: UR-015 - View and manage audio queue
|
|
/// @req-test: DR-020 - Queue management UI (upcoming tracks)
|
|
#[test]
|
|
fn test_get_upcoming() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(5), 0);
|
|
|
|
// Get next 3 items
|
|
let upcoming = queue.get_upcoming(3);
|
|
assert_eq!(upcoming.len(), 3);
|
|
assert_eq!(upcoming[0].id, "item_1");
|
|
assert_eq!(upcoming[1].id, "item_2");
|
|
assert_eq!(upcoming[2].id, "item_3");
|
|
|
|
// Move to item 2 and get upcoming
|
|
queue.next();
|
|
queue.next();
|
|
let upcoming = queue.get_upcoming(3);
|
|
assert_eq!(upcoming.len(), 2); // Only 2 items remaining
|
|
assert_eq!(upcoming[0].id, "item_3");
|
|
assert_eq!(upcoming[1].id, "item_4");
|
|
}
|
|
|
|
/// Test upcoming tracks with repeat all mode
|
|
///
|
|
/// @req-test: UR-015 - View and manage audio queue
|
|
/// @req-test: DR-005 - Queue manager with repeat
|
|
#[test]
|
|
fn test_get_upcoming_with_repeat() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 1);
|
|
queue.set_repeat(RepeatMode::All);
|
|
|
|
// At item_1, get upcoming with repeat
|
|
let upcoming = queue.get_upcoming(4);
|
|
assert_eq!(upcoming.len(), 4);
|
|
assert_eq!(upcoming[0].id, "item_2"); // Next
|
|
assert_eq!(upcoming[1].id, "item_0"); // Wrapped
|
|
assert_eq!(upcoming[2].id, "item_1"); // Wrapped (current again)
|
|
assert_eq!(upcoming[3].id, "item_2"); // Wrapped
|
|
}
|
|
|
|
/// Test upcoming tracks on empty queue
|
|
///
|
|
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
|
|
#[test]
|
|
fn test_get_upcoming_empty() {
|
|
let queue = QueueManager::new();
|
|
let upcoming = queue.get_upcoming(3);
|
|
assert!(upcoming.is_empty());
|
|
}
|
|
|
|
/// Test next stops at end without repeat mode
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (queue end behavior)
|
|
/// @req-test: DR-005 - Queue manager
|
|
#[test]
|
|
fn test_next_no_repeat_reaches_end() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 0);
|
|
queue.set_repeat(RepeatMode::Off);
|
|
|
|
// At item 0, should have next
|
|
assert!(queue.has_next());
|
|
assert_eq!(queue.current_index(), Some(0));
|
|
|
|
// Move to item 1
|
|
queue.next();
|
|
assert!(queue.has_next());
|
|
assert_eq!(queue.current_index(), Some(1));
|
|
|
|
// Move to item 2 (last)
|
|
queue.next();
|
|
assert!(!queue.has_next()); // No more items
|
|
assert_eq!(queue.current_index(), Some(2));
|
|
|
|
// Try to move past end
|
|
let result = queue.next();
|
|
assert!(result.is_none());
|
|
assert_eq!(queue.current_index(), Some(2)); // Should stay at last item
|
|
}
|
|
|
|
/// Test next wraps to beginning with repeat all
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (repeat all wrapping)
|
|
/// @req-test: DR-005 - Queue manager with repeat
|
|
#[test]
|
|
fn test_next_repeat_all_wraps() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 0);
|
|
queue.set_repeat(RepeatMode::All);
|
|
|
|
// With repeat all, has_next should always be true
|
|
assert!(queue.has_next());
|
|
|
|
// Move through all items
|
|
queue.next(); // item_1
|
|
assert!(queue.has_next());
|
|
queue.next(); // item_2
|
|
assert!(queue.has_next());
|
|
|
|
// Wrap to beginning
|
|
let result = queue.next();
|
|
assert!(result.is_some());
|
|
assert_eq!(queue.current().unwrap().id, "item_0");
|
|
assert!(queue.has_next()); // Still has next (loops forever)
|
|
}
|
|
|
|
/// Test next repeats same track with repeat one mode
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (repeat one mode)
|
|
/// @req-test: DR-005 - Queue manager with repeat
|
|
#[test]
|
|
fn test_next_repeat_one_stays() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 1);
|
|
queue.set_repeat(RepeatMode::One);
|
|
|
|
// Should always have next (repeats current)
|
|
assert!(queue.has_next());
|
|
assert_eq!(queue.current_index(), Some(1));
|
|
|
|
// Call next multiple times - should stay on same track
|
|
for _ in 0..5 {
|
|
let result = queue.next();
|
|
assert!(result.is_some());
|
|
assert_eq!(queue.current().unwrap().id, "item_1");
|
|
assert_eq!(queue.current_index(), Some(1));
|
|
assert!(queue.has_next());
|
|
}
|
|
}
|
|
|
|
/// Test shuffle mode follows randomized order
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (shuffle mode)
|
|
/// @req-test: DR-005 - Queue manager with shuffle
|
|
#[test]
|
|
fn test_next_shuffle_follows_order() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(4), 0);
|
|
queue.toggle_shuffle(); // Enable shuffle
|
|
|
|
// Get the shuffle order for verification
|
|
let shuffle_order = queue.shuffle_order.clone();
|
|
assert_eq!(shuffle_order.len(), 4);
|
|
|
|
// Current should be first item in shuffle order
|
|
let first_shuffled_index = shuffle_order[0];
|
|
assert_eq!(queue.current_index(), Some(first_shuffled_index));
|
|
|
|
// Move through shuffle order
|
|
for i in 1..shuffle_order.len() {
|
|
assert!(queue.has_next());
|
|
let result = queue.next();
|
|
assert!(result.is_some());
|
|
let expected_index = shuffle_order[i];
|
|
assert_eq!(queue.current_index(), Some(expected_index));
|
|
}
|
|
|
|
// At end of shuffle without repeat
|
|
assert!(!queue.has_next());
|
|
let result = queue.next();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
/// Test shuffle with repeat all wraps shuffle order
|
|
///
|
|
/// @req-test: UR-005 - Control media playback (shuffle + repeat)
|
|
/// @req-test: DR-005 - Queue manager with shuffle and repeat
|
|
#[test]
|
|
fn test_next_shuffle_with_repeat_all() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 0);
|
|
queue.toggle_shuffle(); // Enable shuffle
|
|
queue.set_repeat(RepeatMode::All);
|
|
|
|
let shuffle_order = queue.shuffle_order.clone();
|
|
|
|
// Move through entire shuffle order
|
|
for _ in 1..shuffle_order.len() {
|
|
queue.next();
|
|
}
|
|
|
|
// At end, should wrap to beginning of shuffle order
|
|
assert!(queue.has_next());
|
|
let result = queue.next();
|
|
assert!(result.is_some());
|
|
assert_eq!(queue.current_index(), Some(shuffle_order[0]));
|
|
}
|
|
|
|
/// Test has_next logic accuracy across different scenarios
|
|
///
|
|
/// @req-test: DR-005 - Queue manager (has_next accuracy)
|
|
/// @req-test: UR-015 - View and manage audio queue
|
|
#[test]
|
|
fn test_has_next_accuracy() {
|
|
// Test 1: Empty queue
|
|
let queue = QueueManager::new();
|
|
assert!(!queue.has_next());
|
|
|
|
// Test 2: Last track with repeat off
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(2), 1); // Start at last item
|
|
queue.set_repeat(RepeatMode::Off);
|
|
assert!(!queue.has_next());
|
|
|
|
// Test 3: Last track with repeat all
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(2), 1); // Start at last item
|
|
queue.set_repeat(RepeatMode::All);
|
|
assert!(queue.has_next()); // Should wrap
|
|
|
|
// Test 4: Repeat one mode (always has next)
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(1), 0);
|
|
queue.set_repeat(RepeatMode::One);
|
|
assert!(queue.has_next()); // Repeats forever
|
|
|
|
// Test 5: Middle of queue with repeat off
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 1); // Middle item
|
|
queue.set_repeat(RepeatMode::Off);
|
|
assert!(queue.has_next()); // Has item_2 next
|
|
|
|
// Test 6: Shuffle at end without repeat
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(3), 0);
|
|
queue.toggle_shuffle(); // Enable shuffle
|
|
queue.set_repeat(RepeatMode::Off);
|
|
// Move to last item in shuffle order
|
|
let shuffle_len = queue.shuffle_order.len();
|
|
for _ in 1..shuffle_len {
|
|
queue.next();
|
|
}
|
|
assert!(!queue.has_next());
|
|
}
|
|
|
|
/// Test has_next on empty queue edge case
|
|
///
|
|
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
|
|
#[test]
|
|
fn test_has_next_empty_queue() {
|
|
let queue = QueueManager::new();
|
|
assert!(!queue.has_next());
|
|
assert_eq!(queue.current_index(), None);
|
|
}
|
|
|
|
/// Test that selecting a specific track in an album starts at the correct index
|
|
///
|
|
/// Reproduces the bug where clicking songs 1-5 always played song 13
|
|
#[test]
|
|
fn test_play_specific_track_from_album() {
|
|
let mut queue = QueueManager::new();
|
|
let items = create_test_items(14); // 14-track album
|
|
|
|
// Simulate playing track 0 (first track)
|
|
queue.set_queue(items.clone(), 0);
|
|
assert_eq!(queue.current_index(), Some(0));
|
|
assert_eq!(queue.current().unwrap().id, "item_0");
|
|
|
|
// Simulate playing track 3 (fourth track)
|
|
queue.set_queue(items.clone(), 3);
|
|
assert_eq!(queue.current_index(), Some(3));
|
|
assert_eq!(queue.current().unwrap().id, "item_3");
|
|
|
|
// Simulate playing track 13 (last track)
|
|
queue.set_queue(items, 13);
|
|
assert_eq!(queue.current_index(), Some(13));
|
|
assert_eq!(queue.current().unwrap().id, "item_13");
|
|
}
|
|
|
|
/// Test that next() then previous() returns to the original track
|
|
#[test]
|
|
fn test_next_previous_roundtrip() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(5), 2); // Start at middle track
|
|
|
|
assert_eq!(queue.current_index(), Some(2));
|
|
|
|
// Go to next track
|
|
queue.next();
|
|
assert_eq!(queue.current_index(), Some(3));
|
|
|
|
// Go back - should return to track 2
|
|
queue.previous();
|
|
assert_eq!(queue.current_index(), Some(2));
|
|
assert_eq!(queue.current().unwrap().id, "item_2");
|
|
}
|
|
|
|
/// Test that previous() at the first track stays at first track
|
|
#[test]
|
|
fn test_previous_at_first_track_stays() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(5), 0); // Start at first track
|
|
|
|
assert_eq!(queue.current_index(), Some(0));
|
|
|
|
// Try to go to previous - should stay at 0
|
|
let result = queue.previous();
|
|
assert!(result.is_none());
|
|
assert_eq!(queue.current_index(), Some(0));
|
|
assert_eq!(queue.current().unwrap().id, "item_0");
|
|
}
|
|
|
|
/// Test that next() at last track (no repeat) stays at last track
|
|
#[test]
|
|
fn test_next_at_last_track_stays() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(5), 4); // Start at last track
|
|
|
|
assert_eq!(queue.current_index(), Some(4));
|
|
|
|
// Try to go to next - should return None and stay at 4
|
|
let result = queue.next();
|
|
assert!(result.is_none());
|
|
assert_eq!(queue.current_index(), Some(4));
|
|
assert_eq!(queue.current().unwrap().id, "item_4");
|
|
}
|
|
|
|
/// Test history validation prevents invalid wraparound
|
|
#[test]
|
|
fn test_history_validation_prevents_wraparound() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(5), 0);
|
|
|
|
// Manually corrupt history to simulate the bug
|
|
// (In the real bug, history would have last track's index)
|
|
queue.history.push(4);
|
|
|
|
// Try to go previous - should detect invalid history and clear it
|
|
let result = queue.previous();
|
|
assert!(result.is_none()); // Should not wrap to track 4
|
|
assert_eq!(queue.current_index(), Some(0)); // Should stay at track 0
|
|
assert!(queue.history.is_empty()); // History should be cleared
|
|
}
|
|
|
|
/// Test multiple next() calls build correct history
|
|
#[test]
|
|
fn test_multiple_next_builds_history() {
|
|
let mut queue = QueueManager::new();
|
|
queue.set_queue(create_test_items(5), 0);
|
|
|
|
// Navigate: 0 -> 1 -> 2 -> 3
|
|
queue.next(); // Now at 1, history=[0]
|
|
queue.next(); // Now at 2, history=[0, 1]
|
|
queue.next(); // Now at 3, history=[0, 1, 2]
|
|
|
|
assert_eq!(queue.current_index(), Some(3));
|
|
|
|
// Go back through history: 3 -> 2 -> 1 -> 0
|
|
queue.previous();
|
|
assert_eq!(queue.current_index(), Some(2));
|
|
|
|
queue.previous();
|
|
assert_eq!(queue.current_index(), Some(1));
|
|
|
|
queue.previous();
|
|
assert_eq!(queue.current_index(), Some(0));
|
|
}
|
|
}
|