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(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "lowercase")] pub enum PlaybackMode { Local, Remote { session_id: String }, Idle, } /// 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) } /// 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) -> 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).await; // Clear transferring flag self.is_transferring.store(false, Ordering::Relaxed); result } async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> { log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED"); debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id); // Get current player state and queue context let (queue_ids, current_index, 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 state = player.state(); 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)?; let position = state.position().unwrap_or(0.0); let context = queue.context().clone(); log::info!( "[PlaybackMode] Queue context: {:?}, {} items, current index: {}", context, ids.len(), adjusted_index ); debug!( "[PlaybackMode] Extracted {} jellyfin IDs, adjusted_index={}, position={:.2}s", ids.len(), adjusted_index, position ); (ids, adjusted_index, position, context) }; // If queue is empty, just switch mode if queue_ids.is_empty() { log::info!("[PlaybackMode] Queue is empty, just switching mode"); self.set_mode(PlaybackMode::Remote { session_id: session_id.to_string(), }); return Ok(()); } log::info!( "[PlaybackMode] Queue has {} items, current index: {}, position: {:.2}s", queue_ids.len(), current_index, position_seconds ); // Get Jellyfin client for remote transfer log::info!("[PlaybackMode] Getting Jellyfin client for transfer..."); debug!("[PlaybackMode] Getting Jellyfin client for transfer..."); let client = { let client_opt = self .jellyfin_client .lock() .map_err(|e| { log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e); error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e); format!("Failed to lock Jellyfin client: {}", e) })?; match client_opt.as_ref() { Some(c) => { log::info!("[PlaybackMode] Jellyfin client is configured"); debug!("[PlaybackMode] Jellyfin client is configured"); c.clone() } None => { log::error!("[PlaybackMode] Jellyfin client NOT configured!"); error!("[PlaybackMode] Jellyfin client NOT configured!"); return Err("Jellyfin client not configured".to_string()); } } }; // Calculate position in ticks let start_position_ticks = if position_seconds > 0.5 { Some((position_seconds * 10_000_000.0) as i64) } else { None }; // Log queue context for debugging (context is tracked but we always send track IDs) match &queue_context { QueueContext::Album { album_id, album_name } => { log::info!( "[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote", album_name, album_id, queue_ids.len() ); } QueueContext::Playlist { playlist_id, playlist_name } => { log::info!( "[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote", playlist_name, playlist_id, queue_ids.len() ); } QueueContext::Custom => { log::info!( "[PlaybackMode] Transferring custom queue with {} tracks to remote", queue_ids.len() ); } } // Always send individual track IDs - Jellyfin's play_on_session expects track IDs, // not album/playlist container IDs let expected_item_id = queue_ids .get(current_index) .cloned() .ok_or("Invalid start index")?; // Send play command to remote session with all track IDs log::info!( "[PlaybackMode] Sending play command to remote session: {} ({} tracks, starting at index {}, position: {:.2}s)", session_id, queue_ids.len(), current_index, position_seconds ); debug!( "[PlaybackMode] Calling play_on_session: session={}, tracks={}, index={}, position_ticks={:?}", session_id, queue_ids.len(), current_index, start_position_ticks ); // Log first few track IDs for debugging if queue_ids.len() > 0 { let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect(); debug!("[PlaybackMode] First track IDs: {:?}...", preview); } client .play_on_session( session_id.to_string(), queue_ids.clone(), current_index, start_position_ticks, ) .await .map_err(|e| { log::error!("[PlaybackMode] Failed to send play command: {}", e); error!("[PlaybackMode] Failed to send play command: {}", e); format!("Failed to start playback on remote session: {}", e) })?; log::info!("[PlaybackMode] Play command sent successfully"); info!("[PlaybackMode] Play command sent successfully to remote session"); // Wait for remote session to load the track (poll with timeout) log::info!("[PlaybackMode] Waiting for remote session to load track..."); let mut attempts = 0; let max_attempts = 50; // 5 seconds max (50 * 100ms) let mut track_loaded = false; while attempts < max_attempts { sleep(Duration::from_millis(100)).await; attempts += 1; match client.get_session(session_id).await { Ok(Some(session)) => { if let Some(now_playing) = &session.now_playing_item { if now_playing.id.as_deref() == Some(&expected_item_id) { log::info!( "[PlaybackMode] Remote session loaded track '{}' after {}ms", now_playing.name.as_deref().unwrap_or("Unknown"), attempts * 100 ); track_loaded = true; break; } } } Ok(None) => { log::warn!("[PlaybackMode] Remote session not found while polling"); return Err("Remote session not found".to_string()); } Err(e) => { log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e); // Continue polling - transient errors are OK } } } if !track_loaded { log::error!("[PlaybackMode] Timeout waiting for remote session to load track"); return Err("Remote session did not load track in time".to_string()); } // Stop local playback (queue should remain intact for remote session) log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared"); { let player = self.player_controller.lock().await; // Log queue state BEFORE stop { let queue_arc = player.queue(); let queue = queue_arc.lock_safe(); info!( "[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}", queue.items().len(), queue.current_index() ); } player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?; // Log queue state AFTER stop (should be unchanged) { let queue_arc = player.queue(); let queue = queue_arc.lock_safe(); info!( "[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}", queue.items().len(), queue.current_index() ); } } // Update mode to remote self.set_mode(PlaybackMode::Remote { session_id: session_id.to_string(), }); // Enable remote volume control on Android (intercepts volume buttons) #[cfg(target_os = "android")] { if let Err(e) = crate::player::enable_remote_volume(50) { log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e); // Non-fatal - continue with transfer } } log::info!("[PlaybackMode] Successfully transferred to remote"); Ok(()) } /// Transfer playback from remote session back to local device pub async fn transfer_to_local( &self, current_item_id: String, position_ticks: i64, ) -> Result<(), String> { log::info!("[PlaybackMode] Transferring to local playback"); // Set transferring flag self.is_transferring.store(true, Ordering::Relaxed); // Perform the transfer let result = self .transfer_to_local_inner(¤t_item_id, position_ticks) .await; // Clear transferring flag self.is_transferring.store(false, Ordering::Relaxed); result } async fn transfer_to_local_inner( &self, current_item_id: &str, position_ticks: i64, ) -> Result<(), String> { // Get current remote session info let session_id = match self.get_mode() { PlaybackMode::Remote { session_id } => session_id, _ => return Err("Not in remote playback mode".to_string()), }; let position_seconds = position_ticks as f64 / 10_000_000.0; log::info!( "[PlaybackMode] Transfer to local: session={}, item_id={}, position={:.2}s", session_id, current_item_id, position_seconds ); // Get Jellyfin client for stopping remote playback let client = { let client_opt = self .jellyfin_client .lock() .map_err(|e| format!("Failed to lock Jellyfin client: {}", e))?; client_opt .as_ref() .ok_or("Jellyfin client not configured")? .clone() }; // Stop remote playback log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id); match client.send_session_command(session_id.clone(), "Stop").await { Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"), Err(e) => { log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e); // Don't fail the transfer if we can't stop the remote session // The user is already playing locally, so this is not critical } } // For now, we'll return an error indicating that the TypeScript side needs to handle // loading the media item, since we don't have access to the repository here yet. // This will be improved in Phase 3 when repository is migrated to Rust. log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it"); // Update mode to local self.set_mode(PlaybackMode::Local); // Disable remote volume control on Android (return to system volume) #[cfg(target_os = "android")] { if let Err(e) = crate::player::disable_remote_volume() { log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e); // Non-fatal - continue with transfer } } log::info!("[PlaybackMode] Successfully transferred to local"); Ok(()) } } #[cfg(test)] mod tests { use super::*; /// Test PlaybackMode enum serialization to JSON /// /// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states) /// @req-test: UR-010 - Control playback of Jellyfin remote sessions #[test] fn test_playback_mode_serialization() { let mode_idle = PlaybackMode::Idle; let json = serde_json::to_string(&mode_idle).unwrap(); assert_eq!(json, r#"{"type":"idle"}"#); let mode_local = PlaybackMode::Local; let json = serde_json::to_string(&mode_local).unwrap(); assert_eq!(json, r#"{"type":"local"}"#); let mode_remote = PlaybackMode::Remote { session_id: "abc123".to_string(), }; let json = serde_json::to_string(&mode_remote).unwrap(); assert!(json.contains(r#""type":"remote""#)); assert!(json.contains(r#""session_id":"abc123""#)); } /// Test PlaybackMode enum deserialization from JSON /// /// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states) #[test] fn test_playback_mode_deserialization() { let json = r#"{"type":"idle"}"#; let mode: PlaybackMode = serde_json::from_str(json).unwrap(); assert_eq!(mode, PlaybackMode::Idle); let json = r#"{"type":"local"}"#; let mode: PlaybackMode = serde_json::from_str(json).unwrap(); assert_eq!(mode, PlaybackMode::Local); let json = r#"{"type":"remote","session_id":"test_session"}"#; let mode: PlaybackMode = serde_json::from_str(json).unwrap(); assert_eq!( mode, PlaybackMode::Remote { session_id: "test_session".to_string() } ); } // Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID mod extract_jellyfin_ids_tests { use crate::player::{MediaItem, MediaSource, MediaType}; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem { MediaItem { id: id.to_string(), title: format!("Track {}", id), name: Some(format!("Track {}", id)), artist: Some("Test Artist".to_string()), album: Some("Test Album".to_string()), album_name: Some("Test Album".to_string()), album_id: Some("album_123".to_string()), artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), artwork_url: None, media_type: MediaType::Audio, source: MediaSource::Remote { stream_url: format!("http://example.com/{}.mp3", id), jellyfin_item_id: jellyfin_id.to_string(), }, video_codec: None, needs_transcoding: false, video_width: None, video_height: None, subtitles: vec![], series_id: None, server_id: None, } } fn create_test_item_local(id: &str) -> MediaItem { MediaItem { id: id.to_string(), title: format!("Local Track {}", id), name: Some(format!("Local Track {}", id)), artist: Some("Test Artist".to_string()), album: None, album_name: None, album_id: None, artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), artwork_url: None, media_type: MediaType::Audio, source: MediaSource::DirectUrl { url: format!("http://example.com/{}.mp3", id), }, video_codec: None, needs_transcoding: false, video_width: None, video_height: None, subtitles: vec![], series_id: None, server_id: None, } } /// Test extracting all Jellyfin track IDs from album /// /// Verifies that all individual track IDs are extracted, not just the album ID. /// /// @req-test: UR-010 - Control playback of Jellyfin remote sessions /// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction) /// @req-test: IR-012 - Jellyfin Sessions API for remote playback control #[test] fn test_extract_all_jellyfin_ids_from_album() { // Simulate an album with 5 tracks - all should be extracted let items: Vec = (1..=5) .map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i))) .collect(); let manager = super::PlaybackModeManager::new( Arc::new(Mutex::new(None)), Arc::new(TokioMutex::new(crate::player::PlayerController::default())), ); let result = manager.extract_jellyfin_ids(&items, 2); assert!(result.is_ok()); let (ids, index) = result.unwrap(); // All 5 track IDs should be extracted (not just the album ID) assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted"); assert_eq!(ids[0], "jf_track_1"); assert_eq!(ids[1], "jf_track_2"); assert_eq!(ids[2], "jf_track_3"); assert_eq!(ids[3], "jf_track_4"); assert_eq!(ids[4], "jf_track_5"); // Index should point to track 3 (original index 2) assert_eq!(index, 2, "Current index should be preserved"); } /// Test extracting Jellyfin IDs filters out local items /// /// @req-test: UR-010 - Control playback of remote sessions (local filtering) /// @req-test: DR-003 - Playback mode manager (local item filtering) #[test] fn test_extract_filters_local_items() { // Mix of Jellyfin and local items - only Jellyfin items should be extracted let items = vec![ create_test_item_with_jellyfin_id("1", "jf_1"), create_test_item_local("2"), // Local, no Jellyfin ID create_test_item_with_jellyfin_id("3", "jf_3"), create_test_item_with_jellyfin_id("4", "jf_4"), ]; let manager = super::PlaybackModeManager::new( Arc::new(Mutex::new(None)), Arc::new(TokioMutex::new(crate::player::PlayerController::default())), ); // Playing track 3 (index 2 in original, should become index 1 after filtering) let result = manager.extract_jellyfin_ids(&items, 2); assert!(result.is_ok()); let (ids, index) = result.unwrap(); // Only 3 Jellyfin tracks should be extracted assert_eq!(ids.len(), 3); assert_eq!(ids[0], "jf_1"); assert_eq!(ids[1], "jf_3"); assert_eq!(ids[2], "jf_4"); // Index should be adjusted (track 3 is now at position 1) assert_eq!(index, 1); } /// Test extraction fails when current item is local /// /// @req-test: DR-003 - Playback mode manager (error handling) /// @req-test: UR-010 - Control playback of remote sessions (validation) #[test] fn test_extract_fails_when_current_item_is_local() { // Current item has no Jellyfin ID - should fail let items = vec![ create_test_item_with_jellyfin_id("1", "jf_1"), create_test_item_local("2"), // Local, no Jellyfin ID create_test_item_with_jellyfin_id("3", "jf_3"), ]; let manager = super::PlaybackModeManager::new( Arc::new(Mutex::new(None)), Arc::new(TokioMutex::new(crate::player::PlayerController::default())), ); // Playing the local track (index 1) should fail let result = manager.extract_jellyfin_ids(&items, 1); assert!(result.is_err()); assert!(result.unwrap_err().contains("not from Jellyfin")); } /// Test extraction fails on empty queue /// /// @req-test: DR-003 - Playback mode manager (edge case: empty queue) #[test] fn test_extract_empty_queue() { let items: Vec = vec![]; let manager = super::PlaybackModeManager::new( Arc::new(Mutex::new(None)), Arc::new(TokioMutex::new(crate::player::PlayerController::default())), ); let result = manager.extract_jellyfin_ids(&items, 0); assert!(result.is_err()); } } }