🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
222 lines
7.4 KiB
TypeScript
222 lines
7.4 KiB
TypeScript
/**
|
|
* 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<string, (event: any) => void> = {};
|
|
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 () => ({})),
|
|
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",
|
|
type: "Episode",
|
|
runTimeTicks: 24 * 60 * 10_000_000, // 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();
|
|
});
|
|
});
|