Files
jellytau/src-tauri/src/player/backend.rs
T
dtourolle ebf9a99b80 docs(traces): tag the twelve "Done but untraced" requirements, and stop the matrix over-reporting
Twelve requirements were marked Done in docs/requirements.md with zero TRACES
anywhere in the tree. The features work — the tags were simply never written —
so the matrix over-reported on exactly the requirements a reviewer would most
want to verify. Each is now tagged at the code that actually implements it:

- JA-006 / JA-009 / JA-013 / JA-014 / JA-015 / JA-018 and IR-022 / IR-024 at
  their Jellyfin call sites in repository/online.rs (search, get_item's
  MediaStreams/People fields, Items/Resume, Shows/NextUp, FavoriteItems DELETE,
  get_person/get_items_by_person), plus the commands that expose them.
- UR-006 / IR-006 across the lockscreen spine: JellyTauPlaybackService (the
  MediaSessionCompat owner), the nativeOnMediaCommand JNI intake, and
  LockscreenMetadata / update_lockscreen_metadata.
- IR-008 at both audio-focus mechanisms — ExoPlayer-managed for audio, the
  manual AudioFocusRequest listener for video — and at the media-type string
  that chooses between them.
- UR-037 (with DR-042, also untraced) on the video-library poster grid:
  LibraryGrid, MediaCard, and the tv/movies routes.

Resolve contradictory statuses across layers, evidence first:

- IR-018/IR-019 were Planned under Done URs because they were scoped to libmpv.
  MpvBackend is the audio-only backend and overrides neither
  set_subtitle_track nor set_audio_track — the trait's not_implemented()
  default still stands — so UR-020/UR-021 are met by ExoPlayer and by the
  HTML5 <video> path instead. Both IRs are re-scoped to those backends and
  marked Done; IT-008/IT-009 and the stale @req-planned markers in backend.rs
  follow.
- IR-005 (MPRIS) stays Planned: there is no MPRIS/D-Bus code or dependency in
  the project and update_lockscreen_metadata is a no-op off Android. UR-006 is
  corrected to Done (Android) rather than the IR being marked Done.
- A note under the IR table records where a UR is met by a different mechanism
  than its IR anticipated.

Define the two dangling IDs the source already referenced: DR-189 (the control
bar never auto-hid on a touchscreen, because its timer was armed only from
onmousemove) and UT-188 (its rule test). The live-denominator assertion in
extract-traces.test.ts moves 187/330 to 188/331 accordingly.

Traced requirements 444 to 459; IR coverage 19/32 to 25/32.
2026-08-16 22:58:55 +02:00

534 lines
17 KiB
Rust

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
///
/// TRACES: UR-003, UR-004 | IR-003, IR-004 | DR-004
pub trait PlayerBackend: Send + Sync {
/// Load a media item for playback
/// TRACES: UR-005
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
/// Start or resume playback
/// TRACES: UR-005
fn play(&mut self) -> Result<(), PlayerError>;
/// Pause playback
/// TRACES: UR-005
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop playback and unload media
/// TRACES: UR-005
fn stop(&mut self) -> Result<(), PlayerError>;
/// Seek to a position in seconds
/// TRACES: UR-005
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
/// Set volume (0.0 - 1.0)
/// TRACES: UR-016
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
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it — MPV is the audio-only backend here, so it keeps
/// this `not_implemented()` default and the Linux video path switches track by
/// re-opening the stream instead (`player_switch_audio_track`).
///
/// TRACES: UR-021 | IR-019, DR-024
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)
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it, so it keeps this `not_implemented()` default;
/// the Linux video path renders subtitles as `<track>` children of the
/// WebKitGTK HTML5 `<video>` element and never calls this.
///
/// TRACES: UR-020 | IR-018, DR-023
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()
}
}
// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
#[cfg(test)]
mod tests {
use super::*;
/// Test NullBackend volume default value
/// TRACES: UR-016 | DR-004 | UT-026
#[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,
..Default::default()
};
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,
image_id: 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,
image_id: 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,
image_id: 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");
}
}
}