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, 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; /// 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 } } /// Manages playback mode transfers between local and remote sessions pub struct PlaybackModeManager { jellyfin_client: Arc>>, player_controller: Arc>, current_mode: Arc>, is_transferring: 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)), } } /// Get current playback mode pub fn get_mode(&self) -> PlaybackMode { self.current_mode.read_safe().clone() } /// Set playback mode (internal use) pub fn set_mode(&self, mode: PlaybackMode) { log::info!("[PlaybackMode] Setting mode to: {:?}", mode); let mut current = self.current_mode.write_safe(); *current = mode; } /// 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