diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt index b3dcc539..a9d93d2d 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt @@ -187,6 +187,9 @@ class JellyTauPlaybackService : MediaSessionService() { override fun onSeekTo(position: Long) { android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position") + // The scrubber is absolute; Rust owns the seek in absolute terms + // (in a background-audio handoff it rebuilds the stream at this + // StartTimeTicks). Send the absolute position as-is. val positionSeconds = position / 1000.0 nativeOnMediaCommand("seek:$positionSeconds") } @@ -259,6 +262,25 @@ class JellyTauPlaybackService : MediaSessionService() { private var lastArtist: String = "" private var lastIsPlaying: Boolean = false + // Base offset (ms) added to every position reported to the lockscreen + // MediaSession. During a background-audio handoff the audio stream is + // requested with StartTimeTicks = the handoff point, so ExoPlayer reports + // position RELATIVE to that point (starting at 0). The metadata duration, + // however, is the full absolute length — so without this base the scrubber + // thumb sits near 0:00 on a full-length bar. Set from the known handoff + // position via setPositionOffset(); 0 for normal playback. + private var positionOffsetMs: Long = 0L + + /** + * Set the base position offset (seconds) applied to lockscreen positions. + * Called by the native layer when entering/exiting a background-audio handoff. + * Pass 0 to clear (normal playback, where ExoPlayer's position is absolute). + */ + fun setPositionOffset(offsetSeconds: Double) { + positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L) + android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms") + } + /** * Update the MediaSession metadata and playback state, plus the notification. * @@ -292,8 +314,8 @@ class JellyTauPlaybackService : MediaSessionService() { session.setMetadata(metadataBuilder.build()) - // Update MediaSession playback state - session.setPlaybackState(buildPlaybackState(isPlaying, position)) + // Update MediaSession playback state (position made absolute via the base offset). + session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs)) // While casting, re-assert the remote volume provider. Metadata pushes // arrive on the session poller thread and can race with (or arrive @@ -322,7 +344,8 @@ class JellyTauPlaybackService : MediaSessionService() { val session = mediaSessionCompat ?: return val notificationStateChanged = isPlaying != lastIsPlaying lastIsPlaying = isPlaying - session.setPlaybackState(buildPlaybackState(isPlaying, position)) + // Absolute position for the scrubber = relative ExoPlayer position + base offset. + session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs)) // Only rebuild the notification when the play/pause icon actually flips. if (notificationStateChanged) { updateNotification(lastTitle, lastArtist, isPlaying) diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 726bf59a..d4f93b89 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -57,6 +57,18 @@ pub struct MediaSessionManagerWrapper(pub Mutex); /// @req: DR-048 - Video settings (auto-play toggle, countdown duration) pub struct VideoSettingsWrapper(pub Mutex); +/// Base offset (seconds) for the active background-audio handoff. +/// +/// The audio-only stream is requested with `StartTimeTicks` = the handoff +/// position, so the server makes that point the stream's zero. ExoPlayer then +/// reports position RELATIVE to that zero. To convert back to an absolute +/// position on exit (so the video resumes where the audio actually reached), we +/// add this stored base to the native player's reported position. +/// +/// TRACES: UR-040 | DR-052 +#[derive(Default)] +pub struct BackgroundAudioOffset(pub Mutex); + /// Response for player state queries #[derive(specta::Type, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -184,6 +196,11 @@ pub struct PlayItemRequest { pub primary_image_tag: Option, #[serde(default)] pub server_id: Option, + /// Total media duration (seconds). Threaded through the background-audio + /// handoff so the lockscreen MediaSession advertises a real duration — a + /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set. + #[serde(default)] + pub duration_seconds: Option, } /// Queue context for remote transfer - what type of queue is this? @@ -555,6 +572,7 @@ pub async fn player_play_item( pub async fn player_enter_background_audio( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, + bg_offset: State<'_, BackgroundAudioOffset>, item: PlayItemRequest, position_seconds: f64, ) -> Result { @@ -579,7 +597,8 @@ pub async fn player_enter_background_audio( primary_image_tag: item.primary_image_tag.clone(), item_type: None, playlist_id: None, - duration: None, + // Carry the real duration so the lockscreen MediaSession can draw a scrubber. + duration: item.duration_seconds, artwork_url: None, media_type: MediaType::Audio, source: MediaSource::Remote { @@ -600,16 +619,23 @@ pub async fn player_enter_background_audio( session_mgr.start_audio_session(media_item.clone()); } + // Remember where the video was: the audio stream's zero == this position + // (the URL was built with StartTimeTicks=position_seconds), so on exit we add + // this base to the native player's relative position to get the absolute one. + *bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0); + + // Same base offset drives the lockscreen scrubber: ExoPlayer reports position + // relative to the stream's StartTimeTicks zero, but the metadata duration is + // absolute, so shift the reported position back to absolute for the scrubber. + let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0)); + let controller = player.0.lock().await; controller .play_item(media_item) .map_err(|e| e.to_string())?; - // Resume at the handoff position. - if position_seconds > 0.0 { - controller - .seek(position_seconds) - .map_err(|e| e.to_string())?; - } + // NOTE: do NOT seek here. The audio-only URL already starts at the handoff + // position via StartTimeTicks; the stream's timeline begins at 0 == that + // point, so an extra seek(position_seconds) would jump PAST the content. controller.emit_queue_changed(); if let Some(emitter) = controller.event_emitter() { @@ -634,17 +660,31 @@ pub async fn player_enter_background_audio( #[specta::specta] pub async fn player_exit_background_audio( player: State<'_, PlayerStateWrapper>, + bg_offset: State<'_, BackgroundAudioOffset>, ) -> Result { + // The base offset (handoff position) + native player's relative position = + // the absolute position to resume the video at. Read/reset the base first. + let base = { + let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?; + let b = *off; + *off = 0.0; + b + }; + + // Back to foreground playback: the lockscreen scrubber is absolute again. + let _ = crate::player::set_lockscreen_position_offset(0.0); + let controller = player.0.lock().await; // Capture position into a `let` BEFORE stop() — never hold work across a lock // re-entrant call (deadlock discipline, CLAUDE.md). - let position = controller.position(); + let relative = controller.position(); controller.stop().map_err(|e| e.to_string())?; + let absolute = base + relative; info!( - "player_exit_background_audio: returning position {:.1}s", - position + "player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s", + base, relative, absolute ); - Ok(position) + Ok(absolute) } /// Play a queue of media items diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6872e639..34ea33a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1166,6 +1166,9 @@ pub fn run() { let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default())); app.manage(video_settings); + // Background-audio handoff base offset (UR-040). + app.manage(commands::player::BackgroundAudioOffset::default()); + // Initialize thumbnail cache info!("[INIT] Initializing thumbnail cache..."); let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { diff --git a/src-tauri/src/player/android/mod.rs b/src-tauri/src/player/android/mod.rs index aa0a10cf..754cd020 100644 --- a/src-tauri/src/player/android/mod.rs +++ b/src-tauri/src/player/android/mod.rs @@ -1315,6 +1315,66 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin Ok(()) } +/// Set the base position offset (seconds) on the lockscreen MediaSession. +/// +/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the +/// service isn't running yet, so it's safe to call unconditionally. +pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> { + let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?; + let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; + + let context = APP_CONTEXT.get().ok_or("Context not initialized")?; + + let class_loader = env + .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) + .map_err(|e| format!("Failed to get ClassLoader: {}", e))? + .l() + .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?; + + let service_class_name = env + .new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService") + .map_err(|e| format!("Failed to create class name string: {}", e))?; + + let service_class_obj = env + .call_method( + &class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValue::Object(&service_class_name.into())], + ) + .map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))? + .l() + .map_err(|e| format!("Failed to convert to Class: {}", e))?; + + let service_class = JClass::from(service_class_obj); + + let service_obj = env + .call_static_method( + &service_class, + "getInstance", + "()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;", + &[], + ) + .map_err(|e| format!("Failed to get service instance: {}", e))? + .l() + .map_err(|e| format!("Failed to convert to object: {}", e))?; + + // Service not running yet - nothing to offset. + if service_obj.is_null() { + return Ok(()); + } + + env.call_method( + &service_obj, + "setPositionOffset", + "(D)V", + &[JValue::Double(offset_seconds)], + ) + .map_err(|e| format!("Failed to set position offset: {}", e))?; + + Ok(()) +} + /// Stub implementations for non-Android platforms #[cfg(not(target_os = "android"))] pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> { diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index c2a85445..29736b7a 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -80,6 +80,22 @@ pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), Stri } } +/// Set the base offset (seconds) added to positions reported to the Android +/// lockscreen scrubber. Used by the background-audio handoff: the audio stream +/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is +/// relative and must be shifted back to absolute to match the full duration. +/// Pass 0.0 to clear on exit. No-op off Android. +pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> { + #[cfg(target_os = "android")] + { + return android::set_position_offset(_offset_seconds); + } + #[cfg(not(target_os = "android"))] + { + Ok(()) + } +} + use crate::utils::lock::MutexSafe; use log::{debug, error, warn}; use std::sync::{Arc, Mutex}; diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 18da6087..35771767 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1808,7 +1808,13 @@ needsTranscoding: boolean; * lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so * existing video-only callers need not send them. */ -artist?: string | null; primaryImageTag?: string | null; serverId?: string | null } +artist?: string | null; primaryImageTag?: string | null; serverId?: string | null; +/** + * Total media duration (seconds). Threaded through the background-audio + * handoff so the lockscreen MediaSession advertises a real duration — a + * zero-duration session renders no scrubber, even with ACTION_SEEK_TO set. + */ +durationSeconds?: number | null } /** * Queue context for remote transfer - what type of queue is this? */ diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index ee95c561..a32da2dd 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -826,10 +826,55 @@ // with `src=""`, so `loadstart`/`canplay` don't fire reliably and the // canplay-fallback timeout was never armed — audio played while the video // stayed invisible. Any of these callers now reveals it. + // Apply the pending background-audio foreground seek, if any. This MUST run + // no matter which readiness signal fired — on the Android WebView HLS/MSE path + // `canplay` is unreliable and the video is revealed via markMediaReady() + // instead, so gating this on handleCanPlay alone meant the seek was silently + // dropped and the reloaded stream played from its start (resume "started from + // the beginning"). Returns true if a pending seek was consumed. + async function applyPendingForegroundSeek(): Promise { + if (pendingForegroundSeek === null || !videoElement) return false; + const seekTo = pendingForegroundSeek; + const shouldPlay = pendingForegroundPlay; + pendingForegroundSeek = null; + pendingForegroundPlay = false; + hasPerformedInitialSeek = true; + + const el = videoElement; + // currentTime is only honored once the element has metadata (duration/seekable). + // If it isn't there yet, defer to loadedmetadata rather than seeking into a + // still-empty timeline (which the element clamps back to 0). + const doSeek = async () => { + try { + el.currentTime = seekTo; + // Displayed position is absolute: element time + transcode seekOffset. + // (Direct stream: seekOffset=0, seekTo=pos. Transcoded: seekOffset=pos, + // seekTo=0.) Both yield the correct absolute position. + currentTime = seekOffset + seekTo; + el.muted = false; + el.volume = 1.0; + if (shouldPlay) await el.play(); + } catch (err) { + console.error("[VideoPlayer] Failed to resume after background audio:", err); + } + }; + + if (el.readyState >= 1 /* HAVE_METADATA */) { + console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1)); + await doSeek(); + } else { + console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1)); + el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true }); + } + return true; + } + function markMediaReady() { if (isMediaReady) return; console.log("[VideoPlayer] Marking media ready"); isMediaReady = true; + // A handoff return can be revealed here (not via canplay) — apply its seek. + void applyPendingForegroundSeek(); } async function handleCanPlay() { @@ -847,19 +892,7 @@ // Returning from background audio: resume the