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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user