diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt index 9852686..cd8203b 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt @@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) { /** Current media ID being played */ private var currentMediaId: String? = null + /** + * Guards against nativeOnPlaybackEnded() firing more than once per loaded + * media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near + * end of a transcoded stream), which would otherwise notify the backend + * twice and, for example, decrement the sleep-timer episode counter twice. + * Reset whenever new media is loaded. + */ + private var endedNotified = false + /** Current media metadata for notification updates */ private var currentTitle: String = "" private var currentArtist: String = "" @@ -208,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) { // Playback completed android.util.Log.d("JellyTauPlayer", "▶ Playback ended") stopPositionUpdates() - nativeOnPlaybackEnded() + // Only notify the backend once per loaded media. ExoPlayer + // can re-enter STATE_ENDED, which would double-count things + // like the sleep-timer episode counter. + if (!endedNotified) { + endedNotified = true + nativeOnPlaybackEnded() + } else { + android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring") + } } Player.STATE_BUFFERING -> { android.util.Log.d("JellyTauPlayer", "▶ Buffering...") @@ -322,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) { fun load(url: String, mediaId: String) { mainHandler.post { currentMediaId = mediaId + endedNotified = false val mediaItem = MediaItem.fromUri(url) exoPlayer.setMediaItem(mediaItem) exoPlayer.prepare() @@ -552,6 +570,7 @@ class JellyTauPlayer(private val appContext: Context) { ) { mainHandler.post { currentMediaId = mediaId + endedNotified = false // Store metadata for notification updates currentTitle = title 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 300cbb4..a17b7a5 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -224,6 +224,10 @@ pub struct PlayTracksRequest { pub start_index: usize, pub shuffle: bool, pub context: PlayTracksContext, + /// Position (seconds) to resume the starting track from. Used when taking + /// over playback from a remote session so we don't restart from 0. + #[serde(default)] + pub start_position: Option, } /// Context information for track playback @@ -1245,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] @@ -1254,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 { @@ -1376,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 { @@ -1422,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 { @@ -1529,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(media_items, request.start_index) - .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/connectivity/mod.rs b/src-tauri/src/connectivity/mod.rs index fe50d59..84fefdb 100644 --- a/src-tauri/src/connectivity/mod.rs +++ b/src-tauri/src/connectivity/mod.rs @@ -82,6 +82,7 @@ impl ConnectivityReporter { /// Current reachability as seen by this reporter (shared with the monitor /// and the UI). Useful for callers that want to branch on connectivity. + #[allow(dead_code)] // public API; currently only exercised by cross-module tests pub async fn is_reachable(&self) -> bool { self.status.read().await.is_server_reachable } 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/lib.rs b/src-tauri/src/lib.rs index f46329b..e5e405e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -356,6 +356,9 @@ fn specta_builder() -> Builder { // Throw on error so generated `commands.*` return Promise and throw, // matching the existing frontend's invoke() try/catch convention. .error_handling(tauri_specta::ErrorHandlingMode::Throw) + .events(tauri_specta::collect_events![ + crate::player::events::PlayerStatusEvent + ]) .commands(tauri_specta::collect_commands![ // Player commands player_play_item, @@ -601,11 +604,17 @@ pub fn run() { // `.export()` here would try to write `../src/lib/api/bindings.ts` at app // startup, which panics on devices (e.g. Android) where that path doesn't exist. let builder = specta_builder(); + let invoke_handler = builder.invoke_handler(); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) - .setup(|app| { + .invoke_handler(invoke_handler) + .setup(move |app| { + // Mount tauri-specta events so PlayerStatusEvent can be emitted to and + // listened for on the frontend via the generated bindings. + builder.mount_events(app); + // Initialize database with proper app data directory // Check for test mode environment variable first let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { @@ -845,7 +854,6 @@ pub fn run() { info!("[INIT] Application setup completed successfully"); Ok(()) }) - .invoke_handler(builder.invoke_handler()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index b592923..056b250 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -20,6 +20,28 @@ pub enum PlaybackMode { 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>>, @@ -159,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!( @@ -173,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); @@ -181,12 +207,24 @@ 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); + // 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, current_index, position_seconds, 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; @@ -195,7 +233,6 @@ impl PlaybackModeManager { 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(); @@ -210,7 +247,19 @@ impl PlaybackModeManager { } let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; - let position = state.position().unwrap_or(0.0); + // Prefer the frontend-supplied position when available. The backend + // position is unreliable as a transfer source: on Linux, *video* plays + // in the HTML5