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
+5 -5
View File
@@ -1,9 +1,9 @@
pub mod reporter;
pub mod throttle;
pub mod sync_processor;
pub mod throttle;
pub use reporter::{PlaybackReporter, PlaybackOperation, PlaybackContext};
#[allow(unused_imports)] // Will be used when position updates are hooked
pub use throttle::EventThrottler;
#[allow(unused_imports)] // Will be used when sync processor is integrated
pub use reporter::{PlaybackContext, PlaybackOperation, PlaybackReporter};
#[allow(unused_imports)] // Will be used when sync processor is integrated
pub use sync_processor::SyncProcessor;
#[allow(unused_imports)] // Will be used when position updates are hooked
pub use throttle::EventThrottler;
+122 -47
View File
@@ -14,7 +14,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer
/// Playback context information
#[derive(Debug, Clone)]
pub struct PlaybackContext {
pub context_type: String, // "container" or "single"
pub context_type: String, // "container" or "single"
pub context_id: Option<String>,
}
@@ -65,7 +65,11 @@ impl PlaybackReporter {
///
/// Always updates local DB first, then attempts server sync if online.
/// If server sync fails, operation is queued for retry.
pub async fn report(&self, operation: PlaybackOperation, is_online: bool) -> Result<(), String> {
pub async fn report(
&self,
operation: PlaybackOperation,
is_online: bool,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
// Always update local DB first (works offline)
@@ -93,7 +97,11 @@ impl PlaybackReporter {
/// Updates local database with playback info
async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
match operation {
PlaybackOperation::Start { item_id, position_ticks, context } => {
PlaybackOperation::Start {
item_id,
position_ticks,
context,
} => {
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
playback_context_type, playback_context_id, pending_sync)
@@ -113,12 +121,22 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
}
PlaybackOperation::Progress { item_id, position_ticks, is_paused: _ } |
PlaybackOperation::Stopped { item_id, position_ticks } => {
PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused: _,
}
| PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
@@ -133,8 +151,14 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for progress/stop: {}", item_id);
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!(
"[PlaybackReporter] Updated local DB for progress/stop: {}",
item_id
);
}
PlaybackOperation::MarkPlayed { item_id } => {
@@ -152,8 +176,14 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for mark played: {}", item_id);
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!(
"[PlaybackReporter] Updated local DB for mark played: {}",
item_id
);
}
}
@@ -163,34 +193,57 @@ impl PlaybackReporter {
/// Syncs to Jellyfin server
async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
let client_guard = self.jellyfin_client.lock().await;
let client = client_guard.as_ref().ok_or("JellyfinClient not initialized")?;
let client = client_guard
.as_ref()
.ok_or("JellyfinClient not initialized")?;
match operation {
PlaybackOperation::Start { item_id, position_ticks, .. } => {
client.report_playback_start(
item_id.clone(),
*position_ticks,
None, // play_session_id
).await?;
PlaybackOperation::Start {
item_id,
position_ticks,
..
} => {
client
.report_playback_start(
item_id.clone(),
*position_ticks,
None, // play_session_id
)
.await?;
log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
}
PlaybackOperation::Progress { item_id, position_ticks, is_paused } => {
client.report_playback_progress(
item_id.clone(),
*position_ticks,
*is_paused,
None, // play_session_id
).await?;
log::debug!("[PlaybackReporter] Reported progress to server: {} (paused: {})", item_id, is_paused);
PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
} => {
client
.report_playback_progress(
item_id.clone(),
*position_ticks,
*is_paused,
None, // play_session_id
)
.await?;
log::debug!(
"[PlaybackReporter] Reported progress to server: {} (paused: {})",
item_id,
is_paused
);
}
PlaybackOperation::Stopped { item_id, position_ticks } => {
client.report_playback_stopped(
item_id.clone(),
*position_ticks,
None, // play_session_id
).await?;
PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
client
.report_playback_stopped(
item_id.clone(),
*position_ticks,
None, // play_session_id
)
.await?;
log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
}
@@ -199,12 +252,13 @@ impl PlaybackReporter {
// For now, report as stopped at max position
// TODO: Fetch item runtime from DB or assume 100% completion
let max_ticks = i64::MAX; // Temporary - should be actual runtime
client.report_playback_stopped(
item_id.clone(),
max_ticks,
None,
).await?;
log::info!("[PlaybackReporter] Reported mark played to server: {}", item_id);
client
.report_playback_stopped(item_id.clone(), max_ticks, None)
.await?;
log::info!(
"[PlaybackReporter] Reported mark played to server: {}",
item_id
);
}
}
@@ -214,13 +268,21 @@ impl PlaybackReporter {
/// Queues operation for later sync
async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
let (op_name, item_id, payload) = match operation {
PlaybackOperation::Start { item_id, position_ticks, context } => {
PlaybackOperation::Start {
item_id,
position_ticks,
context,
} => {
let payload_data = serde_json::json!({
"position_ticks": position_ticks,
"context_type": context.as_ref().map(|c| &c.context_type),
"context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
});
("report_playback_start", Some(item_id.clone()), Some(payload_data.to_string()))
(
"report_playback_start",
Some(item_id.clone()),
Some(payload_data.to_string()),
)
}
PlaybackOperation::Progress { .. } => {
@@ -230,11 +292,18 @@ impl PlaybackReporter {
return Ok(());
}
PlaybackOperation::Stopped { item_id, position_ticks } => {
PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
let payload_data = serde_json::json!({
"position_ticks": position_ticks,
});
("report_playback_stopped", Some(item_id.clone()), Some(payload_data.to_string()))
(
"report_playback_stopped",
Some(item_id.clone()),
Some(payload_data.to_string()),
)
}
PlaybackOperation::MarkPlayed { item_id } => {
@@ -253,7 +322,10 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::info!("[PlaybackReporter] Queued operation: {}", op_name);
Ok(())
@@ -269,7 +341,10 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
Ok(())
@@ -278,10 +353,10 @@ impl PlaybackReporter {
/// Extracts item_id from operation
fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
match operation {
PlaybackOperation::Start { item_id, .. } |
PlaybackOperation::Progress { item_id, .. } |
PlaybackOperation::Stopped { item_id, .. } |
PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
PlaybackOperation::Start { item_id, .. }
| PlaybackOperation::Progress { item_id, .. }
| PlaybackOperation::Stopped { item_id, .. }
| PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
}
}
}
@@ -6,8 +6,8 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
@@ -17,9 +17,9 @@ use crate::storage::db_service::RusqliteService;
/// Configuration for sync processor
pub struct SyncConfig {
pub max_retries: u32, // 5
pub base_retry_delay_ms: u64, // 1000ms
pub batch_size: usize, // 10 items
pub max_retries: u32, // 5
pub base_retry_delay_ms: u64, // 1000ms
pub batch_size: usize, // 10 items
}
impl Default for SyncConfig {