Playback fix
🏗️ 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

This commit is contained in:
2026-07-02 18:13:55 +02:00
parent 6af7f7dcca
commit 1f6977cd01
16 changed files with 653 additions and 101 deletions
@@ -1,22 +1,27 @@
/**
* VideoPlayer scrub regression tests (Android native backend path)
* 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".
*
* These tests mount the REAL VideoPlayer in native-backend mode (what
* Android uses: playerPlayItem responds useHtml5Element=false), scrub the
* seek bar, then drive the same backend signals the app receives at
* runtime (position updates, sleep-timer ticks) and assert the seek bar
* does not snap back.
* 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) --------------------------------
// Capture raw-channel listeners VideoPlayer registers in native mode
// ("player://position-update", "player://state-changed").
const channelHandlers: Record<string, (event: any) => void> = {};
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
@@ -32,6 +37,7 @@ vi.mock("@tauri-apps/api/core", () => ({
}));
const playerPlayItem = vi.fn(async () => ({
// What Android reports: native ExoPlayer backend
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" },
@@ -104,7 +110,7 @@ function sleepTimerTick(remaining = 2) {
});
}
async function mountNativePlayer() {
async function mountAndroidPlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
@@ -115,38 +121,36 @@ async function mountNativePlayer() {
},
});
// Wait for onMount init: backend chosen (native), raw listeners registered.
// Init: backend reports native, component overrides to HTML5 and stops it.
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() =>
expect(channelHandlers["player://position-update"]).toBeDefined()
);
// Backend reports playing at 300s.
channelHandlers["player://state-changed"]({ payload: { state: "playing" } });
channelHandlers["player://position-update"]({
payload: { position: 300, duration: 1440 },
});
await tick();
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(parseFloat(slider.value)).toBeCloseTo(300);
return { ...utils, slider };
expect(video).not.toBeNull();
return { ...utils, slider, video };
}
/** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo(slider: HTMLInputElement, target: number) {
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 (native backend)", () => {
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
@@ -154,10 +158,17 @@ describe("VideoPlayer scrubbing with active sleep timer (native backend)", () =>
sleepTimerExpiredSignal.set(0);
});
it("scrubbing without a timer issues a native seek and keeps the new position", async () => {
const { slider } = await mountNativePlayer();
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);
});
await scrubTo(slider, 600);
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(
@@ -165,14 +176,14 @@ describe("VideoPlayer scrubbing with active sleep timer (native backend)", () =>
600,
"src-1",
null,
false
true // HTML5 path: the webview owns playback after the override
)
);
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
it("scrubbing still works after enabling an episodes sleep timer", async () => {
const { slider } = await mountNativePlayer();
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);
@@ -180,50 +191,31 @@ describe("VideoPlayer scrubbing with active sleep timer (native backend)", () =>
sleepTimerTick(2);
await tick();
await scrubTo(slider, 600);
await scrubTo(slider, video, 600);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
expect(parseFloat(slider.value)).toBeCloseTo(600);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalled());
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, false);
// 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, 900);
await scrubTo(slider, video, 900);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
expect(parseFloat(slider.value)).toBeCloseTo(900);
});
it("REGRESSION: a stale backend position tick right after scrubbing must not snap the bar back", async () => {
const { slider } = await mountNativePlayer();
sleepTimerTick(2);
await tick();
await scrubTo(slider, 600);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalled());
expect(parseFloat(slider.value)).toBeCloseTo(600);
// ExoPlayer's position poller runs on its own cadence: a tick captured
// just before the seek landed arrives now, carrying the OLD position.
channelHandlers["player://position-update"]({
payload: { position: 301, duration: 1440 },
});
// Plus the per-second sleep-timer tick.
sleepTimerTick(2);
await tick();
// The bar must hold the seek target, not snap back to the stale position.
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
it("sleep-timer ticks alone never move the seek bar", async () => {
const { slider } = await mountNativePlayer();
const { slider } = await mountAndroidPlayer();
const before = slider.value;
for (let i = 0; i < 5; i++) {
sleepTimerTick(2);
await tick();
}
expect(parseFloat(slider.value)).toBeCloseTo(300);
expect(slider.value).toBe(before);
expect(playerSeekVideo).not.toHaveBeenCalled();
});
});