Background-audio handoff for video + repository/player refactor

Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
This commit is contained in:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
File diff suppressed because it is too large Load Diff
+65 -26
View File
@@ -1,4 +1,6 @@
//! Queue manipulation commands (add / remove / move / skip).
//!
//! TRACES: UR-015 | DR-005, DR-020
use std::path::PathBuf;
@@ -150,16 +152,25 @@ pub async fn player_add_track_by_id(
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
info!("player_add_track_by_id called: track_id={}, position={}",
request.track_id, request.position);
info!(
"player_add_track_by_id called: track_id={}, position={}",
request.track_id, request.position
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch track metadata via repository
info!("Fetching metadata for track {} via repository", request.track_id);
let track = repository.get_item(&request.track_id).await
info!(
"Fetching metadata for track {} via repository",
request.track_id
);
let track = repository
.get_item(&request.track_id)
.await
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
// Check for local download first
@@ -173,7 +184,9 @@ pub async fn player_add_track_by_id(
}
} else {
// Get stream URL from repository (works online/offline)
let stream_url = repository.get_audio_stream_url(&track.id).await
let stream_url = repository
.get_audio_stream_url(&track.id)
.await
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
MediaSource::Remote {
@@ -188,23 +201,30 @@ pub async fn player_add_track_by_id(
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}))
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
@@ -250,18 +270,25 @@ pub async fn player_add_tracks_by_ids(
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
info!("player_add_tracks_by_ids called: {} tracks, position={}",
request.track_ids.len(), request.position);
info!(
"player_add_tracks_by_ids called: {} tracks, position={}",
request.track_ids.len(),
request.position
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch metadata and build MediaItems for all tracks
let mut media_items = Vec::new();
for track_id in &request.track_ids {
info!("Fetching metadata for track {} via repository", track_id);
let track = repository.get_item(track_id).await
let track = repository
.get_item(track_id)
.await
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
// Check for local download first
@@ -275,7 +302,9 @@ pub async fn player_add_tracks_by_ids(
}
} else {
// Get stream URL from repository (works online/offline)
let stream_url = repository.get_audio_stream_url(&track.id).await
let stream_url = repository
.get_audio_stream_url(&track.id)
.await
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
MediaSource::Remote {
@@ -290,23 +319,30 @@ pub async fn player_add_tracks_by_ids(
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}))
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
@@ -339,7 +375,10 @@ pub async fn player_add_tracks_by_ids(
drop(queue_lock);
controller.emit_queue_changed();
info!("Successfully added {} tracks to queue", request.track_ids.len());
info!(
"Successfully added {} tracks to queue",
request.track_ids.len()
);
Ok(result)
}
+80 -16
View File
@@ -1,5 +1,7 @@
//! Remote Jellyfin session control commands (casting to another device).
//!
//! TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058
//!
//! These thin command adapters forward control actions to the active Jellyfin
//! session via the player's configured `JellyfinClient`.
@@ -17,22 +19,36 @@ pub async fn remote_play_on_session(
item_ids: Vec<String>,
start_index: usize,
) -> Result<(), String> {
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
log::info!(
"[RemoteSession] Playing {} items on session {} (start index: {})",
item_ids.len(),
session_id,
start_index
);
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
client.play_on_session(session_id, item_ids, start_index, None).await?;
client
.play_on_session(session_id, item_ids, start_index, None)
.await?;
log::info!("[RemoteSession] Successfully started playback on remote session");
Ok(())
} else {
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
Err(
"Jellyfin client not configured - please restart the app or log out and log back in"
.to_string(),
)
}
}
@@ -44,11 +60,19 @@ pub async fn remote_send_command(
session_id: String,
command: String,
) -> Result<(), String> {
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
log::info!(
"[RemoteSession] Sending command '{}' to session {}",
command,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -68,11 +92,19 @@ pub async fn remote_session_seek(
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
log::info!(
"[RemoteSession] Seeking to {} ticks on session {}",
position_ticks,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -92,11 +124,19 @@ pub async fn remote_session_set_volume(
session_id: String,
volume: i32,
) -> Result<(), String> {
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
log::info!(
"[RemoteSession] Setting volume to {} on session {}",
volume,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -119,7 +159,11 @@ pub async fn remote_session_toggle_mute(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -145,7 +189,11 @@ pub async fn lms_get_sync_groups(
) -> Result<Vec<LmsSyncGroup>, String> {
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -164,11 +212,19 @@ pub async fn lms_create_sync_group(
master_mac: String,
slave_macs: Vec<String>,
) -> Result<(), String> {
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
log::info!(
"[LmsSync] Fusing zones: master={}, slaves={:?}",
master_mac,
slave_macs
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -189,7 +245,11 @@ pub async fn lms_unsync_player(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -210,7 +270,11 @@ pub async fn lms_dissolve_sync_group(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
+2
View File
@@ -1,5 +1,7 @@
//! Media session state commands.
//!
//! TRACES: UR-005 | DR-009
//!
//! Read and dismiss the current media session (the Now Playing surface backing
//! lockscreen/notification controls).
@@ -1,4 +1,6 @@
//! Audio and video playback settings commands.
//!
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
use tauri::State;
+11 -4
View File
@@ -1,5 +1,7 @@
//! Sleep-timer and autoplay commands.
//!
//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
//!
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
//! logic, plus persistence of autoplay settings to the database.
@@ -7,8 +9,8 @@ use std::sync::Arc;
use tauri::State;
use super::{
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
PlayerStateWrapper,
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
PlayerStatus,
};
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
@@ -127,7 +129,9 @@ pub async fn player_play_next_episode(
let media_item = create_media_item(item, Some(&db)).await?;
let controller = player.0.lock().await;
controller.play_item(media_item).map_err(|e| e.to_string())?;
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
Ok(get_player_status(&controller))
}
@@ -164,7 +168,10 @@ pub async fn player_on_playback_ended(
if let Some(repo) = repo {
controller.on_video_playback_ended(id, repo).await?
} else {
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
log::warn!(
"[Autoplay] No repository available for video autoplay (itemId: {})",
id
);
AutoplayDecision::Stop
}
} else {