mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(packages)!: replace DelegateMixin & ProxyMixin with MediaHost base classes (#1292)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user