Investigation into unifying the playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto one engine with hardware acceleration. Conclusion: video cannot be unified onto a native engine; audio can. The blocker is not mpv-specific. WebKitGTK, WebView2 and Android WebView each draw into their own compositor surface, so a native video surface sits either entirely above or entirely below the webview and cannot interleave with HTML. GStreamer and libVLC fail identically. mpv would additionally regress streaming: it has no adaptive bitrate, while the current hls.js path does. Six specs added: - playback-backend-unification: the analysis and decision, with evidence - android-audio-settings-parity: set_audio_settings on ExoPlayerBackend - android-native-video-spike: timeboxed test of SurfaceView compositing - windows-native-audio-backend: replace the webview <audio> shim with libmpv - libmpv2-migration: dead libmpv git pin -> libmpv2, plus a LICENSE file - playback-docs-corrections: the requirement-status fixes applied here Corrections to requirements.md, all verified against source: - UR-031/DR-034 claimed crossfade was "Done (Linux only)". It is implemented nowhere (mpv_backend.rs has a bare TODO) and is architecturally blocked on mpv, whose single-stream audio chain cannot feed acrossfade's two inputs. - Parity matrix listed crossfade as a Linux/Android gap; it is neither. - The matrix omitted the equalizer, which has the same Linux-only shape. - The suggested ConcatenatingMediaSource is deprecated in current Media3. nativeAdapter.ts cited tauri#10152 as an upstream blocker for native Android video. That issue is a stale feature request, dead since 2024-07-01; the capability shipped in tauri 27d01834 (2024-09-02), and the related black-screen bug was fixed in wry 0.39.4 (we ship 0.55.x). What is genuinely unproven is SurfaceView-behind-WebView compositing, which the spike now tracks.
105 lines
3.8 KiB
TypeScript
105 lines
3.8 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; 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.
|
|
}
|
|
}
|