First working POC
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user