Files
jellytau/src-tauri/src/player/queue.rs
T
dtourolle 11d9d760d8 feat(player): native video on Linux, and one contract for every player (v0.11.0)
mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.

That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.

Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.

  DR-238/246  a seek routed by the stream's container rather than by what the
              engine could do with it - correct only while one player handled
              those streams, silent the moment another did
  DR-239      a property handled but never observed, so the play/pause button
              waited for an event that could not arrive
  DR-240      fullscreen expanding the document while the window stayed put
  DR-241      a seek issued before the engine had a file, failed, and discarded
              - which is why resume began at zero
  DR-247      a Linux-only gate outliving the caller that made it Linux-only,
              breaking the Android build outright
  DR-250      a stop aimed at whichever renderer bookkeeping believed was in
              charge, missing the one actually making sound
  DR-251      a duration of zero believed, leaving the seek bar no scale
  DR-252      a junk float converted to a Duration, panicking the backend the
              instant a length-less stream appeared

So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.

Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.

Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.

Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.

Squashed from worktree-linux-native-video, which keeps the per-defect history.
2026-08-23 10:51:45 +02:00

1019 lines
34 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
}
/// Mutable access to queue items.
///
/// Used to re-point streaming entries at a completed local download
/// without disturbing queue order, shuffle state, or the current index.
pub fn items_mut(&mut self) -> &mut [MediaItem] {
&mut 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;
}
/// Clear the queue entirely, returning it to the empty state.
///
/// Used when playback genuinely stops (sleep timer fires, or the queue ends
/// with repeat off) so the frontend's `currentQueueItem` becomes null and
/// the mini player hides. History and shuffle order are reset too.
pub fn clear(&mut self) {
self.items.clear();
self.current_index = None;
self.history.clear();
self.shuffle_order.clear();
}
/// 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 {
// Audio and direct-URL items never negotiate a transport.
transport: None,
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,
image_id: 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 clearing the queue returns it to the empty state so the frontend
/// hides the mini player on a genuine stop.
#[test]
fn test_clear() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 1);
assert_eq!(queue.current_index(), Some(1));
queue.clear();
assert_eq!(queue.items().len(), 0);
assert_eq!(queue.current_index(), None);
assert!(queue.current().is_none());
}
/// 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 &expected_index in &shuffle_order[1..] {
assert!(queue.has_next());
let result = queue.next();
assert!(result.is_some());
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));
}
}