From 0f10a280e5f55cb1643def34c6608d5f7a3d6165 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 15:09:40 -0700 Subject: [PATCH] feat(spf): add live HLS media-element adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHATWG media-element adapter (src/preload/play/attach/destroy) over createLiveHlsEngine, mirroring SimpleHlsMediaMixin — the foundation a element wraps. Exported from @videojs/spf/live-hls as LiveHlsMediaMixin / LiveHlsMediaElement. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/playback/engines/live-hls/adapter.ts | 159 ++++++++++++++++++ .../src/playback/engines/live-hls/index.ts | 2 + 2 files changed, 161 insertions(+) create mode 100644 packages/spf/src/playback/engines/live-hls/adapter.ts diff --git a/packages/spf/src/playback/engines/live-hls/adapter.ts b/packages/spf/src/playback/engines/live-hls/adapter.ts new file mode 100644 index 00000000..ce9a9499 --- /dev/null +++ b/packages/spf/src/playback/engines/live-hls/adapter.ts @@ -0,0 +1,159 @@ +import type { Constructor, MixinReturn } from '@videojs/utils/types'; +import type { Composition } from '../../../core/composition/create-composition'; +import { + createLiveHlsEngine, + type LiveHlsEngineConfig, + type LiveHlsEngineContext, + type LiveHlsEngineSignals, + type LiveHlsEngineState, +} from './engine'; + +export interface LiveHlsMediaProps { + src: string; + preload: '' | 'none' | 'metadata' | 'auto'; +} + +export const liveHlsMediaDefaultProps: LiveHlsMediaProps = { + src: '', + preload: '', +}; + +export interface LiveHlsMediaAPI extends LiveHlsMediaProps { + readonly engine: Composition; + attach(mediaElement: HTMLMediaElement): void; + detach(): void; + destroy(): void; + play(): Promise; +} + +/** + * Adapter mixin for the live HLS engine — mirrors `SimpleHlsMediaMixin`, + * swapping in `createLiveHlsEngine`. Implements the WHATWG `src`/`preload`/ + * `play()` contract so the live engine drops into anywhere a media element is + * expected. A fresh engine is created on each `src` assignment (full teardown + * of the prior source); the attached media element is preserved across changes. + * + * Distinct from the VoD adapter (rather than a shared parameterized mixin) + * while the live engine stabilizes. + */ +export function LiveHlsMediaMixin>(BaseClass: Base) { + class LiveHlsMediaImpl extends BaseClass { + #engine: Composition; + #config: LiveHlsEngineConfig; + #signals!: LiveHlsEngineSignals; + #preload: '' | 'none' | 'metadata' | 'auto' = liveHlsMediaDefaultProps.preload; + + /** Pending loadstart listener from a deferred play() retry, if any. */ + #loadstartListener: (() => void) | null = null; + + constructor(...args: any[]) { + super(...args); + + const { config } = args?.[0] ?? {}; + this.#config = config; + this.#engine = this.#createEngine(); + } + + get engine(): Composition { + return this.#engine; + } + + attach(mediaElement: HTMLMediaElement): void { + super.attach?.(mediaElement); + this.#signals.context.mediaElement.set(mediaElement); + } + + detach(): void { + this.#cancelPendingPlay(); + this.#signals.context.mediaElement.set(undefined); + super.detach?.(); + } + + destroy(): void { + this.#cancelPendingPlay(); + this.#engine.destroy(); + } + + get preload(): '' | 'none' | 'metadata' | 'auto' { + return this.#preload; + } + + set preload(value: '' | 'none' | 'metadata' | 'auto') { + this.#preload = value; + if (value) { + this.#signals.state.preload.set(value); + } + } + + get src(): string { + return this.#signals.state.presentation.get()?.url ?? ''; + } + + set src(value: string) { + const prevMediaElement = this.#signals.context.mediaElement.get(); + + this.#cancelPendingPlay(); + this.#engine.destroy(); + this.#engine = this.#createEngine(); + + if (this.#preload) { + this.#signals.state.preload.set(this.#preload); + } + + if (prevMediaElement) { + this.#signals.context.mediaElement.set(prevMediaElement); + } + + if (value) { + this.#signals.state.presentation.set({ url: value }); + } + } + + play(): Promise { + const mediaElement = this.#signals.context.mediaElement.get(); + if (!mediaElement) { + return Promise.reject(new Error('LiveHlsMediaElement: no media element attached')); + } + + // Signal play intent — enables loading even with preload="none". + this.#signals.state.loadActivated.set(true); + + return mediaElement.play().catch((err: unknown) => { + // The rejection may be because MSE hasn't attached a blob URL yet. Wait + // for loadstart (src assigned by MSE setup) and retry once. + if (this.src) { + return new Promise((resolve, reject) => { + const listener = () => { + this.#loadstartListener = null; + mediaElement.play().then(resolve, reject); + }; + this.#loadstartListener = listener; + mediaElement.addEventListener('loadstart', listener, { once: true }); + }); + } + throw err; + }); + } + + #createEngine(): Composition { + return createLiveHlsEngine({ + ...this.#config, + onSignalsReady: (signals) => { + this.#signals = signals; + }, + }); + } + + #cancelPendingPlay(): void { + if (!this.#loadstartListener) return; + const mediaElement = this.#signals.context.mediaElement.get(); + mediaElement?.removeEventListener('loadstart', this.#loadstartListener); + this.#loadstartListener = null; + } + } + + return LiveHlsMediaImpl as unknown as MixinReturn; +} + +/** Standalone live SPF media adapter with no base class. */ +export class LiveHlsMediaElement extends LiveHlsMediaMixin(class {}) {} diff --git a/packages/spf/src/playback/engines/live-hls/index.ts b/packages/spf/src/playback/engines/live-hls/index.ts index de3ddb3f..ee30c1c3 100644 --- a/packages/spf/src/playback/engines/live-hls/index.ts +++ b/packages/spf/src/playback/engines/live-hls/index.ts @@ -1,3 +1,5 @@ +export type { LiveHlsMediaAPI, LiveHlsMediaProps } from './adapter'; +export { LiveHlsMediaElement, LiveHlsMediaMixin, liveHlsMediaDefaultProps } from './adapter'; export type { LiveHlsEngineConfig, LiveHlsEngineContext,