/** * VideoPlayer scrub regression tests (Android backend path) * * Reproduces the reported bug: with a sleep timer active, scrubbing the * video seek bar "seeks, then jumps back to the old position". * * Root cause history: * - Native init called onDestroy() after an await -> lifecycle_outside_component * -> the catch treated init as failed and silently flipped useHtml5Element to * true, so seeks went down the HTML5 path while ExoPlayer kept playing. * - The native SurfaceView has never been visible through the webview, so the * INTERIM behavior (until the video-player API refactor) is: when the backend * reports native mode, VideoPlayer deliberately overrides to HTML5 rendering * and stops the native backend (single audio source, webview owns playback). * * These tests pin the interim behavior: Android's native response is * overridden, the backend is stopped exactly once, and scrubbing keeps * working (and holds its position) with a sleep timer active. */ 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 () => ({ // What Android reports: native ExoPlayer backend 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" })); const playerSetSleepTimer = vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 })); const playerCancelSleepTimer = vi.fn(async () => ({ mode: { kind: "off" }, remainingSeconds: 0 })); 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 [])), playerSetSleepTimer: (...a: any[]) => playerSetSleepTimer(...(a as [any])), playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])), 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(), })); // Use the REAL sleepTimer store module so timer activation flows exactly as // in production (playerEvents.ts writes to it on every backend tick). import { render, fireEvent, waitFor } from "@testing-library/svelte"; import { tick } from "svelte"; import VideoPlayer from "./VideoPlayer.svelte"; import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; 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; } /** Simulate one backend sleep-timer tick, exactly as playerEvents.ts does. */ function sleepTimerTick(remaining = 2) { sleepTimer.set({ mode: { kind: "episodes", remaining }, remainingSeconds: 0, }); } 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(), }, }); // Init: backend reports native, component overrides to HTML5 and stops it. 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(); expect(video).not.toBeNull(); return { ...utils, slider, video }; } /** Scrub the seek bar to `target` seconds like a user drag. */ async function scrubTo( slider: HTMLInputElement, video: HTMLVideoElement, target: number ) { await fireEvent.mouseDown(slider); slider.value = String(target); await fireEvent.input(slider); await fireEvent.change(slider); await fireEvent.mouseUp(slider); // Resolve the "wait for seeked" step of the HTML5 native-seek path. await fireEvent(video, new Event("seeked")); await tick(); } describe("VideoPlayer scrubbing with active sleep timer (Android)", () => { beforeEach(() => { vi.clearAllMocks(); for (const key of Object.keys(channelHandlers)) delete channelHandlers[key]; sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 }); sleepTimerExpiredSignal.set(0); }); it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => { await mountAndroidPlayer(); // The native backend must be stopped so it doesn't play audio behind the // webview (frozen picture + double audio source). expect(playerStop).toHaveBeenCalledTimes(1); }); it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => { const { slider, video } = await mountAndroidPlayer(); await scrubTo(slider, video, 600); await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith( "repo-1", 600, "src-1", null, true // HTML5 path: the webview owns playback after the override ) ); expect(parseFloat(slider.value)).toBeCloseTo(600); }); it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => { const { slider, video } = await mountAndroidPlayer(); // Enable "2 more episodes" timer; backend then ticks every second. sleepTimerTick(2); await tick(); sleepTimerTick(2); await tick(); await scrubTo(slider, video, 600); await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1)); expect(parseFloat(slider.value)).toBeCloseTo(600); // Timer ticks after the seek must not snap the bar back. sleepTimerTick(2); await tick(); expect(parseFloat(slider.value)).toBeCloseTo(600); // A second scrub must also work. await scrubTo(slider, video, 900); await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2)); expect(parseFloat(slider.value)).toBeCloseTo(900); }); it("sleep-timer ticks alone never move the seek bar", async () => { const { slider } = await mountAndroidPlayer(); const before = slider.value; for (let i = 0; i < 5; i++) { sleepTimerTick(2); await tick(); } expect(slider.value).toBe(before); expect(playerSeekVideo).not.toHaveBeenCalled(); }); });