/** * 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, DR-096 */ 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; } /** * True for the `AbortError` the browser raises when a pending `play()` promise is * cancelled by a `pause()` (or a source/seek change). It signals "that specific * play attempt was superseded", not "playback failed" — hls.js' stall recovery * produces it routinely, so it must not reach the player's error channel. */ function isPlayInterruptedError(err: unknown): boolean { if (!err || typeof err !== "object") return false; const { name, message } = err as { name?: string; message?: string }; return name === "AbortError" || (message ?? "").includes("interrupted"); } export class Html5PlayerAdapter implements PlayerAdapter { readonly kind = "html5" as const; private attachedElement: HTMLVideoElement | null = null; /** In-flight play() attempt, so concurrent callers share one element.play(). */ private pendingPlay: Promise | 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; // Coalesce concurrent attempts. While an HLS stream stalls, the UI and the // gap-controller recovery path can both ask to play; stacking element.play() // calls is what turns one stall into an AbortError storm. if (this.pendingPlay) return this.pendingPlay; this.pendingPlay = (async () => { try { await el.play(); // handlePlay on the element reports "playing"; no double-report here. } catch (err) { // A play() aborted by a pause() is transient, not a failure: hls.js // nudges the element to recover from a stall, which cancels the pending // play promise while the element keeps trying. Surfacing it would report // an error roughly once a second for the duration of the stall. if (isPlayInterruptedError(err)) { console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)"); } else { this.host.onError(`play() failed: ${err}`); } } finally { this.pendingPlay = null; } })(); return this.pendingPlay; } 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 — swap the source and resume at * `positionSeconds`, an **absolute** position on the item's own timeline. * Contains NO strategy decision; the backend already decided to reload and * supplied the url/position. Preserves the hard-won dual-audio teardown and * canplay wait. * * The position is reached by *seeking the element*, and the transcode offset * is cleared to zero. It used to be the other way round — the offset was set * to the position and nothing seeked — which was correct only while the * reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that * parameter (on an HLS playlist it makes the server reject every segment with * `400`), so a reloaded stream now always starts at the beginning of the item. * Leaving the old arithmetic in place would have left `currentTime` reading * `offset + 0` — the scrubber showing 20:00 while the opening titles play, and * no seek ever happening. * * TRACES: UR-004, UR-005 | DR-181 | UT-183 */ async reloadSource(url: string, positionSeconds: 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(0); 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)); // The reloaded stream begins at the item's zero, so there is no base to add. this.bridge.setSeekOffset(0); this.bridge.setStreamUrl(url); // A source that never becomes playable is a failed reload, not a slow one: // the caller (quality switch, transcoded seek) has to know so it can revert // its selection and surface the error instead of leaving the UI claiming a // stream that is not playing. const ready = await this.waitForEvent(el, "canplay", 10000); if (!ready) { throw new Error(`Reloaded stream never fired "canplay" within 10000ms`); } // Now that the new source is playable, put it where the caller asked for. // Seeking before `canplay` is dropped by the element, which is why this // follows the wait rather than riding along with the URL swap. if (positionSeconds > 0) { el.currentTime = positionSeconds; await this.waitForEvent(el, "seeked", 2000); } 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. */ /** * Resolves `true` when the event fires, `false` if the budget runs out. The * distinction is the caller's to act on: a missing `seeked` is cosmetic, a * missing `canplay` means the reload failed. */ private waitForEvent( el: HTMLVideoElement, event: string, timeoutMs: number ): Promise { return new Promise((resolve) => { let timer: ReturnType; const done = (fired: boolean) => { el.removeEventListener(event, listener); clearTimeout(timer); resolve(fired); }; const listener = () => done(true); el.addEventListener(event, listener); timer = setTimeout(() => done(false), timeoutMs); }); } }