/** * 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: This adapter is currently unreachable — `createAdapter()` hardcodes the * HTML5 kind, so Android video runs through Html5PlayerAdapter. * * That override was introduced citing tauri#10152 as an upstream blocker. That * is no longer accurate: #10152 is a stale *feature request* (dead since * 2024-07-01) asking that `transparent` not be desktop-only, and the capability * shipped in tauri commit 27d01834 (2024-09-02). The related black/white-screen * bug (tauri#8381, #9408) was a broken JNI signature for setBackgroundColor, * fixed in wry 0.39.4; we ship wry 0.55.x. * * What is genuinely unproven is SurfaceView-behind-WebView *compositing* on * Tauri Android — nothing upstream blocks it, and nothing upstream demonstrates * it either. docs/architecture/05-platform-backends.md ("Native Video * Compositing") describes the path that shipped. * * 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 { // player_play_item already initiated native playback before this adapter is // created, so there is no stream to load here — but it carries no start // position, and ExoPlayer always begins at 0. The resume seek must be // issued explicitly or "resume at position" silently plays from the top. // // Recording the position without seeking (what this used to do) is what // broke Android resume: the frontend believed it had resumed while // ExoPlayer played from the beginning. // // Live streams have no resume point — seeking one knocks the HLS window off // its live edge, so they are excluded. // // TRACES: UR-005 | DR-004, DR-028 if (options.initialPosition > 0 && !options.isLive) { this.position = options.initialPosition; await commands.playerSeek(options.initialPosition); } } async play(): Promise { await commands.playerPlay(); } async pause(): Promise { await commands.playerPause(); } async toggle(): Promise { 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 { 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 { 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 { const indexToUse = streamIndex === null ? null : (arrayIndex ?? streamIndex); await commands.playerSetSubtitleTrack(indexToUse); } getPosition(): number { return this.position; } async dispose(): Promise { // The backend is stopped via player_stop by the owning view; nothing to free. } }