/** * 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