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.
With native video on, coming back from background audio left a black screen: a
play overlay pinned at 0:00, a seek bar at zero, and a play button that did
nothing. Nothing crashed — the process stayed up and the frontend kept logging —
the transition was simply dropped.
The two render paths resume by different means, and exitBackgroundAudioHandoff
only ever performed one of them. The webview <video> reloads off its stream URL:
an $effect watches it, reinitialises HLS or sets element.src, and canplay drives
the seek and play. ExoPlayer owns no element and nothing watches the URL on its
behalf — native playback is only ever started by an explicit player_play_item
plus adapter load, which the component issues once, from onMount. So reassigning
the URL restarted precisely nothing, and since player_exit_background_audio had
already stopped the handoff's audio player, the backend came back holding no item
at all. That is why the play button was inert: there was nothing loaded to play.
The return now re-issues that pair on the native path, in the same order as the
initial load, carrying the position the audio reached. Subtitle configurations are
reused from the ones resolved at mount — ExoPlayer sideloads them as
MediaItem.SubtitleConfigurations and cannot accept one after prepare().
Which path to take is decided by planHandoffReturn, a pure helper in
backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the
player. It also folds in shouldResumeOnForeground, so a pause taken on the
lockscreen during the handoff still wins over the snapshot captured on the way
out.
Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54,
return restored native video playing at 70:18. Previously the same sequence left
the player idle and black.
The requirements count pin in extract-traces.test.ts moves with the new DR-196.
Pausing from the lockscreen did nothing while a video's audio played in
the background. The handoff starts native ExoPlayer audio and only then
tears the WebView <video> down, and that teardown fires a DOM `pause`
the frontend reports like any other — leaving html5_playing = Some(false).
Transport therefore stayed aimed at the element: the lockscreen pause
emitted a ControlCommand into a <video> that no longer existed while the
native player carried on.
The controller now tracks a background-audio handoff explicitly. Entering
one hands transport authority to the native backend and drops the dying
element's state/position/media-loaded reports, which also stop flipping
the UI to paused and dragging the position backwards. Exiting restores
the element as the player.
A lockscreen pause also has to survive the return to the foreground: the
video used to resume from a snapshot taken at handoff time, undoing the
pause on the way back in. shouldResumeOnForeground() lets an explicit
`paused` from the player override that snapshot.
TRACES: UR-040, UR-005 | DR-052, DR-097
Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.