Duncan Tourolle 385d2270c9 fix(android): keep lockscreen/media controls in sync with playback
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>
2026-06-27 23:55:26 +02:00

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);
}
}