Files
jellytau/src/lib/components/player/mediaReady.ts
T
dtourolle 95129d04a3 fix(player): make Android native video actually visible, and usable
DR-172 reverted native video to opt-in after it shipped as audio with no
picture, naming the compositing as the suspect. The compositing was fine. Five
separate defects sat between ExoPlayer and the screen, each able to produce that
exact symptom on its own, and each invisible to the others.

DR-185 — the app shell painted over the surface. app.css clears the page's
opaque layers through three selectors, one of which targets `[data-app-shell]`,
an attribute NO component has ever set, in any commit. The shell paints
--color-background across the whole viewport and VideoPlayer stacks above it, so
the WebView composited opaque no matter what else was cleared. Invisible three
ways over: the CSS is valid, the selector is plausible, and a rule matching
nothing looks exactly like a rule matching something already transparent.

DR-182 — nothing could lift the poster card. Every markMediaReady() call site is
an HTML5 <video> event, and the native branch renders no element, so the black
title card covered the surface for the entire session. The first fix hooked
`player://position-update` / `player://state-changed`; those channels are never
emitted by the backend, so it passed a test that fired them by hand and did
nothing on a device. Driven from the player store now, as the seek bar already
was.

DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by
walking the view tree, while WebView binds injected objects at page-load time,
and the identity guard then declined to re-inject forever. setTransparent(true)
could never arrive. Installed from WryActivity.onWebViewCreate instead, which
wry calls immediately before the first loadUrl.

DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers
anywhere, mirroring the DR-151 defect: every native video left its surface
parented to the content view and the next one stacked another beneath it.

DR-191 — the overlay stopped repainting. Incremental damage (the clock's text,
the control bar's opacity) never reached the screen while structural changes did,
so the progress bar froze, the controls would not fade, and the play overlay
appeared to work because it is added and removed from the DOM. Driven from the
Activity via postInvalidateOnAnimation while compositing is on.

Two UI defects only this path could reveal came with them: isPlaying froze at
its initial value, leaving the play overlay dimming and covering the video
(DR-186), and the control bar's auto-hide was armed solely by mousemove, which a
touchscreen never fires (DR-189). Immersive mode now applies on entering the
player rather than only via the fullscreen button (DR-187).

Verified on a device (Honor ROD2-W09, Android 16): logcat carries
`WebView transparent = true` and `Marking media ready` with video on screen —
the pair DR-172 went looking for and could not find — and skip, seek, rotation
and subtitle rendering were exercised by hand.

The default stays OFF (DR-188). Turning it on surfaced a further unverified
sub-path: returning from background audio is HTML5-only, so playback stays dead
(DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified
sub-path made default over an unverified one.
2026-08-16 15:28:10 +02:00

49 lines
2.2 KiB
TypeScript

/**
* When the video area may be revealed on the **native** (ExoPlayer) path.
*
* TRACES: UR-003, UR-004 | DR-182 | UT-184
*
* VideoPlayer draws an opaque `bg-black` poster/title card over the video area
* until `isMediaReady`. Every signal that clears it is emitted by the HTML5
* `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the
* `playing` event, and two `readyState` timeouts. The native path has no such
* element, so on Android nothing ever cleared the card: ExoPlayer decoded to a
* live SurfaceView behind a black div, which is the "audio with no picture"
* report of DR-172 and is indistinguishable from a compositing failure.
*
* The backend's own events are the equivalent signals, and this is the rule for
* reading them. It is a pure function rather than a branch inside the component
* because the component cannot be exercised without a DOM and a mounted player,
* and this decision is exactly the part that was missing and needs a guard.
*/
/** A player event that might mean "the surface has a picture on it". */
export type NativeRevealSignal =
| { kind: "state"; state: string }
| { kind: "position"; position: number; duration: number };
/**
* Whether `signal` proves the native backend is rendering, and the poster card
* should therefore come down.
*
* Two signals qualify, mirroring the HTML5 path's primary event and its
* backstop:
*
* - **`state === "playing"`** — the direct equivalent of the `<video>`
* `playing` event. ExoPlayer reports this once it is actually drawing.
* - **a position tick carrying a real position or duration** — the equivalent
* of the `readyState` fallbacks. It covers a first state event that is
* dropped or arrives before the listener is attached; a tick means the media
* is loaded and the surface has content.
*
* Everything else — `buffering`, `paused`, `stopped`, `error` — leaves the card
* up. Revealing on `error` in particular would replace the title card with a
* transparent hole showing the launcher through the app.
*/
export function nativeSignalRevealsVideo(signal: NativeRevealSignal): boolean {
if (signal.kind === "state") {
return signal.state === "playing";
}
return signal.duration > 0 || signal.position > 0;
}