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