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
+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);
}