Introduce PlayerAdapter contract; decision logic shared in Rust backend
Establish a decoupled player boundary so UI and backend interact with video through one contract, with the HTML5 (Linux/interim-Android) and native (ExoPlayer) providers as interchangeable primitive-executor adapters. - PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource, play/pause, setVolume, selectSubtitle); it never branches on strategy. - Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track return a strategy); the facade dispatches the chosen primitive to the active adapter. Both providers share the one decision path — logic lives once, in Rust. - Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets backend control (lockscreen/remote/sleep) drive the webview <video> element. - Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause silently no-opping when the element was re-bound). - Do not emit a "stopped" player state on natural end-of-video: it flipped the player/mode to idle mid-handoff and suppressed next-episode auto-advance under a sleep timer. Jellyfin progress reporting is preserved; the backend's on_video_playback_ended owns the transition. - VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter). - Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 => ({}));
|
||||
|
||||
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),
|
||||
},
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user