The lockscreen controls drifted out of sync, especially while casting, and couldn't control remote playback. Two media sessions were competing (a Media3 MediaSession driving transport vs a MediaSessionCompat driving the notification), position was only pushed on play/pause so the scrubber froze mid-track, and remote mode showed stale local metadata with dead buttons. - Make MediaSessionCompat the single source of truth; route all transport commands (both the Compat callback and the Media3 wrappedPlayer) through Rust via nativeOnMediaCommand instead of touching ExoPlayer directly. - Push position on every 250ms tick via a lightweight updatePlaybackPosition, and report 0.0 playback speed when paused so Android stops extrapolating. - Mirror the remote session's now-playing onto the lockscreen from the native session poller (works while the screen is locked, unlike WebView timers) via a new player::update_lockscreen_metadata JNI bridge. - Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/ prev/seek to the remote Jellyfin session; Stop while casting emits RemoteDisconnectRequested, which the frontend handles by transferring to local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
326 lines
12 KiB
Rust
326 lines
12 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::{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<Mutex<Option<JellyfinClient>>>,
|
|
playback_mode_manager: Arc<PlaybackModeManager>,
|
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
|
|
|
// 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)),
|
|
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);
|
|
}
|
|
|
|
/// 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<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);
|
|
}
|
|
}
|