Duncan Tourolle e3797f32ca
Some checks failed
Traceability Validation / Check Requirement Traces (push) Failing after 1m18s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
many changes
2026-02-14 00:09:47 +01:00

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");
}
}
}