Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
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));
|
|
}
|
|
}
|