//! 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::{Arc, Mutex, RwLock}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; use std::time::Duration; use crate::jellyfin::JellyfinClient; use crate::player::PlayerEventEmitter; use crate::playback_mode::{PlaybackMode, PlaybackModeManager}; /// 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>>, playback_mode_manager: Arc, event_emitter: Arc>>>, // Polling state is_running: Arc, current_hint: Arc>, current_interval_ms: Arc, // Thread handle (for cleanup) thread_handle: Arc>>>, } impl SessionPollerManager { /// Create a new SessionPollerManager pub fn new( jellyfin_client: Arc>>, playback_mode_manager: Arc, ) -> Self { Self { jellyfin_client, playback_mode_manager, event_emitter: 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) { *self.event_emitter.lock_safe() = Some(emitter); } /// 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 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 let sessions_result = rt.block_on(async { let client_opt = client.lock_safe().clone(); match client_opt { Some(c) => c.get_sessions().await, None => { debug!("[SessionPoller] Jellyfin client not configured, skipping poll"); Ok(Vec::new()) } } }); // 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, 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); } }