refactor(packages)!: replace DelegateMixin & ProxyMixin with MediaHost base classes (#1292)

This commit is contained in:
Wesley Luyten
2026-04-14 00:18:11 -05:00
committed by GitHub
parent f609373cd7
commit 8f1653efcd
91 changed files with 2840 additions and 1816 deletions
+2 -1
View File
@@ -12,5 +12,6 @@ export type { PlaybackRateOwners, PlaybackRateState } from './features/track-pla
export { trackPlaybackRate } from './features/track-playback-rate';
export { appendSegment } from './media/append-segment';
export { flushBuffer } from './media/buffer-flusher';
export { SpfMedia } from './playback-engine/adapter';
export type { SpfMediaAPI } from './playback-engine/adapter';
export { SpfMedia, SpfMediaMixin } from './playback-engine/adapter';
export { destroyVttParser, parseVttSegment } from './text/parse-vtt-segment';
+140 -121
View File
@@ -1,9 +1,20 @@
import type { Constructor, MixinReturn } from '@videojs/utils/types';
import { update } from '../../core/signals/primitives';
import type { PlaybackEngineConfig } from './engine';
import { createPlaybackEngine, type PlaybackEngine } from './engine';
export interface SpfMediaAPI {
readonly engine: PlaybackEngine;
src: string;
preload: '' | 'none' | 'metadata' | 'auto';
attach(mediaElement: HTMLMediaElement): void;
detach(): void;
destroy(): void;
play(): Promise<void>;
}
/**
* HTMLMediaElement-compatible adapter for the SPF playback engine.
* Mixin that adds SPF playback engine behavior to any base class.
*
* Implements the src/play() contract per the WHATWG HTML spec so that SPF can
* be used anywhere a media element API is expected.
@@ -14,141 +25,149 @@ import { createPlaybackEngine, type PlaybackEngine } from './engine';
* changes and re-applied to the new engine automatically.
*
* @example
* const media = new SpfMedia({ preferredAudioLanguage: 'en' });
* class SimpleHlsMedia extends SpfMediaMixin(HTMLVideoElementHost) {}
*
* const media = new SimpleHlsMedia();
* media.attach(document.querySelector('video'));
* media.src = 'https://stream.mux.com/abc123.m3u8';
*
* // Change source — old engine is destroyed, new one starts clean:
* media.src = 'https://stream.mux.com/xyz456.m3u8';
*
* // Explicit teardown:
* media.destroy();
*/
export class SpfMedia {
#engine: PlaybackEngine;
#config: PlaybackEngineConfig;
#preload: '' | 'none' | 'metadata' | 'auto' = '';
export function SpfMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {
class SpfMediaImpl extends BaseClass {
#engine: PlaybackEngine;
#config: PlaybackEngineConfig;
#preload: '' | 'none' | 'metadata' | 'auto' = '';
/** Pending loadstart listener from a deferred play() retry, if any. */
#loadstartListener: (() => void) | null = null;
/** Pending loadstart listener from a deferred play() retry, if any. */
#loadstartListener: (() => void) | null = null;
constructor(config: PlaybackEngineConfig = {}) {
this.#config = config;
this.#engine = createPlaybackEngine(config);
}
constructor(...args: any[]) {
super(...args);
get engine(): PlaybackEngine {
return this.#engine;
}
// ---------------------------------------------------------------------------
// Media element lifecycle
// ---------------------------------------------------------------------------
attach(mediaElement: HTMLMediaElement): void {
update(this.#engine.owners, { mediaElement });
}
detach(): void {
this.#cancelPendingPlay();
update(this.#engine.owners, { mediaElement: undefined });
}
destroy(): void {
this.#cancelPendingPlay();
this.#engine.destroy();
}
// ---------------------------------------------------------------------------
// preload — synchronous IDL attribute (WHATWG §4.8.11.2)
// ---------------------------------------------------------------------------
get preload(): '' | 'none' | 'metadata' | 'auto' {
return this.#preload;
}
set preload(value: '' | 'none' | 'metadata' | 'auto') {
this.#preload = value;
if (value) {
update(this.#engine.state, { preload: value });
}
// value = '' clears #preload (so the next engine recreation won't re-apply
// an explicit value) but does not patch current state — the existing preload
// stays in effect until the next src change creates a fresh engine.
}
// ---------------------------------------------------------------------------
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
// Each assignment destroys the current engine and starts a fresh one, exactly
// as the browser's load algorithm resets all media element state on src change.
// ---------------------------------------------------------------------------
get src(): string {
return this.#engine.state.get().presentation?.url ?? '';
}
set src(value: string) {
const prevMediaElement = this.#engine.owners.get().mediaElement;
this.#cancelPendingPlay();
this.#engine.destroy();
this.#engine = createPlaybackEngine(this.#config);
// Apply explicit preload before setting owners so syncPreloadAttribute skips
// element inference and the explicit value is preserved across src changes.
if (this.#preload) {
update(this.#engine.state, { preload: this.#preload });
const { config } = args?.[0] ?? {};
this.#config = config;
this.#engine = createPlaybackEngine(config);
}
if (prevMediaElement) {
update(this.#engine.owners, { mediaElement: prevMediaElement });
get engine(): PlaybackEngine {
return this.#engine;
}
if (value) {
update(this.#engine.state, { presentation: { url: value } });
}
}
// -------------------------------------------------------------------------
// Media element lifecycle
// -------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// play() — WHATWG §4.8.11.8
// Delegates to the attached media element's native play().
// ---------------------------------------------------------------------------
play(): Promise<void> {
const { mediaElement } = this.#engine.owners.get();
if (!mediaElement) {
return Promise.reject(new Error('SpfMedia: no media element attached'));
attach(mediaElement: HTMLMediaElement): void {
super.attach?.(mediaElement);
update(this.#engine.owners, { mediaElement });
}
// Signal play intent — enables loading even with preload="none"
update(this.#engine.state, { playbackInitiated: true });
detach(): void {
this.#cancelPendingPlay();
update(this.#engine.owners, { mediaElement: undefined });
super.detach?.();
}
return mediaElement.play().catch((err: unknown) => {
// If we have a pending HLS source, 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<void>((resolve, reject) => {
const listener = () => {
this.#loadstartListener = null;
mediaElement.play().then(resolve, reject);
};
this.#loadstartListener = listener;
mediaElement.addEventListener('loadstart', listener, { once: true });
});
destroy(): void {
this.#cancelPendingPlay();
this.#engine.destroy();
}
// -------------------------------------------------------------------------
// preload — synchronous IDL attribute (WHATWG §4.8.11.2)
// -------------------------------------------------------------------------
get preload(): '' | 'none' | 'metadata' | 'auto' {
return this.#preload;
}
set preload(value: '' | 'none' | 'metadata' | 'auto') {
this.#preload = value;
if (value) {
update(this.#engine.state, { preload: value });
}
throw err;
});
// value = '' clears #preload (so the next engine recreation won't re-apply
// an explicit value) but does not patch current state — the existing preload
// stays in effect until the next src change creates a fresh engine.
}
// -------------------------------------------------------------------------
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
// Each assignment destroys the current engine and starts a fresh one, exactly
// as the browser's load algorithm resets all media element state on src change.
// -------------------------------------------------------------------------
get src(): string {
return this.#engine.state.get().presentation?.url ?? '';
}
set src(value: string) {
const prevMediaElement = this.#engine.owners.get().mediaElement;
this.#cancelPendingPlay();
this.#engine.destroy();
this.#engine = createPlaybackEngine(this.#config);
// Apply explicit preload before setting owners so syncPreloadAttribute skips
// element inference and the explicit value is preserved across src changes.
if (this.#preload) {
update(this.#engine.state, { preload: this.#preload });
}
if (prevMediaElement) {
update(this.#engine.owners, { mediaElement: prevMediaElement });
}
if (value) {
update(this.#engine.state, { presentation: { url: value } });
}
}
// -------------------------------------------------------------------------
// play() — WHATWG §4.8.11.8
// Delegates to the attached media element's native play().
// -------------------------------------------------------------------------
play(): Promise<void> {
const { mediaElement } = this.#engine.owners.get();
if (!mediaElement) {
return Promise.reject(new Error('SpfMedia: no media element attached'));
}
// Signal play intent — enables loading even with preload="none"
update(this.#engine.state, { playbackInitiated: true });
return mediaElement.play().catch((err: unknown) => {
// If we have a pending HLS source, 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<void>((resolve, reject) => {
const listener = () => {
this.#loadstartListener = null;
mediaElement.play().then(resolve, reject);
};
this.#loadstartListener = listener;
mediaElement.addEventListener('loadstart', listener, { once: true });
});
}
throw err;
});
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
#cancelPendingPlay(): void {
if (!this.#loadstartListener) return;
const { mediaElement } = this.#engine.owners.get();
mediaElement?.removeEventListener('loadstart', this.#loadstartListener);
this.#loadstartListener = null;
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
#cancelPendingPlay(): void {
if (!this.#loadstartListener) return;
const { mediaElement } = this.#engine.owners.get();
mediaElement?.removeEventListener('loadstart', this.#loadstartListener);
this.#loadstartListener = null;
}
return SpfMediaImpl as unknown as MixinReturn<Base, SpfMediaAPI>;
}
/** Standalone SPF media adapter with no base class. */
export class SpfMedia extends SpfMediaMixin(class {}) {}
@@ -1,4 +1,5 @@
export { effect } from '../../core/signals/effect';
export { SpfMedia } from './adapter';
export type { SpfMediaAPI } from './adapter';
export { SpfMedia, SpfMediaMixin } from './adapter';
export type { PlaybackEngine, PlaybackEngineConfig, PlaybackEngineOwners, PlaybackEngineState } from './engine';
export { createPlaybackEngine } from './engine';