First working POC
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::repository::types::MediaItem;
|
||||
|
||||
/// Autoplay decision result - determines what happens after playback ends
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "action", rename_all = "camelCase")]
|
||||
pub enum AutoplayDecision {
|
||||
/// Stop playback (no next item or timer expired)
|
||||
Stop,
|
||||
/// Advance to next track in queue (for audio/movies)
|
||||
AdvanceToNext,
|
||||
/// Show next episode popup with countdown
|
||||
ShowNextEpisodePopup {
|
||||
current_episode: MediaItem,
|
||||
next_episode: MediaItem,
|
||||
countdown_seconds: u32,
|
||||
auto_advance: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Autoplay settings (controls next episode behavior)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoplaySettings {
|
||||
/// Whether autoplay is enabled for next episodes
|
||||
pub enabled: bool,
|
||||
/// Countdown duration in seconds before auto-playing next episode
|
||||
pub countdown_seconds: u32,
|
||||
}
|
||||
|
||||
impl Default for AutoplaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
countdown_seconds: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoplaySettings {
|
||||
/// Validate and clamp countdown seconds to reasonable range (5-30 seconds)
|
||||
pub fn with_validated_countdown(mut self) -> Self {
|
||||
self.countdown_seconds = self.countdown_seconds.clamp(5, 30);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_autoplay_settings_defaults() {
|
||||
let settings = AutoplaySettings::default();
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.countdown_seconds, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_countdown_validation() {
|
||||
let settings = AutoplaySettings {
|
||||
enabled: true,
|
||||
countdown_seconds: 2, // Too short
|
||||
}
|
||||
.with_validated_countdown();
|
||||
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
|
||||
|
||||
let settings = AutoplaySettings {
|
||||
enabled: true,
|
||||
countdown_seconds: 60, // Too long
|
||||
}
|
||||
.with_validated_countdown();
|
||||
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
|
||||
|
||||
let settings = AutoplaySettings {
|
||||
enabled: true,
|
||||
countdown_seconds: 15, // Valid
|
||||
}
|
||||
.with_validated_countdown();
|
||||
assert_eq!(settings.countdown_seconds, 15); // Unchanged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
use super::media::MediaItem;
|
||||
use super::state::PlayerState;
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
/// Error type for player operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlayerError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlayerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlayerError {}
|
||||
|
||||
impl PlayerError {
|
||||
pub fn not_implemented() -> Self {
|
||||
Self {
|
||||
message: "Not implemented".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a playback failure error
|
||||
///
|
||||
/// Only available on Android where ExoPlayer uses it for JNI errors
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn playback_failed<S: Into<String>>(message: S) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Player backend trait - implemented by platform-specific players
|
||||
///
|
||||
/// @req: UR-003 - Play videos
|
||||
/// @req: UR-004 - Play audio uninterrupted
|
||||
/// @req: IR-003 - Integration of libmpv for Linux playback
|
||||
/// @req: IR-004 - Integration of ExoPlayer for Android playback
|
||||
/// @req: DR-004 - PlayerBackend trait for platform-agnostic playback
|
||||
pub trait PlayerBackend: Send + Sync {
|
||||
/// Load a media item for playback
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (load operation)
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
||||
|
||||
/// Start or resume playback
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (play operation)
|
||||
fn play(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Pause playback
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (pause operation)
|
||||
fn pause(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Stop playback and unload media
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (stop operation)
|
||||
fn stop(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Seek to a position in seconds
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (scrub operation)
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
||||
|
||||
/// Set volume (0.0 - 1.0)
|
||||
///
|
||||
/// @req: UR-016 - Change system settings while playing (volume)
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
||||
|
||||
/// Get current playback position in seconds
|
||||
fn position(&self) -> f64;
|
||||
|
||||
/// Get total duration in seconds
|
||||
fn duration(&self) -> Option<f64>;
|
||||
|
||||
/// Get current player state
|
||||
fn state(&self) -> PlayerState;
|
||||
|
||||
/// Get current volume
|
||||
fn volume(&self) -> f32;
|
||||
|
||||
/// Apply audio settings (crossfade, gapless, normalization)
|
||||
///
|
||||
/// @req-partial: UR-031 (Linux only) - Crossfade between audio tracks
|
||||
/// @req-partial: UR-032 (Linux only) - Gapless playback for seamless album listening
|
||||
/// @req-partial: UR-033 (Linux only) - Volume normalization to prevent volume jumps
|
||||
/// @req: DR-034 - Crossfade engine with configurable duration (0-12s)
|
||||
/// @req: DR-035 - Gapless playback between sequential tracks
|
||||
/// @req: DR-036 - Volume normalization with preset levels (Loud/Normal/Quiet)
|
||||
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current audio settings
|
||||
///
|
||||
/// @req: DR-034 - Crossfade engine
|
||||
/// @req: DR-035 - Gapless playback
|
||||
/// @req: DR-036 - Volume normalization
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
AudioSettings::default()
|
||||
}
|
||||
|
||||
/// Set the active audio track by stream index
|
||||
///
|
||||
/// @req-planned: UR-021 - Select audio track for video content
|
||||
/// @req-planned: IR-019 - libmpv audio track selection
|
||||
/// @req-planned: DR-024 - Audio track selection UI in video player
|
||||
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
}
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
///
|
||||
/// @req-planned: UR-020 - Select subtitles for video content
|
||||
/// @req-planned: IR-018 - libmpv subtitle rendering and selection
|
||||
/// @req-planned: DR-023 - Subtitle selection UI in video player
|
||||
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
}
|
||||
}
|
||||
|
||||
/// Null player backend (for testing or when no real player is available)
|
||||
///
|
||||
/// @req: DR-004 - PlayerBackend trait (mock implementation for testing)
|
||||
pub struct NullBackend {
|
||||
state: PlayerState,
|
||||
volume: f32,
|
||||
position: f64,
|
||||
duration: Option<f64>,
|
||||
audio_settings: AudioSettings,
|
||||
}
|
||||
|
||||
impl Default for NullBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NullBackend {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: PlayerState::Idle,
|
||||
volume: 1.0,
|
||||
position: 0.0,
|
||||
duration: None,
|
||||
audio_settings: AudioSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerBackend for NullBackend {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
self.state = PlayerState::Loading {
|
||||
media: media.clone(),
|
||||
};
|
||||
// Simulate immediate load
|
||||
self.duration = media.duration;
|
||||
self.position = 0.0;
|
||||
self.state = PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position: 0.0,
|
||||
duration: media.duration.unwrap_or(0.0),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Paused { media, position, duration } = &self.state {
|
||||
self.state = PlayerState::Playing {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
duration: *duration,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Playing { media, position, duration } = &self.state {
|
||||
self.state = PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
duration: *duration,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
self.state = PlayerState::Idle;
|
||||
self.position = 0.0;
|
||||
self.duration = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
self.position = position;
|
||||
match &mut self.state {
|
||||
PlayerState::Playing { position: pos, .. } => *pos = position,
|
||||
PlayerState::Paused { position: pos, .. } => *pos = position,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
self.volume = volume.clamp(0.0, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.position
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
self.duration
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
fn volume(&self) -> f32 {
|
||||
self.volume
|
||||
}
|
||||
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
self.audio_settings = settings.clone().with_crossfade_clamped();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.audio_settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Test NullBackend volume default value
|
||||
///
|
||||
/// @req-test: UT-026 - NullBackend volume default value
|
||||
/// @req-test: DR-004 - PlayerBackend trait
|
||||
#[test]
|
||||
fn test_null_backend_volume_default() {
|
||||
let backend = NullBackend::new();
|
||||
assert_eq!(backend.volume(), 1.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend set volume
|
||||
///
|
||||
/// @req-test: UT-027 - NullBackend set volume
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_set_volume() {
|
||||
let mut backend = NullBackend::new();
|
||||
backend.set_volume(0.5).unwrap();
|
||||
assert_eq!(backend.volume(), 0.5);
|
||||
}
|
||||
|
||||
/// Test NullBackend volume clamping (high)
|
||||
///
|
||||
/// @req-test: UT-028 - NullBackend volume clamping (high/low)
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_volume_clamping_high() {
|
||||
let mut backend = NullBackend::new();
|
||||
backend.set_volume(1.5).unwrap();
|
||||
assert_eq!(backend.volume(), 1.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend volume clamping (low)
|
||||
///
|
||||
/// @req-test: UT-028 - NullBackend volume clamping (high/low)
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_volume_clamping_low() {
|
||||
let mut backend = NullBackend::new();
|
||||
backend.set_volume(-0.5).unwrap();
|
||||
assert_eq!(backend.volume(), 0.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend volume boundary values
|
||||
///
|
||||
/// @req-test: UT-029 - NullBackend volume boundary values
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_volume_boundary() {
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
backend.set_volume(0.0).unwrap();
|
||||
assert_eq!(backend.volume(), 0.0);
|
||||
|
||||
backend.set_volume(1.0).unwrap();
|
||||
assert_eq!(backend.volume(), 1.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend audio settings default values
|
||||
///
|
||||
/// @req-test: DR-034 - Crossfade engine
|
||||
/// @req-test: DR-035 - Gapless playback
|
||||
/// @req-test: DR-036 - Volume normalization
|
||||
#[test]
|
||||
fn test_null_backend_audio_settings_default() {
|
||||
let backend = NullBackend::new();
|
||||
let settings = backend.audio_settings();
|
||||
assert_eq!(settings.crossfade_duration, 0.0);
|
||||
assert!(settings.gapless_playback);
|
||||
assert!(!settings.normalize_volume);
|
||||
}
|
||||
|
||||
/// Test NullBackend set audio settings
|
||||
///
|
||||
/// @req-test: DR-034 - Crossfade engine with configurable duration
|
||||
/// @req-test: DR-035 - Gapless playback between sequential tracks
|
||||
/// @req-test: DR-036 - Volume normalization with preset levels
|
||||
#[test]
|
||||
fn test_null_backend_set_audio_settings() {
|
||||
use crate::settings::VolumeLevel;
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 5.0,
|
||||
gapless_playback: false,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
|
||||
let result = backend.audio_settings();
|
||||
assert_eq!(result.crossfade_duration, 5.0);
|
||||
assert!(!result.gapless_playback);
|
||||
assert!(result.normalize_volume);
|
||||
assert_eq!(result.volume_level, VolumeLevel::Loud);
|
||||
}
|
||||
|
||||
/// Test NullBackend audio settings crossfade clamping to 12s max
|
||||
///
|
||||
/// @req-test: DR-034 - Crossfade engine with configurable duration (0-12s)
|
||||
#[test]
|
||||
fn test_null_backend_audio_settings_crossfade_clamping() {
|
||||
let mut backend = NullBackend::new();
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 20.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
assert_eq!(backend.audio_settings().crossfade_duration, 12.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend seek updates position
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (scrub operation)
|
||||
/// @req-test: DR-004 - PlayerBackend trait
|
||||
#[test]
|
||||
fn test_null_backend_seek_updates_position() {
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
// Create a test media item
|
||||
let media = MediaItem {
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/test.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
};
|
||||
|
||||
// Load and play the media
|
||||
backend.load(&media).unwrap();
|
||||
backend.play().unwrap();
|
||||
|
||||
// Verify initial position
|
||||
assert_eq!(backend.position(), 0.0);
|
||||
|
||||
// Seek to 30 seconds
|
||||
backend.seek(30.0).unwrap();
|
||||
assert_eq!(backend.position(), 30.0);
|
||||
|
||||
// Seek to 60 seconds
|
||||
backend.seek(60.0).unwrap();
|
||||
assert_eq!(backend.position(), 60.0);
|
||||
|
||||
// Seek backward
|
||||
backend.seek(15.0).unwrap();
|
||||
assert_eq!(backend.position(), 15.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend seek while paused
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (scrub while paused)
|
||||
/// @req-test: DR-001 - Player state machine (seeking from paused state)
|
||||
#[test]
|
||||
fn test_null_backend_seek_while_paused() {
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/test.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
};
|
||||
|
||||
// Load media (starts paused)
|
||||
backend.load(&media).unwrap();
|
||||
|
||||
// Verify state is paused
|
||||
assert!(matches!(backend.state(), PlayerState::Paused { .. }));
|
||||
|
||||
// Seek while paused
|
||||
backend.seek(45.0).unwrap();
|
||||
assert_eq!(backend.position(), 45.0);
|
||||
|
||||
// Verify still paused
|
||||
assert!(matches!(backend.state(), PlayerState::Paused { .. }));
|
||||
}
|
||||
|
||||
/// Test NullBackend position updates reflected in state
|
||||
///
|
||||
/// @req-test: DR-001 - Player state machine (position tracking)
|
||||
/// @req-test: UR-005 - Control media playback (position accuracy)
|
||||
#[test]
|
||||
fn test_null_backend_position_updates_in_state() {
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/test.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
};
|
||||
|
||||
backend.load(&media).unwrap();
|
||||
backend.play().unwrap();
|
||||
|
||||
// Seek to 30 seconds
|
||||
backend.seek(30.0).unwrap();
|
||||
|
||||
// Verify the state reflects the new position
|
||||
if let PlayerState::Playing { position, .. } = backend.state() {
|
||||
assert_eq!(position, 30.0);
|
||||
} else {
|
||||
panic!("Expected Playing state");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Player events for frontend communication via Tauri events.
|
||||
//!
|
||||
//! These events are emitted from the player backend to notify the frontend
|
||||
//! of playback state changes, position updates, etc.
|
||||
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::{MediaSessionType, SleepTimerMode};
|
||||
|
||||
/// Events emitted by the player backend to the frontend via Tauri events.
|
||||
///
|
||||
/// These are distinct from `PlayerEvent` in state.rs, which handles internal
|
||||
/// state machine transitions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PlayerStatusEvent {
|
||||
/// Playback position updated (emitted periodically during playback)
|
||||
PositionUpdate {
|
||||
/// Current position in seconds
|
||||
position: f64,
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Player state changed
|
||||
StateChanged {
|
||||
/// New state: "playing", "paused", "stopped", "loading", "idle"
|
||||
state: String,
|
||||
/// ID of the current media item, if any
|
||||
media_id: Option<String>,
|
||||
},
|
||||
/// Media has finished loading and is ready to play
|
||||
MediaLoaded {
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Playback has ended naturally (reached end of media)
|
||||
PlaybackEnded,
|
||||
/// Buffering state changed
|
||||
Buffering {
|
||||
/// Buffering progress (0-100)
|
||||
percent: u8,
|
||||
},
|
||||
/// An error occurred during playback
|
||||
Error {
|
||||
/// Error message
|
||||
message: String,
|
||||
/// Whether the error is recoverable
|
||||
recoverable: bool,
|
||||
},
|
||||
/// Volume changed
|
||||
VolumeChanged {
|
||||
/// New volume level (0.0-1.0)
|
||||
volume: f32,
|
||||
/// Whether audio is muted
|
||||
muted: bool,
|
||||
},
|
||||
/// Sleep timer state changed
|
||||
SleepTimerChanged {
|
||||
/// Sleep timer mode
|
||||
mode: SleepTimerMode,
|
||||
/// Remaining seconds (for time-based timer)
|
||||
remaining_seconds: u32,
|
||||
},
|
||||
/// Show next episode popup with countdown
|
||||
ShowNextEpisodePopup {
|
||||
/// Current episode that just finished
|
||||
current_episode: crate::repository::types::MediaItem,
|
||||
/// Next episode to play
|
||||
next_episode: crate::repository::types::MediaItem,
|
||||
/// Countdown duration in seconds
|
||||
countdown_seconds: u32,
|
||||
/// Whether to auto-advance when countdown reaches 0
|
||||
auto_advance: bool,
|
||||
},
|
||||
/// Countdown tick (emitted every second during autoplay countdown)
|
||||
CountdownTick {
|
||||
/// Remaining seconds in countdown
|
||||
remaining_seconds: u32,
|
||||
},
|
||||
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
||||
QueueChanged {
|
||||
/// All items in the queue
|
||||
items: Vec<crate::player::media::MediaItem>,
|
||||
/// Current item index
|
||||
current_index: Option<usize>,
|
||||
/// Whether shuffle is enabled
|
||||
shuffle: bool,
|
||||
/// Current repeat mode
|
||||
repeat: crate::player::queue::RepeatMode,
|
||||
/// Whether there's a next track available
|
||||
has_next: bool,
|
||||
/// Whether there's a previous track available
|
||||
has_previous: bool,
|
||||
},
|
||||
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
||||
SessionChanged {
|
||||
/// Current session state
|
||||
session: MediaSessionType,
|
||||
},
|
||||
/// Remote sessions updated (for cast/remote control UI)
|
||||
SessionsUpdated {
|
||||
/// All active controllable sessions from Jellyfin
|
||||
sessions: Vec<crate::jellyfin::client::SessionInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Tauri event name for player status events
|
||||
pub const PLAYER_EVENT_NAME: &str = "player-event";
|
||||
|
||||
/// Trait for emitting player events to the frontend.
|
||||
///
|
||||
/// This abstraction allows backends to emit events without depending
|
||||
/// directly on Tauri, making them easier to test.
|
||||
pub trait PlayerEventEmitter: Send + Sync {
|
||||
/// Emit a player status event to the frontend
|
||||
fn emit(&self, event: PlayerStatusEvent);
|
||||
}
|
||||
|
||||
/// Tauri-based implementation of PlayerEventEmitter.
|
||||
///
|
||||
/// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows.
|
||||
pub struct TauriEventEmitter {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl TauriEventEmitter {
|
||||
/// Create a new TauriEventEmitter with the given app handle.
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for TauriEventEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
|
||||
error!("Failed to emit player event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for event emitters.
|
||||
#[allow(dead_code)]
|
||||
pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
|
||||
/// Test event emitter that captures events for verification
|
||||
pub struct TestEventEmitter {
|
||||
events: Mutex<Vec<PlayerStatusEvent>>,
|
||||
}
|
||||
|
||||
impl TestEventEmitter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn events(&self) -> Vec<PlayerStatusEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.events.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for TestEventEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_update_serialization() {
|
||||
let event = PlayerStatusEvent::PositionUpdate {
|
||||
position: 30.5,
|
||||
duration: 180.0,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("position_update"));
|
||||
assert!(json.contains("30.5"));
|
||||
assert!(json.contains("180"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_changed_serialization() {
|
||||
let event = PlayerStatusEvent::StateChanged {
|
||||
state: "playing".to_string(),
|
||||
media_id: Some("test-id-123".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("state_changed"));
|
||||
assert!(json.contains("playing"));
|
||||
assert!(json.contains("test-id-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_changed_no_media_id() {
|
||||
let event = PlayerStatusEvent::StateChanged {
|
||||
state: "idle".to_string(),
|
||||
media_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("state_changed"));
|
||||
assert!(json.contains("idle"));
|
||||
assert!(json.contains("null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_loaded_serialization() {
|
||||
let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("media_loaded"));
|
||||
assert!(json.contains("245.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_playback_ended_serialization() {
|
||||
let event = PlayerStatusEvent::PlaybackEnded;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("playback_ended"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffering_serialization() {
|
||||
let event = PlayerStatusEvent::Buffering { percent: 75 };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("buffering"));
|
||||
assert!(json.contains("75"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_serialization() {
|
||||
let event = PlayerStatusEvent::Error {
|
||||
message: "Failed to load media".to_string(),
|
||||
recoverable: true,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("error"));
|
||||
assert!(json.contains("Failed to load media"));
|
||||
assert!(json.contains("true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_changed_serialization() {
|
||||
let event = PlayerStatusEvent::VolumeChanged {
|
||||
volume: 0.75,
|
||||
muted: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("volume_changed"));
|
||||
assert!(json.contains("0.75"));
|
||||
assert!(json.contains("false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_emitter_captures_events() {
|
||||
let emitter = TestEventEmitter::new();
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
assert_eq!(emitter.events().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_emitter_multiple_events() {
|
||||
let emitter = TestEventEmitter::new();
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position: 10.0,
|
||||
duration: 100.0,
|
||||
});
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: "paused".to_string(),
|
||||
media_id: None,
|
||||
});
|
||||
assert_eq!(emitter.events().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_emitter_thread_safety() {
|
||||
let emitter = Arc::new(TestEventEmitter::new());
|
||||
let mut handles = vec![];
|
||||
|
||||
for i in 0..10 {
|
||||
let emitter_clone = Arc::clone(&emitter);
|
||||
let handle = thread::spawn(move || {
|
||||
emitter_clone.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position: i as f64,
|
||||
duration: 100.0,
|
||||
});
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(emitter.events().len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_event_emitter() {
|
||||
let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new());
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
// Verify it compiles and works as a trait object
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Context for the current queue - where did the queue items come from?
|
||||
/// This is used for remote playback transfer to send album/playlist context.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum QueueContext {
|
||||
/// Playing from a specific album
|
||||
Album {
|
||||
album_id: String,
|
||||
album_name: String,
|
||||
},
|
||||
/// Playing from a specific playlist
|
||||
Playlist {
|
||||
playlist_id: String,
|
||||
playlist_name: String,
|
||||
},
|
||||
/// Custom queue (search results, manual queue, etc.)
|
||||
/// Will create a temporary playlist on remote transfer
|
||||
#[default]
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Represents a subtitle track
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SubtitleTrack {
|
||||
/// Stream index in the media source
|
||||
pub index: i32,
|
||||
/// Subtitle URL
|
||||
pub url: String,
|
||||
/// Language code (e.g., "eng", "spa")
|
||||
pub language: Option<String>,
|
||||
/// Display title
|
||||
pub label: Option<String>,
|
||||
/// MIME type (e.g., "text/vtt", "application/x-subrip")
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Represents a media item that can be played
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
/// Unique identifier
|
||||
pub id: String,
|
||||
/// Display title
|
||||
pub title: String,
|
||||
/// Name (alias for title - for frontend compatibility)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Artist name(s) for audio
|
||||
pub artist: Option<String>,
|
||||
/// Album name for audio
|
||||
pub album: Option<String>,
|
||||
/// Album name (alias - for frontend compatibility)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub album_name: Option<String>,
|
||||
/// Album ID (Jellyfin ID) for remote transfer context
|
||||
#[serde(default)]
|
||||
pub album_id: Option<String>,
|
||||
/// Artist items with IDs for clickable links
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
|
||||
/// Artists as array of strings (fallback when artist_items not available)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artists: Option<Vec<String>>,
|
||||
/// Primary image tag for artwork
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Item type (Audio, Movie, Episode, etc.)
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub item_type: Option<String>,
|
||||
/// Playlist ID (Jellyfin ID) for remote transfer context
|
||||
#[serde(default)]
|
||||
pub playlist_id: Option<String>,
|
||||
/// Duration in seconds
|
||||
pub duration: Option<f64>,
|
||||
/// URL or path to artwork image
|
||||
pub artwork_url: Option<String>,
|
||||
/// Type of media
|
||||
pub media_type: MediaType,
|
||||
/// Source of the media
|
||||
pub source: MediaSource,
|
||||
/// Video codec (e.g., "h264", "hevc") for video media
|
||||
#[serde(default)]
|
||||
pub video_codec: Option<String>,
|
||||
/// Whether the video requires server-side transcoding
|
||||
#[serde(default)]
|
||||
pub needs_transcoding: bool,
|
||||
/// Video width in pixels
|
||||
#[serde(default)]
|
||||
pub video_width: Option<u32>,
|
||||
/// Video height in pixels
|
||||
#[serde(default)]
|
||||
pub video_height: Option<u32>,
|
||||
/// Available subtitle tracks
|
||||
#[serde(default)]
|
||||
pub subtitles: Vec<SubtitleTrack>,
|
||||
/// Series ID (for TV show episodes) - used for series audio preferences
|
||||
#[serde(default)]
|
||||
pub series_id: Option<String>,
|
||||
/// Server ID - used for series audio preferences
|
||||
#[serde(default)]
|
||||
pub server_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MediaType {
|
||||
Audio,
|
||||
Video,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum MediaSource {
|
||||
/// Streaming from Jellyfin server
|
||||
Remote {
|
||||
stream_url: String,
|
||||
jellyfin_item_id: String,
|
||||
},
|
||||
/// Downloaded/cached locally
|
||||
Local {
|
||||
file_path: PathBuf,
|
||||
/// Original Jellyfin ID for sync-back
|
||||
jellyfin_item_id: Option<String>,
|
||||
},
|
||||
/// Direct URL (e.g., channel plugins)
|
||||
DirectUrl { url: String },
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
/// Get the Jellyfin item ID if available
|
||||
pub fn jellyfin_id(&self) -> Option<&str> {
|
||||
match &self.source {
|
||||
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id),
|
||||
MediaSource::Local { jellyfin_item_id, .. } => jellyfin_item_id.as_deref(),
|
||||
MediaSource::DirectUrl { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the playback URL or file path
|
||||
///
|
||||
/// Only available on Android where ExoPlayer needs direct URL access
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn playback_url(&self) -> String {
|
||||
match &self.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::Local { file_path, .. } => {
|
||||
file_path.to_string_lossy().to_string()
|
||||
}
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
||||
use log::{debug, error, info, warn};
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
use libmpv::Mpv;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// MPV-based player backend for Linux
|
||||
///
|
||||
/// Uses libmpv for audio playback with full control over playback state,
|
||||
/// position tracking, and event handling.
|
||||
pub struct MpvBackend {
|
||||
mpv: Arc<Mpv>,
|
||||
state: Arc<Mutex<InternalState>>,
|
||||
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
||||
audio_settings: AudioSettings,
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
last_seek_time: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
struct InternalState {
|
||||
current_media: Option<MediaItem>,
|
||||
volume: f32,
|
||||
}
|
||||
|
||||
/// Detect which audio system is available on the system
|
||||
fn detect_audio_system() -> String {
|
||||
info!("[MpvBackend] Detecting audio system...");
|
||||
|
||||
// Try PulseAudio/PipeWire first (most common on modern Linux)
|
||||
if let Ok(output) = Command::new("pactl").arg("info").output() {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.contains("PipeWire") {
|
||||
info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)");
|
||||
return "pulse".to_string();
|
||||
} else if stdout.contains("PulseAudio") {
|
||||
info!("[MpvBackend] Detected PulseAudio");
|
||||
return "pulse".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try detecting PipeWire directly
|
||||
if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() {
|
||||
if output.status.success() {
|
||||
info!("[MpvBackend] Detected PipeWire");
|
||||
return "pulse".to_string(); // PipeWire works with pulse driver
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ALSA is available
|
||||
if std::path::Path::new("/proc/asound/cards").exists() {
|
||||
info!("[MpvBackend] Falling back to ALSA");
|
||||
return "alsa".to_string();
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
warn!("[MpvBackend] Could not detect audio system, using 'auto'");
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
/// Helper to get stream URL from MediaItem
|
||||
fn get_stream_url(media: &MediaItem) -> String {
|
||||
match &media.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::Local { file_path, .. } => {
|
||||
format!("file://{}", file_path.to_string_lossy())
|
||||
}
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
impl MpvBackend {
|
||||
/// Create a new MPV backend
|
||||
pub fn new(
|
||||
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
) -> Result<Self, PlayerError> {
|
||||
info!("[MpvBackend] Initializing MPV backend...");
|
||||
|
||||
// MPV requires LC_NUMERIC to be set to "C" locale
|
||||
// Set it before initializing MPV, then restore it after
|
||||
use std::ffi::CString;
|
||||
unsafe {
|
||||
let c_locale = CString::new("C").unwrap();
|
||||
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
|
||||
}
|
||||
|
||||
let mpv = Mpv::new().map_err(|e| PlayerError {
|
||||
message: format!("Failed to initialize MPV: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Detect and configure audio output
|
||||
let audio_driver = detect_audio_system();
|
||||
info!("[MpvBackend] Configuring audio output driver: {}", audio_driver);
|
||||
|
||||
mpv.set_property("ao", audio_driver.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e),
|
||||
})?;
|
||||
|
||||
// Enable verbose logging for audio initialization
|
||||
mpv.set_property("msg-level", "all=warn,ao=debug")
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e);
|
||||
});
|
||||
|
||||
// Configure MPV for audio playback
|
||||
mpv.set_property("audio-display", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV audio-display: {:?}", e),
|
||||
})?;
|
||||
|
||||
mpv.set_property("video", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Set volume to 100% (we'll control via MPV's volume property)
|
||||
mpv.set_property("volume", 100i64)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set initial volume: {:?}", e),
|
||||
})?;
|
||||
|
||||
let state = Arc::new(Mutex::new(InternalState {
|
||||
current_media: None,
|
||||
volume: 1.0,
|
||||
}));
|
||||
|
||||
let backend = MpvBackend {
|
||||
mpv: Arc::new(mpv),
|
||||
state,
|
||||
event_emitter,
|
||||
audio_settings: AudioSettings::default(),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
last_seek_time: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
|
||||
// Start event loop in background thread
|
||||
backend.start_event_loop();
|
||||
|
||||
info!("[MpvBackend] Initialized successfully");
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Start the MPV event loop in a background thread
|
||||
fn start_event_loop(&self) {
|
||||
let mpv = self.mpv.clone();
|
||||
let event_emitter = self.event_emitter.clone();
|
||||
let state = self.state.clone();
|
||||
let reporter = self.playback_reporter.clone();
|
||||
let throttler = self.position_throttler.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
info!("[MpvBackend] Event loop started");
|
||||
|
||||
let mut ev_ctx = mpv.create_event_context();
|
||||
ev_ctx.disable_deprecated_events().unwrap_or_else(|e| {
|
||||
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
|
||||
});
|
||||
|
||||
loop {
|
||||
match ev_ctx.wait_event(1.0) {
|
||||
Some(Ok(event)) => match event {
|
||||
libmpv::events::Event::StartFile => {
|
||||
debug!("[MpvBackend] Starting file");
|
||||
}
|
||||
libmpv::events::Event::FileLoaded => {
|
||||
info!("[MpvBackend] File loaded");
|
||||
|
||||
// Get duration
|
||||
if let Ok(duration) = mpv.get_property::<f64>("duration") {
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||
}
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::PlaybackRestart => {
|
||||
debug!("[MpvBackend] Playback started/resumed");
|
||||
|
||||
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: "playing".to_string(),
|
||||
media_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||
// Handle pause state changes
|
||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
||||
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: if is_paused { "paused" } else { "playing" }.to_string(),
|
||||
media_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::EndFile(reason) => {
|
||||
debug!("[MpvBackend] End file with reason: {}", reason);
|
||||
|
||||
// Only emit PlaybackEnded for natural track completion (EOF = 0)
|
||||
// Don't emit for Stop (2), Quit (3), Error (4), or other reasons
|
||||
// Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4
|
||||
const MPV_END_FILE_REASON_EOF: u32 = 0;
|
||||
const MPV_END_FILE_REASON_STOP: u32 = 2;
|
||||
const MPV_END_FILE_REASON_QUIT: u32 = 3;
|
||||
const MPV_END_FILE_REASON_ERROR: u32 = 4;
|
||||
|
||||
if reason == MPV_END_FILE_REASON_EOF {
|
||||
debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded");
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
} else if reason == MPV_END_FILE_REASON_STOP {
|
||||
debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded");
|
||||
// Don't emit - user is loading a new track
|
||||
} else if reason == MPV_END_FILE_REASON_QUIT {
|
||||
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
|
||||
// Don't emit - player is shutting down
|
||||
} else if reason == MPV_END_FILE_REASON_ERROR {
|
||||
warn!("[MpvBackend] Track ended with error, NOT emitting PlaybackEnded");
|
||||
// Don't emit - we should handle errors separately
|
||||
} else {
|
||||
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::Shutdown => {
|
||||
info!("[MpvBackend] Shutdown event received");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
error!("[MpvBackend] Event error: {:?}", e);
|
||||
}
|
||||
None => {
|
||||
// Timeout, continue
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
info!("[MpvBackend] Event loop ended");
|
||||
});
|
||||
|
||||
// Start position update thread
|
||||
let mpv_for_position = self.mpv.clone();
|
||||
let emitter_for_position = self.event_emitter.clone();
|
||||
let state_for_position = self.state.clone();
|
||||
let reporter_for_position = reporter.clone();
|
||||
let throttler_for_position = throttler.clone();
|
||||
let last_seek_time_for_position = self.last_seek_time.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
|
||||
// Get current position and duration
|
||||
// Note: We emit position updates even when paused so scrubbing works
|
||||
if let (Ok(pos), Ok(dur)) = (
|
||||
mpv_for_position.get_property::<f64>("time-pos"),
|
||||
mpv_for_position.get_property::<f64>("duration"),
|
||||
) {
|
||||
// Check if we recently seeked - skip position updates briefly after seeks
|
||||
// to avoid "jumping to zero" visual glitches while MPV is seeking
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
let last_seek = last_seek_time_for_position.load(Ordering::Relaxed);
|
||||
let time_since_seek = now.saturating_sub(last_seek);
|
||||
|
||||
// Skip position updates for 150ms after a seek to let MPV stabilize
|
||||
if time_since_seek < 150 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit position update event (even when paused, for scrubbing)
|
||||
if let Some(emitter) = &emitter_for_position {
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position: pos,
|
||||
duration: dur,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we're playing for progress reporting
|
||||
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
|
||||
|
||||
// Only report progress to server when playing (not paused)
|
||||
if !is_paused {
|
||||
// Throttled progress reporting (every 30s)
|
||||
let jellyfin_id = {
|
||||
let state = state_for_position.lock().unwrap();
|
||||
state.current_media.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
};
|
||||
|
||||
if let Some(item_id) = jellyfin_id {
|
||||
if throttler_for_position.should_report(&item_id) {
|
||||
let position_ticks = seconds_to_ticks(pos);
|
||||
let reporter_clone = reporter_for_position.clone();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
// Spawn async task to report progress
|
||||
// Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
let reporter_guard = reporter_clone.lock().await;
|
||||
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
||||
let operation = PlaybackOperation::Progress {
|
||||
item_id: item_id_clone.clone(),
|
||||
position_ticks,
|
||||
is_paused: false,
|
||||
};
|
||||
|
||||
match reporter_instance.report(operation, true).await {
|
||||
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
|
||||
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: spawn in a new thread with its own runtime
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
let reporter_guard = reporter_clone.lock().await;
|
||||
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
||||
let operation = PlaybackOperation::Progress {
|
||||
item_id: item_id_clone.clone(),
|
||||
position_ticks,
|
||||
is_paused: false,
|
||||
};
|
||||
|
||||
match reporter_instance.report(operation, true).await {
|
||||
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
|
||||
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
throttler_for_position.mark_reported(&item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerBackend for MpvBackend {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
let stream_url = get_stream_url(media);
|
||||
info!("[MpvBackend] Loading: {} - {}", media.title, stream_url);
|
||||
|
||||
// Update state
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.current_media = Some(media.clone());
|
||||
}
|
||||
|
||||
// Load the media file
|
||||
self.mpv
|
||||
.command("loadfile", &[&stream_url])
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to load file: {:?}", e),
|
||||
})?;
|
||||
|
||||
debug!("[MpvBackend] Load command sent successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Play command");
|
||||
|
||||
self.mpv
|
||||
.set_property("pause", false)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to play: {:?}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Pause command");
|
||||
|
||||
self.mpv
|
||||
.set_property("pause", true)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to pause: {:?}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Stop command");
|
||||
|
||||
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
|
||||
message: format!("Failed to stop: {:?}", e),
|
||||
})?;
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.current_media = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Seek to {} seconds", position);
|
||||
|
||||
// Record the seek time to suppress position updates briefly
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
self.last_seek_time.store(now, Ordering::Relaxed);
|
||||
|
||||
self.mpv
|
||||
.set_property("time-pos", position)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to seek: {:?}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
debug!("[MpvBackend] Set volume to {}", clamped);
|
||||
|
||||
// MPV expects volume as percentage (0-100)
|
||||
let mpv_volume = volume_to_percent(clamped as f64) as i64;
|
||||
|
||||
self.mpv
|
||||
.set_property("volume", mpv_volume)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set volume: {:?}", e),
|
||||
})?;
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.volume = clamped;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.mpv
|
||||
.get_property::<f64>("time-pos")
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
self.mpv
|
||||
.get_property::<f64>("duration")
|
||||
.ok()
|
||||
.filter(|d| *d > 0.0)
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
let state = self.state.lock().unwrap();
|
||||
|
||||
if let Some(ref media) = state.current_media {
|
||||
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
|
||||
let position = self.position();
|
||||
let duration = self.duration().unwrap_or(0.0);
|
||||
|
||||
if is_paused {
|
||||
PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position,
|
||||
duration,
|
||||
}
|
||||
} else {
|
||||
PlayerState::Playing {
|
||||
media: media.clone(),
|
||||
position,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PlayerState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(&self) -> f32 {
|
||||
let state = self.state.lock().unwrap();
|
||||
state.volume
|
||||
}
|
||||
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
info!("[MpvBackend] Applying audio settings");
|
||||
self.audio_settings = settings.clone();
|
||||
|
||||
// Apply gapless playback
|
||||
if settings.gapless_playback {
|
||||
self.mpv
|
||||
.set_property("gapless-audio", "yes")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to enable gapless: {:?}", e),
|
||||
})?;
|
||||
} else {
|
||||
self.mpv
|
||||
.set_property("gapless-audio", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to disable gapless: {:?}", e),
|
||||
})?;
|
||||
}
|
||||
|
||||
// TODO: Implement crossfade via MPV audio filters if needed
|
||||
// TODO: Implement volume normalization if needed
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.audio_settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MpvBackend {
|
||||
fn drop(&mut self) {
|
||||
info!("[MpvBackend] Shutting down");
|
||||
// MPV will be automatically cleaned up
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/// Tests for MpvBackend to prevent regressions
|
||||
///
|
||||
/// These tests are designed to catch common issues like:
|
||||
/// - Tokio runtime panics when spawning async tasks from std::thread
|
||||
/// - Position update thread failures
|
||||
/// - Event emission issues
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// Test that simulates the position update thread spawning async tasks
|
||||
/// without a Tokio runtime (the bug we just fixed)
|
||||
#[test]
|
||||
fn test_position_thread_handles_missing_tokio_runtime() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let success = Arc::new(AtomicBool::new(false));
|
||||
let success_clone = success.clone();
|
||||
|
||||
// Spawn a regular thread (no Tokio runtime)
|
||||
let handle = std::thread::spawn(move || {
|
||||
// This simulates what the position update thread does
|
||||
// It should handle the case where there's no Tokio runtime
|
||||
|
||||
// Try to get the current Tokio runtime handle
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
// We have a runtime, use it
|
||||
handle.spawn(async move {
|
||||
// Async work here
|
||||
});
|
||||
} else {
|
||||
// No runtime, spawn a new thread with its own runtime
|
||||
// This is the fix we applied
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
// Async work here
|
||||
success_clone.store(true, Ordering::SeqCst);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
// Give the spawned thread time to complete
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
assert!(
|
||||
success.load(Ordering::SeqCst),
|
||||
"Should successfully execute async code from std::thread without panicking"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that the Tokio runtime fallback pattern works correctly
|
||||
#[test]
|
||||
fn test_tokio_runtime_fallback_pattern() {
|
||||
let counter = Arc::new(Mutex::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
|
||||
// Spawn from a regular thread (no runtime)
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
// Has runtime (shouldn't happen in this test)
|
||||
handle.spawn(async move {
|
||||
*counter_clone.lock().unwrap() += 1;
|
||||
});
|
||||
} else {
|
||||
// No runtime - use fallback (should happen in this test)
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
*counter_clone.lock().unwrap() += 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
// Wait for async task to complete
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
let count = *counter.lock().unwrap();
|
||||
assert_eq!(count, 1, "Fallback pattern should execute async code successfully");
|
||||
}
|
||||
|
||||
/// Test that position update logic works in a thread
|
||||
#[test]
|
||||
fn test_position_update_in_thread() {
|
||||
use std::time::Duration;
|
||||
|
||||
let positions = Arc::new(Mutex::new(Vec::new()));
|
||||
let positions_clone = positions.clone();
|
||||
|
||||
// Simulate the position update thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
for i in 0..5 {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
// Simulate getting position from player
|
||||
let position = i as f64 * 0.25;
|
||||
|
||||
// Store position (simulating event emission)
|
||||
positions_clone.lock().unwrap().push(position);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let recorded_positions = positions.lock().unwrap();
|
||||
assert_eq!(recorded_positions.len(), 5, "Should have recorded 5 position updates");
|
||||
|
||||
// Verify positions are increasing
|
||||
for (i, pos) in recorded_positions.iter().enumerate() {
|
||||
let expected = i as f64 * 0.25;
|
||||
assert!(
|
||||
(*pos - expected).abs() < 0.001,
|
||||
"Position {} should be close to {}",
|
||||
pos,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test async progress reporting pattern
|
||||
#[tokio::test]
|
||||
async fn test_progress_reporting_with_tokio_mutex() {
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackReporter};
|
||||
|
||||
// Create mock reporter (None for this test)
|
||||
let reporter = Arc::new(TokioMutex::new(None::<PlaybackReporter>));
|
||||
let throttler = Arc::new(EventThrottler::new());
|
||||
|
||||
// Simulate progress reporting
|
||||
let item_id = "test_item_123".to_string();
|
||||
|
||||
// This should not panic even though reporter is None
|
||||
let reporter_guard = reporter.lock().await;
|
||||
if let Some(_reporter_instance) = reporter_guard.as_ref() {
|
||||
// Would report here if reporter was configured
|
||||
} else {
|
||||
// Reporter not configured - this is OK
|
||||
}
|
||||
drop(reporter_guard);
|
||||
|
||||
// Verify throttler works
|
||||
assert!(
|
||||
throttler.should_report(&item_id),
|
||||
"First report should be allowed"
|
||||
);
|
||||
|
||||
throttler.mark_reported(&item_id);
|
||||
|
||||
// Immediate second report should be throttled
|
||||
// (EventThrottler has internal logic for this)
|
||||
}
|
||||
|
||||
/// Test that position updates are emitted even when paused (for scrubbing)
|
||||
/// This is critical for UI responsiveness when seeking while paused
|
||||
#[test]
|
||||
fn test_position_updates_while_paused() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
let update_count = Arc::new(AtomicUsize::new(0));
|
||||
let update_count_clone = update_count.clone();
|
||||
|
||||
// Simulate a position update thread that runs regardless of pause state
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Simulate 5 position updates
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// In the real implementation, we check position from MPV
|
||||
// and emit PositionUpdate events even when paused
|
||||
// This simulates that behavior:
|
||||
let _is_paused = true; // Simulating paused state
|
||||
|
||||
// Key: We DON'T skip the update when paused
|
||||
// This allows scrubbing to work
|
||||
update_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = update_count.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
final_count, 5,
|
||||
"Position updates should be emitted even when paused (got {} updates)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that progress reporting is skipped when paused
|
||||
/// Progress reporting to the server should only happen during active playback
|
||||
#[test]
|
||||
fn test_progress_reporting_skipped_when_paused() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
let report_count = Arc::new(AtomicUsize::new(0));
|
||||
let report_count_clone = report_count.clone();
|
||||
|
||||
// Simulate the progress reporting logic
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Simulate 5 update cycles
|
||||
for i in 0..5 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Position updates are emitted (tested separately)
|
||||
// But progress reporting depends on pause state
|
||||
let is_paused = i % 2 == 0; // Alternate between paused and playing
|
||||
|
||||
// Key: Only report when NOT paused
|
||||
if !is_paused {
|
||||
report_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = report_count.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
final_count, 2,
|
||||
"Progress reporting should only happen when not paused (got {} reports)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that position updates are suppressed briefly after a seek
|
||||
/// This prevents "jumping to zero" visual glitches during seek operations
|
||||
#[test]
|
||||
fn test_position_updates_suppressed_after_seek() {
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
let last_seek_time = Arc::new(AtomicU64::new(0));
|
||||
let last_seek_time_clone = last_seek_time.clone();
|
||||
let update_count = Arc::new(AtomicUsize::new(0));
|
||||
let update_count_clone = update_count.clone();
|
||||
|
||||
// Simulate a seek happening
|
||||
let seek_time = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
last_seek_time.store(seek_time, Ordering::Relaxed);
|
||||
|
||||
// Simulate position update thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Try 2 position updates at 50ms intervals (well within the 150ms window)
|
||||
for _ in 0..2 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Check if we should suppress updates (within 150ms of seek)
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
let last_seek = last_seek_time_clone.load(Ordering::Relaxed);
|
||||
let time_since_seek = now.saturating_sub(last_seek);
|
||||
|
||||
if time_since_seek < 150 {
|
||||
// Suppress update (don't increment counter)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit update
|
||||
update_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = update_count.load(Ordering::SeqCst);
|
||||
// With 50ms intervals and 150ms suppression window, updates at 50ms and 100ms
|
||||
// should both be suppressed
|
||||
assert_eq!(
|
||||
final_count, 0,
|
||||
"Position updates should be suppressed within 150ms of seek (got {} updates)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that position updates resume after seek suppression window
|
||||
#[test]
|
||||
fn test_position_updates_resume_after_seek_window() {
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
let last_seek_time = Arc::new(AtomicU64::new(0));
|
||||
let last_seek_time_clone = last_seek_time.clone();
|
||||
let update_count = Arc::new(AtomicUsize::new(0));
|
||||
let update_count_clone = update_count.clone();
|
||||
|
||||
// Simulate a seek that happened 200ms ago (past the suppression window)
|
||||
let seek_time = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64
|
||||
- 200; // 200ms ago
|
||||
last_seek_time.store(seek_time, Ordering::Relaxed);
|
||||
|
||||
// Simulate position update thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Try 3 position updates
|
||||
for _ in 0..3 {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
// Check if we should suppress updates (within 150ms of seek)
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
let last_seek = last_seek_time_clone.load(Ordering::Relaxed);
|
||||
let time_since_seek = now.saturating_sub(last_seek);
|
||||
|
||||
if time_since_seek < 150 {
|
||||
continue; // Should not happen in this test
|
||||
}
|
||||
|
||||
// Emit update
|
||||
update_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = update_count.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
final_count, 3,
|
||||
"Position updates should resume after seek suppression window (got {} updates)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
use rand::seq::SliceRandom;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::media::{MediaItem, MediaSource, QueueContext};
|
||||
|
||||
/// Repeat mode for the queue
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (repeat mode)
|
||||
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RepeatMode {
|
||||
#[default]
|
||||
Off,
|
||||
All,
|
||||
One,
|
||||
}
|
||||
|
||||
/// Queue manager for playlist functionality
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (queue navigation)
|
||||
/// @req: UR-015 - View and manage current audio queue (add, reorder tracks)
|
||||
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
||||
/// @req: DR-020 - Queue management UI (add, remove, reorder)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueueManager {
|
||||
/// All items in the queue
|
||||
items: Vec<MediaItem>,
|
||||
/// Current item index
|
||||
current_index: Option<usize>,
|
||||
/// Whether shuffle is enabled
|
||||
shuffle: bool,
|
||||
/// Current repeat mode
|
||||
repeat: RepeatMode,
|
||||
/// Shuffled order of indices (used when shuffle is on)
|
||||
shuffle_order: Vec<usize>,
|
||||
/// History of played indices (for going back with shuffle)
|
||||
history: Vec<usize>,
|
||||
/// Context for the queue (album, playlist, or custom)
|
||||
/// Used for remote playback transfer to maintain album/playlist context
|
||||
#[serde(default)]
|
||||
context: QueueContext,
|
||||
}
|
||||
|
||||
impl Default for QueueManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
current_index: None,
|
||||
shuffle: false,
|
||||
repeat: RepeatMode::Off,
|
||||
shuffle_order: Vec::new(),
|
||||
history: Vec::new(),
|
||||
context: QueueContext::Custom,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all items in the queue
|
||||
pub fn items(&self) -> &[MediaItem] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// Get the current item index
|
||||
pub fn current_index(&self) -> Option<usize> {
|
||||
self.current_index
|
||||
}
|
||||
|
||||
/// Get the current item
|
||||
pub fn current(&self) -> Option<&MediaItem> {
|
||||
self.current_index.and_then(|i| self.items.get(i))
|
||||
}
|
||||
|
||||
/// Check if shuffle is enabled
|
||||
pub fn is_shuffle(&self) -> bool {
|
||||
self.shuffle
|
||||
}
|
||||
|
||||
/// Get the current repeat mode
|
||||
pub fn repeat_mode(&self) -> RepeatMode {
|
||||
self.repeat
|
||||
}
|
||||
|
||||
/// Get the current queue context (album, playlist, or custom)
|
||||
pub fn context(&self) -> &QueueContext {
|
||||
&self.context
|
||||
}
|
||||
|
||||
/// Set the queue context
|
||||
pub fn set_context(&mut self, context: QueueContext) {
|
||||
self.context = context;
|
||||
}
|
||||
|
||||
/// Set the queue with new items (resets context to Custom)
|
||||
pub fn set_queue(&mut self, items: Vec<MediaItem>, start_index: usize) {
|
||||
self.set_queue_with_context(items, start_index, QueueContext::Custom);
|
||||
}
|
||||
|
||||
/// Set the queue with new items and explicit context
|
||||
pub fn set_queue_with_context(
|
||||
&mut self,
|
||||
items: Vec<MediaItem>,
|
||||
start_index: usize,
|
||||
context: QueueContext,
|
||||
) {
|
||||
let start_index = start_index.min(items.len().saturating_sub(1));
|
||||
|
||||
if self.shuffle && !items.is_empty() {
|
||||
self.shuffle_order = self.generate_shuffle_order(items.len(), Some(start_index));
|
||||
} else {
|
||||
self.shuffle_order.clear();
|
||||
}
|
||||
|
||||
self.items = items;
|
||||
self.current_index = if self.items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(start_index)
|
||||
};
|
||||
self.history.clear();
|
||||
self.context = context;
|
||||
}
|
||||
|
||||
/// Add items to the queue
|
||||
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let insert_index = match position {
|
||||
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
|
||||
AddPosition::End => self.items.len(),
|
||||
};
|
||||
|
||||
// Insert items
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
self.items.insert(insert_index + i, item);
|
||||
}
|
||||
|
||||
// Update current index if needed
|
||||
if let Some(current) = self.current_index {
|
||||
if insert_index <= current {
|
||||
self.current_index = Some(current + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate shuffle order if shuffle is on
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an item from the queue
|
||||
pub fn remove(&mut self, index: usize) -> Option<MediaItem> {
|
||||
if index >= self.items.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let removed = self.items.remove(index);
|
||||
|
||||
// Update current index
|
||||
if let Some(current) = self.current_index {
|
||||
if index < current {
|
||||
self.current_index = Some(current - 1);
|
||||
} else if index == current {
|
||||
self.current_index = if self.items.is_empty() {
|
||||
None
|
||||
} else if index >= self.items.len() {
|
||||
Some(self.items.len() - 1)
|
||||
} else {
|
||||
Some(index)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update shuffle order
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.shuffle_order
|
||||
.iter()
|
||||
.filter(|&&i| i != index)
|
||||
.map(|&i| if i > index { i - 1 } else { i })
|
||||
.collect();
|
||||
}
|
||||
|
||||
Some(removed)
|
||||
}
|
||||
|
||||
/// Move to the next item
|
||||
pub fn next(&mut self) -> Option<&MediaItem> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current = self.current_index?;
|
||||
|
||||
let next_index = if self.repeat == RepeatMode::One {
|
||||
// Repeat current track
|
||||
current
|
||||
} else if self.shuffle {
|
||||
// Find current position in shuffle order and get next
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
|
||||
if pos + 1 < self.shuffle_order.len() {
|
||||
self.shuffle_order[pos + 1]
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
// Wrap around to beginning of shuffle
|
||||
self.shuffle_order[0]
|
||||
} else {
|
||||
log::debug!("[Queue] next() at end of shuffle order, no next track");
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
// Normal sequential order
|
||||
if current + 1 < self.items.len() {
|
||||
current + 1
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
0
|
||||
} else {
|
||||
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Only add to history if we're actually changing position
|
||||
if next_index != current {
|
||||
self.history.push(current);
|
||||
log::debug!("[Queue] next() moving: {} -> {}", current, next_index);
|
||||
} else {
|
||||
log::debug!("[Queue] next() repeat one, staying at index {}", current);
|
||||
}
|
||||
|
||||
self.current_index = Some(next_index);
|
||||
self.items.get(next_index)
|
||||
}
|
||||
|
||||
/// Move to the previous item
|
||||
pub fn previous(&mut self) -> Option<&MediaItem> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current = self.current_index?;
|
||||
|
||||
// If we have history, go back in history
|
||||
// But validate it to prevent wraparound bugs
|
||||
if let Some(prev) = self.history.pop() {
|
||||
// Safety check: ensure the history entry is valid
|
||||
if prev >= self.items.len() {
|
||||
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
|
||||
prev, self.items.len());
|
||||
self.history.clear();
|
||||
return None;
|
||||
}
|
||||
|
||||
// In non-shuffle mode, previous track should be before current (or this is from a skip_to)
|
||||
// This prevents going from first track to last track
|
||||
if !self.shuffle && prev >= current {
|
||||
log::warn!("[Queue] Suspicious history: going from index {} to {} (non-shuffle mode), clearing history",
|
||||
current, prev);
|
||||
self.history.clear();
|
||||
return None;
|
||||
}
|
||||
|
||||
log::debug!("[Queue] previous() using history: {} -> {}", current, prev);
|
||||
self.current_index = Some(prev);
|
||||
return self.items.get(prev);
|
||||
}
|
||||
|
||||
log::debug!("[Queue] previous() no history, current={}", current);
|
||||
|
||||
let prev_index = if self.shuffle {
|
||||
// In shuffle mode without history, go to previous in shuffle order
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
|
||||
if pos > 0 {
|
||||
self.shuffle_order[pos - 1]
|
||||
} else {
|
||||
log::debug!("[Queue] previous() at start of shuffle order, staying at current");
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
// Normal sequential order
|
||||
if current > 0 {
|
||||
current - 1
|
||||
} else {
|
||||
log::debug!("[Queue] previous() at index 0, staying at current");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("[Queue] previous() moving: {} -> {}", current, prev_index);
|
||||
self.current_index = Some(prev_index);
|
||||
self.items.get(prev_index)
|
||||
}
|
||||
|
||||
/// Skip to a specific index
|
||||
pub fn skip_to(&mut self, index: usize) -> Option<&MediaItem> {
|
||||
if index >= self.items.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(current) = self.current_index {
|
||||
self.history.push(current);
|
||||
}
|
||||
|
||||
self.current_index = Some(index);
|
||||
self.items.get(index)
|
||||
}
|
||||
|
||||
/// Toggle shuffle mode
|
||||
pub fn toggle_shuffle(&mut self) {
|
||||
self.shuffle = !self.shuffle;
|
||||
|
||||
if self.shuffle && !self.items.is_empty() {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
} else {
|
||||
self.shuffle_order.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle through repeat modes
|
||||
pub fn cycle_repeat(&mut self) {
|
||||
self.repeat = match self.repeat {
|
||||
RepeatMode::Off => RepeatMode::All,
|
||||
RepeatMode::All => RepeatMode::One,
|
||||
RepeatMode::One => RepeatMode::Off,
|
||||
};
|
||||
}
|
||||
|
||||
/// Set repeat mode directly (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn set_repeat(&mut self, mode: RepeatMode) {
|
||||
self.repeat = mode;
|
||||
}
|
||||
|
||||
/// Check if there's a next item available
|
||||
pub fn has_next(&self) -> bool {
|
||||
if self.items.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self.current_index {
|
||||
None => false,
|
||||
Some(current) => {
|
||||
if self.repeat == RepeatMode::All || self.repeat == RepeatMode::One {
|
||||
true
|
||||
} else if self.shuffle {
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
||||
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
|
||||
} else {
|
||||
current + 1 < self.items.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if there's a previous item available
|
||||
pub fn has_previous(&self) -> bool {
|
||||
!self.history.is_empty() || {
|
||||
match self.current_index {
|
||||
None => false,
|
||||
Some(current) => {
|
||||
if self.shuffle {
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
||||
pos.map(|p| p > 0).unwrap_or(false)
|
||||
} else {
|
||||
current > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next N upcoming items (for preloading)
|
||||
/// Returns items that will play after the current item, respecting shuffle order
|
||||
pub fn get_upcoming(&self, count: usize) -> Vec<&MediaItem> {
|
||||
if self.items.is_empty() || count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let current = match self.current_index {
|
||||
Some(idx) => idx,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut upcoming = Vec::with_capacity(count);
|
||||
|
||||
if self.shuffle {
|
||||
// Find current position in shuffle order
|
||||
if let Some(pos) = self.shuffle_order.iter().position(|&i| i == current) {
|
||||
for i in 1..=count {
|
||||
let next_pos = pos + i;
|
||||
if next_pos < self.shuffle_order.len() {
|
||||
if let Some(item) = self.items.get(self.shuffle_order[next_pos]) {
|
||||
upcoming.push(item);
|
||||
}
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
// Wrap around if repeat all is enabled
|
||||
let wrapped_pos = (next_pos) % self.shuffle_order.len();
|
||||
if let Some(item) = self.items.get(self.shuffle_order[wrapped_pos]) {
|
||||
upcoming.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal sequential order
|
||||
for i in 1..=count {
|
||||
let next_idx = current + i;
|
||||
if next_idx < self.items.len() {
|
||||
upcoming.push(&self.items[next_idx]);
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
// Wrap around if repeat all is enabled
|
||||
let wrapped_idx = next_idx % self.items.len();
|
||||
upcoming.push(&self.items[wrapped_idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upcoming
|
||||
}
|
||||
|
||||
/// Move an item from one index to another
|
||||
pub fn move_item(&mut self, from_index: usize, to_index: usize) -> bool {
|
||||
if from_index >= self.items.len() || to_index >= self.items.len() || from_index == to_index
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the item and insert at new position
|
||||
let item = self.items.remove(from_index);
|
||||
self.items.insert(to_index, item);
|
||||
|
||||
// Update current_index if affected
|
||||
if let Some(current) = self.current_index {
|
||||
if current == from_index {
|
||||
// The moved item was the current one
|
||||
self.current_index = Some(to_index);
|
||||
} else if from_index < current && to_index >= current {
|
||||
// Item moved from before current to after/at current
|
||||
self.current_index = Some(current - 1);
|
||||
} else if from_index > current && to_index <= current {
|
||||
// Item moved from after current to before/at current
|
||||
self.current_index = Some(current + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Update shuffle order if shuffle is on
|
||||
if self.shuffle && !self.shuffle_order.is_empty() {
|
||||
// Regenerate shuffle order to maintain consistency
|
||||
self.shuffle_order =
|
||||
self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Update the stream URL of the current item (for transcoded seeking)
|
||||
/// Returns true if the update was successful
|
||||
pub fn update_current_stream_url(&mut self, new_url: String) -> bool {
|
||||
if let Some(current_index) = self.current_index {
|
||||
if let Some(item) = self.items.get_mut(current_index) {
|
||||
// Only update if it's a Remote source
|
||||
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
|
||||
item.source = MediaSource::Remote {
|
||||
stream_url: new_url,
|
||||
jellyfin_item_id: jellyfin_item_id.clone(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Generate a shuffle order, optionally starting from a specific index
|
||||
fn generate_shuffle_order(&self, length: usize, start_index: Option<usize>) -> Vec<usize> {
|
||||
let mut indices: Vec<usize> = (0..length).collect();
|
||||
let mut rng = rand::thread_rng();
|
||||
indices.shuffle(&mut rng);
|
||||
|
||||
// Move start index to the front if specified
|
||||
if let Some(start) = start_index {
|
||||
if let Some(pos) = indices.iter().position(|&i| i == start) {
|
||||
indices.remove(pos);
|
||||
indices.insert(0, start);
|
||||
}
|
||||
}
|
||||
|
||||
indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Position to add items to the queue
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddPosition {
|
||||
/// Add immediately after current item
|
||||
Next,
|
||||
/// Add at the end of the queue
|
||||
End,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::media::{MediaSource, MediaType};
|
||||
|
||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||
(0..count)
|
||||
.map(|i| MediaItem {
|
||||
id: format!("item_{}", i),
|
||||
title: format!("Track {}", i + 1),
|
||||
name: Some(format!("Track {}", i + 1)),
|
||||
artist: Some("Artist".to_string()),
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: format!("http://example.com/track_{}.mp3", i),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Test setting up the queue with items
|
||||
///
|
||||
/// @req-test: UR-015 - View and manage audio queue (add tracks)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
|
||||
#[test]
|
||||
fn test_set_queue() {
|
||||
let mut queue = QueueManager::new();
|
||||
let items = create_test_items(5);
|
||||
|
||||
queue.set_queue(items.clone(), 0);
|
||||
|
||||
assert_eq!(queue.items().len(), 5);
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
}
|
||||
|
||||
/// Test next track navigation
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (skip to next track)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
|
||||
#[test]
|
||||
fn test_next() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
|
||||
queue.next();
|
||||
assert_eq!(queue.current().unwrap().id, "item_1");
|
||||
|
||||
queue.next();
|
||||
assert_eq!(queue.current().unwrap().id, "item_2");
|
||||
|
||||
// No next without repeat
|
||||
assert!(queue.next().is_none());
|
||||
}
|
||||
|
||||
/// Test repeat all mode wraps to beginning
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (repeat all mode)
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_repeat_all() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(2), 0);
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
queue.next(); // Move to item_1
|
||||
let next = queue.next(); // Should wrap to item_0
|
||||
|
||||
assert!(next.is_some());
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
}
|
||||
|
||||
/// Test previous track navigation
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (previous track)
|
||||
/// @req-test: DR-005 - Queue manager
|
||||
#[test]
|
||||
fn test_previous() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 2);
|
||||
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
}
|
||||
|
||||
/// Test viewing upcoming tracks in queue
|
||||
///
|
||||
/// @req-test: UR-015 - View and manage audio queue
|
||||
/// @req-test: DR-020 - Queue management UI (upcoming tracks)
|
||||
#[test]
|
||||
fn test_get_upcoming() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0);
|
||||
|
||||
// Get next 3 items
|
||||
let upcoming = queue.get_upcoming(3);
|
||||
assert_eq!(upcoming.len(), 3);
|
||||
assert_eq!(upcoming[0].id, "item_1");
|
||||
assert_eq!(upcoming[1].id, "item_2");
|
||||
assert_eq!(upcoming[2].id, "item_3");
|
||||
|
||||
// Move to item 2 and get upcoming
|
||||
queue.next();
|
||||
queue.next();
|
||||
let upcoming = queue.get_upcoming(3);
|
||||
assert_eq!(upcoming.len(), 2); // Only 2 items remaining
|
||||
assert_eq!(upcoming[0].id, "item_3");
|
||||
assert_eq!(upcoming[1].id, "item_4");
|
||||
}
|
||||
|
||||
/// Test upcoming tracks with repeat all mode
|
||||
///
|
||||
/// @req-test: UR-015 - View and manage audio queue
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_get_upcoming_with_repeat() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 1);
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
// At item_1, get upcoming with repeat
|
||||
let upcoming = queue.get_upcoming(4);
|
||||
assert_eq!(upcoming.len(), 4);
|
||||
assert_eq!(upcoming[0].id, "item_2"); // Next
|
||||
assert_eq!(upcoming[1].id, "item_0"); // Wrapped
|
||||
assert_eq!(upcoming[2].id, "item_1"); // Wrapped (current again)
|
||||
assert_eq!(upcoming[3].id, "item_2"); // Wrapped
|
||||
}
|
||||
|
||||
/// Test upcoming tracks on empty queue
|
||||
///
|
||||
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
|
||||
#[test]
|
||||
fn test_get_upcoming_empty() {
|
||||
let queue = QueueManager::new();
|
||||
let upcoming = queue.get_upcoming(3);
|
||||
assert!(upcoming.is_empty());
|
||||
}
|
||||
|
||||
/// Test next stops at end without repeat mode
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (queue end behavior)
|
||||
/// @req-test: DR-005 - Queue manager
|
||||
#[test]
|
||||
fn test_next_no_repeat_reaches_end() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
|
||||
// At item 0, should have next
|
||||
assert!(queue.has_next());
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
|
||||
// Move to item 1
|
||||
queue.next();
|
||||
assert!(queue.has_next());
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
|
||||
// Move to item 2 (last)
|
||||
queue.next();
|
||||
assert!(!queue.has_next()); // No more items
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
|
||||
// Try to move past end
|
||||
let result = queue.next();
|
||||
assert!(result.is_none());
|
||||
assert_eq!(queue.current_index(), Some(2)); // Should stay at last item
|
||||
}
|
||||
|
||||
/// Test next wraps to beginning with repeat all
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (repeat all wrapping)
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_next_repeat_all_wraps() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
// With repeat all, has_next should always be true
|
||||
assert!(queue.has_next());
|
||||
|
||||
// Move through all items
|
||||
queue.next(); // item_1
|
||||
assert!(queue.has_next());
|
||||
queue.next(); // item_2
|
||||
assert!(queue.has_next());
|
||||
|
||||
// Wrap to beginning
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
assert!(queue.has_next()); // Still has next (loops forever)
|
||||
}
|
||||
|
||||
/// Test next repeats same track with repeat one mode
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (repeat one mode)
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_next_repeat_one_stays() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 1);
|
||||
queue.set_repeat(RepeatMode::One);
|
||||
|
||||
// Should always have next (repeats current)
|
||||
assert!(queue.has_next());
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
|
||||
// Call next multiple times - should stay on same track
|
||||
for _ in 0..5 {
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(queue.current().unwrap().id, "item_1");
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
assert!(queue.has_next());
|
||||
}
|
||||
}
|
||||
|
||||
/// Test shuffle mode follows randomized order
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (shuffle mode)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle
|
||||
#[test]
|
||||
fn test_next_shuffle_follows_order() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(4), 0);
|
||||
queue.toggle_shuffle(); // Enable shuffle
|
||||
|
||||
// Get the shuffle order for verification
|
||||
let shuffle_order = queue.shuffle_order.clone();
|
||||
assert_eq!(shuffle_order.len(), 4);
|
||||
|
||||
// Current should be first item in shuffle order
|
||||
let first_shuffled_index = shuffle_order[0];
|
||||
assert_eq!(queue.current_index(), Some(first_shuffled_index));
|
||||
|
||||
// Move through shuffle order
|
||||
for i in 1..shuffle_order.len() {
|
||||
assert!(queue.has_next());
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
let expected_index = shuffle_order[i];
|
||||
assert_eq!(queue.current_index(), Some(expected_index));
|
||||
}
|
||||
|
||||
// At end of shuffle without repeat
|
||||
assert!(!queue.has_next());
|
||||
let result = queue.next();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
/// Test shuffle with repeat all wraps shuffle order
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (shuffle + repeat)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle and repeat
|
||||
#[test]
|
||||
fn test_next_shuffle_with_repeat_all() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.toggle_shuffle(); // Enable shuffle
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
let shuffle_order = queue.shuffle_order.clone();
|
||||
|
||||
// Move through entire shuffle order
|
||||
for _ in 1..shuffle_order.len() {
|
||||
queue.next();
|
||||
}
|
||||
|
||||
// At end, should wrap to beginning of shuffle order
|
||||
assert!(queue.has_next());
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(queue.current_index(), Some(shuffle_order[0]));
|
||||
}
|
||||
|
||||
/// Test has_next logic accuracy across different scenarios
|
||||
///
|
||||
/// @req-test: DR-005 - Queue manager (has_next accuracy)
|
||||
/// @req-test: UR-015 - View and manage audio queue
|
||||
#[test]
|
||||
fn test_has_next_accuracy() {
|
||||
// Test 1: Empty queue
|
||||
let queue = QueueManager::new();
|
||||
assert!(!queue.has_next());
|
||||
|
||||
// Test 2: Last track with repeat off
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(2), 1); // Start at last item
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
assert!(!queue.has_next());
|
||||
|
||||
// Test 3: Last track with repeat all
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(2), 1); // Start at last item
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
assert!(queue.has_next()); // Should wrap
|
||||
|
||||
// Test 4: Repeat one mode (always has next)
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(1), 0);
|
||||
queue.set_repeat(RepeatMode::One);
|
||||
assert!(queue.has_next()); // Repeats forever
|
||||
|
||||
// Test 5: Middle of queue with repeat off
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 1); // Middle item
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
assert!(queue.has_next()); // Has item_2 next
|
||||
|
||||
// Test 6: Shuffle at end without repeat
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.toggle_shuffle(); // Enable shuffle
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
// Move to last item in shuffle order
|
||||
let shuffle_len = queue.shuffle_order.len();
|
||||
for _ in 1..shuffle_len {
|
||||
queue.next();
|
||||
}
|
||||
assert!(!queue.has_next());
|
||||
}
|
||||
|
||||
/// Test has_next on empty queue edge case
|
||||
///
|
||||
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
|
||||
#[test]
|
||||
fn test_has_next_empty_queue() {
|
||||
let queue = QueueManager::new();
|
||||
assert!(!queue.has_next());
|
||||
assert_eq!(queue.current_index(), None);
|
||||
}
|
||||
|
||||
/// Test that selecting a specific track in an album starts at the correct index
|
||||
///
|
||||
/// Reproduces the bug where clicking songs 1-5 always played song 13
|
||||
#[test]
|
||||
fn test_play_specific_track_from_album() {
|
||||
let mut queue = QueueManager::new();
|
||||
let items = create_test_items(14); // 14-track album
|
||||
|
||||
// Simulate playing track 0 (first track)
|
||||
queue.set_queue(items.clone(), 0);
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
|
||||
// Simulate playing track 3 (fourth track)
|
||||
queue.set_queue(items.clone(), 3);
|
||||
assert_eq!(queue.current_index(), Some(3));
|
||||
assert_eq!(queue.current().unwrap().id, "item_3");
|
||||
|
||||
// Simulate playing track 13 (last track)
|
||||
queue.set_queue(items, 13);
|
||||
assert_eq!(queue.current_index(), Some(13));
|
||||
assert_eq!(queue.current().unwrap().id, "item_13");
|
||||
}
|
||||
|
||||
/// Test that next() then previous() returns to the original track
|
||||
#[test]
|
||||
fn test_next_previous_roundtrip() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 2); // Start at middle track
|
||||
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
|
||||
// Go to next track
|
||||
queue.next();
|
||||
assert_eq!(queue.current_index(), Some(3));
|
||||
|
||||
// Go back - should return to track 2
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
assert_eq!(queue.current().unwrap().id, "item_2");
|
||||
}
|
||||
|
||||
/// Test that previous() at the first track stays at first track
|
||||
#[test]
|
||||
fn test_previous_at_first_track_stays() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0); // Start at first track
|
||||
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
|
||||
// Try to go to previous - should stay at 0
|
||||
let result = queue.previous();
|
||||
assert!(result.is_none());
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
}
|
||||
|
||||
/// Test that next() at last track (no repeat) stays at last track
|
||||
#[test]
|
||||
fn test_next_at_last_track_stays() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 4); // Start at last track
|
||||
|
||||
assert_eq!(queue.current_index(), Some(4));
|
||||
|
||||
// Try to go to next - should return None and stay at 4
|
||||
let result = queue.next();
|
||||
assert!(result.is_none());
|
||||
assert_eq!(queue.current_index(), Some(4));
|
||||
assert_eq!(queue.current().unwrap().id, "item_4");
|
||||
}
|
||||
|
||||
/// Test history validation prevents invalid wraparound
|
||||
#[test]
|
||||
fn test_history_validation_prevents_wraparound() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0);
|
||||
|
||||
// Manually corrupt history to simulate the bug
|
||||
// (In the real bug, history would have last track's index)
|
||||
queue.history.push(4);
|
||||
|
||||
// Try to go previous - should detect invalid history and clear it
|
||||
let result = queue.previous();
|
||||
assert!(result.is_none()); // Should not wrap to track 4
|
||||
assert_eq!(queue.current_index(), Some(0)); // Should stay at track 0
|
||||
assert!(queue.history.is_empty()); // History should be cleared
|
||||
}
|
||||
|
||||
/// Test multiple next() calls build correct history
|
||||
#[test]
|
||||
fn test_multiple_next_builds_history() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0);
|
||||
|
||||
// Navigate: 0 -> 1 -> 2 -> 3
|
||||
queue.next(); // Now at 1, history=[0]
|
||||
queue.next(); // Now at 2, history=[0, 1]
|
||||
queue.next(); // Now at 3, history=[0, 1, 2]
|
||||
|
||||
assert_eq!(queue.current_index(), Some(3));
|
||||
|
||||
// Go back through history: 3 -> 2 -> 1 -> 0
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Media Session Management
|
||||
*
|
||||
* Tracks high-level playback context (Audio/Movie/TvShow/Idle) that persists
|
||||
* beyond individual playback states. Enables persistent UI (miniplayer) and
|
||||
* proper transitions between content types.
|
||||
*
|
||||
* See SoftwareArchitecture.md Section 2.1 for state machine diagram.
|
||||
*/
|
||||
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::media::MediaItem;
|
||||
|
||||
/// Media session type tracking the high-level playback context
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MediaSessionType {
|
||||
/// No active session - browsing library
|
||||
Idle,
|
||||
|
||||
/// Audio playback session (music, audiobooks, podcasts)
|
||||
/// Persists until explicitly dismissed
|
||||
Audio {
|
||||
/// Last/current track being played
|
||||
last_item: Option<MediaItem>,
|
||||
/// True = playing/paused, False = stopped/ended
|
||||
is_active: bool,
|
||||
},
|
||||
|
||||
/// Movie playback (single video, auto-dismiss on end)
|
||||
Movie {
|
||||
/// Currently loaded movie
|
||||
item: MediaItem,
|
||||
/// True = playing/paused, False = ended
|
||||
is_active: bool,
|
||||
},
|
||||
|
||||
/// TV show playback (supports next episode auto-advance)
|
||||
TvShow {
|
||||
/// Currently loaded episode
|
||||
item: MediaItem,
|
||||
/// Series ID for fetching next episodes
|
||||
series_id: String,
|
||||
/// True = playing/paused, False = ended
|
||||
is_active: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl MediaSessionType {
|
||||
/// Check if this is an active session (any type except Idle)
|
||||
#[allow(dead_code)]
|
||||
pub fn is_active_session(&self) -> bool {
|
||||
!matches!(self, MediaSessionType::Idle)
|
||||
}
|
||||
|
||||
/// Check if playback is currently active within the session
|
||||
#[allow(dead_code)]
|
||||
pub fn is_playing_or_paused(&self) -> bool {
|
||||
match self {
|
||||
MediaSessionType::Audio { is_active, .. } => *is_active,
|
||||
MediaSessionType::Movie { is_active, .. } => *is_active,
|
||||
MediaSessionType::TvShow { is_active, .. } => *is_active,
|
||||
MediaSessionType::Idle => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current media item if any
|
||||
#[allow(dead_code)]
|
||||
pub fn current_item(&self) -> Option<&MediaItem> {
|
||||
match self {
|
||||
MediaSessionType::Audio { last_item, .. } => last_item.as_ref(),
|
||||
MediaSessionType::Movie { item, .. } => Some(item),
|
||||
MediaSessionType::TvShow { item, .. } => Some(item),
|
||||
MediaSessionType::Idle => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages media session state transitions
|
||||
pub struct MediaSessionManager {
|
||||
current: MediaSessionType,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MediaSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current: MediaSessionType::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current session state
|
||||
pub fn current(&self) -> &MediaSessionType {
|
||||
&self.current
|
||||
}
|
||||
|
||||
/// Start an audio session with a queue
|
||||
/// Transitions: Idle → Audio(active), Any → Audio(active)
|
||||
pub fn start_audio_session(&mut self, first_item: MediaItem) {
|
||||
info!("[MediaSession] Starting audio session: {}", first_item.title);
|
||||
self.current = MediaSessionType::Audio {
|
||||
last_item: Some(first_item),
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Update audio session with new track (during playback)
|
||||
pub fn update_audio_track(&mut self, item: MediaItem) {
|
||||
if let MediaSessionType::Audio { last_item, is_active } = &mut self.current {
|
||||
info!("[MediaSession] Updating audio track: {}", item.title);
|
||||
*last_item = Some(item);
|
||||
*is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark audio session as inactive (playback ended, queue finished)
|
||||
/// Session persists for resume
|
||||
pub fn audio_session_inactive(&mut self) {
|
||||
if let MediaSessionType::Audio { is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Audio session now inactive (queue ended)");
|
||||
*is_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume audio session
|
||||
pub fn resume_audio_session(&mut self) {
|
||||
if let MediaSessionType::Audio { is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Resuming audio session");
|
||||
*is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a movie session
|
||||
/// Transitions: Any → Movie(active)
|
||||
pub fn start_movie_session(&mut self, item: MediaItem) {
|
||||
info!("[MediaSession] Starting movie session: {}", item.title);
|
||||
self.current = MediaSessionType::Movie {
|
||||
item,
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Mark movie session as inactive (playback ended)
|
||||
/// Movie sessions auto-dismiss to Idle
|
||||
pub fn movie_session_ended(&mut self) {
|
||||
if matches!(self.current, MediaSessionType::Movie { .. }) {
|
||||
info!("[MediaSession] Movie ended, transitioning to Idle");
|
||||
self.current = MediaSessionType::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a TV show session
|
||||
/// Transitions: Any → TvShow(active)
|
||||
pub fn start_tv_session(&mut self, item: MediaItem, series_id: String) {
|
||||
info!("[MediaSession] Starting TV show session: {}", item.title);
|
||||
self.current = MediaSessionType::TvShow {
|
||||
item,
|
||||
series_id,
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Mark TV show session as inactive (episode ended, awaiting next)
|
||||
pub fn tv_session_episode_ended(&mut self) {
|
||||
if let MediaSessionType::TvShow { is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Episode ended, awaiting next episode");
|
||||
*is_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to next episode in TV session
|
||||
pub fn tv_session_next_episode(&mut self, next_item: MediaItem) {
|
||||
if let MediaSessionType::TvShow { item, is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Advancing to next episode: {}", next_item.title);
|
||||
*item = next_item;
|
||||
*is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// End TV show session (series complete or user dismissed)
|
||||
pub fn tv_session_ended(&mut self) {
|
||||
if matches!(self.current, MediaSessionType::TvShow { .. }) {
|
||||
info!("[MediaSession] TV show session ended, transitioning to Idle");
|
||||
self.current = MediaSessionType::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss/clear current session (user action)
|
||||
/// Transitions: Any → Idle
|
||||
pub fn dismiss(&mut self) {
|
||||
info!("[MediaSession] Dismissing session: {:?}", self.current);
|
||||
self.current = MediaSessionType::Idle;
|
||||
}
|
||||
|
||||
/// Check if we should show miniplayer
|
||||
pub fn should_show_miniplayer(&self) -> bool {
|
||||
matches!(self.current, MediaSessionType::Audio { .. })
|
||||
}
|
||||
|
||||
/// Check if we should show video player
|
||||
pub fn should_show_video_player(&self) -> bool {
|
||||
matches!(
|
||||
self.current,
|
||||
MediaSessionType::Movie { .. } | MediaSessionType::TvShow { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaSessionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::media::MediaSource;
|
||||
|
||||
fn create_test_audio_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
artwork_url: None,
|
||||
media_type: crate::player::media::MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/audio.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_movie_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
artist: None,
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
item_type: Some("Movie".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(7200.0),
|
||||
artwork_url: None,
|
||||
media_type: crate::player::media::MediaType::Video,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/movie.mp4".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_state_is_idle() {
|
||||
let manager = MediaSessionManager::new();
|
||||
assert_eq!(manager.current(), &MediaSessionType::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_session_lifecycle() {
|
||||
let mut manager = MediaSessionManager::new();
|
||||
|
||||
// Start audio session
|
||||
let track = create_test_audio_item("Track 1");
|
||||
manager.start_audio_session(track.clone());
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: true, .. }
|
||||
));
|
||||
assert!(manager.should_show_miniplayer());
|
||||
|
||||
// Update to next track
|
||||
let track2 = create_test_audio_item("Track 2");
|
||||
manager.update_audio_track(track2);
|
||||
assert!(manager.current().is_playing_or_paused());
|
||||
|
||||
// Queue ends, session goes inactive
|
||||
manager.audio_session_inactive();
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: false, .. }
|
||||
));
|
||||
assert!(manager.should_show_miniplayer()); // Still shows!
|
||||
|
||||
// Resume
|
||||
manager.resume_audio_session();
|
||||
assert!(manager.current().is_playing_or_paused());
|
||||
|
||||
// Dismiss
|
||||
manager.dismiss();
|
||||
assert_eq!(manager.current(), &MediaSessionType::Idle);
|
||||
assert!(!manager.should_show_miniplayer());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_movie_session_auto_dismiss() {
|
||||
let mut manager = MediaSessionManager::new();
|
||||
|
||||
let movie = create_test_movie_item("Test Movie");
|
||||
manager.start_movie_session(movie);
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Movie { is_active: true, .. }
|
||||
));
|
||||
assert!(manager.should_show_video_player());
|
||||
|
||||
// Movie ends, auto-dismiss to Idle
|
||||
manager.movie_session_ended();
|
||||
assert_eq!(manager.current(), &MediaSessionType::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_replacement() {
|
||||
let mut manager = MediaSessionManager::new();
|
||||
|
||||
// Start with audio
|
||||
let track = create_test_audio_item("Track 1");
|
||||
manager.start_audio_session(track);
|
||||
assert!(manager.should_show_miniplayer());
|
||||
|
||||
// Switch to movie (replaces audio session)
|
||||
let movie = create_test_movie_item("Test Movie");
|
||||
manager.start_movie_session(movie);
|
||||
assert!(!manager.should_show_miniplayer());
|
||||
assert!(manager.should_show_video_player());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Sleep timer mode - determines when playback should stop
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum SleepTimerMode {
|
||||
/// Timer is off
|
||||
Off,
|
||||
/// Stop after a specific time duration
|
||||
Time {
|
||||
#[serde(rename = "endTime")]
|
||||
end_time: i64, // Unix timestamp in milliseconds
|
||||
},
|
||||
/// Stop at the end of current track
|
||||
EndOfTrack,
|
||||
/// Stop after N more episodes complete (TV episodes only, not audio tracks)
|
||||
Episodes { remaining: u32 },
|
||||
}
|
||||
|
||||
/// Sleep timer state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SleepTimerState {
|
||||
pub mode: SleepTimerMode,
|
||||
pub remaining_seconds: u32,
|
||||
}
|
||||
|
||||
impl Default for SleepTimerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: SleepTimerMode::Off,
|
||||
remaining_seconds: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SleepTimerState {
|
||||
/// Check if timer is active
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self.mode, SleepTimerMode::Off)
|
||||
}
|
||||
|
||||
/// Update remaining seconds for time-based timer
|
||||
pub fn update_remaining_seconds(&mut self) {
|
||||
if let SleepTimerMode::Time { end_time } = self.mode {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
self.remaining_seconds = if now >= end_time {
|
||||
0
|
||||
} else {
|
||||
((end_time - now) / 1000).max(0) as u32
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement episode counter, returns true if should stop
|
||||
/// Only counts TV episodes, not audio tracks
|
||||
pub fn decrement_episode(&mut self) -> bool {
|
||||
match &mut self.mode {
|
||||
SleepTimerMode::Episodes { remaining } => {
|
||||
if *remaining <= 1 {
|
||||
self.mode = SleepTimerMode::Off;
|
||||
self.remaining_seconds = 0;
|
||||
true
|
||||
} else {
|
||||
*remaining -= 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the timer
|
||||
pub fn cancel(&mut self) {
|
||||
self.mode = SleepTimerMode::Off;
|
||||
self.remaining_seconds = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sleep_timer_episode_decrement() {
|
||||
let mut timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Episodes { remaining: 2 },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
|
||||
assert!(!timer.decrement_episode());
|
||||
assert!(matches!(
|
||||
timer.mode,
|
||||
SleepTimerMode::Episodes { remaining: 1 }
|
||||
));
|
||||
|
||||
assert!(timer.decrement_episode());
|
||||
assert!(matches!(timer.mode, SleepTimerMode::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sleep_timer_is_active() {
|
||||
let timer = SleepTimerState::default();
|
||||
assert!(!timer.is_active());
|
||||
|
||||
let timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Time { end_time: 0 },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
assert!(timer.is_active());
|
||||
|
||||
let timer = SleepTimerState {
|
||||
mode: SleepTimerMode::EndOfTrack,
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
assert!(timer.is_active());
|
||||
|
||||
let timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Episodes { remaining: 3 },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
assert!(timer.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sleep_timer_cancel() {
|
||||
let mut timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Episodes { remaining: 5 },
|
||||
remaining_seconds: 300,
|
||||
};
|
||||
|
||||
timer.cancel();
|
||||
assert!(matches!(timer.mode, SleepTimerMode::Off));
|
||||
assert_eq!(timer.remaining_seconds, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_remaining_seconds() {
|
||||
let end_time = chrono::Utc::now().timestamp_millis() + 30000; // 30 seconds from now
|
||||
let mut timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Time { end_time },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
|
||||
timer.update_remaining_seconds();
|
||||
// Should be approximately 30 seconds (allow for small time difference)
|
||||
assert!(timer.remaining_seconds >= 29 && timer.remaining_seconds <= 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::media::MediaItem;
|
||||
|
||||
/// Tracks why playback ended to determine autoplay behavior
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (autoplay logic)
|
||||
/// @req: DR-001 - Player state machine (end reason tracking)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EndReason {
|
||||
/// Track played to completion (natural end) - trigger autoplay
|
||||
Finished,
|
||||
/// User pressed next/previous - already handled, don't autoplay
|
||||
UserSkip,
|
||||
/// User stopped playback - don't autoplay
|
||||
UserStop,
|
||||
/// Playback error - don't autoplay
|
||||
Error,
|
||||
/// User selected a different track - don't autoplay
|
||||
NewTrackLoaded,
|
||||
}
|
||||
|
||||
/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
|
||||
///
|
||||
/// @req: DR-001 - Player state machine (idle, loading, playing, paused, seeking, error)
|
||||
/// @req: UR-005 - Control media playback (state tracking)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum PlayerState {
|
||||
#[default]
|
||||
/// No media loaded
|
||||
Idle,
|
||||
/// Media is being loaded/buffered
|
||||
Loading { media: MediaItem },
|
||||
/// Media is playing
|
||||
Playing {
|
||||
media: MediaItem,
|
||||
/// Current position in seconds
|
||||
position: f64,
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Media is paused
|
||||
Paused {
|
||||
media: MediaItem,
|
||||
/// Current position in seconds
|
||||
position: f64,
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Seeking to a new position
|
||||
Seeking {
|
||||
media: MediaItem,
|
||||
/// Target position in seconds
|
||||
target: f64,
|
||||
},
|
||||
/// An error occurred
|
||||
Error {
|
||||
media: Option<MediaItem>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlayerState {
|
||||
/// Get the current playback position if available
|
||||
pub fn position(&self) -> Option<f64> {
|
||||
match self {
|
||||
PlayerState::Playing { position, .. } => Some(*position),
|
||||
PlayerState::Paused { position, .. } => Some(*position),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the player is currently playing
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, PlayerState::Playing { .. })
|
||||
}
|
||||
|
||||
/// Check if the player is currently paused
|
||||
#[allow(dead_code)]
|
||||
pub fn is_paused(&self) -> bool {
|
||||
matches!(self, PlayerState::Paused { .. })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user