The native (ExoPlayer) video path never applied the resume position, so "resume from where you left off" always played from the start on Android. Two layers each assumed the other did the seek: - The only code acting on `initialPosition` was handleCanPlay, an HTML5 <video> event handler. The native path has no <video> element, so `canplay` never fires and that seek never ran. - NativePlayerAdapter.load() had an initialPosition branch, but it only recorded the number, claiming "the native backend performs the actual seek internally". It does not: PlayItemRequest carries no start position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0. - VideoPlayer never called adapter.load() at all, so even that branch was unreachable. The frontend therefore believed it had resumed (the seek bar showed the resume point) while ExoPlayer played from the beginning. NativePlayerAdapter.load() now issues the backend seek, excluding live streams (no resume point; seeking knocks the HLS window off its live edge). VideoPlayer calls it on the native branch and marks the initial seek as performed so the existing $effect does not fire a duplicate. The HTML5 path is untouched: seeking before metadata is clamped to 0, which is exactly what handleCanPlay waits for. Verified red->green: the new test failed with "Number of calls: 0" before the fix. Full frontend suite passes (933 tests); svelte-check and check:boundary are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
4.6 KiB
TypeScript
124 lines
4.6 KiB
TypeScript
/**
|
|
* Unit tests for NativePlayerAdapter — thin delegate to backend commands.
|
|
* Pins the primitive→command mapping so the ExoPlayer path stays correct.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const playerPlay = vi.fn((..._a: any[]): any => ({}));
|
|
const playerPause = vi.fn((..._a: any[]): any => ({}));
|
|
const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" }));
|
|
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
|
|
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
|
|
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
|
|
const playerSeek = vi.fn((..._a: any[]): any => ({}));
|
|
|
|
vi.mock("$lib/api/bindings", () => ({
|
|
commands: {
|
|
playerPlay: (...a: any[]) => playerPlay(...a),
|
|
playerPause: (...a: any[]) => playerPause(...a),
|
|
playerToggle: (...a: any[]) => playerToggle(...a),
|
|
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
|
|
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
|
|
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
|
|
playerSeek: (...a: any[]) => playerSeek(...a),
|
|
},
|
|
}));
|
|
|
|
import { NativePlayerAdapter } from "./nativeAdapter";
|
|
import type { AdapterHost } from "./types";
|
|
|
|
function makeHost(): AdapterHost {
|
|
return {
|
|
onState: vi.fn(), onPosition: vi.fn(), onMediaLoaded: vi.fn(), onEnded: vi.fn(),
|
|
onError: vi.fn(), onStreamUrlChanged: vi.fn(), onBuffering: vi.fn(), onReady: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe("NativePlayerAdapter", () => {
|
|
let adapter: NativePlayerAdapter;
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
adapter = new NativePlayerAdapter(makeHost());
|
|
});
|
|
|
|
it("is a native-kind adapter", () => {
|
|
expect(adapter.kind).toBe("native");
|
|
});
|
|
|
|
it("delegates play/pause to backend commands", async () => {
|
|
await adapter.play();
|
|
await adapter.pause();
|
|
expect(playerPlay).toHaveBeenCalledTimes(1);
|
|
expect(playerPause).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("toggle() reflects the backend's resulting playing state", async () => {
|
|
expect(await adapter.toggle()).toBe(true);
|
|
expect(playerToggle).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("records position on seek/reload primitives (backend does the real work)", async () => {
|
|
await adapter.seekElement(55, 0);
|
|
expect(adapter.getPosition()).toBe(55);
|
|
await adapter.reloadSource("ignored", 200);
|
|
expect(adapter.getPosition()).toBe(200);
|
|
});
|
|
|
|
it("load() seeds a resume position", async () => {
|
|
await adapter.load("url", {
|
|
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
|
initialPosition: 90, isLive: false, audioTrackIndex: null,
|
|
knownDuration: 0, subtitleTracks: [],
|
|
});
|
|
expect(adapter.getPosition()).toBe(90);
|
|
});
|
|
|
|
// Regression: resume-at-position was broken on Android. player_play_item
|
|
// carries no start position, and ExoPlayer always begins at 0, so recording
|
|
// the number frontend-side left the backend playing from the beginning. The
|
|
// adapter must actually *issue* the seek.
|
|
it("load() issues the resume seek to the backend, not just records it", async () => {
|
|
await adapter.load("url", {
|
|
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
|
initialPosition: 90, isLive: false, audioTrackIndex: null,
|
|
knownDuration: 0, subtitleTracks: [],
|
|
});
|
|
expect(playerSeek).toHaveBeenCalledWith(90);
|
|
});
|
|
|
|
it("load() does not seek when starting from the beginning", async () => {
|
|
await adapter.load("url", {
|
|
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
|
initialPosition: 0, isLive: false, audioTrackIndex: null,
|
|
knownDuration: 0, subtitleTracks: [],
|
|
});
|
|
expect(playerSeek).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// A live stream has no meaningful resume point; seeking one is at best a
|
|
// no-op and at worst knocks the HLS window off its live edge.
|
|
it("load() never seeks a live stream", async () => {
|
|
await adapter.load("url", {
|
|
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
|
initialPosition: 90, isLive: true, audioTrackIndex: null,
|
|
knownDuration: 0, subtitleTracks: [],
|
|
});
|
|
expect(playerSeek).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("setVolume clamps and delegates; setMuted toggles mute", () => {
|
|
adapter.setVolume(2);
|
|
expect(playerSetVolume).toHaveBeenCalledWith(1);
|
|
adapter.setMuted(true);
|
|
expect(playerToggleMute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("selectSubtitle maps null to disable and uses arrayIndex when given", async () => {
|
|
await adapter.selectSubtitle(null);
|
|
expect(playerSetSubtitleTrack).toHaveBeenCalledWith(null);
|
|
await adapter.selectSubtitle(5, 2);
|
|
expect(playerSetSubtitleTrack).toHaveBeenCalledWith(2);
|
|
});
|
|
});
|