Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
238 lines
8.4 KiB
TypeScript
238 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)");
|
|
}
|
|
});
|
|
});
|