Fix android playback issue
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* VideoPlayer scrub regression tests (Android native 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.
|
||||
*/
|
||||
|
||||
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) => {
|
||||
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" }));
|
||||
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 mountNativePlayer() {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||
mediaSourceId: "src-1",
|
||||
needsTranscoding: false,
|
||||
onClose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for onMount init: backend chosen (native), raw listeners registered.
|
||||
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();
|
||||
|
||||
const slider = utils.container.querySelector(
|
||||
'input[type="range"]'
|
||||
) as HTMLInputElement;
|
||||
expect(slider).not.toBeNull();
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(300);
|
||||
return { ...utils, slider };
|
||||
}
|
||||
|
||||
/** Scrub the seek bar to `target` seconds like a user drag. */
|
||||
async function scrubTo(slider: HTMLInputElement, target: number) {
|
||||
await fireEvent.mouseDown(slider);
|
||||
slider.value = String(target);
|
||||
await fireEvent.input(slider);
|
||||
await fireEvent.change(slider);
|
||||
await fireEvent.mouseUp(slider);
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe("VideoPlayer scrubbing with active sleep timer (native backend)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
||||
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
|
||||
sleepTimerExpiredSignal.set(0);
|
||||
});
|
||||
|
||||
it("scrubbing without a timer issues a native seek and keeps the new position", async () => {
|
||||
const { slider } = await mountNativePlayer();
|
||||
|
||||
await scrubTo(slider, 600);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith(
|
||||
"repo-1",
|
||||
600,
|
||||
"src-1",
|
||||
null,
|
||||
false
|
||||
)
|
||||
);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
it("scrubbing still works after enabling an episodes sleep timer", async () => {
|
||||
const { slider } = await mountNativePlayer();
|
||||
|
||||
// Enable "2 more episodes" timer; backend then ticks every second.
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
|
||||
await scrubTo(slider, 600);
|
||||
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalled());
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, false);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
|
||||
// A second scrub must also work.
|
||||
await scrubTo(slider, 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();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
}
|
||||
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(300);
|
||||
expect(playerSeekVideo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user