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:
@@ -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"),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user