514 lines
16 KiB
Rust
514 lines
16 KiB
Rust
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
|
|
///
|
|
/// TRACES: UR-003, UR-004 | IR-003, IR-004 | DR-004
|
|
pub trait PlayerBackend: Send + Sync {
|
|
/// Load a media item for playback
|
|
/// TRACES: UR-005
|
|
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
|
|
|
/// Start or resume playback
|
|
/// TRACES: UR-005
|
|
fn play(&mut self) -> Result<(), PlayerError>;
|
|
|
|
/// Pause playback
|
|
/// TRACES: UR-005
|
|
fn pause(&mut self) -> Result<(), PlayerError>;
|
|
|
|
/// Stop playback and unload media
|
|
/// TRACES: UR-005
|
|
fn stop(&mut self) -> Result<(), PlayerError>;
|
|
|
|
/// Seek to a position in seconds
|
|
/// TRACES: UR-005
|
|
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
|
|
|
/// Set volume (0.0 - 1.0)
|
|
/// TRACES: UR-016
|
|
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()
|
|
}
|
|
}
|
|
|
|
// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Test NullBackend volume default value
|
|
/// TRACES: UR-016 | DR-004 | UT-026
|
|
#[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");
|
|
}
|
|
}
|
|
}
|