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
+35 -24
View File
@@ -7,14 +7,14 @@
use crate::utils::lock::{MutexSafe, RwLockSafe};
use log::{debug, info, warn};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::Duration;
use crate::jellyfin::JellyfinClient;
use crate::player::PlayerEventEmitter;
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
use crate::player::PlayerEventEmitter;
/// Hint for adjusting poll frequency based on UI state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -103,10 +103,8 @@ impl SessionPollerManager {
while is_running.load(Ordering::Relaxed) {
// Calculate poll interval based on mode and hint
let new_interval = Self::calculate_interval(
&mode_manager.get_mode(),
*hint.read_safe(),
);
let new_interval =
Self::calculate_interval(&mode_manager.get_mode(), *hint.read_safe());
interval_ms.store(new_interval, Ordering::Relaxed);
@@ -158,9 +156,7 @@ impl SessionPollerManager {
}
if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions,
});
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { sessions });
}
}
Err(e) => {
@@ -201,10 +197,7 @@ impl SessionPollerManager {
/// album, duration and position to the media notification. Silently does
/// nothing if the session isn't found or has no now-playing item (e.g. the
/// remote stopped) - the next state change will refresh it.
fn push_remote_lockscreen(
sessions: &[crate::jellyfin::client::SessionInfo],
session_id: &str,
) {
fn push_remote_lockscreen(sessions: &[crate::jellyfin::client::SessionInfo], session_id: &str) {
// 100ns Jellyfin ticks -> milliseconds.
const TICKS_PER_MS: i64 = 10_000;
@@ -251,7 +244,10 @@ impl SessionPollerManager {
};
if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
warn!("[SessionPoller] Failed to update lockscreen metadata: {}", e);
warn!(
"[SessionPoller] Failed to update lockscreen metadata: {}",
e
);
}
}
@@ -262,7 +258,7 @@ impl SessionPollerManager {
PollingHint::CastDiscovery => 15000, // Slow discovery
PollingHint::Normal => {
match mode {
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
}
}
@@ -271,7 +267,10 @@ impl SessionPollerManager {
/// Manually trigger a poll (for frontend refresh button)
pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
let client = self.jellyfin_client.lock_safe().clone()
let client = self
.jellyfin_client
.lock_safe()
.clone()
.ok_or("Jellyfin client not configured")?;
client.get_sessions().await
@@ -302,7 +301,9 @@ mod tests {
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::CastActive
),
500
@@ -310,16 +311,24 @@ mod tests {
// CastDiscovery hint should always be 15s regardless of mode
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastDiscovery),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastDiscovery),
SessionPollerManager::calculate_interval(
&PlaybackMode::Idle,
PollingHint::CastDiscovery
),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
&PlaybackMode::Local,
PollingHint::CastDiscovery
),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::CastDiscovery
),
15000
@@ -338,7 +347,9 @@ mod tests {
// Remote mode -> 2s
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::Normal
),
2000