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:
Christian Pillsbury
2026-05-05 12:07:26 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 17d44a5d32
commit 0cfd3bb395
103 changed files with 2509 additions and 1277 deletions
@@ -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"]
}