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}