/**
* 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
* , 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 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();
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