Files
jellytau/src-tauri/src/session_poller/mod.rs
T
dtourolle 3fbf6afdbc 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.
2026-07-22 21:52:07 +02:00

370 lines
14 KiB
Rust

//! Session polling manager for remote playback control.
//!
//! Manages background polling of Jellyfin sessions with dynamic frequency adjustment
//! based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.
//!
//! TRACES: UR-010 | JA-021
use crate::utils::lock::{MutexSafe, RwLockSafe};
use log::{debug, info, warn};
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::playback_mode::{PlaybackMode, PlaybackModeManager};
use crate::player::PlayerEventEmitter;
/// Hint for adjusting poll frequency based on UI state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PollingHint {
/// CastButton is active and needs frequent updates (500ms)
CastActive,
/// CastButton is in discovery mode (15s)
CastDiscovery,
/// No special hint, use mode-based frequency (default)
Normal,
}
/// Manages background polling of Jellyfin sessions
pub struct SessionPollerManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
playback_mode_manager: Arc<PlaybackModeManager>,
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
/// Optional connectivity reporter. The session poller is the one piece of
/// server traffic that runs continuously even when the user is idle (not
/// browsing the library), so feeding its poll outcomes into the reporter is
/// what lets the app detect going offline — and, crucially, recover when the
/// server returns — without any user interaction. Repository traffic alone
/// can't do this because it only happens while browsing.
connectivity_reporter: Arc<Mutex<Option<crate::connectivity::ConnectivityReporter>>>,
// Polling state
is_running: Arc<AtomicBool>,
current_hint: Arc<RwLock<PollingHint>>,
current_interval_ms: Arc<AtomicU64>,
// Thread handle (for cleanup)
thread_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
}
impl SessionPollerManager {
/// Create a new SessionPollerManager
pub fn new(
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
playback_mode_manager: Arc<PlaybackModeManager>,
) -> Self {
Self {
jellyfin_client,
playback_mode_manager,
event_emitter: Arc::new(Mutex::new(None)),
connectivity_reporter: Arc::new(Mutex::new(None)),
is_running: Arc::new(AtomicBool::new(false)),
current_hint: Arc::new(RwLock::new(PollingHint::Normal)),
current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s
thread_handle: Arc::new(Mutex::new(None)),
}
}
/// Set event emitter for broadcasting session updates
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
*self.event_emitter.lock_safe() = Some(emitter);
}
/// Wire the connectivity reporter so each poll outcome updates reachability.
/// A successful poll recovers the app to online instantly; sustained poll
/// failures flip it offline (subject to the reporter's debounce window).
pub fn set_connectivity_reporter(&self, reporter: crate::connectivity::ConnectivityReporter) {
*self.connectivity_reporter.lock_safe() = Some(reporter);
}
/// Start the background polling thread
pub fn start(&self) {
if self.is_running.swap(true, Ordering::Relaxed) {
warn!("[SessionPoller] Already running, ignoring start request");
return;
}
info!("[SessionPoller] Starting session polling");
// Clone Arc references for the thread
let client = self.jellyfin_client.clone();
let mode_manager = self.playback_mode_manager.clone();
let emitter = self.event_emitter.clone();
let connectivity_reporter = self.connectivity_reporter.clone();
let is_running = self.is_running.clone();
let hint = self.current_hint.clone();
let interval_ms = self.current_interval_ms.clone();
let handle = thread::spawn(move || {
// Create Tokio runtime for async operations in this thread
let rt = tokio::runtime::Runtime::new().unwrap();
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());
interval_ms.store(new_interval, Ordering::Relaxed);
debug!("[SessionPoller] Polling with interval: {}ms", new_interval);
// Fetch sessions. `had_client` distinguishes "server didn't
// answer" from "no client configured" so we only feed real
// request outcomes into the connectivity reporter.
let (sessions_result, had_client) = rt.block_on(async {
let client_opt = client.lock_safe().clone();
match client_opt {
Some(c) => (c.get_sessions().await, true),
None => {
debug!("[SessionPoller] Jellyfin client not configured, skipping poll");
(Ok(Vec::new()), false)
}
}
});
// Drive the connectivity reporter from this poll's outcome. This
// is what recovers the app to online when the server returns
// while the user is idle, and detects going offline when no
// library browsing is happening. See connectivity/mod.rs.
if had_client {
if let Some(reporter) = connectivity_reporter.lock_safe().clone() {
rt.block_on(async {
match &sessions_result {
Ok(_) => reporter.report_success().await,
Err(e) => reporter.report_network_failure(Some(e.clone())).await,
}
});
}
}
// Emit event if successful
match sessions_result {
Ok(sessions) => {
debug!("[SessionPoller] Fetched {} sessions", sessions.len());
// In remote (cast) mode, mirror the remote session's
// now-playing onto the Android lockscreen. The local
// ExoPlayer is idle while casting, so without this the
// lockscreen shows stale local metadata and a frozen
// scrubber. Driving it here (native poll thread) rather
// than from the WebView keeps it live even when the
// screen is locked and JS timers are throttled.
if let PlaybackMode::Remote { session_id } = mode_manager.get_mode() {
Self::push_remote_lockscreen(&sessions, &session_id);
}
if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { sessions });
}
}
Err(e) => {
warn!("[SessionPoller] Failed to fetch sessions: {}", e);
}
}
// Sleep for the calculated interval
thread::sleep(Duration::from_millis(new_interval));
}
info!("[SessionPoller] Polling thread stopped");
});
*self.thread_handle.lock_safe() = Some(handle);
}
/// Stop the polling thread
pub fn stop(&self) {
info!("[SessionPoller] Stopping session polling");
self.is_running.store(false, Ordering::Relaxed);
// Join the thread if possible (don't block indefinitely)
if let Some(handle) = self.thread_handle.lock_safe().take() {
let _ = handle.join();
}
}
/// Set UI hint for polling frequency adjustment
pub fn set_polling_hint(&self, hint: PollingHint) {
debug!("[SessionPoller] Setting polling hint: {:?}", hint);
*self.current_hint.write_safe() = hint;
}
/// Push the remote session's now-playing onto the Android lockscreen.
///
/// Looks up the active remote session by id and forwards its title/artist/
/// 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) {
// 100ns Jellyfin ticks -> milliseconds.
const TICKS_PER_MS: i64 = 10_000;
let Some(session) = sessions
.iter()
.find(|s| s.id.as_deref() == Some(session_id))
else {
return;
};
let Some(now_playing) = session.now_playing_item.as_ref() else {
return;
};
let title = now_playing.name.clone().unwrap_or_default();
let artist = now_playing
.artists
.as_ref()
.map(|a| a.join(", "))
.filter(|s| !s.is_empty())
.or_else(|| now_playing.album_artist.clone())
.unwrap_or_default();
let album = now_playing.album.clone();
let duration_ms = now_playing.run_time_ticks.unwrap_or(0) / TICKS_PER_MS;
let (position_ms, is_playing) = session
.play_state
.as_ref()
.map(|ps| {
(
ps.position_ticks.unwrap_or(0) / TICKS_PER_MS,
!ps.is_paused.unwrap_or(false),
)
})
.unwrap_or((0, false));
let meta = crate::player::LockscreenMetadata {
title,
artist,
album,
duration_ms,
position_ms,
is_playing,
};
if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
warn!(
"[SessionPoller] Failed to update lockscreen metadata: {}",
e
);
}
}
/// Calculate polling interval based on mode and hint
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
match hint {
PollingHint::CastActive => 500, // Very fast for active control
PollingHint::CastDiscovery => 15000, // Slow discovery
PollingHint::Normal => {
match mode {
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
}
}
}
}
/// 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()
.ok_or("Jellyfin client not configured")?;
client.get_sessions().await
}
}
impl Drop for SessionPollerManager {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test polling interval calculation for different modes and hints
#[test]
fn test_calculate_interval() {
// CastActive hint should always be 500ms regardless of mode
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastActive),
500
);
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastActive),
500
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::CastActive
),
500
);
// 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
),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::CastDiscovery
),
15000
);
// Normal hint should depend on mode
// Idle and Local modes -> 10s
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::Normal),
10000
);
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::Normal),
10000
);
// Remote mode -> 2s
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::Normal
),
2000
);
}
/// Test PollingHint enum equality
#[test]
fn test_polling_hint_equality() {
assert_eq!(PollingHint::Normal, PollingHint::Normal);
assert_eq!(PollingHint::CastActive, PollingHint::CastActive);
assert_eq!(PollingHint::CastDiscovery, PollingHint::CastDiscovery);
assert_ne!(PollingHint::Normal, PollingHint::CastActive);
assert_ne!(PollingHint::CastActive, PollingHint::CastDiscovery);
}
}