From 342f95cac159ae387eb66b18abeae5c21afc1068 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 1 Jul 2026 21:52:27 +0200 Subject: [PATCH] Wire up playback reporting, fix duration flash, hide video from audio mini player Playback reporting (position sync / resume-on-another-device): - player_configure_jellyfin now builds a PlaybackReporter sharing the player controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every auth path (login/restore/reauth); previously they never did. - The PlaybackReporterWrapper now shares the same Arc the controller and MPV progress loop report through, instead of a dead parallel Option. - Android position callbacks now emit throttled progress reports (30s/item), mirroring the MPV backend. Duration flash on pause: - resolveDuration() prefers the live store duration for the already-loaded track over the runTimeTicks estimate, so pausing no longer clobbers the slider's max to 0 when runTimeTicks is missing. Video leaking into audio mini player: - isVideoItem() also checks the backend PlayerMediaItem mediaType discriminator, so a video started via player_play_item (no Jellyfin `type`, mediaType "video") no longer surfaces in the audio mini player. Middle-truncation of long media names: - New truncateMiddle util applied to track/episode/card/mini-player titles so distinguishing tails (episode numbers, suffixes) stay visible. Adds regression tests for the duration and mini-player fixes. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/commands/player/mod.rs | 29 +++- src-tauri/src/lib.rs | 8 +- src-tauri/src/player/android/mod.rs | 72 ++++++++- src-tauri/src/player/mod.rs | 5 +- .../downloads/StorageManagement.svelte | 3 +- .../library/ArtistDetailView.svelte | 3 +- .../library/EpisodeFocusView.svelte | 3 +- src/lib/components/library/EpisodeRow.svelte | 3 +- .../library/GenericGenreBrowser.svelte | 3 +- .../components/library/LibraryListView.svelte | 3 +- src/lib/components/library/MediaCard.svelte | 3 +- src/lib/components/library/TrackList.svelte | 5 +- src/lib/components/player/AudioPlayer.svelte | 3 +- src/lib/components/player/MiniPlayer.svelte | 3 +- src/lib/components/player/Queue.svelte | 3 +- .../services/playerEvents.regression.test.ts | 147 ++++++++++++++++++ src/lib/services/playerEvents.ts | 34 +++- src/lib/stores/player.ts | 27 +++- src/lib/stores/playerVisibility.test.ts | 19 +++ src/lib/utils/truncateMiddle.test.ts | 39 +++++ src/lib/utils/truncateMiddle.ts | 33 ++++ 21 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 src/lib/services/playerEvents.regression.test.ts create mode 100644 src/lib/utils/truncateMiddle.test.ts create mode 100644 src/lib/utils/truncateMiddle.ts diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index a17b7a5..5296ae7 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -1826,9 +1826,10 @@ pub async fn player_get_cache_config( #[specta::specta] pub async fn player_configure_jellyfin( player: State<'_, PlayerStateWrapper>, + db: State<'_, DatabaseWrapper>, server_url: String, access_token: String, - _user_id: String, + user_id: String, device_id: String, ) -> Result<(), String> { log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting"); @@ -1839,12 +1840,31 @@ pub async fn player_configure_jellyfin( device_id, }; - let client = JellyfinClient::new(config)?; + // Legacy client (used for remote session control / casting). + let client = JellyfinClient::new(config.clone())?; + + // Build the PlaybackReporter the player and backends (MPV + ExoPlayer) + // actually report through. Without this, Start/Progress/Stopped never reach + // Jellyfin, so playback position never syncs and you can't resume on another + // device. The reporter shares the player controller's Arc, so populating it + // here lights up reporting on both desktop and Android, on every auth path + // that configures the player (login / restore / reauth). + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + let reporter_client = JellyfinClient::new(config)?; + let reporter = crate::playback_reporting::PlaybackReporter::new( + db_service, + Arc::new(TokioMutex::new(Some(reporter_client))), + user_id, + ); let controller = player.0.lock().await; controller.set_jellyfin_client(Some(client)); + controller.set_playback_reporter(Some(reporter)).await; - log::info!("[PlayerCommand] Jellyfin client configured successfully"); + log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully"); Ok(()) } @@ -1858,8 +1878,9 @@ pub async fn player_disable_jellyfin( let controller = player.0.lock().await; controller.set_jellyfin_client(None); + controller.set_playback_reporter(None).await; - log::info!("[PlayerCommand] Jellyfin client disabled"); + log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled"); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5abfe82..ad3cbdf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1018,9 +1018,13 @@ pub fn run() { let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager); app.manage(repository_manager_wrapper); - // Initialize playback reporter wrapper (initially empty, set on login) + // Initialize playback reporter wrapper. This MUST share the same Arc + // the player controller and MPV progress loop report through (created + // above at `playback_reporter`), otherwise `playback_reporter_init` + // would populate a dead, parallel Option and no Start/Progress/Stopped + // would ever reach Jellyfin. info!("[INIT] Initializing playback reporter wrapper..."); - let playback_reporter_wrapper = PlaybackReporterWrapper(Arc::new(tokio::sync::Mutex::new(None))); + let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone()); app.manage(playback_reporter_wrapper); info!("[INIT] Application setup completed successfully"); diff --git a/src-tauri/src/player/android/mod.rs b/src-tauri/src/player/android/mod.rs index a198a18..0cf85f3 100644 --- a/src-tauri/src/player/android/mod.rs +++ b/src-tauri/src/player/android/mod.rs @@ -17,7 +17,8 @@ use super::backend::{PlayerBackend, PlayerError}; use super::events::{PlayerStatusEvent, SharedEventEmitter}; use super::media::{MediaItem, MediaType}; use super::state::PlayerState; -use crate::playback_reporting::{PlaybackReporter, EventThrottler}; +use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation}; +use crate::utils::conversions::seconds_to_ticks; /// Global reference to the JavaVM for JNI callbacks static JAVA_VM: OnceLock = OnceLock::new(); @@ -584,6 +585,75 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO } else { log::error!("[Android] WARNING: No event emitter for position update!"); } + + // Throttled progress reporting to Jellyfin so playback position syncs and can + // be resumed on another device. ExoPlayer only fires position updates while + // playing, but guard on the stored state anyway. Mirrors the MPV backend's + // progress loop; both share the same EventThrottler (every 30s per item). + report_android_progress(position); +} + +/// Report throttled playback progress to Jellyfin from the Android position +/// callback. No-op until the reporter/throttler are wired (post-login) or when +/// not actively playing. +fn report_android_progress(position: f64) { + let item_id = match SHARED_STATE.get() { + Some(state) => { + let state = state.lock_safe(); + if !state.state.is_playing() { + return; + } + match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) { + Some(id) => id, + None => return, + } + } + None => return, + }; + + let throttler = match POSITION_THROTTLER.get() { + Some(t) => t, + None => return, + }; + if !throttler.should_report(&item_id) { + return; + } + + let reporter_arc = match PLAYBACK_REPORTER.get() { + Some(r) => r.clone(), + None => return, + }; + + let position_ticks = seconds_to_ticks(position); + let item_id_for_task = item_id.clone(); + + // The reporter is async; the JNI callback is sync. Spawn onto the Tokio + // runtime when present, otherwise a throwaway runtime on a new thread. + let spawn_report = move || async move { + let reporter_guard = reporter_arc.lock().await; + if let Some(reporter) = reporter_guard.as_ref() { + let operation = PlaybackOperation::Progress { + item_id: item_id_for_task.clone(), + position_ticks, + is_paused: false, + }; + match reporter.report(operation, true).await { + Ok(_) => debug!("[Android] Reported progress for {}", item_id_for_task), + Err(e) => log::warn!("[Android] Failed to report progress: {}", e), + } + } + }; + + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(spawn_report()); + } else { + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(spawn_report()); + }); + } + + throttler.mark_reported(&item_id); } /// Called when player state changes. diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 2d1833c..67ed0ff 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -165,9 +165,8 @@ impl PlayerController { self.jellyfin_client.clone() } - /// Configure the playback reporter for dual sync (local DB + server) - /// Will be called from initialization commands after login - #[allow(dead_code)] + /// Configure the playback reporter for dual sync (local DB + server). + /// Called from `player_configure_jellyfin` on login/restore/reauth. pub async fn set_playback_reporter(&self, reporter: Option) { let mut reporter_guard = self.playback_reporter.lock().await; *reporter_guard = reporter; diff --git a/src/lib/components/downloads/StorageManagement.svelte b/src/lib/components/downloads/StorageManagement.svelte index 08ba655..2a0740f 100644 --- a/src/lib/components/downloads/StorageManagement.svelte +++ b/src/lib/components/downloads/StorageManagement.svelte @@ -1,5 +1,6 @@