diff --git a/src-tauri/src/commands/playback_mode.rs b/src-tauri/src/commands/playback_mode.rs index 63d0208..3988ca1 100644 --- a/src-tauri/src/commands/playback_mode.rs +++ b/src-tauri/src/commands/playback_mode.rs @@ -41,12 +41,14 @@ pub fn playback_mode_is_transferring( pub async fn playback_mode_transfer_to_remote( manager: State<'_, PlaybackModeManagerWrapper>, session_id: String, + position: Option, ) -> Result<(), String> { log::info!( - "[PlaybackModeCommands] Transferring to remote session: {}", - session_id + "[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})", + session_id, + position ); - manager.0.transfer_to_remote(session_id).await + manager.0.transfer_to_remote(session_id, position).await } /// Transfer playback from remote session back to local device diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 31bf6e0..a17b7a5 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -1249,6 +1249,59 @@ pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus { } } +/// Start a freshly-built queue on the active remote session. +/// +/// Used by the "play tracks"/"play album track" commands when we're in remote +/// mode: instead of starting local MPV playback, we cast the selected tracks to +/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's +/// play_on_session call, but for a brand-new selection (so there's no resume +/// position - playback starts from the chosen track's beginning). +/// +/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and +/// the start index is adjusted to the remaining Jellyfin items. Returns an error +/// if the selected track itself has no Jellyfin ID. +async fn play_selection_on_remote( + controller: &PlayerController, + session_id: &str, + media_items: &[MediaItem], + start_index: usize, +) -> Result<(), String> { + // Collect Jellyfin IDs, tracking where the selected track lands after any + // local-only items are dropped. + let mut jellyfin_ids: Vec = Vec::new(); + let mut adjusted_index: Option = None; + for (i, item) in media_items.iter().enumerate() { + if let Some(id) = item.jellyfin_id() { + if i == start_index { + adjusted_index = Some(jellyfin_ids.len()); + } + jellyfin_ids.push(id.to_string()); + } + } + + let start_index = adjusted_index + .ok_or("Cannot play on remote: selected track is not from Jellyfin")?; + + if jellyfin_ids.is_empty() { + return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string()); + } + + let client = { + let client_arc = controller.jellyfin_client(); + let client_opt = client_arc.lock().map_err(|e| e.to_string())?; + client_opt + .as_ref() + .ok_or("Jellyfin client not configured")? + .clone() + }; + + // Fresh selection: start from the beginning of the chosen track. + client + .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None) + .await + .map_err(|e| format!("Failed to start playback on remote session: {}", e)) +} + /// Play a track from an album - backend fetches all album tracks and builds queue #[tauri::command] @@ -1258,6 +1311,7 @@ pub async fn player_play_album_track( session: State<'_, MediaSessionManagerWrapper>, db: State<'_, DatabaseWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, + playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, repository_handle: String, request: PlayAlbumTrackRequest, ) -> Result { @@ -1380,11 +1434,24 @@ pub async fn player_play_album_track( session_mgr.start_audio_session(first_item.clone()); } - // Play the queue let controller = player.0.lock().await; - controller - .play_queue(media_items, start_index) - .map_err(|e| e.to_string())?; + + // When controlling a remote session, cast the selection there instead of + // starting local MPV playback. We still load the queue locally (below) so + // the queue/context stay in sync for the UI and for transferring back. + let remote_session = match playback_mode.0.get_mode() { + crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id), + _ => None, + }; + + if let Some(session_id) = &remote_session { + play_selection_on_remote(&controller, session_id, &media_items, start_index).await?; + controller.set_queue(media_items, start_index).map_err(|e| e.to_string())?; + } else { + controller + .play_queue(media_items, start_index) + .map_err(|e| e.to_string())?; + } // Set the queue context for remote transfer { @@ -1426,6 +1493,7 @@ pub async fn player_play_tracks( session: State<'_, MediaSessionManagerWrapper>, db: State<'_, DatabaseWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, + playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, repository_handle: String, request: PlayTracksRequest, ) -> Result { @@ -1533,10 +1601,33 @@ pub async fn player_play_tracks( session_mgr.start_audio_session(first_item.clone()); } - // Play queue let controller = player.0.lock().await; - controller.play_queue_from(media_items, request.start_index, request.start_position) - .map_err(|e| e.to_string())?; + + // When controlling a remote session, cast the selection there instead of + // starting local MPV playback. Skip this while a transfer is in flight: the + // transfer-to-local path calls this command to load the queue locally and + // the mode is still Remote until the transfer completes - routing it back to + // the remote would undo the transfer. + let remote_session = match playback_mode.0.get_mode() { + crate::playback_mode::PlaybackMode::Remote { session_id } + if !playback_mode.0.is_transferring() => + { + Some(session_id) + } + _ => None, + }; + + if let Some(session_id) = &remote_session { + play_selection_on_remote(&controller, session_id, &media_items, request.start_index) + .await?; + controller + .set_queue(media_items, request.start_index) + .map_err(|e| e.to_string())?; + } else { + controller + .play_queue_from(media_items, request.start_index, request.start_position) + .map_err(|e| e.to_string())?; + } // Set queue context { diff --git a/src-tauri/src/jellyfin/client.rs b/src-tauri/src/jellyfin/client.rs index b4bec70..59597c6 100644 --- a/src-tauri/src/jellyfin/client.rs +++ b/src-tauri/src/jellyfin/client.rs @@ -284,22 +284,35 @@ impl JellyfinClient { } /// Seek on a remote session + /// + /// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the + /// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the + /// body (as we used to) is silently ignored and the remote never seeks. pub async fn session_seek( &self, session_id: String, position_ticks: i64, ) -> Result<(), String> { - #[derive(serde::Serialize)] - #[serde(rename_all = "PascalCase")] - struct SeekRequest { - seek_position_ticks: i64, + let url = format!( + "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}", + self.config.server_url, session_id, position_ticks + ); + + let response = self.http_client + .post(&url) + .header("X-Emby-Authorization", self.get_auth_header()) + .send() + .await + .map_err(|e| format!("Network request failed: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text)); } - let request = SeekRequest { - seek_position_ticks: position_ticks, - }; - - self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await + log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id); + Ok(()) } /// Send a full GeneralCommand to a remote session. diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index dc3acea..5602f1b 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -181,7 +181,11 @@ impl PlaybackModeManager { } /// Transfer playback from local device to remote Jellyfin session - pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> { + 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!( @@ -195,7 +199,7 @@ impl PlaybackModeManager { debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner"); // Perform the transfer - let result = self.transfer_to_remote_inner(&session_id).await; + let result = self.transfer_to_remote_inner(&session_id, position_override).await; // Clear transferring flag self.is_transferring.store(false, Ordering::Relaxed); @@ -203,7 +207,11 @@ impl PlaybackModeManager { result } - async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> { + 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); @@ -231,11 +239,19 @@ impl PlaybackModeManager { } let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; - // Read the LIVE backend position, not the snapshot embedded in PlayerState. - // On Android the embedded position is only refreshed on play/pause - // transitions, so PlayerState::position() is stale (often 0) mid-track; - // PlayerController::position() always reflects the backend's current time. - let position = player.position(); + // Prefer the frontend-supplied position when available. The backend + // position is unreliable as a transfer source: on Linux, *video* plays + // in the HTML5