Play/pause did nothing on the Android native video path — from the on-screen tap, from the control bar, and from a direct player_toggle invocation — while seek and skip kept working. That asymmetry was the whole clue: seek decides in player_seek_video, transport decides in toggle_playback. DR-195 is the cause. `html5_playing` is Rust's record of "a webview <video> is active and in this state", and toggle_playback/play/pause all route transport to that element whenever it is set. The player route mirrored element state into it UNCONDITIONALLY — from handleReportStart and, fatally, from handleReportProgress, which VideoPlayer calls on a 10-second interval. So on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. It also explains the flashing: the control bar and the JRay overlay both key off isPlaying, which was being contradicted on every tick. The mirror now lives in mirrorElementStateToRust() in VideoPlayer, gated on useHtml5Element — the only place that knows whether an element renders at all. The route cannot tell the paths apart, which is exactly how it came to lie. DR-193 hands transport authority back to the native backend when an item loads into it. Necessary but insufficient alone: the progress interval put the flag straight back, which is why the first device test after it still failed. DR-192 presents native video through a TextureView instead of a SurfaceView. A SurfaceView renders on its own layer outside the app window and punches a transparent region through it, and everything drawn above that hole — here, the entire Svelte UI — depends on that composition path. The overlay dropped its incremental damage: the DOM advanced (slider 476 -> 479 across three seconds) behind a screen showing neither, so the progress bar froze, controls would not fade and rotation lost the transport UI, while structural DOM changes got through, which is why the play overlay always appeared to work. It supersedes DR-191, which forced redraws in a loop and treated the symptom. DR-194 hides the video view across a resize and reveals it two frames later. A TextureView retains its last frame, so between a rotation and the re-fit landing that frame is stretched across the old rect and the previous frame flashes in what should be the letterbox bars. Verified on device (Honor ROD2-W09, Android 16) by driving ADB and reading the live DOM over the devtools socket: surface tap pauses (position frozen across 12 seconds, overlay raised, transport flipped) and resumes; the control bar does both. UT-189 drives the real 10-second interval under fake timers — an earlier version asserted on a freshly mounted player, passed with the guard deleted, and guarded nothing. Still open, and deliberately not claimed: DR-192's effect on the overlay repaint is unverified on device, DR-194's letterbox reset is untested, and the native default (DR-188) stays off pending DR-190, the background-audio return.
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
/**
|
|
* 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 () => ({}));
|
|
const playerReportState = vi.fn(async () => null);
|
|
|
|
vi.mock("$lib/api/bindings", () => ({
|
|
commands: {
|
|
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
|
|
playerStop: (...a: any[]) => playerStop(...(a as [])),
|
|
playerReportState: (...a: any[]) => playerReportState(...(a as [])),
|
|
playerReportPosition: vi.fn(async () => null),
|
|
playerReportMediaLoaded: vi.fn(async () => null),
|
|
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("never reports webview element state on the native path (DR-195)", async () => {
|
|
// The report that mattered came from the 10-second progress interval, so
|
|
// the test has to reach it: the interval needs `onReportProgress` wired and
|
|
// `isPlaying` true, then time has to pass. Asserting on a freshly mounted
|
|
// player proves nothing — an earlier version of this test did exactly that
|
|
// and passed with the guard deleted.
|
|
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(),
|
|
onReportProgress: vi.fn(),
|
|
},
|
|
});
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(utils.container.querySelector("video")).toBeNull();
|
|
|
|
// Backend playing, so the interval's `isPlaying` guard is satisfied.
|
|
player.setPlaying(makeEpisode(), 5, 1440);
|
|
await vi.advanceTimersByTimeAsync(25_000);
|
|
|
|
// `html5_playing` is Rust's record of "a webview element is active", and
|
|
// `toggle_playback`/`play`/`pause` all route transport to that element
|
|
// whenever it is set. Reporting it with no element in existence is what
|
|
// left the pause button dead on the native path — from the surface tap,
|
|
// the control bar, and a direct `player_toggle` invocation alike — while
|
|
// seek and skip kept working, because they decide elsewhere.
|
|
expect(playerReportState).not.toHaveBeenCalled();
|
|
} 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();
|
|
});
|
|
});
|