A spec was a promise; sixteen of them had become descriptions of code that already shipped, sitting beside four that describe work still outstanding, with nothing in the file telling the two apart. Half the statuses were also wrong — audio-equalizer read "Accepted" with the EQ live on both platforms, the native video spec said the flag stays off after the default was flipped on. The shipped designs move into docs/architecture, which is the maintained description of the build, and the spec files go. Git history keeps the originals; what a future change still needs is carried across: - 01-rust-backend: favourites rewritten (the old section named a file that no longer exists and called shipped buttons "planned"), domain vocabulary owned by Rust (SearchScope, exclusions, the bitrate ladder), background workers - 02-svelte-frontend: app shell and chrome, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging - 03-data-flow: locally-indexed search - 05-platform-backends: audio settings on ExoPlayer, the equalizer's band vocabulary, native video compositing, the background-audio handoff - 06-downloads-and-offline: one storage model, offline catalog visibility - 09-security: path confinement and input binding docs/specs/README.md now says what the directory is for and where each shipped design went. Deferred work the specs recorded is kept beside the code it concerns rather than lost: season-bounded autoplay, the two dead search commands, why indexing is a full crawl. requirements.md had fourteen stale statuses — Android audio parity still read "Linux only", DR-150 still said the native-video default was off, DR-190 was Proposed after DR-196 implemented it, and five tooling requirements were Proposed after landing. Three unbuilt specs suggested requirement ids that have since been allocated to other work; each now carries a warning.
117 lines
4.4 KiB
TypeScript
117 lines
4.4 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/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<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.
|
|
}
|
|
}
|