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.
1019 lines
34 KiB
Rust
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));
|
|
}
|
|
}
|