Files
jellytau/src/lib/components/player/VideoPlayer.scrubRegression.test.ts
T
dtourolle ec8a7610f5 domain: player/reporting ticks -> milliseconds (phase 4c)
Playback position now crosses the IPC boundary in milliseconds. Ticks
survive only inside Rust (DB storage, Jellyfin API) and at the genuine
remote-session boundary (session seek / transfer / RemoteControls).

Rust command signatures (ms in, converted to ticks internally):
- storage_update_playback_progress / _context: position_ms
- repository_report_playback_start / _progress / _stopped: position_ms
- PlaybackProgress.position_ticks -> position_ms (converted in the query)

Frontend:
- playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume:
  seconds*1000 / durationMs/1000 instead of tick math.
- repository-client + syncService param names -> positionMs.
- Tests updated to ms fixtures/assertions.

Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks,
sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the
remote Jellyfin session API.

Rust 456, frontend 644, check clean.
2026-07-23 22:02:29 +02:00

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",
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();
});
});