Files
jellytau/src/lib/player/adapters/nativeAdapter.ts
T
dtourolleandClaude Opus 4.8 a64e1b1fb4 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>
2026-07-02 19:56:20 +02:00

97 lines
3.3 KiB
TypeScript

/**
* 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.
}
}