fix(player): stop PiP dropping the video and restarting the audio behind it

Watching in a picture-in-picture window would occasionally drop to audio-only,
and the audio would resume from wherever the video had been when PiP was
entered while the picture had carried on past it. Two independent faults, both
needed to produce that.

The position froze (DR-265). VideoPlayer tracks the absolute position in its
own `currentTime` rather than reading `videoElement.currentTime` at the point
of use, because transcoded HLS resets the element to 0 on every segment
rebuild. While playing, that variable had exactly one writer: a
requestAnimationFrame loop. RAF is driven by the document being rendered, and
an Android activity behind a PiP window is paused, so the loop stops while the
element plays on. The `timeupdate` handler that would have covered the gap was
written as a fallback "for when RAF isn't running" and gated itself on
`!isPlaying` -- switching itself off at precisely the moment it was the only
source left. Everything downstream froze with it: the seek bar, the ten-second
progress reports, the position mirrored into Rust, and the handoff. The gate is
now `shouldApplyTimeUpdate` and turns only on things that genuinely own the
position -- an in-flight seek, a seek-bar drag, an element below
HAVE_CURRENT_DATA. Both writers producing the same derived value costs nothing.

The handoff fired at all (DR-266). PiP and the background-audio handoff are
alternatives -- one keeps the picture, the other throws it away -- but
exclusivity was enforced from one side only: arming the toggle suppressed
*auto*-PiP, while the PiP button stayed ungated, so pressing it left both
armed. What then stood between them was `isInPictureInPictureMode`, sampled
once inside MainActivity.onStop(). That sample is not reliable: the keyguard
dismissing the window, the window being stashed, or OEM variance in when
onPictureInPictureModeChanged(false) lands can all leave the activity stopped
with a window still on screen and the flag reading false. Now entering PiP
disarms background audio, both directions go through one BackgroundBehaviour
pair, and the PiP question accepts either witness -- the native sample or the
frontend's latch over jellytau-pip-entered/exited. The latch cannot report a
window that has closed: both events reach the WebView through the same message
queue in dispatch order. The decision itself stays in Rust; the frontend only
supplies a fact it can establish more reliably than the activity can.

Red first, both: the existing behaviour was extracted into pure helpers, the
tests written against the correct behaviour, and both watched to fail before
either was changed.
This commit is contained in:
2026-08-27 17:56:52 +02:00
parent 66e7889030
commit c0399e4ebd
6 changed files with 279 additions and 17 deletions
+55 -14
View File
@@ -80,8 +80,13 @@
shouldExitBackgroundAudio,
shouldResumeOnForeground,
planHandoffReturn,
setBackgroundAudioArmed,
enteringPictureInPicture,
inPictureInPicture,
type BackgroundAudioState,
type BackgroundBehaviour,
} from "./backgroundAudioHandoff";
import { shouldApplyTimeUpdate } from "./timeTracking";
import { createLogger } from "$lib/utils/logger";
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
@@ -1350,14 +1355,27 @@
}
}
// Fallback: Update time on timeupdate event (for when RAF isn't running)
// Second position source, alongside the RAF loop. It used to exclude itself
// whenever the video was playing, on the theory that RAF had it covered --
// but RAF only runs while the document is rendered, and an Android activity
// behind a PiP window is paused. `currentTime` then froze at the moment PiP
// was entered while the element played on, and every consumer of it froze
// too: the seek bar, the progress reports, the position mirrored into Rust,
// and -- the visible symptom -- the background-audio handoff, which resumed
// the audio-only stream back at the PiP-entry position. (DR-265)
function handleTimeUpdate() {
if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
const newCurrentTime = seekOffset + videoElement.currentTime;
if (videoElement.readyState >= 2) {
currentTime = newCurrentTime;
}
if (!videoElement) return;
if (
!shouldApplyTimeUpdate({
isPlaying,
isSeeking,
isDraggingSeekBar,
readyState: videoElement.readyState,
})
) {
return;
}
currentTime = seekOffset + videoElement.currentTime;
}
function handleLoadedMetadata() {
@@ -1833,6 +1851,11 @@
const pipSupported = isPipSupported();
function handlePictureInPicture() {
// Pressing PiP is an unambiguous request to keep the picture, so it disarms
// the behaviour that throws the picture away. Exclusivity was previously
// enforced only from the toggle's side (it suppressed *auto*-PiP), leaving
// this button able to arm both at once. (DR-266)
applyBackgroundBehaviour(enteringPictureInPicture(backgroundBehaviour()));
enterPip();
}
@@ -1855,16 +1878,26 @@
// what we stopped -- never something the user paused themselves.
let pausedByBackgrounding = false;
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
/** The pair of background behaviours as they currently stand. */
function backgroundBehaviour(): BackgroundBehaviour {
return setBackgroundAudioArmed(backgroundAudioOn);
}
/**
* Push a background-behaviour pair to both natives, so exactly one is armed.
*/
function applyBackgroundBehaviour(next: BackgroundBehaviour) {
backgroundAudioOn = next.backgroundAudioArmed;
log.debug("Background-audio toggle ->", backgroundAudioOn);
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
// so exactly one background behavior is active.
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
if (!armed) {
const armed = setBackgroundAudioEnabled(next.backgroundAudioArmed);
if (!armed && next.backgroundAudioArmed) {
log.warn("Background audio NOT armed natively (no bridge)");
}
setAutoEnterEnabled(!backgroundAudioOn);
setAutoEnterEnabled(next.autoPipEnabled);
}
function toggleBackgroundAudio() {
applyBackgroundBehaviour(setBackgroundAudioArmed(!backgroundAudioOn));
}
// App went to background/locked while background-audio is armed: hand off to
@@ -1879,7 +1912,15 @@
try {
action = await commands.playerBackgroundAction(
signal.backgroundAudioArmed,
signal.inPictureInPicture,
// Not `signal.inPictureInPicture` alone. That is one sample of
// `isInPictureInPictureMode`, taken inside onStop(); there are
// orderings -- the keyguard dismissing the window, the window being
// stashed, OEM variance in when onPictureInPictureModeChanged(false)
// lands -- where it reads false with the window still on screen, and
// the video the user is watching is handed off to audio. `isInPip` is
// a latch over the pip-entered/exited events, which arrive on the same
// queue ahead of this one. (DR-266)
inPictureInPicture(signal.inPictureInPicture, isInPip),
);
} catch (e) {
// Never leave playback in an undefined state because a decision call