mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): HLS engine composition walkthrough + doc-driven cleanups (#1512)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
17d44a5d32
commit
0cfd3bb395
@@ -0,0 +1,186 @@
|
||||
import type { Constructor, MixinReturn } from '@videojs/utils/types';
|
||||
import type { Composition } from '../../../core/composition/create-composition';
|
||||
import { update } from '../../../core/signals/primitives';
|
||||
import {
|
||||
createSimpleHlsEngine,
|
||||
type SimpleHlsEngineConfig,
|
||||
type SimpleHlsEngineOwners,
|
||||
type SimpleHlsEngineState,
|
||||
} from './engine';
|
||||
|
||||
export interface SimpleHlsMediaProps {
|
||||
src: string;
|
||||
preload: '' | 'none' | 'metadata' | 'auto';
|
||||
}
|
||||
|
||||
export const simpleHlsMediaDefaultProps: SimpleHlsMediaProps = {
|
||||
src: '',
|
||||
preload: '',
|
||||
};
|
||||
|
||||
export interface SimpleHlsMediaAPI extends SimpleHlsMediaProps {
|
||||
readonly engine: Composition<SimpleHlsEngineState, SimpleHlsEngineOwners>;
|
||||
attach(mediaElement: HTMLMediaElement): void;
|
||||
detach(): void;
|
||||
destroy(): void;
|
||||
play(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* A new engine is created on every src assignment — this fully tears down all
|
||||
* state, SourceBuffers, and in-flight requests from the previous source before
|
||||
* the next one begins. The media element reference is preserved across src
|
||||
* changes and re-applied to the new engine automatically.
|
||||
*
|
||||
* @example
|
||||
* class SimpleHlsMedia extends SimpleHlsMediaMixin(HTMLVideoElementHost) {}
|
||||
*
|
||||
* const media = new SimpleHlsMedia();
|
||||
* media.attach(document.querySelector('video'));
|
||||
* media.src = 'https://stream.mux.com/abc123.m3u8';
|
||||
*/
|
||||
export function SimpleHlsMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {
|
||||
class SimpleHlsMediaImpl extends BaseClass {
|
||||
#engine: Composition<SimpleHlsEngineState, SimpleHlsEngineOwners>;
|
||||
#config: SimpleHlsEngineConfig;
|
||||
#preload: '' | 'none' | 'metadata' | 'auto' = simpleHlsMediaDefaultProps.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 = createSimpleHlsEngine(config);
|
||||
}
|
||||
|
||||
get engine(): Composition<SimpleHlsEngineState, SimpleHlsEngineOwners> {
|
||||
return this.#engine;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Media element lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
attach(mediaElement: HTMLMediaElement): void {
|
||||
super.attach?.(mediaElement);
|
||||
update(this.#engine.owners, { mediaElement });
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.#cancelPendingPlay();
|
||||
update(this.#engine.owners, { mediaElement: undefined });
|
||||
super.detach?.();
|
||||
}
|
||||
|
||||
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 = createSimpleHlsEngine(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('SimpleHlsMediaElement: 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;
|
||||
}
|
||||
}
|
||||
|
||||
return SimpleHlsMediaImpl as unknown as MixinReturn<Base, SimpleHlsMediaAPI>;
|
||||
}
|
||||
|
||||
/** Standalone SPF media adapter with no base class. */
|
||||
export class SimpleHlsMediaElement extends SimpleHlsMediaMixin(class {}) {}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { type Composition, createComposition } from '../../../core/composition/create-composition';
|
||||
import type { Signal } from '../../../core/signals/primitives';
|
||||
import type { BandwidthState } from '../../../media/abr/bandwidth-estimator';
|
||||
import { resolveVttSegment } from '../../../media/dom/text/resolve-vtt-segment';
|
||||
import type { MaybeResolvedPresentation } from '../../../media/types';
|
||||
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
|
||||
import type { TextTracksActor } from '../../actors/dom/text-tracks';
|
||||
import type { TextTrackSegmentLoaderActor } from '../../actors/text-track-segment-loader';
|
||||
import { calculatePresentationDuration } from '../../behaviors/calculate-presentation-duration';
|
||||
import { endOfStream } from '../../behaviors/dom/end-of-stream';
|
||||
import { loadSegments } from '../../behaviors/dom/load-segments';
|
||||
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { setupSourceBuffers } from '../../behaviors/dom/setup-sourcebuffer';
|
||||
import { setupTextTrackActors as _setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors';
|
||||
import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
import { trackPlaybackInitiated } from '../../behaviors/dom/track-playback-initiated';
|
||||
import { updateDuration } from '../../behaviors/dom/update-duration';
|
||||
import { loadTextTrackCues } from '../../behaviors/load-text-track-cues';
|
||||
import { switchQuality as _switchQuality } from '../../behaviors/quality-switching';
|
||||
import { resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { resolveTrack } from '../../behaviors/resolve-track';
|
||||
import {
|
||||
selectAudioTrack as _selectAudioTrack,
|
||||
selectTextTrack as _selectTextTrack,
|
||||
selectVideoTrack as _selectVideoTrack,
|
||||
} from '../../behaviors/select-tracks';
|
||||
import { syncPreloadAttribute } from '../../behaviors/sync-preload-attribute';
|
||||
|
||||
// ============================================================================
|
||||
// HLS Engine State & Owners
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* State shape for the HLS playback engine.
|
||||
*
|
||||
* This is the union of all state required by the behaviors composed into
|
||||
* the HLS engine. Each behavior declares its own state interface; this
|
||||
* type satisfies all of them.
|
||||
*/
|
||||
export interface SimpleHlsEngineState {
|
||||
/**
|
||||
* The presentation being played. A caller writes `{ url }`;
|
||||
* `resolvePresentation` parses the manifest and populates the rest.
|
||||
*/
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
preload?: 'auto' | 'metadata' | 'none';
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
bandwidthState?: BandwidthState;
|
||||
abrDisabled?: boolean;
|
||||
currentTime?: number;
|
||||
playbackInitiated?: boolean;
|
||||
mediaSourceReadyState?: MediaSource['readyState'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Owners shape for the HLS playback engine.
|
||||
*
|
||||
* Platform objects and actor references managed by HLS behaviors.
|
||||
*/
|
||||
export interface SimpleHlsEngineOwners {
|
||||
mediaElement?: HTMLMediaElement | undefined;
|
||||
mediaSource?: MediaSource;
|
||||
videoBuffer?: SourceBuffer;
|
||||
audioBuffer?: SourceBuffer;
|
||||
videoBufferActor?: SourceBufferActor;
|
||||
audioBufferActor?: SourceBufferActor;
|
||||
textTracksActor?: TextTracksActor;
|
||||
segmentLoaderActor?: TextTrackSegmentLoaderActor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the HLS playback engine.
|
||||
*
|
||||
* Each option is consumed by the appropriate behavior — the engine itself
|
||||
* has no config beyond what its behaviors read.
|
||||
*/
|
||||
export interface SimpleHlsEngineConfig {
|
||||
initialBandwidth?: number;
|
||||
preferredAudioLanguage?: string;
|
||||
preferredSubtitleLanguage?: string;
|
||||
includeForcedTracks?: boolean;
|
||||
enableDefaultTrack?: boolean;
|
||||
}
|
||||
|
||||
/** Shorthand for the deps shape used by HLS engine behaviors. */
|
||||
type Deps = {
|
||||
state: Signal<SimpleHlsEngineState>;
|
||||
owners: Signal<SimpleHlsEngineOwners>;
|
||||
config: SimpleHlsEngineConfig;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Thin media-type wrappers
|
||||
//
|
||||
// Behaviors parameterized by media type get thin wrappers that close over
|
||||
// the type value, so the engine composition reads as a flat list of
|
||||
// behaviors without inline config.
|
||||
// ============================================================================
|
||||
|
||||
const loadVideoSegments = (deps: Deps) => loadSegments(deps, { type: 'video' });
|
||||
const loadAudioSegments = (deps: Deps) => loadSegments(deps, { type: 'audio' });
|
||||
|
||||
const resolveVideoTrack = (deps: Deps) => resolveTrack(deps, { type: 'video' as const });
|
||||
const resolveAudioTrack = (deps: Deps) => resolveTrack(deps, { type: 'audio' as const });
|
||||
const resolveTextTrack = (deps: Deps) => resolveTrack(deps, { type: 'text' as const });
|
||||
|
||||
const setupTextTrackActors = ({ owners }: Deps) =>
|
||||
_setupTextTrackActors({ owners, config: { resolveTextTrackSegment: resolveVttSegment } });
|
||||
|
||||
// ============================================================================
|
||||
// Config-aware behavior wrappers
|
||||
//
|
||||
// Behaviors that read from engine config get wrappers that thread the
|
||||
// relevant config fields into the behavior's own config parameter.
|
||||
// ============================================================================
|
||||
|
||||
const selectVideoTrack = ({ config, ...deps }: Deps) =>
|
||||
_selectVideoTrack(deps, {
|
||||
type: 'video',
|
||||
...(config.initialBandwidth !== undefined && { initialBandwidth: config.initialBandwidth }),
|
||||
});
|
||||
|
||||
const selectAudioTrack = ({ config, ...deps }: Deps) =>
|
||||
_selectAudioTrack(deps, {
|
||||
type: 'audio',
|
||||
...(config.preferredAudioLanguage !== undefined && {
|
||||
preferredAudioLanguage: config.preferredAudioLanguage,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectTextTrack = ({ config, ...deps }: Deps) =>
|
||||
_selectTextTrack(deps, {
|
||||
type: 'text',
|
||||
...(config.preferredSubtitleLanguage !== undefined && {
|
||||
preferredSubtitleLanguage: config.preferredSubtitleLanguage,
|
||||
}),
|
||||
...(config.includeForcedTracks !== undefined && { includeForcedTracks: config.includeForcedTracks }),
|
||||
...(config.enableDefaultTrack !== undefined && { enableDefaultTrack: config.enableDefaultTrack }),
|
||||
});
|
||||
|
||||
const switchQuality = ({ config, ...deps }: Deps) =>
|
||||
_switchQuality(deps, config.initialBandwidth !== undefined ? { defaultBandwidth: config.initialBandwidth } : {});
|
||||
|
||||
// ============================================================================
|
||||
// HLS Playback Engine
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create an HLS playback engine.
|
||||
*
|
||||
* Composes SPF behaviors into a reactive pipeline for HLS playback over MSE:
|
||||
* manifest resolution, track selection, ABR, segment loading, and
|
||||
* end-of-stream coordination.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const engine = createSimpleHlsEngine({
|
||||
* initialBandwidth: 2_000_000,
|
||||
* preferredAudioLanguage: 'en',
|
||||
* });
|
||||
*
|
||||
* engine.owners.set({ ...engine.owners.get(), mediaElement: videoEl });
|
||||
* engine.state.set({ ...engine.state.get(), presentation: { url: 'https://example.com/stream.m3u8' } });
|
||||
*
|
||||
* videoEl.play();
|
||||
*
|
||||
* await engine.destroy();
|
||||
* ```
|
||||
*/
|
||||
export function createSimpleHlsEngine(
|
||||
config: SimpleHlsEngineConfig = {}
|
||||
): Composition<SimpleHlsEngineState, SimpleHlsEngineOwners> {
|
||||
return createComposition<SimpleHlsEngineState, SimpleHlsEngineOwners, SimpleHlsEngineConfig>(
|
||||
[
|
||||
syncPreloadAttribute,
|
||||
trackPlaybackInitiated,
|
||||
resolvePresentation,
|
||||
|
||||
// Track selection (reads config for initial preferences)
|
||||
selectVideoTrack,
|
||||
selectAudioTrack,
|
||||
selectTextTrack,
|
||||
|
||||
// Resolve selected tracks (fetch media playlists)
|
||||
resolveVideoTrack,
|
||||
resolveAudioTrack,
|
||||
resolveTextTrack,
|
||||
|
||||
// Presentation duration
|
||||
calculatePresentationDuration,
|
||||
|
||||
// MSE setup
|
||||
setupMediaSource,
|
||||
updateDuration,
|
||||
setupSourceBuffers,
|
||||
|
||||
// Playback tracking
|
||||
trackCurrentTime,
|
||||
switchQuality,
|
||||
|
||||
// Segment loading
|
||||
loadVideoSegments,
|
||||
loadAudioSegments,
|
||||
|
||||
// End of stream coordination
|
||||
endOfStream,
|
||||
|
||||
// Text tracks
|
||||
syncTextTracks,
|
||||
setupTextTrackActors,
|
||||
loadTextTrackCues,
|
||||
],
|
||||
{
|
||||
config,
|
||||
initialState: {
|
||||
bandwidthState: {
|
||||
fastEstimate: 0,
|
||||
fastTotalWeight: 0,
|
||||
slowEstimate: 0,
|
||||
slowTotalWeight: 0,
|
||||
bytesSampled: 0,
|
||||
},
|
||||
},
|
||||
initialOwners: {},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { SimpleHlsMediaAPI, SimpleHlsMediaProps } from './adapter';
|
||||
export { SimpleHlsMediaElement, SimpleHlsMediaMixin, simpleHlsMediaDefaultProps } from './adapter';
|
||||
export type {
|
||||
SimpleHlsEngineConfig,
|
||||
SimpleHlsEngineOwners,
|
||||
SimpleHlsEngineState,
|
||||
} from './engine';
|
||||
export { createSimpleHlsEngine } from './engine';
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* SimpleHlsMediaElement adapter tests.
|
||||
*
|
||||
* Covers the HTMLMediaElement-compatible contract for src and play(), per the
|
||||
* WHATWG HTML spec (https://html.spec.whatwg.org/multipage/media.html).
|
||||
*
|
||||
* Notable spec anchors:
|
||||
* - src IDL attribute reflects synchronously (§4.8.11.2)
|
||||
* - Setting src invokes the load algorithm (§4.8.11.5)
|
||||
* - play() returns a Promise that resolves when playback starts (§4.8.11.8)
|
||||
*
|
||||
* Remote-source integration tests (e.g. full pipeline with Mux streams) are
|
||||
* intentionally deferred; see comments below for planned coverage.
|
||||
*
|
||||
* Future: consider web-platform-tests (wpt) fixtures for deeper spec coverage.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SimpleHlsMediaElement } from '../adapter';
|
||||
|
||||
describe('SimpleHlsMediaElement', () => {
|
||||
// Prevent real network calls from engines that auto-trigger resolution
|
||||
// (e.g. when a media element with default preload="auto" is attached alongside a src).
|
||||
// A never-settling promise avoids unhandled rejections without affecting test assertions.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => new Promise<Response>(() => {}))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// src — synchronous IDL attribute reflection (WHATWG §4.8.11.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('src', () => {
|
||||
it('returns empty string before any src is set', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
expect(media.src).toBe('');
|
||||
});
|
||||
|
||||
it('reflects the set value synchronously', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
// Must be synchronous — no await needed
|
||||
expect(media.src).toBe('https://example.com/v.m3u8');
|
||||
});
|
||||
|
||||
it('reflects the most recently set value', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v1.m3u8';
|
||||
media.src = 'https://example.com/v2.m3u8';
|
||||
expect(media.src).toBe('https://example.com/v2.m3u8');
|
||||
});
|
||||
|
||||
it('reflects empty string when set to empty', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
media.src = '';
|
||||
expect(media.src).toBe('');
|
||||
});
|
||||
|
||||
// Setting src triggers the load algorithm — engine state update is synchronous
|
||||
it('synchronously updates engine presentation state when src is set', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
expect(media.engine.state.get().presentation?.url).toBe('https://example.com/v.m3u8');
|
||||
});
|
||||
|
||||
it('synchronously updates engine presentation state when src changes', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v1.m3u8';
|
||||
media.src = 'https://example.com/v2.m3u8';
|
||||
expect(media.engine.state.get().presentation?.url).toBe('https://example.com/v2.m3u8');
|
||||
});
|
||||
|
||||
it('clears engine presentation state when src is set to empty string', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
media.src = '';
|
||||
expect(media.engine.state.get().presentation?.url).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// attach / detach — media element lifecycle (reuses the same engine)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('attach / detach', () => {
|
||||
it('exposes the engine immediately (created at construction, not on attach)', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
expect(media.engine).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reuses the same engine instance across attach calls', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el1 = document.createElement('video');
|
||||
const el2 = document.createElement('video');
|
||||
media.attach(el1);
|
||||
const engineAfterFirstAttach = media.engine;
|
||||
media.attach(el2);
|
||||
expect(media.engine).toBe(engineAfterFirstAttach);
|
||||
});
|
||||
|
||||
it('reuses the same engine instance across attach/detach cycles', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
const engine = media.engine;
|
||||
media.detach();
|
||||
media.attach(document.createElement('video'));
|
||||
expect(media.engine).toBe(engine);
|
||||
});
|
||||
|
||||
it('creates a new engine when src is set', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const initial = media.engine;
|
||||
media.src = 'https://example.com/v1.m3u8';
|
||||
expect(media.engine).not.toBe(initial);
|
||||
});
|
||||
|
||||
it('destroys the old engine when src changes', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v1.m3u8';
|
||||
const spy = vi.spyOn(media.engine, 'destroy');
|
||||
media.src = 'https://example.com/v2.m3u8';
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('re-attaches the media element to the new engine when src changes', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.src = 'https://example.com/v1.m3u8';
|
||||
expect(media.engine.owners.get().mediaElement).toBe(el);
|
||||
});
|
||||
|
||||
it('cancels pending play listener when src changes', async () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.src = 'https://example.com/v1.m3u8';
|
||||
el.play = () => Promise.reject(new Error('no supported sources'));
|
||||
media.play().catch(() => {});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const spy = vi.spyOn(el, 'removeEventListener');
|
||||
media.src = 'https://example.com/v2.m3u8';
|
||||
expect(spy).toHaveBeenCalledWith('loadstart', expect.any(Function));
|
||||
});
|
||||
|
||||
it('sets mediaElement in owners when attached', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
expect(media.engine.owners.get().mediaElement).toBe(el);
|
||||
});
|
||||
|
||||
it('clears mediaElement in owners when detached', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
media.detach();
|
||||
expect(media.engine.owners.get().mediaElement).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates mediaElement when re-attached to a different element', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el1 = document.createElement('video');
|
||||
const el2 = document.createElement('video');
|
||||
media.attach(el1);
|
||||
media.attach(el2);
|
||||
expect(media.engine.owners.get().mediaElement).toBe(el2);
|
||||
});
|
||||
|
||||
it('preserves src across attach/detach cycles', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
media.attach(document.createElement('video'));
|
||||
media.detach();
|
||||
expect(media.src).toBe('https://example.com/v.m3u8');
|
||||
});
|
||||
|
||||
it('src set before attach is reflected in engine state', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
media.attach(document.createElement('video'));
|
||||
expect(media.engine.state.get().presentation?.url).toBe('https://example.com/v.m3u8');
|
||||
});
|
||||
|
||||
it('detach does not destroy the engine', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
const spy = vi.spyOn(media.engine, 'destroy');
|
||||
media.detach();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// play() — WHATWG §4.8.11.8
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('play()', () => {
|
||||
it('returns a Promise', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
const result = media.play();
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
// Prevent unhandled rejection — play without src is expected to fail
|
||||
result.catch(() => {});
|
||||
});
|
||||
|
||||
it('sets playbackInitiated on engine state when called', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
media.play().catch(() => {});
|
||||
expect(media.engine.state.get().playbackInitiated).toBe(true);
|
||||
});
|
||||
|
||||
it('retries play() via loadstart when element has no src but adapter has one', async () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
|
||||
// Simulate element having no blob URL yet on first call, as if MSE
|
||||
// hasn't attached yet, then succeed on retry
|
||||
let playCallCount = 0;
|
||||
const originalPlay = el.play.bind(el);
|
||||
el.play = () => {
|
||||
playCallCount++;
|
||||
if (playCallCount === 1) {
|
||||
return Promise.reject(new Error('no supported sources'));
|
||||
}
|
||||
return originalPlay();
|
||||
};
|
||||
|
||||
const playPromise = media.play();
|
||||
|
||||
// Push past all pending microtasks (state flush + .catch() handler)
|
||||
// before dispatching loadstart so the listener is registered in time
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Simulate MSE attaching the blob URL
|
||||
el.dispatchEvent(new Event('loadstart'));
|
||||
|
||||
await playPromise.catch(() => {});
|
||||
expect(playCallCount).toBe(2);
|
||||
});
|
||||
|
||||
it('re-throws when play() rejects and no adapter src is set', async () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
// No src on adapter — nothing pending to wait for
|
||||
|
||||
const err = new Error('autoplay policy');
|
||||
el.play = () => Promise.reject(err);
|
||||
|
||||
await expect(media.play()).rejects.toThrow('autoplay policy');
|
||||
});
|
||||
|
||||
it('removes the pending loadstart listener on detach', async () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
|
||||
el.play = () => Promise.reject(new Error('no supported sources'));
|
||||
media.play().catch(() => {});
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const spy = vi.spyOn(el, 'removeEventListener');
|
||||
media.detach();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('loadstart', expect.any(Function));
|
||||
});
|
||||
|
||||
it('removes the pending loadstart listener on destroy', async () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
|
||||
el.play = () => Promise.reject(new Error('no supported sources'));
|
||||
media.play().catch(() => {});
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const spy = vi.spyOn(el, 'removeEventListener');
|
||||
media.destroy();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('loadstart', expect.any(Function));
|
||||
});
|
||||
|
||||
// TODO: Add integration tests with a real HLS stream once test fixtures are
|
||||
// in place (e.g. Mux stream, WPT-style fixture server).
|
||||
// Expected: play() resolves after the media element fires 'playing'.
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// preload — synchronous IDL attribute (WHATWG §4.8.11.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('preload', () => {
|
||||
it('returns empty string before any preload is set', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
expect(media.preload).toBe('');
|
||||
});
|
||||
|
||||
it('reflects the set value synchronously', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.preload = 'auto';
|
||||
expect(media.preload).toBe('auto');
|
||||
});
|
||||
|
||||
it('updates engine state immediately when set', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.preload = 'none';
|
||||
expect(media.engine.state.get().preload).toBe('none');
|
||||
});
|
||||
|
||||
it('setting preload to empty string resets the stored value but does not clear current engine state', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.preload = 'auto';
|
||||
media.preload = '';
|
||||
// '' only clears #preload so the next engine recreation won't re-apply
|
||||
// an explicit value — it does not patch the current engine state.
|
||||
expect(media.engine.state.get().preload).toBe('auto');
|
||||
});
|
||||
|
||||
it('survives src reassignment — explicit preload is preserved across engine recreation', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
media.preload = 'none';
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
expect(media.preload).toBe('none');
|
||||
expect(media.engine.state.get().preload).toBe('none');
|
||||
});
|
||||
|
||||
it('explicit preload is re-applied before owners.patch on src change so syncPreloadAttribute skips inference', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.preload = 'none';
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
// syncPreloadAttribute fires when owners.patch re-attaches the element,
|
||||
// but since preload was already patched into the new engine's state, it skips.
|
||||
expect(media.engine.state.get().preload).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// destroy() — explicit teardown (separate from detach)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('destroy()', () => {
|
||||
it('destroys the underlying engine', () => {
|
||||
const media = new SimpleHlsMediaElement();
|
||||
const spy = vi.spyOn(media.engine, 'destroy');
|
||||
media.destroy();
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"declarationDir": "../../../../types/playback/engines/hls"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../../../../utils" },
|
||||
{ "path": "../../../core" },
|
||||
{ "path": "../../../network" },
|
||||
{ "path": "../../../media" },
|
||||
{ "path": "../../../media/dom" },
|
||||
{ "path": "../../behaviors" },
|
||||
{ "path": "../../behaviors/dom" },
|
||||
{ "path": "../../actors" },
|
||||
{ "path": "../../actors/dom" }
|
||||
],
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user