Adds the frontend's first linter and formatter — the Rust half has had cargo fmt --check and clippy in CI for a while, while 274 TS/Svelte files had only svelte-check. ESLint runs clean; 159 findings are recorded as warnings rather than suppressed, so the backlog is visible without painting CI red. Also: `bun run test` no longer drops into watch mode (the "Before Committing" list told people to run a command that never returns), the traceability ratchet moves 82% -> 88%, a pre-commit hook enforces the fast half of that list instead of relying on memory, the dead webdriverio e2e suite and its five devDeps are removed, and the Rust toolchain is pinned to 1.97.1 so the developer machine and the CI builder image stop being five releases apart. TRACES: | DR-205, DR-206, DR-207
279 lines
11 KiB
TypeScript
279 lines
11 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, DR-096
|
|
*/
|
|
|
|
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("Html5PlayerAdapter");
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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<void> | 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;
|
|
// 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)) {
|
|
log.debug("play() interrupted by pause (stall recovery)");
|
|
} else {
|
|
this.host.onError(`play() failed: ${err}`);
|
|
}
|
|
} finally {
|
|
this.pendingPlay = null;
|
|
}
|
|
})();
|
|
|
|
return this.pendingPlay;
|
|
}
|
|
|
|
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 — 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<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(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<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. */
|
|
/**
|
|
* 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<boolean> {
|
|
return new Promise<boolean>((resolve) => {
|
|
const done = (fired: boolean) => {
|
|
el.removeEventListener(event, listener);
|
|
clearTimeout(timer);
|
|
resolve(fired);
|
|
};
|
|
const listener = () => done(true);
|
|
el.addEventListener(event, listener);
|
|
// `done` closes over `timer`, but can only run once the listener fires or
|
|
// the timeout elapses — both strictly after this assignment.
|
|
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
|
|
});
|
|
}
|
|
}
|