use crate::utils::lock::{MutexSafe, RwLockSafe}; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, RwLock, }; use tokio::sync::Mutex as TokioMutex; use tokio::time::{sleep, Duration}; use crate::jellyfin::JellyfinClient; use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext}; /// Playback mode - local device, remote session, or idle #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "lowercase")] pub enum PlaybackMode { Local, Remote { session_id: String }, Idle, } /// Number of Jellyfin ticks per second (100ns units). const TICKS_PER_SECOND: f64 = 10_000_000.0; /// Below this many seconds we treat the position as "at the start" and don't /// send a resume position, so a fresh track casts from 0 rather than ~0. const RESUME_THRESHOLD_SECONDS: f64 = 0.5; /// Volume level (0-100) the remote volume slider starts at. The real level is /// corrected by the session poller once the remote session reports its volume. const DEFAULT_REMOTE_VOLUME: i32 = 50; /// Convert a live playback position (seconds) into the `StartPositionTicks` to /// hand to a remote session, or `None` if we're effectively at the start. /// /// Pure helper so the resume-position math is unit-testable without a remote /// session or HTTP. The *source* of `position_seconds` matters too: callers /// must pass the live backend position (`PlayerController::position()`), not the /// snapshot embedded in `PlayerState`, which is stale mid-track on Android. fn start_position_ticks_from_seconds(position_seconds: f64) -> Option { if position_seconds > RESUME_THRESHOLD_SECONDS { Some((position_seconds * TICKS_PER_SECOND) as i64) } else { None } } /// Platform hook for attaching/detaching the OS remote-volume control. /// /// On Android, entering remote mode hands the `MediaSession` a /// `VolumeProviderCompat` so hardware volume buttons and the system slider drive /// the *remote* session; leaving remote mode must hand it back to the local /// media stream. Behind a trait so the routing rule (see /// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real /// implementation is JNI and only exists on Android. pub trait RemoteVolumeControl: Send + Sync { /// Attach remote-volume control (and, on Android, start the playback service). fn enable(&self, initial_volume: i32); /// Return volume control to the local device speaker. fn disable(&self); } /// Production hook: forwards to the Android JNI bridge; no-op elsewhere. struct PlatformRemoteVolumeControl; impl RemoteVolumeControl for PlatformRemoteVolumeControl { #[allow(unused_variables)] fn enable(&self, initial_volume: i32) { #[cfg(target_os = "android")] { if let Err(e) = crate::player::enable_remote_volume(initial_volume) { log::warn!( "[PlaybackMode] Failed to enable remote volume/service: {}", e ); // Non-fatal - continue; the next poll tick will retry metadata. } } } fn disable(&self) { #[cfg(target_os = "android")] { if let Err(e) = crate::player::disable_remote_volume() { log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e); // Non-fatal - the mode change itself has already happened. } } } } /// Manages playback mode transfers between local and remote sessions pub struct PlaybackModeManager { jellyfin_client: Arc>>, player_controller: Arc>, current_mode: Arc>, is_transferring: Arc, /// Optional emitter used to notify the frontend when the mode changes, so its /// mirror store stays in sync with this authoritative one. `None` in tests. event_emitter: Arc>>>, /// Platform hook for OS-level remote volume routing (swapped in tests). remote_volume: Arc, } impl PlaybackModeManager { /// Create a new playback mode manager pub fn new( jellyfin_client: Arc>>, player_controller: Arc>, ) -> Self { Self { jellyfin_client, player_controller, current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)), is_transferring: Arc::new(AtomicBool::new(false)), event_emitter: Arc::new(Mutex::new(None)), remote_volume: Arc::new(PlatformRemoteVolumeControl), } } /// Construct with a custom remote-volume hook (tests). #[cfg(test)] fn with_remote_volume( jellyfin_client: Arc>>, player_controller: Arc>, remote_volume: Arc, ) -> Self { Self { jellyfin_client, player_controller, current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)), is_transferring: Arc::new(AtomicBool::new(false)), event_emitter: Arc::new(Mutex::new(None)), remote_volume, } } /// Wire the event emitter so `set_mode` notifies the frontend. Called once /// during setup; safe to leave unset (tests do), in which case mode changes /// simply aren't broadcast. pub fn set_event_emitter(&self, emitter: Arc) { *self.event_emitter.lock_safe() = Some(emitter); } /// Get current playback mode pub fn get_mode(&self) -> PlaybackMode { self.current_mode.read_safe().clone() } /// Set playback mode (internal use). /// /// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so /// the frontend's mirror store reconciles to this authoritative value. The /// write lock is released before emitting to avoid holding it across the /// emitter call. /// /// Also owns **OS volume routing**, which is derived from the transition /// rather than from each call site: entering remote mode attaches the remote /// volume control, and *any* exit from remote mode hands it back to the local /// speaker. Doing this per-call-site is what caused the bug where stopping a /// remote session (`player_stop` → Idle) left Android stuck on the remote /// volume slider — only the transfer-to-local path tore it down. /// /// TRACES: UR-010 | DR-059, IR-021 pub fn set_mode(&self, mode: PlaybackMode) { log::info!("[PlaybackMode] Setting mode to: {:?}", mode); let (changed, was_remote) = { let mut current = self.current_mode.write_safe(); let changed = *current != mode; let was_remote = matches!(*current, PlaybackMode::Remote { .. }); *current = mode.clone(); (changed, was_remote) }; if !changed { return; } // Volume routing follows the transition. Note remote->remote (switching // target session) re-arms rather than releasing control. let is_remote = matches!(mode, PlaybackMode::Remote { .. }); if is_remote { self.remote_volume.enable(DEFAULT_REMOTE_VOLUME); } else if was_remote { log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control"); self.remote_volume.disable(); } let (mode_str, session_id) = match &mode { PlaybackMode::Local => ("local".to_string(), None), PlaybackMode::Idle => ("idle".to_string(), None), PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())), }; if let Some(emitter) = self.event_emitter.lock_safe().as_ref() { emitter.emit(PlayerStatusEvent::PlaybackModeChanged { mode: mode_str, session_id, }); } } /// Start the Android playback service and hand it remote-volume control. /// /// Must run on EVERY transition into remote mode, because it is what starts /// the foreground service. Without a running service there is no media /// notification (the lockscreen card is missing) AND system volume buttons /// aren't intercepted for the remote session (remote volume control dead). /// Both symptoms share this one cause, so this must not be skipped on any /// remote-entry path (notably the empty-queue early return in /// `transfer_to_remote_inner`). No-op / non-Android builds do nothing. /// /// [`set_mode`](Self::set_mode) already arms this on entry into remote mode; /// calling it again is harmless (the service start is idempotent) and keeps /// the guarantee when the mode was already remote, which `set_mode` skips as /// a no-op transition. fn enable_remote_control(&self) { self.remote_volume.enable(DEFAULT_REMOTE_VOLUME); } /// Check if currently transferring pub fn is_transferring(&self) -> bool { self.is_transferring.load(Ordering::Relaxed) } /// Set the transferring flag directly. /// /// The remote->local transfer is driven from the frontend in two steps /// (`player_play_tracks` to start local playback, then /// `playback_mode_transfer_to_local` to stop the remote). The first step's /// routing depends on this flag: while it's set, `player_play_tracks` plays /// locally instead of casting back to the remote session. The frontend must /// raise the flag *before* that first call and lower it when the sequence is /// done (or aborts), so it can't be left stuck on. pub fn set_transferring(&self, transferring: bool) { self.is_transferring.store(transferring, Ordering::Relaxed); } /// Send volume command to remote session /// Commands: "SetVolume", "VolumeUp", "VolumeDown" #[allow(dead_code)] // Called from Android JNI callback pub async fn send_remote_volume_command( &self, command: &str, volume: i32, ) -> Result<(), String> { log::info!( "[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume ); // Get the current session ID let session_id = match self.get_mode() { PlaybackMode::Remote { session_id } => session_id, _ => { log::warn!("[PlaybackMode] Ignoring remote volume command - not in remote mode"); return Ok(()); } }; log::info!( "[PlaybackMode] Current mode is Remote, session_id={}", session_id ); // Get Jellyfin client let client = { log::info!("[PlaybackMode] Attempting to lock Jellyfin client..."); let client_opt = self.jellyfin_client.lock().map_err(|e| { log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e); format!("Failed to lock Jellyfin client: {}", e) })?; log::info!("[PlaybackMode] Jellyfin client lock acquired"); match client_opt.as_ref() { Some(c) => { log::info!("[PlaybackMode] Jellyfin client is configured, cloning..."); c.clone() } None => { log::error!("[PlaybackMode] Jellyfin client is NOT configured!"); return Err("Jellyfin client not configured".to_string()); } } }; log::info!("[PlaybackMode] About to call client.session_set_volume..."); // Send the volume command log::info!( "[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume ); let result = client.session_set_volume(session_id, volume).await; match &result { Ok(_) => log::info!("[PlaybackMode] session_set_volume returned Ok"), Err(e) => log::error!("[PlaybackMode] session_set_volume returned Err: {}", e), } result } /// Extract Jellyfin item IDs from queue items /// Returns (item_ids, adjusted_current_index) fn extract_jellyfin_ids( &self, items: &[crate::player::MediaItem], original_index: usize, ) -> Result<(Vec, usize), String> { let mut jellyfin_ids: Vec = Vec::new(); let mut adjusted_index: Option = None; let mut jellyfin_item_count = 0; for (i, item) in items.iter().enumerate() { if let Some(id) = item.jellyfin_id() { jellyfin_ids.push(id.to_string()); // If this is the currently playing item, record its new index if i == original_index { adjusted_index = Some(jellyfin_item_count); } jellyfin_item_count += 1; } } // Ensure the currently playing item has a Jellyfin ID let final_index = match adjusted_index { Some(idx) => idx, None => { log::warn!( "[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID", original_index ); return Err( "Cannot transfer: currently playing item is not from Jellyfin".to_string(), ); } }; log::info!( "[PlaybackMode] Extracted {} Jellyfin IDs from queue (original index: {} -> adjusted: {})", jellyfin_ids.len(), original_index, final_index ); Ok((jellyfin_ids, final_index)) } /// Transfer playback from local device to remote Jellyfin session pub async fn transfer_to_remote( &self, session_id: String, position_override: Option, ) -> Result<(), String> { debug!("[PlaybackMode] transfer_to_remote ENTERED"); debug!("[PlaybackMode] session_id: {}", session_id); log::info!( "[PlaybackMode] Transferring to remote session: {}", session_id ); // Set transferring flag debug!("[PlaybackMode] Setting is_transferring flag"); self.is_transferring.store(true, Ordering::Relaxed); debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner"); // Perform the transfer let result = self .transfer_to_remote_inner(&session_id, position_override) .await; // Clear transferring flag self.is_transferring.store(false, Ordering::Relaxed); result } async fn transfer_to_remote_inner( &self, session_id: &str, position_override: Option, ) -> Result<(), String> { log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED"); debug!( "[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id ); // If we're already controlling a remote session, that *old* session — not // the idle local player — is the source of truth for the current track and // position. Capture it so we can resume there and stop it afterwards. let previous_remote_session = match self.get_mode() { PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev), _ => None, }; // Get current player state and queue context let (queue_ids, mut current_index, mut position_seconds, queue_context) = { log::info!("[PlaybackMode] Acquiring player controller lock..."); debug!("[PlaybackMode] Acquiring player controller lock..."); let player = self.player_controller.lock().await; log::info!("[PlaybackMode] Player controller lock acquired"); debug!("[PlaybackMode] Player controller lock acquired"); let queue_arc = player.queue(); let queue = queue_arc.lock_safe(); let original_index = queue.current_index().unwrap_or(0); let items = queue.items(); log::info!( "[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index ); debug!( "[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index ); // Log each item's jellyfin_id for debugging for (i, item) in items.iter().enumerate() { let jf_id = item.jellyfin_id().unwrap_or("NONE"); log::debug!( "[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id ); } let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; // Prefer the frontend-supplied position when available. The backend // position is unreliable as a transfer source: on Linux, *video* plays // in the HTML5