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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Control-bar auto-hide rule (DR-189).
|
||||
*
|
||||
* TRACES: UR-003, UR-066 | DR-189 | UT-188
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldHideControls } from "./controlsVisibility";
|
||||
|
||||
const playing = { isPlaying: true, isSeeking: false, menuOpen: false };
|
||||
|
||||
describe("shouldHideControls", () => {
|
||||
it("hides the bar during uninterrupted playback", () => {
|
||||
expect(shouldHideControls(playing)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the bar while paused", () => {
|
||||
// A user who paused by tapping the surface has no other way back.
|
||||
expect(shouldHideControls({ ...playing, isPlaying: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the bar while seeking", () => {
|
||||
// The position readout is the point of the bar mid-seek.
|
||||
expect(shouldHideControls({ ...playing, isSeeking: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the bar while a menu is open", () => {
|
||||
// The menus are anchored to the bar; hiding it takes the open menu with it.
|
||||
expect(shouldHideControls({ ...playing, menuOpen: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* When the player's control bar may auto-hide.
|
||||
*
|
||||
* TRACES: UR-003, UR-066 | DR-189 | UT-188
|
||||
*
|
||||
* The bar's hide timer used to be armed from exactly one place — the container's
|
||||
* `onmousemove`. A touchscreen never fires `mousemove`, so on Android the timer
|
||||
* was never set and the bar stayed on screen for the whole film. It went
|
||||
* unnoticed while the video itself was invisible: with nothing to obscure, a
|
||||
* permanent control bar looks like the UI, not like a defect.
|
||||
*
|
||||
* The decision is separated from the timer so it can be tested without a clock
|
||||
* or a DOM: it is a rule about state, and the parts that were wrong here were
|
||||
* the conditions, not the `setTimeout`.
|
||||
*/
|
||||
|
||||
/** Everything that decides whether the bar may disappear right now. */
|
||||
export interface ControlsHideContext {
|
||||
/** Hiding controls over a paused player strands the user with no affordance. */
|
||||
isPlaying: boolean;
|
||||
/** A seek in flight is exactly when the position readout is worth watching. */
|
||||
isSeeking: boolean;
|
||||
/** True while any of the track / subtitle / quality menus is open. */
|
||||
menuOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the control bar may hide now.
|
||||
*
|
||||
* Requires playback to be running: a paused player keeps its controls, which is
|
||||
* both the convention and the only way back for a user who paused by tapping.
|
||||
* A menu open over the bar pins it too — the menus are anchored to the bar, so
|
||||
* hiding it would take the open menu with it, mid-interaction.
|
||||
*/
|
||||
export function shouldHideControls(ctx: ControlsHideContext): boolean {
|
||||
if (!ctx.isPlaying) return false;
|
||||
if (ctx.isSeeking) return false;
|
||||
if (ctx.menuOpen) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Native-path reveal rule (DR-182).
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-182 | UT-184
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { nativeSignalRevealsVideo } from "./mediaReady";
|
||||
|
||||
describe("nativeSignalRevealsVideo", () => {
|
||||
it("reveals on the backend's playing state", () => {
|
||||
expect(nativeSignalRevealsVideo({ kind: "state", state: "playing" })).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["buffering", "paused", "stopped", "ended", "error", "idle", ""])(
|
||||
"leaves the poster up on state %s",
|
||||
(state) => {
|
||||
expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("reveals on a position tick that carries a duration", () => {
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals on a position tick that has advanced, even with no duration", () => {
|
||||
// Live streams report no duration; an advancing position is still proof
|
||||
// that the surface has content.
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the poster up on an empty position tick", () => {
|
||||
// A tick before anything is loaded proves nothing, and revealing here would
|
||||
// show a transparent hole through the app.
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a negative position as progress", () => {
|
||||
expect(
|
||||
nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -27,31 +27,52 @@ const STORAGE_KEY = "jellytau-experimental-native-video";
|
||||
const NATIVE_VIDEO_ATTR = "data-native-video";
|
||||
|
||||
/**
|
||||
* Whether the native path is on. **Off** unless the user turned it on.
|
||||
* Whether the native path is on, defaulting to **on** when the user has never
|
||||
* chosen.
|
||||
*
|
||||
* DR-161 briefly made this default to on, so picture-in-picture could shrink a
|
||||
* real video surface. On a device that shipped as **audio with no picture**:
|
||||
* ExoPlayer decoded correctly and fed its SurfaceView, but the SurfaceView sits
|
||||
* *behind* the WebView and the compositing that clears the opaque layers above it
|
||||
* never took effect — logcat showed `WebView transparent = false` and never
|
||||
* `= true`. So the video was rendering the whole time, behind the page.
|
||||
* This default has moved three times, so the history is the documentation:
|
||||
*
|
||||
* That is the defect the flag existed to contain, and it is why the default is
|
||||
* back off: video working matters more than PiP showing the native surface, and
|
||||
* PiP still works without it via the HTML5 path (DR-160). Native video remains
|
||||
* available in Settings for anyone testing it.
|
||||
* - **off** while the path was a spike (DR-150).
|
||||
* - **on** for picture-in-picture (DR-161), which shipped as *audio with no
|
||||
* picture* — ExoPlayer decoded correctly into a live SurfaceView while the
|
||||
* page stayed opaque over it.
|
||||
* - **off** again (DR-172), which named the compositing as the suspect but did
|
||||
* not find it.
|
||||
* - **on** now, because the four defects behind that symptom were found and
|
||||
* each is fixed and verified on a device: the app shell painted over the
|
||||
* surface through a CSS rule targeting an attribute nothing set (DR-185); the
|
||||
* poster card had no way to lift on a path with no `<video>` element
|
||||
* (DR-182); the JS bridges raced the page load, so `setTransparent(true)`
|
||||
* could never arrive (DR-183); and the SurfaceView was never detached
|
||||
* (DR-184). Two further UI defects that only this path could show — the play
|
||||
* overlay never clearing (DR-186) and the system bars staying over the player
|
||||
* (DR-187) — are fixed with it.
|
||||
*
|
||||
* The picture is genuinely fixed and device-verified — `WebView transparent =
|
||||
* true` and `Marking media ready` now appear in logcat with video on screen,
|
||||
* the pair DR-172 went looking for and could not find. **The default is still
|
||||
* off**, because turning it on surfaced a different gap: the background-audio
|
||||
* handoff (UR-040) can only *return* through the HTML5 element.
|
||||
* `applyPendingForegroundSeek` bails on `!videoElement`, the HLS re-init effect
|
||||
* bails on `!useHtml5Element`, and `handleCanPlay` — the event that owns the
|
||||
* post-handoff position and play state — is an element event that never fires
|
||||
* natively. So coming back from background audio leaves playback dead.
|
||||
*
|
||||
* That is the same shape of mistake as DR-161: a verified sub-path shipped as a
|
||||
* default over an unverified one. The evidence standard this branch set for the
|
||||
* picture applies to the handoff too, so the flip waits for it (DR-190).
|
||||
*
|
||||
* An explicit stored choice still wins in both directions, so anyone who turned
|
||||
* it on keeps it on.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-172
|
||||
* TRACES: UR-003, UR-004 | DR-188
|
||||
*/
|
||||
function load(): boolean {
|
||||
if (typeof localStorage === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
// Private-mode / disabled storage — default to the safe (HTML5) path.
|
||||
// Private-mode / disabled storage — default to the path whose handoff works.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -80,9 +101,9 @@ function createExperimentalNativeVideoStore() {
|
||||
}
|
||||
|
||||
/**
|
||||
* User opt-in for the native Android video path. **Defaults to off** again since
|
||||
* DR-172 — see `load()`. The name says "experimental" because the flag
|
||||
* remains a suppressor of Rust's backend choice, not a promoter of it.
|
||||
* User opt-in for the native Android video path. **Defaults to off** — see
|
||||
* `load()`. The name says "experimental" because the flag remains a suppressor
|
||||
* of Rust's backend choice, not a promoter of it.
|
||||
*/
|
||||
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Every opaque layer the native-video CSS claims to clear must actually exist.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-185 | UT-186
|
||||
*
|
||||
* The compositing rules in app.css clear the page's painted backgrounds so the
|
||||
* ExoPlayer SurfaceView behind the WebView can be seen. One of the three
|
||||
* selectors, `[data-app-shell]`, was written against an attribute that **no
|
||||
* component ever set** — in any commit — so the app shell went on painting
|
||||
* `--color-background` across the whole viewport, underneath a player that had
|
||||
* correctly made itself transparent. The WebView therefore composited opaque
|
||||
* and the surface could never show through.
|
||||
*
|
||||
* That failure is invisible three ways over: the CSS is valid, the selector is
|
||||
* plausible, and the symptom (black screen, audio fine) is identical to a
|
||||
* genuine compositing failure — which is how it survived DR-150 through DR-172.
|
||||
* A rule that matches nothing is the specific defect worth a tripwire, so this
|
||||
* asserts the relationship rather than the rule: every attribute the block
|
||||
* targets is set somewhere in the app.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const srcRoot = path.resolve(here, "../..");
|
||||
|
||||
function read(file: string): string {
|
||||
return fs.readFileSync(file, "utf-8");
|
||||
}
|
||||
|
||||
/** Every .svelte file under src/. */
|
||||
function svelteFiles(dir: string, found: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) svelteFiles(full, found);
|
||||
else if (entry.name.endsWith(".svelte")) found.push(full);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selector list of the `[data-native-video="active"]` rule in app.css.
|
||||
* Returned verbatim, one selector per entry.
|
||||
*/
|
||||
function compositingSelectors(css: string): string[] {
|
||||
const marker = 'html[data-native-video="active"]';
|
||||
const start = css.indexOf(marker);
|
||||
expect(start, "app.css no longer contains the native-video rule").toBeGreaterThan(-1);
|
||||
const open = css.indexOf("{", start);
|
||||
return css
|
||||
.slice(start, open)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
describe("native-video compositing layers (DR-185)", () => {
|
||||
const css = read(path.join(srcRoot, "app.css"));
|
||||
const selectors = compositingSelectors(css);
|
||||
const markup = svelteFiles(srcRoot).map(read).join("\n");
|
||||
|
||||
it("clears the app shell, which paints over the whole viewport", () => {
|
||||
// The shell is the layer directly between the player and the WebView; if it
|
||||
// stays painted, nothing below it can be seen however transparent the
|
||||
// player and the WebView widget are.
|
||||
expect(selectors.some((s) => s.includes("[data-app-shell]"))).toBe(true);
|
||||
expect(markup).toContain("data-app-shell");
|
||||
});
|
||||
|
||||
it("targets no attribute that nothing in the app sets", () => {
|
||||
const attributes = selectors
|
||||
.flatMap((selector) => [...selector.matchAll(/\[([a-zA-Z-]+)(?:[=\]])/g)])
|
||||
.map((match) => match[1])
|
||||
// data-native-video is set imperatively on <html> by nativeVideo.ts, not
|
||||
// in markup, so it is verified against that module instead.
|
||||
.filter((attr) => attr !== "data-native-video");
|
||||
|
||||
const unset = [...new Set(attributes)].filter((attr) => !markup.includes(attr));
|
||||
expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`)
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
it("still sets data-native-video on <html> from the store", () => {
|
||||
const store = read(path.join(srcRoot, "lib/stores/nativeVideo.ts"));
|
||||
expect(store).toContain("data-native-video");
|
||||
expect(store).toContain("documentElement");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Native video surface compositing, Android only.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-150, DR-151
|
||||
* TRACES: UR-003, UR-004 | DR-150, DR-151, DR-183
|
||||
*
|
||||
* On Android, ExoPlayer renders video into a SurfaceView that sits *behind* the
|
||||
* Tauri WebView (`setZOrderMediaOverlay(false)`, added at index 0 of the content
|
||||
@@ -65,8 +65,23 @@ export function enableNativeVideoCompositing(): void {
|
||||
// Page layer first: if the Kotlin call succeeded but this threw, the user
|
||||
// would see through the app to the home screen.
|
||||
nativeVideoActive.set(true);
|
||||
const androidVideoSurface = bridge();
|
||||
if (!androidVideoSurface) {
|
||||
// Say so loudly. Every bridge call in this file is optional-chained, so a
|
||||
// missing bridge is silent — and a silently-skipped setTransparent(true) is
|
||||
// indistinguishable on screen from a compositing failure: ExoPlayer renders
|
||||
// correctly behind a WebView that never stopped painting its own opaque
|
||||
// background. That ambiguity is what DR-172 was left holding. MainActivity's
|
||||
// console bridge forwards this to logcat under the JellyTauWeb tag.
|
||||
console.error(
|
||||
"[videoSurface] AndroidVideoSurface bridge is MISSING - the webview will " +
|
||||
"stay opaque and native video will play as audio with no picture"
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
bridge()?.setTransparent(true);
|
||||
androidVideoSurface.setTransparent(true);
|
||||
console.log("[videoSurface] compositing enabled (setTransparent(true) sent)");
|
||||
} catch (err) {
|
||||
console.warn("[videoSurface] setTransparent(true) failed:", err);
|
||||
nativeVideoActive.set(false);
|
||||
|
||||
Reference in New Issue
Block a user