First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+82
View File
@@ -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
}
}
+524
View File
@@ -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");
}
}
}
+315
View File
@@ -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
}
}
+155
View File
@@ -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
+548
View File
@@ -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
}
}
+341
View File
@@ -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
);
}
}
+977
View File
@@ -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));
}
}
+357
View File
@@ -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());
}
}
+149
View File
@@ -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);
}
}
+85
View File
@@ -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 { .. })
}
}