Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 4m55s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 20s
DR-161 flipped experimentalNativeVideo on by default so picture-in-picture could shrink a real video surface. On a device that shipped sound with a blank screen. The decode path was never at fault. Logcat shows ExoPlayer running and feeding a live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView sits behind the WebView, and the step that clears the opaque layers above it never took effect — `WebView transparent = false` is logged, `= true` never appears. The video was rendering correctly the whole time, behind an opaque page. This is precisely the defect the flag existed to contain; VideoPlayer.scrubRegression.test.ts had already recorded that "the native SurfaceView has never been visible through the webview". Enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it — DR-160 drives PiP from the WebView <video> — and working video outranks PiP showing a native surface. The flag stays in Settings, now described as incomplete rather than as a performance win, so anyone helping test it still can. Fixing the compositing is the prerequisite for trying this default again (DR-172).
244 lines
8.4 KiB
TypeScript
244 lines
8.4 KiB
TypeScript
/**
|
|
* VideoPlayer seek-bar TOUCH scrub regression tests (Android).
|
|
*
|
|
* Reported bug: on Android, dragging the progress bar does not change the
|
|
* playback location.
|
|
*
|
|
* The gesture listener lives on the outer container and touch events bubble.
|
|
* `handleTouchStart` ignores touches that land on a control (the seek bar is an
|
|
* <input>, inside `data-player-controls`) — but `handleTouchMove` does not, so a
|
|
* seek-bar drag is still interpreted as a container swipe. That mis-read swipe
|
|
* fires `togglePlayPause()` (undoing a first-tap toggle that never happened) and
|
|
* hijacks the drag into brightness control.
|
|
*
|
|
* The existing scrub regression tests only drive the slider with MOUSE events,
|
|
* which never reach the touch handlers — which is why this survived.
|
|
*
|
|
* The seek was also committed only from `change`, which Android's WebView does
|
|
* not reliably fire for a touch interaction on a range input — so a tap moved
|
|
* the thumb and no seek ever ran. Release now commits from touchend/mouseup too.
|
|
*
|
|
* TRACES: UR-005, UR-061 | DR-098, DR-099 | UT-089, UT-090
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// ---- Mocks (must precede component import) --------------------------------
|
|
|
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
|
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
|
// off, VideoPlayer overrides Android's native backend response to HTML5
|
|
// rendering and stops the native backend. That is the default again (DR-172,
|
|
// after native video shipped as audio with no picture), so this mock now agrees
|
|
// with the default rather than opposing it — kept explicit so the tests state
|
|
// which path they guard instead of inheriting whatever the default happens to be.
|
|
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(false);
|
|
return () => {};
|
|
},
|
|
set: () => {},
|
|
current: () => false,
|
|
},
|
|
};
|
|
});
|
|
|
|
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 () => ({
|
|
useHtml5Element: false,
|
|
backend: "exoplayer",
|
|
state: { kind: "playing" },
|
|
}));
|
|
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
|
strategy: "native",
|
|
position,
|
|
}));
|
|
const playerStop = vi.fn(async () => ({}));
|
|
const playerToggle = vi.fn(async () => ({ state: "playing" }));
|
|
|
|
vi.mock("$lib/api/bindings", () => ({
|
|
commands: {
|
|
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
|
|
playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])),
|
|
playerStop: (...a: any[]) => playerStop(...(a as [])),
|
|
playerToggle: (...a: any[]) => playerToggle(...(a as [])),
|
|
playerPlay: vi.fn(async () => ({})),
|
|
playerPause: vi.fn(async () => ({})),
|
|
playerSetSleepTimer: vi.fn(async () => ({})),
|
|
playerCancelSleepTimer: vi.fn(async () => ({})),
|
|
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
|
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
|
// The player loads the streaming-quality picker on mount; without these the
|
|
// mock throws and every test in the file fails before it starts.
|
|
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(),
|
|
}));
|
|
|
|
import { render, fireEvent, waitFor } from "@testing-library/svelte";
|
|
import { tick } from "svelte";
|
|
import VideoPlayer from "./VideoPlayer.svelte";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
|
|
function makeEpisode(): MediaItem {
|
|
return {
|
|
id: "ep1",
|
|
name: "Episode 1",
|
|
kind: "episode",
|
|
durationMs: 24 * 60 * 1000, // 24 min
|
|
} as MediaItem;
|
|
}
|
|
|
|
async function mountAndroidPlayer() {
|
|
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());
|
|
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
|
|
|
const slider = utils.container.querySelector(
|
|
'input[type="range"]'
|
|
) as HTMLInputElement;
|
|
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
|
expect(slider).not.toBeNull();
|
|
return { ...utils, slider, video };
|
|
}
|
|
|
|
function touch(x: number, y: number) {
|
|
return { clientX: x, clientY: y } as Touch;
|
|
}
|
|
|
|
/**
|
|
* Drag the seek bar with TOUCH events, the way a finger does on Android.
|
|
*
|
|
* A real drag along the bar moves the finger far enough that the container's
|
|
* swipe detector (50px) would trigger if it were still listening.
|
|
*/
|
|
async function touchScrubTo(
|
|
slider: HTMLInputElement,
|
|
video: HTMLVideoElement,
|
|
target: number
|
|
) {
|
|
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
|
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
|
// drag; the horizontal travel is what matters.
|
|
await fireEvent.touchMove(slider, { touches: [touch(400, 690)] });
|
|
slider.value = String(target);
|
|
await fireEvent.input(slider);
|
|
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
|
|
await fireEvent.change(slider);
|
|
await fireEvent.touchEnd(slider, { touches: [] });
|
|
if (video) await fireEvent(video, new Event("seeked"));
|
|
await tick();
|
|
}
|
|
|
|
describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
|
});
|
|
|
|
it("a touch drag on the seek bar seeks to the dragged position", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
await waitFor(() =>
|
|
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
|
);
|
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
|
});
|
|
|
|
it("a touch drag on the seek bar never toggles play/pause", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
// The container gesture layer must stay out of a control drag entirely:
|
|
// no swipe mis-read, so no play/pause correction.
|
|
expect(playerToggle).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("commits the seek on touchend even when the engine never fires `change`", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
// Android's WebView does not reliably fire `change` for a touch interaction
|
|
// on a range input. A tap on the track still moves the thumb and fires
|
|
// `input` — the seek must be committed on release regardless.
|
|
await fireEvent.touchStart(slider, { touches: [touch(400, 700)] });
|
|
slider.value = "600";
|
|
await fireEvent.input(slider);
|
|
await fireEvent.touchEnd(slider, { touches: [] });
|
|
if (video) await fireEvent(video, new Event("seeked"));
|
|
await tick();
|
|
|
|
await waitFor(() =>
|
|
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
|
);
|
|
});
|
|
|
|
it("commits the seek exactly once when both touchend and change fire", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
|
|
const { slider, video, container } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
// Brightness is applied as a CSS filter on the <video>; a control drag must
|
|
// leave it untouched.
|
|
const el = container.querySelector("video") as HTMLVideoElement | null;
|
|
if (el) {
|
|
expect(el.style.filter).toBe("brightness(1)");
|
|
}
|
|
});
|
|
});
|