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
@@ -0,0 +1,306 @@
/**
* VideoPlayer native-path reveal tests (Android / ExoPlayer)
*
* Reproduces "native video plays as audio with no picture" (DR-172).
*
* The poster/title card is an opaque `bg-black` overlay drawn while
* `isMediaReady` is false. Every signal that clears it — `canplay`,
* `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event and two
* `readyState` timeouts — comes from the HTML5 `<video>` element. On the native
* path there is no such element, so nothing ever cleared it: ExoPlayer decoded
* and fed its SurfaceView correctly the whole time, behind a black div.
*
* These tests pin the **flag-on** path: the backend reports native, the user
* opted in, and the video area must be revealed by the *backend's* own signals.
*
* TRACES: UR-003, UR-004, UR-041 | DR-182 | UT-185
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// The native path is what these tests guard, so the opt-in flag is mocked ON.
// Stated explicitly rather than inherited: the default has moved twice
// (DR-161 on, DR-172 off) and a test that inherits it silently changes meaning.
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
return {
...actual,
experimentalNativeVideo: {
subscribe: (run: (v: boolean) => void) => {
run(true);
return () => {};
},
set: () => {},
current: () => true,
},
};
});
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
const playerPlayItem = vi.fn(async () => ({
// What Android reports: native ExoPlayer backend, no HTML5 element.
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" },
}));
const playerStop = vi.fn(async () => ({}));
vi.mock("$lib/api/bindings", () => ({
commands: {
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
playerStop: (...a: any[]) => playerStop(...(a as [])),
playerSeek: vi.fn(async () => ({})),
playerPlay: vi.fn(async () => ({})),
playerPause: vi.fn(async () => ({})),
playerToggle: vi.fn(async () => ({ state: "playing" })),
playerSeekVideo: vi.fn(async (_h: string, position: number) => ({
strategy: "native",
position,
})),
playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: vi.fn(async () => ({})),
playerSetSleepTimer: vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 })),
playerCancelSleepTimer: vi.fn(async () => ({
mode: { kind: "off" },
remainingSeconds: 0,
})),
playerGetStreamingQualities: vi.fn(async () => []),
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
},
events: {
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({
getHandle: () => "repo-1",
getSubtitleUrl: async () => "",
jrayActorsAt: async () => [],
}),
},
}));
vi.mock("$app/navigation", () => ({
goto: vi.fn(),
}));
// The immersive bridge is native-only; assert the call rather than its effect.
const enterImmersive = vi.fn();
vi.mock("$lib/utils/immersive", () => ({
enterImmersive: (...a: any[]) => enterImmersive(...a),
exitImmersive: vi.fn(),
isImmersiveSupported: () => true,
}));
import { render, waitFor } from "@testing-library/svelte";
import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import { player } from "$lib/stores/player";
import type { MediaItem } from "$lib/api/types";
function makeEpisode(): MediaItem {
return {
id: "ep1",
name: "Episode 1",
kind: "episode",
durationMs: 24 * 60 * 1000,
} as MediaItem;
}
async function mountNativePlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
},
});
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
// The native path must NOT be overridden to HTML5 and must NOT be stopped —
// if it were, these tests would be guarding the HTML5 path by accident.
await waitFor(() =>
expect(utils.container.querySelector("video")).toBeNull()
);
expect(playerStop).not.toHaveBeenCalled();
return utils;
}
/** The opaque poster/title card drawn while the media is not yet revealed. */
function poster(container: HTMLElement): HTMLElement | null {
return container.querySelector('[data-testid="video-poster"]');
}
/**
* Report backend playback state the way the app actually does.
*
* NOT via `player://position-update` / `player://state-changed`: those channels
* are **never emitted by the backend**, which is exactly the trap this test
* exists to avoid. An earlier version of it fired those handlers by hand, went
* green, and guarded nothing — on the device the poster stayed up while
* ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and
* the store is what the component must read.
*/
async function backendReports(
kind: "playing" | "paused" | "error",
position = 0,
duration = 0
) {
const media = makeEpisode();
if (kind === "playing") player.setPlaying(media, position, duration);
else if (kind === "paused") player.setPaused(media, position, duration);
else player.setError("Decoder failed", media);
await tick();
}
describe("VideoPlayer native path reveals the video (DR-172)", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
player.setIdle();
});
it("keeps the poster up until the backend reports something", async () => {
const { container } = await mountNativePlayer();
// Nothing has been heard from ExoPlayer yet, so the title card is correct.
expect(poster(container)).not.toBeNull();
});
it("clears the poster when the backend reports playing", async () => {
const { container } = await mountNativePlayer();
await backendReports("playing", 0, 1440);
// The surface is rendering behind the webview; an opaque overlay over it is
// exactly the "audio with no picture" defect.
await waitFor(() => expect(poster(container)).toBeNull());
});
it("clears the poster when the backend reports a paused position with a duration", async () => {
const { container } = await mountNativePlayer();
// Backstop for a backend that starts paused: a position carrying a real
// duration means the media is loaded and the surface has content,
// mirroring the HTML5 readyState fallback.
await backendReports("paused", 12, 1440);
await waitFor(() => expect(poster(container)).toBeNull());
});
it("clears the play overlay when the backend resumes after a pause (DR-186)", async () => {
const { container } = await mountNativePlayer();
await backendReports("paused", 5, 1440);
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
);
await backendReports("playing", 6, 1440);
// This overlay is `bg-black/30` across the whole video area: left up, it
// both dims and covers the ExoPlayer surface while it plays. Before the
// mirror, nothing after init could take it down, because the only other
// writer was the never-emitted `player://state-changed` channel.
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
});
it("raises the play overlay again when the backend reports paused (DR-186)", async () => {
const { container } = await mountNativePlayer();
await backendReports("playing", 5, 1440);
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
);
await backendReports("paused", 6, 1440);
// The mirror has to work in both directions, or pausing leaves no affordance
// to resume.
await waitFor(() =>
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
);
});
it("hides the system bars on entry, not only on the fullscreen button (DR-187)", async () => {
await mountNativePlayer();
// The player owns the whole screen; on the native path the system bars would
// otherwise sit directly on top of the ExoPlayer surface.
expect(enterImmersive).toHaveBeenCalled();
});
it("hides the control bar once playback starts, however late (DR-189)", async () => {
// Reproduce the device sequence: the backend is still starting when the
// player mounts, so playback begins *after* the first countdown window.
playerPlayItem.mockResolvedValueOnce({
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "loading" },
} as any);
vi.useFakeTimers();
try {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
},
});
await vi.advanceTimersByTimeAsync(50);
// The three seconds after entry elapse while the backend is still
// starting, so the bar correctly stays up. This is the exact window that
// defeated the first attempt: a one-shot timer armed on entry fired here,
// declined, and was never re-armed.
await vi.advanceTimersByTimeAsync(3500);
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain("opacity-0");
// Playback starts late; the countdown has to restart on its own.
player.setPlaying(makeEpisode(), 5, 1440);
await vi.advanceTimersByTimeAsync(3500);
await vi.waitFor(() =>
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain("opacity-0")
);
} finally {
vi.useRealTimers();
}
});
it("does not clear the poster on an errored backend", async () => {
const { container } = await mountNativePlayer();
await backendReports("error");
// Revealing here would replace the title card with a transparent hole
// showing the launcher through the app.
expect(poster(container)).not.toBeNull();
});
});