The native (ExoPlayer) video path never applied the resume position, so "resume from where you left off" always played from the start on Android. Two layers each assumed the other did the seek: - The only code acting on `initialPosition` was handleCanPlay, an HTML5 <video> event handler. The native path has no <video> element, so `canplay` never fires and that seek never ran. - NativePlayerAdapter.load() had an initialPosition branch, but it only recorded the number, claiming "the native backend performs the actual seek internally". It does not: PlayItemRequest carries no start position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0. - VideoPlayer never called adapter.load() at all, so even that branch was unreachable. The frontend therefore believed it had resumed (the seek bar showed the resume point) while ExoPlayer played from the beginning. NativePlayerAdapter.load() now issues the backend seek, excluding live streams (no resume point; seeking knocks the HLS window off its live edge). VideoPlayer calls it on the native branch and marks the initial seek as performed so the existing $effect does not fire a duplicate. The HTML5 path is untouched: seeking before metadata is clamped to 0, which is exactly what handleCanPlay waits for. Verified red->green: the new test failed with "Number of calls: 0" before the fix. Full frontend suite passes (933 tests); svelte-check and check:boundary are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
4.3 KiB
TypeScript
116 lines
4.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: 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/specs/android-native-video-spike.md tracks that experiment.
|
|
*
|
|
* 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, 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<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.
|
|
}
|
|
}
|