Skip to main content

#
jellytau_lib/player/
backend.rs

    1use super::media::MediaItem;
    2use super::state::PlayerState;
    3use crate::settings::AudioSettings;
    4
    5/// Error type for player operations
    6#[derive(Debug, Clone)]
    7pub struct PlayerError {
    8    pub message: String,
    9}
    10
    11impl std::fmt::Display for PlayerError {
    12    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    13        write!(f, "{}", self.message)
    14    }
    15}
    16
    17impl std::error::Error for PlayerError {}
    18
    19impl PlayerError {
    20    pub fn not_implemented() -> Self {
    21        Self {
    22            message: "Not implemented".to_string(),
    23        }
    24    }
    25
    26    /// Create a playback failure error
    27    ///
    28    /// Only available on Android where ExoPlayer uses it for JNI errors
    29    #[cfg(target_os = "android")]
    30    pub fn playback_failed<S: Into<String>>(message: S) -> Self {
    31        Self {
    32            message: message.into(),
    33        }
    34    }
    35}
    36
    37/// Player backend trait - implemented by platform-specific players
    38///
    39/// TRACES: UR-003, UR-004 | IR-003, IR-004 | DR-004
    40pub trait PlayerBackend: Send + Sync {
    41    /// Load a media item for playback
    42    /// TRACES: UR-005
    43    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
    44
    45    /// Start or resume playback
    46    /// TRACES: UR-005
    47    fn play(&mut self) -> Result<(), PlayerError>;
    48
    49    /// Pause playback
    50    /// TRACES: UR-005
    51    fn pause(&mut self) -> Result<(), PlayerError>;
    52
    53    /// Stop playback and unload media
    54    /// TRACES: UR-005
    55    fn stop(&mut self) -> Result<(), PlayerError>;
    56
    57    /// Seek to a position in seconds
    58    /// TRACES: UR-005
    59    fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
    60
    61    /// Set volume (0.0 - 1.0)
    62    /// TRACES: UR-016
    63    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
    64
    65    /// Get current playback position in seconds
    66    fn position(&self) -> f64;
    67
    68    /// Get total duration in seconds
    69    fn duration(&self) -> Option<f64>;
    70
    71    /// Get current player state
    72    fn state(&self) -> PlayerState;
    73
    74    /// Get current volume
    75    fn volume(&self) -> f32;
    76
    77    /// Apply audio settings (crossfade, gapless, normalization)
    78    ///
    79    /// @req-partial: UR-031 (Linux only) - Crossfade between audio tracks
    80    /// @req-partial: UR-032 (Linux only) - Gapless playback for seamless album listening
    81    /// @req-partial: UR-033 (Linux only) - Volume normalization to prevent volume jumps
    82    /// @req: DR-034 - Crossfade engine with configurable duration (0-12s)
    83    /// @req: DR-035 - Gapless playback between sequential tracks
    84    /// @req: DR-036 - Volume normalization with preset levels (Loud/Normal/Quiet)
    85    fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
    86        // Default implementation does nothing - override in platform-specific backends
    87        Ok(())
    88    }
    89
    90    /// Get current audio settings
    91    ///
    92    /// @req: DR-034 - Crossfade engine
    93    /// @req: DR-035 - Gapless playback
    94    /// @req: DR-036 - Volume normalization
    95    fn audio_settings(&self) -> AudioSettings {
    96        AudioSettings::default()
    97    }
    98
    99    /// Set the active audio track by stream index
    100    ///
    101    /// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
    102    /// does **not** override it — MPV is the audio-only backend here, so it keeps
    103    /// this `not_implemented()` default and the Linux video path switches track by
    104    /// re-opening the stream instead (`player_switch_audio_track`).
    105    ///
    106    /// TRACES: UR-021 | IR-019, DR-024
    107    fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
    108        // Default implementation does nothing - override in platform-specific backends
    109        Err(PlayerError::not_implemented())
    110    }
    111
    112    /// Set the active subtitle track by stream index (None to disable subtitles)
    113    ///
    114    /// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
    115    /// does **not** override it, so it keeps this `not_implemented()` default;
    116    /// the Linux video path renders subtitles as `<track>` children of the
    117    /// WebKitGTK HTML5 `<video>` element and never calls this.
    118    ///
    119    /// TRACES: UR-020 | IR-018, DR-023
    120    fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
    121        // Default implementation does nothing - override in platform-specific backends
    122        Err(PlayerError::not_implemented())
    123    }
    124}
    125
    126/// Null player backend (for testing or when no real player is available)
    127///
    128/// @req: DR-004 - PlayerBackend trait (mock implementation for testing)
    129pub struct NullBackend {
    130    state: PlayerState,
    131    volume: f32,
    132    position: f64,
    133    duration: Option<f64>,
    134    audio_settings: AudioSettings,
    135}
    136
    137impl Default for NullBackend {
    138    fn default() -> Self {
    139        Self::new()
    140    }
    141}
    142
    143impl NullBackend {
    144    pub fn new() -> Self {
    145        Self {
    146            state: PlayerState::Idle,
    147            volume: 1.0,
    148            position: 0.0,
    149            duration: None,
    150            audio_settings: AudioSettings::default(),
    151        }
    152    }
    153}
    154
    155impl PlayerBackend for NullBackend {
    156    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
    157        self.state = PlayerState::Loading {
    158            media: media.clone(),
    159        };
    160        // Simulate immediate load
    161        self.duration = media.duration;
    162        self.position = 0.0;
    163        self.state = PlayerState::Paused {
    164            media: media.clone(),
    165            position: 0.0,
    166            duration: media.duration.unwrap_or(0.0),
    167        };
    168        Ok(())
    169    }
    170
    171    fn play(&mut self) -> Result<(), PlayerError> {
    172        if let PlayerState::Paused {
    173            media,
    174            position,
    175            duration,
    176        } = &self.state
    177        {
    178            self.state = PlayerState::Playing {
    179                media: media.clone(),
    180                position: *position,
    181                duration: *duration,
    182            };
    183        }
    184        Ok(())
    185    }
    186
    187    fn pause(&mut self) -> Result<(), PlayerError> {
    188        if let PlayerState::Playing {
    189            media,
    190            position,
    191            duration,
    192        } = &self.state
    193        {
    194            self.state = PlayerState::Paused {
    195                media: media.clone(),
    196                position: *position,
    197                duration: *duration,
    198            };
    199        }
    200        Ok(())
    201    }
    202
    203    fn stop(&mut self) -> Result<(), PlayerError> {
    204        self.state = PlayerState::Idle;
    205        self.position = 0.0;
    206        self.duration = None;
    207        Ok(())
    208    }
    209
    210    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
    211        self.position = position;
    212        match &mut self.state {
    213            PlayerState::Playing { position: pos, .. } => *pos = position,
    214            PlayerState::Paused { position: pos, .. } => *pos = position,
    215            _ => {}
    216        }
    217        Ok(())
    218    }
    219
    220    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
    221        self.volume = volume.clamp(0.0, 1.0);
    222        Ok(())
    223    }
    224
    225    fn position(&self) -> f64 {
    226        self.position
    227    }
    228
    229    fn duration(&self) -> Option<f64> {
    230        self.duration
    231    }
    232
    233    fn state(&self) -> PlayerState {
    234        self.state.clone()
    235    }
    236
    237    fn volume(&self) -> f32 {
    238        self.volume
    239    }
    240
    241    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
    242        self.audio_settings = settings.clone().with_crossfade_clamped();
    243        Ok(())
    244    }
    245
    246    fn audio_settings(&self) -> AudioSettings {
    247        self.audio_settings.clone()
    248    }
    249}
    250
    251// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
    252/// Forward the trait through a box.
    253///
    254/// `Box<dyn PlayerBackend>` does not implement `PlayerBackend` on its own, so
    255/// without this the boxed engine built at the composition root cannot be handed
    256/// to anything generic over the trait — `LegacyPlayer` in particular.
    257impl PlayerBackend for Box<dyn PlayerBackend> {
    258    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
    259        (**self).load(media)
    260    }
    261    fn play(&mut self) -> Result<(), PlayerError> {
    262        (**self).play()
    263    }
    264    fn pause(&mut self) -> Result<(), PlayerError> {
    265        (**self).pause()
    266    }
    267    fn stop(&mut self) -> Result<(), PlayerError> {
    268        (**self).stop()
    269    }
    270    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
    271        (**self).seek(position)
    272    }
    273    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
    274        (**self).set_volume(volume)
    275    }
    276    fn position(&self) -> f64 {
    277        (**self).position()
    278    }
    279    fn duration(&self) -> Option<f64> {
    280        (**self).duration()
    281    }
    282    fn state(&self) -> PlayerState {
    283        (**self).state()
    284    }
    285    fn volume(&self) -> f32 {
    286        (**self).volume()
    287    }
    288    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
    289        (**self).set_audio_settings(settings)
    290    }
    291    fn audio_settings(&self) -> AudioSettings {
    292        (**self).audio_settings()
    293    }
    294    fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
    295        (**self).set_audio_track(stream_index)
    296    }
    297    fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
    298        (**self).set_subtitle_track(stream_index)
    299    }
    300}
    301
    302#[cfg(test)]
    303mod tests {
    304    use super::*;
    305
    306    /// Test NullBackend volume default value
    307    /// TRACES: UR-016 | DR-004 | UT-026
    308    #[test]
    309    fn test_null_backend_volume_default() {
    310        let backend = NullBackend::new();
    311        assert_eq!(backend.volume(), 1.0);
    312    }
    313
    314    /// Test NullBackend set volume
    315    ///
    316    /// @req-test: UT-027 - NullBackend set volume
    317    /// @req-test: UR-016 - Change system settings while playing (volume)
    318    #[test]
    319    fn test_null_backend_set_volume() {
    320        let mut backend = NullBackend::new();
    321        backend.set_volume(0.5).unwrap();
    322        assert_eq!(backend.volume(), 0.5);
    323    }
    324
    325    /// Test NullBackend volume clamping (high)
    326    ///
    327    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
    328    /// @req-test: UR-016 - Change system settings while playing (volume)
    329    #[test]
    330    fn test_null_backend_volume_clamping_high() {
    331        let mut backend = NullBackend::new();
    332        backend.set_volume(1.5).unwrap();
    333        assert_eq!(backend.volume(), 1.0);
    334    }
    335
    336    /// Test NullBackend volume clamping (low)
    337    ///
    338    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
    339    /// @req-test: UR-016 - Change system settings while playing (volume)
    340    #[test]
    341    fn test_null_backend_volume_clamping_low() {
    342        let mut backend = NullBackend::new();
    343        backend.set_volume(-0.5).unwrap();
    344        assert_eq!(backend.volume(), 0.0);
    345    }
    346
    347    /// Test NullBackend volume boundary values
    348    ///
    349    /// @req-test: UT-029 - NullBackend volume boundary values
    350    /// @req-test: UR-016 - Change system settings while playing (volume)
    351    #[test]
    352    fn test_null_backend_volume_boundary() {
    353        let mut backend = NullBackend::new();
    354
    355        backend.set_volume(0.0).unwrap();
    356        assert_eq!(backend.volume(), 0.0);
    357
    358        backend.set_volume(1.0).unwrap();
    359        assert_eq!(backend.volume(), 1.0);
    360    }
    361
    362    /// Test NullBackend audio settings default values
    363    ///
    364    /// @req-test: DR-034 - Crossfade engine
    365    /// @req-test: DR-035 - Gapless playback
    366    /// @req-test: DR-036 - Volume normalization
    367    #[test]
    368    fn test_null_backend_audio_settings_default() {
    369        let backend = NullBackend::new();
    370        let settings = backend.audio_settings();
    371        assert_eq!(settings.crossfade_duration, 0.0);
    372        assert!(settings.gapless_playback);
    373        assert!(!settings.normalize_volume);
    374    }
    375
    376    /// Test NullBackend set audio settings
    377    ///
    378    /// @req-test: DR-034 - Crossfade engine with configurable duration
    379    /// @req-test: DR-035 - Gapless playback between sequential tracks
    380    /// @req-test: DR-036 - Volume normalization with preset levels
    381    #[test]
    382    fn test_null_backend_set_audio_settings() {
    383        use crate::settings::VolumeLevel;
    384
    385        let mut backend = NullBackend::new();
    386        let settings = AudioSettings {
    387            crossfade_duration: 5.0,
    388            gapless_playback: false,
    389            normalize_volume: true,
    390            volume_level: VolumeLevel::Loud,
    391            ..Default::default()
    392        };
    393
    394        backend.set_audio_settings(&settings).unwrap();
    395
    396        let result = backend.audio_settings();
    397        assert_eq!(result.crossfade_duration, 5.0);
    398        assert!(!result.gapless_playback);
    399        assert!(result.normalize_volume);
    400        assert_eq!(result.volume_level, VolumeLevel::Loud);
    401    }
    402
    403    /// Test NullBackend audio settings crossfade clamping to 12s max
    404    ///
    405    /// @req-test: DR-034 - Crossfade engine with configurable duration (0-12s)
    406    #[test]
    407    fn test_null_backend_audio_settings_crossfade_clamping() {
    408        let mut backend = NullBackend::new();
    409        let settings = AudioSettings {
    410            crossfade_duration: 20.0,
    411            ..Default::default()
    412        };
    413
    414        backend.set_audio_settings(&settings).unwrap();
    415        assert_eq!(backend.audio_settings().crossfade_duration, 12.0);
    416    }
    417
    418    /// Test NullBackend seek updates position
    419    ///
    420    /// @req-test: UR-005 - Control media playback (scrub operation)
    421    /// @req-test: DR-004 - PlayerBackend trait
    422    #[test]
    423    fn test_null_backend_seek_updates_position() {
    424        use crate::player::media::{MediaItem, MediaSource, MediaType};
    425
    426        let mut backend = NullBackend::new();
    427
    428        // Create a test media item
    429        let media = MediaItem {
    430            // Audio and direct-URL items never negotiate a transport.
    431            transport: None,
    432            id: "test_media".to_string(),
    433            title: "Test Track".to_string(),
    434            name: Some("Test Track".to_string()),
    435            artist: Some("Test Artist".to_string()),
    436            album: Some("Test Album".to_string()),
    437            album_name: Some("Test Album".to_string()),
    438            album_id: None,
    439            artist_items: None,
    440            artists: Some(vec!["Test Artist".to_string()]),
    441            primary_image_tag: None,
    442            image_id: None,
    443            item_type: Some("Audio".to_string()),
    444            playlist_id: None,
    445            duration: Some(180.0),
    446            artwork_url: None,
    447            media_type: MediaType::Audio,
    448            source: MediaSource::DirectUrl {
    449                url: "http://example.com/test.mp3".to_string(),
    450            },
    451            video_codec: None,
    452            needs_transcoding: false,
    453            video_width: None,
    454            video_height: None,
    455            subtitles: vec![],
    456            series_id: None,
    457            server_id: None,
    458        };
    459
    460        // Load and play the media
    461        backend.load(&media).unwrap();
    462        backend.play().unwrap();
    463
    464        // Verify initial position
    465        assert_eq!(backend.position(), 0.0);
    466
    467        // Seek to 30 seconds
    468        backend.seek(30.0).unwrap();
    469        assert_eq!(backend.position(), 30.0);
    470
    471        // Seek to 60 seconds
    472        backend.seek(60.0).unwrap();
    473        assert_eq!(backend.position(), 60.0);
    474
    475        // Seek backward
    476        backend.seek(15.0).unwrap();
    477        assert_eq!(backend.position(), 15.0);
    478    }
    479
    480    /// Test NullBackend seek while paused
    481    ///
    482    /// @req-test: UR-005 - Control media playback (scrub while paused)
    483    /// @req-test: DR-001 - Player state machine (seeking from paused state)
    484    #[test]
    485    fn test_null_backend_seek_while_paused() {
    486        use crate::player::media::{MediaItem, MediaSource, MediaType};
    487
    488        let mut backend = NullBackend::new();
    489
    490        let media = MediaItem {
    491            // Audio and direct-URL items never negotiate a transport.
    492            transport: None,
    493            id: "test_media".to_string(),
    494            title: "Test Track".to_string(),
    495            name: Some("Test Track".to_string()),
    496            artist: Some("Test Artist".to_string()),
    497            album: Some("Test Album".to_string()),
    498            album_name: Some("Test Album".to_string()),
    499            album_id: None,
    500            artist_items: None,
    501            artists: Some(vec!["Test Artist".to_string()]),
    502            primary_image_tag: None,
    503            image_id: None,
    504            item_type: Some("Audio".to_string()),
    505            playlist_id: None,
    506            duration: Some(180.0),
    507            artwork_url: None,
    508            media_type: MediaType::Audio,
    509            source: MediaSource::DirectUrl {
    510                url: "http://example.com/test.mp3".to_string(),
    511            },
    512            video_codec: None,
    513            needs_transcoding: false,
    514            video_width: None,
    515            video_height: None,
    516            subtitles: vec![],
    517            series_id: None,
    518            server_id: None,
    519        };
    520
    521        // Load media (starts paused)
    522        backend.load(&media).unwrap();
    523
    524        // Verify state is paused
    525        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
    526
    527        // Seek while paused
    528        backend.seek(45.0).unwrap();
    529        assert_eq!(backend.position(), 45.0);
    530
    531        // Verify still paused
    532        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
    533    }
    534
    535    /// Test NullBackend position updates reflected in state
    536    ///
    537    /// @req-test: DR-001 - Player state machine (position tracking)
    538    /// @req-test: UR-005 - Control media playback (position accuracy)
    539    #[test]
    540    fn test_null_backend_position_updates_in_state() {
    541        use crate::player::media::{MediaItem, MediaSource, MediaType};
    542
    543        let mut backend = NullBackend::new();
    544
    545        let media = MediaItem {
    546            // Audio and direct-URL items never negotiate a transport.
    547            transport: None,
    548            id: "test_media".to_string(),
    549            title: "Test Track".to_string(),
    550            name: Some("Test Track".to_string()),
    551            artist: Some("Test Artist".to_string()),
    552            album: Some("Test Album".to_string()),
    553            album_name: Some("Test Album".to_string()),
    554            album_id: None,
    555            artist_items: None,
    556            artists: Some(vec!["Test Artist".to_string()]),
    557            primary_image_tag: None,
    558            image_id: None,
    559            item_type: Some("Audio".to_string()),
    560            playlist_id: None,
    561            duration: Some(180.0),
    562            artwork_url: None,
    563            media_type: MediaType::Audio,
    564            source: MediaSource::DirectUrl {
    565                url: "http://example.com/test.mp3".to_string(),
    566            },
    567            video_codec: None,
    568            needs_transcoding: false,
    569            video_width: None,
    570            video_height: None,
    571            subtitles: vec![],
    572            series_id: None,
    573            server_id: None,
    574        };
    575
    576        backend.load(&media).unwrap();
    577        backend.play().unwrap();
    578
    579        // Seek to 30 seconds
    580        backend.seek(30.0).unwrap();
    581
    582        // Verify the state reflects the new position
    583        if let PlayerState::Playing { position, .. } = backend.state() {
    584            assert_eq!(position, 30.0);
    585        } else {
    586            panic!("Expected Playing state");
    587        }
    588    }
    589}