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.
This commit is contained in:
2026-08-16 15:28:10 +02:00
parent f0f98feae8
commit 95129d04a3
18 changed files with 5628 additions and 4552 deletions
+128 -12
View File
@@ -25,7 +25,7 @@
type RenderableSubtitleTrack,
} from "./subtitleTracks";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition, playerState } from "$lib/stores/player";
import { playbackPosition, playbackDuration, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player";
import {
@@ -40,6 +40,8 @@
enableNativeVideoCompositing,
disableNativeVideoCompositing,
} from "$lib/utils/videoSurface";
import { nativeSignalRevealsVideo } from "./mediaReady";
import { shouldHideControls } from "./controlsVisibility";
import {
isPipSupported,
enterPip,
@@ -154,7 +156,9 @@
let pipListenerCleanup: (() => void) | null = null;
let showSleepTimerModal = $state(false);
let isBuffering = $state(false);
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
// Bumped by every reveal so the auto-hide effect restarts its countdown even
// when no other input to that decision changed (a tap during playback).
let lastControlsInteraction = $state(0);
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
let isSeeking = $state(false);
// Capture only the initial streamUrl prop; later prop changes are applied via
@@ -441,6 +445,88 @@
}
});
// Auto-hide the control bar.
//
// An `$effect` rather than a timer armed by input, because the conditions that
// *permit* hiding arrive on their own schedule. The first attempt armed a
// one-shot timer from `revealControls()` on entry; three seconds later
// playback had not started yet, `shouldHideControls` correctly declined, and
// nothing re-armed it — so the bar sat over the video for the whole film. The
// timer has to follow the state, not the input event.
//
// Re-runs whenever any input changes: each run cancels the previous timer, so
// starting playback, closing a menu or finishing a seek re-arms it, and
// pausing or opening a menu cancels it. `lastControlsInteraction` is read so a
// tap restarts the countdown even when nothing else changed.
//
// TRACES: UR-003, UR-066 | DR-189 | UT-188
$effect(() => {
void lastControlsInteraction;
if (!showControls) return;
if (
!shouldHideControls({
isPlaying,
isSeeking,
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
})
) {
return;
}
const timer = setTimeout(() => {
showControls = false;
}, 3000);
return () => clearTimeout(timer);
});
// Reveal the video on the native path.
//
// The poster/title card is opaque and covers the whole video area, so on this
// path it is the only thing between the viewer and the ExoPlayer surface —
// every other markMediaReady() call site is a `<video>` element event, and
// there is no `<video>` here.
//
// Driven from the same stores as the seek bar above, deliberately: the
// `player://position-update` and `player://state-changed` channels the native
// branch subscribes to are **never emitted by the backend** (see the comment
// on the effect above — the seek bar had to be moved off them for the same
// reason). Hooking the reveal to those channels looks right, passes a test
// that fires them by hand, and does nothing on a device.
//
// TRACES: UR-003, UR-004 | DR-182 | UT-185
$effect(() => {
if (useHtml5Element || isMediaReady) return;
const state = $playerState.kind;
const position = $playbackPosition;
const duration = $playbackDuration;
if (
nativeSignalRevealsVideo({ kind: "state", state }) ||
nativeSignalRevealsVideo({ kind: "position", position, duration })
) {
markMediaReady();
}
});
// Mirror the backend's play/pause into the UI on the native path.
//
// `isPlaying` is assigned once from the player_play_item response and then
// only by the `player://state-changed` listener — a channel the backend never
// emits, exactly as for the reveal above. So on the native path it was
// whatever the initial response said, forever: with ExoPlayer playing, the UI
// still believed it was paused, which raised the `bg-black/30` play overlay
// over the video surface and left the transport button showing ▶. The video
// was both dimmed and covered while it played.
//
// The player is the authoritative source of playback state and the UI is a
// consumer of it (see the architecture docs), so this reads the same store
// `playerEvents.ts` feeds rather than tracking it locally. HTML5 keeps its own
// element-event wiring, which is authoritative for that path.
//
// TRACES: UR-003, UR-005 | DR-186 | UT-187
$effect(() => {
if (useHtml5Element) return;
isPlaying = $playerState.kind === "playing";
});
// Set up HLS.js for HLS streams
$effect(() => {
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
@@ -695,6 +781,21 @@
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
}
// The video player owns the whole screen, so the system bars go away with it
// — not only when the fullscreen button is pressed, which was the sole
// caller of enterImmersive(). The status and navigation bars stayed painted
// over the player on entry, and on the native path they sit directly on top
// of the ExoPlayer surface, which fills the content view.
//
// Synchronous, before any await, per the native-mode pitfall above. Paired
// with the unconditional exitImmersive() in onDestroy. (UR-066, DR-187)
enterImmersive();
// Arm the control-bar auto-hide on entry. Without this the bar only ever
// hides after the first pointer/touch event, which on a touchscreen meant
// "after the user happens to tap" — and before DR-189 wired touch up, never.
revealControls();
// Initialize player via Rust - Rust will decide which backend to use based on platform
if (media && currentStreamUrl) {
try {
@@ -1676,18 +1777,25 @@
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleMouseMove() {
/**
* Show the control bar and arm its auto-hide.
*
* This used to be `handleMouseMove` and was wired *only* to the container's
* `onmousemove`. A touchscreen never fires `mousemove`, so on Android the
* timer was never armed and the bar stayed up for the whole film — hidden in
* plain sight while the native video surface was itself invisible. It is now
* armed on entry and on every touch interaction as well.
*
* TRACES: UR-003, UR-066 | DR-189 | UT-188
*/
function revealControls() {
showControls = true;
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
lastControlsInteraction = Date.now();
}
// Kept as the mouse entry point; desktop still drives it from pointer motion.
const handleMouseMove = revealControls;
async function seekRelative(seconds: number) {
isSeeking = true;
@@ -1850,6 +1958,10 @@
playerGestureActive = false;
swipeGestureActive = false;
swipeType = null;
// Touch is the only input on the platform this player mostly runs on, and
// it is what `mousemove` never covers: show the bar and re-arm its hide.
// (DR-189)
revealControls();
}
/**
@@ -2117,7 +2229,10 @@
<!-- Title card with loading spinner (Loading state from DR-001) -->
{#if !isMediaReady}
<div class="absolute inset-0 flex items-center justify-center bg-black">
<div
data-testid="video-poster"
class="absolute inset-0 flex items-center justify-center bg-black"
>
<!-- Poster/Title Card -->
{#if media?.imageId}
<CachedImage
@@ -2196,6 +2311,7 @@
See DR-098. -->
<button
data-player-surface
data-testid="play-overlay"
class="absolute inset-0 flex items-center justify-center bg-black/30"
onclick={handleSurfaceClick}
aria-label="Play"