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>(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; /// 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) -> 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, 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 { 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"); } } }