fix resuming video playback after background audio only mode.
Traceability Validation / Check Requirement Traces (pull_request) Failing after 3h14m1s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (pull_request) Has been cancelled

This commit is contained in:
2026-07-22 22:28:07 +02:00
parent 3fbf6afdbc
commit acf1bb200d
7 changed files with 249 additions and 39 deletions
@@ -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)
+51 -11
View File
@@ -57,6 +57,18 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
/// 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<f64>);
/// 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<String>,
#[serde(default)]
pub server_id: Option<String>,
/// 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<f64>,
}
/// 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<PlayerStatus, String> {
@@ -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<f64, String> {
// 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
+3
View File
@@ -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") {
+60
View File
@@ -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> {
+16
View File
@@ -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};
+7 -1
View File
@@ -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?
*/
+86 -24
View File
@@ -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<boolean> {
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 <video> at the position native
// audio reached, restoring the prior play/pause state. Takes precedence over
// the resume-point seek below (which is for a fresh load, not a handoff).
if (pendingForegroundSeek !== null && videoElement) {
const seekTo = pendingForegroundSeek;
const shouldPlay = pendingForegroundPlay;
pendingForegroundSeek = null;
pendingForegroundPlay = false;
hasPerformedInitialSeek = true;
try {
videoElement.currentTime = seekTo;
currentTime = seekTo;
if (shouldPlay) await videoElement.play();
} catch (err) {
console.error("[VideoPlayer] Failed to resume after background audio:", err);
}
if (await applyPendingForegroundSeek()) {
return;
}
@@ -972,7 +1005,7 @@
// Check if video is actually ready despite event not firing
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
isMediaReady = true;
markMediaReady();
}
}
}, 5000);
@@ -1192,6 +1225,8 @@
artist: media.seriesName ?? null,
primaryImageTag: media.primaryImageTag ?? null,
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
},
pos,
);
@@ -1216,19 +1251,46 @@
const wasPlaying = handoffState.wasPlaying;
handoffState = { ...initialHandoffState };
try {
// Absolute position the native audio reached (base offset applied in Rust).
const pos = await commands.playerExitBackgroundAudio();
// Reload the video at the returned position. Resetting these re-runs the
// HLS init $effect and reveals/seeks the element as on a fresh load.
hasPerformedInitialSeek = false;
lastAppliedInitialPosition = undefined;
seekOffset = 0;
console.log("[VideoPlayer] Returning from background audio at:", pos.toFixed(1));
isMediaReady = false;
// Re-point the element at the (unchanged) video stream URL; assigning a new
// reference restarts the HLS effect even if the string is identical.
currentStreamUrl = streamUrl;
// Seek to where native audio left off once the element is ready again.
pendingForegroundSeek = pos;
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
// post-handoff position. Keep the initial-position change-effect quiescent:
// leaving hasPerformedInitialSeek=true and pinning lastAppliedInitialPosition
// to the current prop means the effect sees no "change" and won't fire a
// stale seek back to the original resume point (clobbering the handoff pos).
hasPerformedInitialSeek = true;
lastAppliedInitialPosition = initialPosition;
pendingForegroundPlay = wasPlaying;
// Determine the target URL + how the element/offset should be positioned.
let targetUrl: string;
if (needsTranscoding && onSeek) {
// Transcoded HLS can't seek by setting currentTime — the stream must be
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
// The reloaded segment's timeline starts at 0, so seekOffset carries the
// absolute base and the element seeks to 0 (handled on canplay).
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
seekOffset = pos;
currentTime = pos;
pendingForegroundSeek = 0;
} else {
// Direct stream: reload the original URL and seek the element to pos.
targetUrl = streamUrl;
seekOffset = 0;
pendingForegroundSeek = pos;
}
// Force the HLS-init $effect to re-run even if the URL string is unchanged:
// blank it first, then set it on the next microtask so Svelte sees a real
// transition. Without this, assigning the same value is a no-op and the
// player stays stuck on the loading spinner (HLS never re-initialises).
currentStreamUrl = "";
await Promise.resolve();
currentStreamUrl = targetUrl;
} catch (err) {
console.error("[VideoPlayer] Background-audio return failed:", err);
}