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>
202 lines
6.9 KiB
TypeScript
202 lines
6.9 KiB
TypeScript
/**
|
|
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
|
|
* implementation. It owns the high-level control surface for an HTML5 `<video>`
|
|
* element and reports the element's lifecycle back into Rust via its
|
|
* {@link AdapterHost}.
|
|
*
|
|
* Design note on the split with VideoPlayer.svelte:
|
|
* The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
|
|
* "reload stream" seek/audio-track dance with its dual-audio teardown and
|
|
* canplay waits) are inherently coupled to Svelte reactive state and the DOM
|
|
* element. Rather than relocate that reactive machinery wholesale (high
|
|
* regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
|
|
* callbacks the owning component supplies. The adapter is the single OWNER of the
|
|
* control contract (play/pause/seek/track/volume) and of reporting; the bridge is
|
|
* the seam to the component's element/HLS/reactive state. This keeps all control
|
|
* intents flowing through the PlayerAdapter interface while preserving the
|
|
* hard-won element behavior verbatim.
|
|
*
|
|
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
|
*/
|
|
|
|
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
|
|
|
/**
|
|
* Narrow seam the owning component provides so the adapter can execute the
|
|
* element/HLS-coupled parts of a control action without re-implementing the
|
|
* component's reactive HLS lifecycle. Every function here is a thin wrapper over
|
|
* work the component already does.
|
|
*/
|
|
export interface Html5ElementBridge {
|
|
/** The bound <video> element, or null before mount / after teardown. */
|
|
getElement(): HTMLVideoElement | null;
|
|
/** Current seek offset (seconds) for transcoded streams. */
|
|
getSeekOffset(): number;
|
|
setSeekOffset(offset: number): void;
|
|
/** Update the stream URL the component renders (triggers its HLS $effect). */
|
|
setStreamUrl(url: string): void;
|
|
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
|
|
destroyHls(): void;
|
|
/** Media source id for seek/audio-track URLs. */
|
|
getMediaSourceId(): string | null;
|
|
}
|
|
|
|
export class Html5PlayerAdapter implements PlayerAdapter {
|
|
readonly kind = "html5" as const;
|
|
|
|
private attachedElement: HTMLVideoElement | null = null;
|
|
private host: AdapterHost;
|
|
private bridge: Html5ElementBridge;
|
|
|
|
constructor(host: AdapterHost, bridge: Html5ElementBridge) {
|
|
this.host = host;
|
|
this.bridge = bridge;
|
|
}
|
|
|
|
/**
|
|
* Resolve the LIVE <video> element. The bridge's `getElement()` returns the
|
|
* component's current reactive `videoElement`, which is authoritative: the
|
|
* element can be re-bound when the {#if} block re-renders, so a value captured
|
|
* once in `attach()` may go stale (this caused play/pause to silently no-op).
|
|
* Falls back to the attach()-captured element for unit tests whose bridge
|
|
* returns null.
|
|
*/
|
|
private get element(): HTMLVideoElement | null {
|
|
return this.bridge.getElement() ?? this.attachedElement;
|
|
}
|
|
|
|
attach(element: HTMLVideoElement | null): void {
|
|
this.attachedElement = element;
|
|
}
|
|
|
|
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
|
|
// The component's reactive HLS $effect performs the actual attach/load when
|
|
// the stream URL is set; loading is therefore driven by setStreamUrl. The
|
|
// component's canplay/frag-buffered path reports readiness through the host.
|
|
this.bridge.setSeekOffset(0);
|
|
this.bridge.setStreamUrl(streamUrl);
|
|
this.host.onState("loading");
|
|
}
|
|
|
|
async play(): Promise<void> {
|
|
const el = this.element;
|
|
if (!el) return;
|
|
try {
|
|
await el.play();
|
|
// handlePlay on the element reports "playing"; no double-report here.
|
|
} catch (err) {
|
|
this.host.onError(`play() failed: ${err}`);
|
|
}
|
|
}
|
|
|
|
async pause(): Promise<void> {
|
|
this.element?.pause();
|
|
}
|
|
|
|
async toggle(): Promise<boolean> {
|
|
const el = this.element;
|
|
if (!el) return false;
|
|
if (el.paused) {
|
|
await this.play();
|
|
return true;
|
|
}
|
|
await this.pause();
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* PRIMITIVE: in-place element seek (no reload). The backend already decided
|
|
* this seek does not need a transcode reload.
|
|
*/
|
|
async seekElement(positionSeconds: number, offset: number): Promise<void> {
|
|
const el = this.element;
|
|
if (!el) return;
|
|
el.currentTime = positionSeconds;
|
|
this.bridge.setSeekOffset(offset);
|
|
await this.waitForEvent(el, "seeked", 2000);
|
|
}
|
|
|
|
/**
|
|
* PRIMITIVE: compound reload — the invariant HTML5 sequence to swap the source
|
|
* and resume at `offset`. Contains NO strategy decision; the backend already
|
|
* decided to reload and supplied the url/offset. Preserves the hard-won
|
|
* dual-audio teardown and canplay wait.
|
|
*/
|
|
async reloadSource(url: string, offset: number): Promise<void> {
|
|
const el = this.element;
|
|
if (!el) {
|
|
// Still update the stream URL so the component's HLS $effect can pick it up.
|
|
this.bridge.setSeekOffset(offset);
|
|
this.bridge.setStreamUrl(url);
|
|
return;
|
|
}
|
|
const wasPlaying = !el.paused;
|
|
el.pause();
|
|
this.bridge.destroyHls();
|
|
if (el.src) {
|
|
el.removeAttribute("src");
|
|
el.load();
|
|
}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
this.bridge.setSeekOffset(offset);
|
|
this.bridge.setStreamUrl(url);
|
|
await this.waitForEvent(el, "canplay", 10000);
|
|
if (wasPlaying) await el.play();
|
|
}
|
|
|
|
setVolume(volume: number): void {
|
|
if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
|
|
}
|
|
|
|
setMuted(muted: boolean): void {
|
|
if (this.element) this.element.muted = muted;
|
|
}
|
|
|
|
/** Subtitle selection: HTML5 toggles textTracks on the element directly. */
|
|
async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
|
|
const el = this.element;
|
|
if (!el || !el.textTracks) return;
|
|
for (let i = 0; i < el.textTracks.length; i++) {
|
|
el.textTracks[i].mode = "disabled";
|
|
}
|
|
if (streamIndex !== null) {
|
|
const tracks = el.querySelectorAll("track");
|
|
tracks.forEach((track) => {
|
|
const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
|
|
if (idx === streamIndex && track.track) {
|
|
track.track.mode = "showing";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
getPosition(): number {
|
|
const el = this.element;
|
|
if (!el) return 0;
|
|
return el.currentTime + this.bridge.getSeekOffset();
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
this.bridge.destroyHls();
|
|
const el = this.element;
|
|
if (el) {
|
|
el.pause();
|
|
el.removeAttribute("src");
|
|
el.load();
|
|
}
|
|
this.attachedElement = null;
|
|
}
|
|
|
|
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
|
|
private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<void> {
|
|
return new Promise<void>((resolve) => {
|
|
const done = () => {
|
|
el.removeEventListener(event, done);
|
|
resolve();
|
|
};
|
|
el.addEventListener(event, done);
|
|
setTimeout(done, timeoutMs);
|
|
});
|
|
}
|
|
}
|