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:
2026-07-02 19:56:20 +02:00
co-authored by Claude Opus 4.8
parent 1f6977cd01
commit a64e1b1fb4
15 changed files with 1198 additions and 301 deletions
@@ -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);
});
});