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,96 @@
|
||||
/**
|
||||
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation.
|
||||
*
|
||||
* ExoPlayer is driven entirely by the Rust backend (JNI), which already emits
|
||||
* PlayerStatusEvents and handles seek/audio-track internally. So this adapter is
|
||||
* a thin delegate to backend commands; there is no DOM element to touch and no
|
||||
* hls.js. State reporting is unnecessary here because the native backend emits
|
||||
* events directly — the adapter's job is only to forward control intents.
|
||||
*
|
||||
* NOTE: On current Tauri, native Android video rendering is blocked upstream
|
||||
* (transparent webview / SurfaceView compositing — tauri#10152), so video on
|
||||
* Android currently runs through the HTML5 adapter via the interim override in
|
||||
* the factory. This adapter exists for the audio/native path and for when that
|
||||
* upstream limitation is resolved.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-028
|
||||
*/
|
||||
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
|
||||
export class NativePlayerAdapter implements PlayerAdapter {
|
||||
readonly kind = "native" as const;
|
||||
|
||||
// Kept for symmetry / future reporting needs; the native backend emits events.
|
||||
private host: AdapterHost;
|
||||
private position = 0;
|
||||
|
||||
constructor(host: AdapterHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
// The native surface is owned by the backend; nothing to attach in the DOM.
|
||||
attach(_element: HTMLVideoElement | null): void {}
|
||||
|
||||
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||
// player_play_item already initiated native playback before this adapter is
|
||||
// created; nothing further to do. Seed a resume position if requested (the
|
||||
// native backend performs the actual seek internally).
|
||||
if (options.initialPosition > 0) {
|
||||
this.position = options.initialPosition;
|
||||
}
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
await commands.playerPlay();
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await commands.playerPause();
|
||||
}
|
||||
|
||||
async toggle(): Promise<boolean> {
|
||||
const response = (await commands.playerToggle()) as any;
|
||||
return response?.state === "playing";
|
||||
}
|
||||
|
||||
/**
|
||||
* PRIMITIVE: in-place seek. For the native backend, the backend drives
|
||||
* ExoPlayer's seek internally, so this simply records the target position.
|
||||
* (The decision to seek-in-place vs reload was already made by the backend.)
|
||||
*/
|
||||
async seekElement(positionSeconds: number, _offset: number): Promise<void> {
|
||||
this.position = positionSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* PRIMITIVE: reload source. For the native backend the backend already
|
||||
* performed the reload+seek internally as part of the seek decision; nothing
|
||||
* to do on the frontend beyond recording position.
|
||||
*/
|
||||
async reloadSource(_url: string, offset: number): Promise<void> {
|
||||
this.position = offset;
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
void commands.playerSetVolume(Math.max(0, Math.min(1, volume)));
|
||||
}
|
||||
|
||||
setMuted(_muted: boolean): void {
|
||||
void commands.playerToggleMute();
|
||||
}
|
||||
|
||||
async selectSubtitle(streamIndex: number | null, arrayIndex?: number): Promise<void> {
|
||||
const indexToUse = streamIndex === null ? null : arrayIndex ?? streamIndex;
|
||||
await commands.playerSetSubtitleTrack(indexToUse);
|
||||
}
|
||||
|
||||
getPosition(): number {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
// The backend is stopped via player_stop by the owning view; nothing to free.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user