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:
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldApplyTimeUpdate } from "./timeTracking";
|
||||
|
||||
/**
|
||||
* TRACES: UT-245 | DR-265
|
||||
*/
|
||||
describe("shouldApplyTimeUpdate", () => {
|
||||
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
|
||||
|
||||
it("applies the update while the video is PLAYING", () => {
|
||||
// THE REPORTED BUG. `timeupdate` was the only position source that still
|
||||
// fires once requestAnimationFrame stops -- which is exactly what happens
|
||||
// when the activity is paused behind a picture-in-picture window. Gating it
|
||||
// on `!isPlaying` disabled it precisely when it was the only thing left,
|
||||
// so the component's `currentTime` froze at the moment PiP was entered
|
||||
// while the element played on. The background-audio handoff then resumed
|
||||
// the audio-only stream at that frozen position.
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("still applies the update while paused", () => {
|
||||
// The case it always handled: RAF is stopped, timeupdate carries the seek.
|
||||
expect(shouldApplyTimeUpdate(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("yields to an in-flight seek", () => {
|
||||
// A seek owns the position until it settles; a stale element read landing
|
||||
// mid-seek is what makes a scrubbed video snap back.
|
||||
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("yields while the user is dragging the seek bar", () => {
|
||||
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an element with no usable data yet", () => {
|
||||
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
|
||||
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user