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
+127 -45
View File
@@ -127,7 +127,10 @@ impl PlaybackModeManager {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("[PlaybackMode] Failed to enable remote volume/service: {}", e);
log::warn!(
"[PlaybackMode] Failed to enable remote volume/service: {}",
e
);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
@@ -154,8 +157,16 @@ impl PlaybackModeManager {
/// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
pub async fn send_remote_volume_command(
&self,
command: &str,
volume: i32,
) -> Result<(), String> {
log::info!(
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
command,
volume
);
// Get the current session ID
let session_id = match self.get_mode() {
@@ -166,18 +177,18 @@ impl PlaybackModeManager {
}
};
log::info!("[PlaybackMode] Current mode is Remote, session_id={}", session_id);
log::info!(
"[PlaybackMode] Current mode is Remote, session_id={}",
session_id
);
// Get Jellyfin client
let client = {
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
let client_opt = self.jellyfin_client.lock().map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
log::info!("[PlaybackMode] Jellyfin client lock acquired");
@@ -196,7 +207,12 @@ impl PlaybackModeManager {
log::info!("[PlaybackMode] About to call client.session_set_volume...");
// Send the volume command
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
log::info!(
"[PlaybackMode] Sending {} command to session {} (volume: {})",
command,
session_id,
volume
);
let result = client.session_set_volume(session_id, volume).await;
match &result {
@@ -209,8 +225,11 @@ impl PlaybackModeManager {
/// Extract Jellyfin item IDs from queue items
/// Returns (item_ids, adjusted_current_index)
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
fn extract_jellyfin_ids(
&self,
items: &[crate::player::MediaItem],
original_index: usize,
) -> Result<(Vec<String>, usize), String> {
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
let mut jellyfin_item_count = 0;
@@ -236,7 +255,9 @@ impl PlaybackModeManager {
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
original_index
);
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
return Err(
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
);
}
};
@@ -269,7 +290,9 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
let result = self
.transfer_to_remote_inner(&session_id, position_override)
.await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
@@ -283,7 +306,10 @@ impl PlaybackModeManager {
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
debug!(
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
session_id
);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
@@ -307,13 +333,26 @@ impl PlaybackModeManager {
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
log::info!(
"[PlaybackMode] Queue has {} items, original_index={}",
items.len(),
original_index
);
debug!(
"[PlaybackMode] Queue has {} items, original_index={}",
items.len(),
original_index
);
// Log each item's jellyfin_id for debugging
for (i, item) in items.iter().enumerate() {
let jf_id = item.jellyfin_id().unwrap_or("NONE");
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
log::debug!(
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
i,
item.id,
jf_id
);
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
@@ -372,14 +411,11 @@ impl PlaybackModeManager {
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
let client = {
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
let client_opt = self.jellyfin_client.lock().map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
match client_opt.as_ref() {
Some(c) => {
@@ -405,7 +441,9 @@ impl PlaybackModeManager {
match client.get_session(prev_session_id).await {
Ok(Some(session)) => {
// Resume at the previous session's position.
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
if let Some(ticks) =
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
{
position_seconds = ticks as f64 / TICKS_PER_SECOND;
log::info!(
"[PlaybackMode] Using previous remote position: {:.2}s",
@@ -413,7 +451,11 @@ impl PlaybackModeManager {
);
}
// Resume on whichever track the previous session reached.
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
if let Some(now_id) = session
.now_playing_item
.as_ref()
.and_then(|i| i.id.as_deref())
{
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
log::info!(
"[PlaybackMode] Previous session is on track {} (queue index {})",
@@ -430,8 +472,13 @@ impl PlaybackModeManager {
}
}
}
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
Ok(None) => log::warn!(
"[PlaybackMode] Previous remote session not found while reading state"
),
Err(e) => log::warn!(
"[PlaybackMode] Failed to read previous remote session: {}",
e
),
}
}
@@ -440,7 +487,10 @@ impl PlaybackModeManager {
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
QueueContext::Album { album_id, album_name } => {
QueueContext::Album {
album_id,
album_name,
} => {
log::info!(
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
album_name,
@@ -448,7 +498,10 @@ impl PlaybackModeManager {
queue_ids.len()
);
}
QueueContext::Playlist { playlist_id, playlist_name } => {
QueueContext::Playlist {
playlist_id,
playlist_name,
} => {
log::info!(
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
playlist_name,
@@ -540,7 +593,11 @@ impl PlaybackModeManager {
return Err("Remote session not found".to_string());
}
Err(e) => {
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
log::warn!(
"[PlaybackMode] Error polling session (attempt {}): {}",
attempts,
e
);
// Continue polling - transient errors are OK
}
}
@@ -572,9 +629,15 @@ impl PlaybackModeManager {
// up with two devices playing at once. Do this only after the new session
// is confirmed playing, so a failure here doesn't leave us with silence.
if let Some(prev_session_id) = previous_remote_session {
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
log::info!(
"[PlaybackMode] Stopping previous remote session {}",
prev_session_id
);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
log::warn!(
"[PlaybackMode] Failed to stop previous remote session: {}",
e
);
}
}
@@ -594,7 +657,9 @@ impl PlaybackModeManager {
);
}
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
player
.stop()
.map_err(|e| format!("Failed to stop playback: {}", e))?;
// Log queue state AFTER stop (should be unchanged)
{
@@ -677,11 +742,20 @@ impl PlaybackModeManager {
};
// Stop remote playback
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
match client.send_session_command(session_id.clone(), "Stop").await {
log::info!(
"[PlaybackMode] Stopping remote playback on session: {}",
session_id
);
match client
.send_session_command(session_id.clone(), "Stop")
.await
{
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
Err(e) => {
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
log::warn!(
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
e
);
// Don't fail the transfer if we can't stop the remote session
// The user is already playing locally, so this is not critical
}
@@ -843,7 +917,10 @@ mod tests {
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
assert_eq!(
start_position_ticks_from_seconds(123.45),
Some(1_234_500_000)
);
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
@@ -931,7 +1008,12 @@ mod tests {
fn test_extract_all_jellyfin_ids_from_album() {
// Simulate an album with 5 tracks - all should be extracted
let items: Vec<MediaItem> = (1..=5)
.map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i)))
.map(|i| {
create_test_item_with_jellyfin_id(
&format!("track_{}", i),
&format!("jf_track_{}", i),
)
})
.collect();
let manager = super::PlaybackModeManager::new(
@@ -965,7 +1047,7 @@ mod tests {
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
create_test_item_with_jellyfin_id("4", "jf_4"),
];
@@ -1000,7 +1082,7 @@ mod tests {
// Current item has no Jellyfin ID - should fail
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
];