/** * Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter * implementation. It owns the high-level control surface for an HTML5 `` * 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 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 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 { // 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 { 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 { this.element?.pause(); } async toggle(): Promise { 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 { 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 { 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 { 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 { 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 { return new Promise((resolve) => { const done = () => { el.removeEventListener(event, done); resolve(); }; el.addEventListener(event, done); setTimeout(done, timeoutMs); }); } }