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#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// Test NullBackend volume default value
257    /// TRACES: UR-016 | DR-004 | UT-026
258    #[test]
259    fn test_null_backend_volume_default() {
260        let backend = NullBackend::new();
261        assert_eq!(backend.volume(), 1.0);
262    }
263
264    /// Test NullBackend set volume
265    ///
266    /// @req-test: UT-027 - NullBackend set volume
267    /// @req-test: UR-016 - Change system settings while playing (volume)
268    #[test]
269    fn test_null_backend_set_volume() {
270        let mut backend = NullBackend::new();
271        backend.set_volume(0.5).unwrap();
272        assert_eq!(backend.volume(), 0.5);
273    }
274
275    /// Test NullBackend volume clamping (high)
276    ///
277    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
278    /// @req-test: UR-016 - Change system settings while playing (volume)
279    #[test]
280    fn test_null_backend_volume_clamping_high() {
281        let mut backend = NullBackend::new();
282        backend.set_volume(1.5).unwrap();
283        assert_eq!(backend.volume(), 1.0);
284    }
285
286    /// Test NullBackend volume clamping (low)
287    ///
288    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
289    /// @req-test: UR-016 - Change system settings while playing (volume)
290    #[test]
291    fn test_null_backend_volume_clamping_low() {
292        let mut backend = NullBackend::new();
293        backend.set_volume(-0.5).unwrap();
294        assert_eq!(backend.volume(), 0.0);
295    }
296
297    /// Test NullBackend volume boundary values
298    ///
299    /// @req-test: UT-029 - NullBackend volume boundary values
300    /// @req-test: UR-016 - Change system settings while playing (volume)
301    #[test]
302    fn test_null_backend_volume_boundary() {
303        let mut backend = NullBackend::new();
304
305        backend.set_volume(0.0).unwrap();
306        assert_eq!(backend.volume(), 0.0);
307
308        backend.set_volume(1.0).unwrap();
309        assert_eq!(backend.volume(), 1.0);
310    }
311
312    /// Test NullBackend audio settings default values
313    ///
314    /// @req-test: DR-034 - Crossfade engine
315    /// @req-test: DR-035 - Gapless playback
316    /// @req-test: DR-036 - Volume normalization
317    #[test]
318    fn test_null_backend_audio_settings_default() {
319        let backend = NullBackend::new();
320        let settings = backend.audio_settings();
321        assert_eq!(settings.crossfade_duration, 0.0);
322        assert!(settings.gapless_playback);
323        assert!(!settings.normalize_volume);
324    }
325
326    /// Test NullBackend set audio settings
327    ///
328    /// @req-test: DR-034 - Crossfade engine with configurable duration
329    /// @req-test: DR-035 - Gapless playback between sequential tracks
330    /// @req-test: DR-036 - Volume normalization with preset levels
331    #[test]
332    fn test_null_backend_set_audio_settings() {
333        use crate::settings::VolumeLevel;
334
335        let mut backend = NullBackend::new();
336        let settings = AudioSettings {
337            crossfade_duration: 5.0,
338            gapless_playback: false,
339            normalize_volume: true,
340            volume_level: VolumeLevel::Loud,
341            ..Default::default()
342        };
343
344        backend.set_audio_settings(&settings).unwrap();
345
346        let result = backend.audio_settings();
347        assert_eq!(result.crossfade_duration, 5.0);
348        assert!(!result.gapless_playback);
349        assert!(result.normalize_volume);
350        assert_eq!(result.volume_level, VolumeLevel::Loud);
351    }
352
353    /// Test NullBackend audio settings crossfade clamping to 12s max
354    ///
355    /// @req-test: DR-034 - Crossfade engine with configurable duration (0-12s)
356    #[test]
357    fn test_null_backend_audio_settings_crossfade_clamping() {
358        let mut backend = NullBackend::new();
359        let settings = AudioSettings {
360            crossfade_duration: 20.0,
361            ..Default::default()
362        };
363
364        backend.set_audio_settings(&settings).unwrap();
365        assert_eq!(backend.audio_settings().crossfade_duration, 12.0);
366    }
367
368    /// Test NullBackend seek updates position
369    ///
370    /// @req-test: UR-005 - Control media playback (scrub operation)
371    /// @req-test: DR-004 - PlayerBackend trait
372    #[test]
373    fn test_null_backend_seek_updates_position() {
374        use crate::player::media::{MediaItem, MediaSource, MediaType};
375
376        let mut backend = NullBackend::new();
377
378        // Create a test media item
379        let media = MediaItem {
380            id: "test_media".to_string(),
381            title: "Test Track".to_string(),
382            name: Some("Test Track".to_string()),
383            artist: Some("Test Artist".to_string()),
384            album: Some("Test Album".to_string()),
385            album_name: Some("Test Album".to_string()),
386            album_id: None,
387            artist_items: None,
388            artists: Some(vec!["Test Artist".to_string()]),
389            primary_image_tag: None,
390            image_id: None,
391            item_type: Some("Audio".to_string()),
392            playlist_id: None,
393            duration: Some(180.0),
394            artwork_url: None,
395            media_type: MediaType::Audio,
396            source: MediaSource::DirectUrl {
397                url: "http://example.com/test.mp3".to_string(),
398            },
399            video_codec: None,
400            needs_transcoding: false,
401            video_width: None,
402            video_height: None,
403            subtitles: vec![],
404            series_id: None,
405            server_id: None,
406        };
407
408        // Load and play the media
409        backend.load(&media).unwrap();
410        backend.play().unwrap();
411
412        // Verify initial position
413        assert_eq!(backend.position(), 0.0);
414
415        // Seek to 30 seconds
416        backend.seek(30.0).unwrap();
417        assert_eq!(backend.position(), 30.0);
418
419        // Seek to 60 seconds
420        backend.seek(60.0).unwrap();
421        assert_eq!(backend.position(), 60.0);
422
423        // Seek backward
424        backend.seek(15.0).unwrap();
425        assert_eq!(backend.position(), 15.0);
426    }
427
428    /// Test NullBackend seek while paused
429    ///
430    /// @req-test: UR-005 - Control media playback (scrub while paused)
431    /// @req-test: DR-001 - Player state machine (seeking from paused state)
432    #[test]
433    fn test_null_backend_seek_while_paused() {
434        use crate::player::media::{MediaItem, MediaSource, MediaType};
435
436        let mut backend = NullBackend::new();
437
438        let media = MediaItem {
439            id: "test_media".to_string(),
440            title: "Test Track".to_string(),
441            name: Some("Test Track".to_string()),
442            artist: Some("Test Artist".to_string()),
443            album: Some("Test Album".to_string()),
444            album_name: Some("Test Album".to_string()),
445            album_id: None,
446            artist_items: None,
447            artists: Some(vec!["Test Artist".to_string()]),
448            primary_image_tag: None,
449            image_id: None,
450            item_type: Some("Audio".to_string()),
451            playlist_id: None,
452            duration: Some(180.0),
453            artwork_url: None,
454            media_type: MediaType::Audio,
455            source: MediaSource::DirectUrl {
456                url: "http://example.com/test.mp3".to_string(),
457            },
458            video_codec: None,
459            needs_transcoding: false,
460            video_width: None,
461            video_height: None,
462            subtitles: vec![],
463            series_id: None,
464            server_id: None,
465        };
466
467        // Load media (starts paused)
468        backend.load(&media).unwrap();
469
470        // Verify state is paused
471        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
472
473        // Seek while paused
474        backend.seek(45.0).unwrap();
475        assert_eq!(backend.position(), 45.0);
476
477        // Verify still paused
478        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
479    }
480
481    /// Test NullBackend position updates reflected in state
482    ///
483    /// @req-test: DR-001 - Player state machine (position tracking)
484    /// @req-test: UR-005 - Control media playback (position accuracy)
485    #[test]
486    fn test_null_backend_position_updates_in_state() {
487        use crate::player::media::{MediaItem, MediaSource, MediaType};
488
489        let mut backend = NullBackend::new();
490
491        let media = MediaItem {
492            id: "test_media".to_string(),
493            title: "Test Track".to_string(),
494            name: Some("Test Track".to_string()),
495            artist: Some("Test Artist".to_string()),
496            album: Some("Test Album".to_string()),
497            album_name: Some("Test Album".to_string()),
498            album_id: None,
499            artist_items: None,
500            artists: Some(vec!["Test Artist".to_string()]),
501            primary_image_tag: None,
502            image_id: None,
503            item_type: Some("Audio".to_string()),
504            playlist_id: None,
505            duration: Some(180.0),
506            artwork_url: None,
507            media_type: MediaType::Audio,
508            source: MediaSource::DirectUrl {
509                url: "http://example.com/test.mp3".to_string(),
510            },
511            video_codec: None,
512            needs_transcoding: false,
513            video_width: None,
514            video_height: None,
515            subtitles: vec![],
516            series_id: None,
517            server_id: None,
518        };
519
520        backend.load(&media).unwrap();
521        backend.play().unwrap();
522
523        // Seek to 30 seconds
524        backend.seek(30.0).unwrap();
525
526        // Verify the state reflects the new position
527        if let PlayerState::Playing { position, .. } = backend.state() {
528            assert_eq!(position, 30.0);
529        } else {
530            panic!("Expected Playing state");
531        }
532    }
533}