feat(spf): basic audio only use case + use-case-composition doc-type + implementation skills (#1584)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-05-27 12:48:40 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 338020e1d5
commit 1a3cb45b29
46 changed files with 4584 additions and 423 deletions
@@ -27,11 +27,19 @@
* Downstream of `resolveVideoTrack` / `resolveAudioTrack`; upstream of
* `updateMediaSourceDuration` (which writes the value through to `mediaSource.duration`).
*/
import { defineBehavior } from '../../core/composition/create-composition';
import type { Behavior } from '../../core/composition/create-composition';
import { effect } from '../../core/signals/effect';
import { type ReadonlySignal, type Signal, snapshot, untrack, update } from '../../core/signals/primitives';
import { type ReadonlySignal, type Signal, untrack, update } from '../../core/signals/primitives';
import type { MaybeResolvedPresentation } from '../../media/types';
/**
* Input shape passed to the duration resolver. Represents the union of
* track-selection slots the default `getResolvedSelectedTrackDuration`
* resolver inspects to pick a representative resolved track. Variants
* that compose neither audio nor video selection still satisfy this
* shape — the missing fields read as `undefined` and the resolver
* falls through.
*/
export interface PresentationDurationState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
@@ -55,8 +63,16 @@ function calculatePresentationDurationSetup({
}: {
state: {
presentation: Signal<PresentationDurationState['presentation']>;
selectedVideoTrackId: ReadonlySignal<PresentationDurationState['selectedVideoTrackId']>;
selectedAudioTrackId: ReadonlySignal<PresentationDurationState['selectedAudioTrackId']>;
// selectedVideoTrackId / selectedAudioTrackId are read defensively —
// they are *not* declared in this behavior's stateKeys. The slots are
// contributed by other behaviors (`switchVideoQuality` writes the
// video slot in the default engine; `selectAudioTrack` writes the
// audio slot) which compose conditionally per engine variant. Treating
// each signal as optional lets calculatePresentationDuration stay
// variant-agnostic — it feeds whatever's present to the resolver and
// lets the resolver decide.
selectedVideoTrackId?: ReadonlySignal<PresentationDurationState['selectedVideoTrackId']>;
selectedAudioTrackId?: ReadonlySignal<PresentationDurationState['selectedAudioTrackId']>;
};
config: PresentationDurationConfig;
}): () => void {
@@ -68,15 +84,39 @@ function calculatePresentationDurationSetup({
// writable duration on their own (resolver returns undefined until a
// track resolves, at which point resolve-track writes back through
// state.presentation anyway), so we read the rest untracked.
const duration = config.resolveDuration(untrack(() => snapshot(state)));
const resolverInput: PresentationDurationState = untrack(() => ({
presentation,
selectedVideoTrackId: state.selectedVideoTrackId?.get(),
selectedAudioTrackId: state.selectedAudioTrackId?.get(),
}));
const duration = config.resolveDuration(resolverInput);
if (duration === undefined || Number.isNaN(duration) || duration <= 0) return;
update(state.presentation, (current) => (current ? { ...current, duration } : current));
// Patch-object form requires `T extends object`; `state.presentation`'s
// T is `MaybeResolvedPresentation | undefined`, which doesn't satisfy
// the constraint. The guard at the top of the effect already
// established that the slot holds a defined value, so cast to narrow
// the signal type and use the cleaner merge form.
update(state.presentation as Signal<MaybeResolvedPresentation>, { duration });
});
}
export const calculatePresentationDuration = defineBehavior({
stateKeys: ['presentation', 'selectedVideoTrackId', 'selectedAudioTrackId'],
/**
* `calculatePresentationDuration` uses a manual `Behavior<>` literal
* (rather than `defineBehavior`) so it can declare just `presentation`
* in its stateKeys while the typed setup-param shape includes the
* optional `selectedVideoTrackId` / `selectedAudioTrackId` reads used
* at runtime. Mirrors the pattern in `endOfStream` for the same
* reason: the behavior is uniform-across-tracks and reads slots
* contributed by other behaviors, so it shouldn't leak those slot
* declarations into variants that don't compose the contributors.
*/
export const calculatePresentationDuration: Behavior<
{ presentation: Signal<PresentationDurationState['presentation']> },
Record<string, never>,
PresentationDurationConfig
> = {
stateKeys: ['presentation'],
contextKeys: [],
setup: calculatePresentationDurationSetup,
});
};
@@ -91,7 +91,7 @@
* from `presentation.duration` once per source; this behavior writes the
* final value from the buffered end. Decision domains don't overlap.
*/
import { defineBehavior } from '../../../core/composition/create-composition';
import type { Behavior } from '../../../core/composition/create-composition';
import { createMachineReactor } from '../../../core/reactors/create-machine-reactor';
import { effect } from '../../../core/signals/effect';
import { computed, type ReadonlySignal, signal } from '../../../core/signals/primitives';
@@ -169,8 +169,9 @@ function endOfStreamSetup({
};
context: {
mediaSource: ReadonlySignal<EndOfStreamContext['mediaSource']>;
videoBufferActor: ReadonlySignal<EndOfStreamContext['videoBufferActor']>;
audioBufferActor: ReadonlySignal<EndOfStreamContext['audioBufferActor']>;
// See behavior definition for details on these optional context signals.
videoBufferActor?: ReadonlySignal<SourceBufferActor | undefined>;
audioBufferActor?: ReadonlySignal<SourceBufferActor | undefined>;
};
}): () => void {
// Behavior-local mirror of `mediaSource.readyState === 'open'`. Subscribes
@@ -198,8 +199,8 @@ function endOfStreamSetup({
state.presentation.get(),
context.mediaSource.get(),
msIsOpen.get(),
context.videoBufferActor.get(),
context.audioBufferActor.get(),
context.videoBufferActor?.get(),
context.audioBufferActor?.get(),
state.currentTime.get()
)
);
@@ -253,8 +254,24 @@ function endOfStreamSetup({
};
}
export const endOfStream = defineBehavior({
/**
* `endOfStream` uses a manual `Behavior<>` literal (rather than
* `defineBehavior`) because it reads `videoBufferActor` /
* `audioBufferActor` defensively without declaring them in its
* contextKeys — those slots are contributed by other behaviors and
* compose conditionally per engine variant. The `Behavior<>` literal
* opts out of the exhaustiveness check so the typed context shape can
* include the optional fields used at runtime. See the comment on
* `endOfStreamSetup`'s context param for the discipline.
*/
export const endOfStream: Behavior<
{
presentation: ReadonlySignal<EndOfStreamState['presentation']>;
currentTime: ReadonlySignal<EndOfStreamState['currentTime']>;
},
{ mediaSource: ReadonlySignal<EndOfStreamContext['mediaSource']> }
> = {
stateKeys: ['presentation', 'currentTime'],
contextKeys: ['mediaSource', 'videoBufferActor', 'audioBufferActor'],
contextKeys: ['mediaSource'],
setup: endOfStreamSetup,
});
};
@@ -12,7 +12,20 @@ function makeState(initial: EndOfStreamState = {}): StateSignals<EndOfStreamStat
};
}
function makeContext(initial: EndOfStreamContext = {}): ContextSignals<EndOfStreamContext> {
// Test-only context shape: extends EndOfStreamContext (which only declares
// `mediaSource` as a contributed slot) with the optional buffer-actor
// signals endOfStream reads defensively. The behavior under test composes
// against whatever's actually present in scope — these tests stand the
// signals up directly to exercise that runtime read path.
interface EndOfStreamTestContext extends EndOfStreamContext {
videoBufferActor?: SourceBufferActor;
audioBufferActor?: SourceBufferActor;
}
function makeContext(initial: EndOfStreamTestContext = {}): ContextSignals<EndOfStreamContext> & {
videoBufferActor: ReturnType<typeof signal<SourceBufferActor | undefined>>;
audioBufferActor: ReturnType<typeof signal<SourceBufferActor | undefined>>;
} {
return {
mediaSource: signal<MediaSource | undefined>(initial.mediaSource),
videoBufferActor: signal<SourceBufferActor | undefined>(initial.videoBufferActor),
@@ -20,12 +33,17 @@ function makeContext(initial: EndOfStreamContext = {}): ContextSignals<EndOfStre
};
}
function setupEndOfStream(initialState: EndOfStreamState, initialContext: EndOfStreamContext) {
function setupEndOfStream(initialState: EndOfStreamState, initialContext: EndOfStreamTestContext) {
// Default `currentTime` well past any test scenario's last-segment startTime
// — tests that exercise the currentTime gate pass their own value.
const state = makeState({ currentTime: 1000, ...initialState });
const context = makeContext(initialContext);
const cleanup = endOfStream.setup({ state, context });
// endOfStream uses a manual Behavior<> literal (not defineBehavior), so
// its public setup signature requires config even though the behavior
// doesn't consume it. Pass {} explicitly. cleanup widens to
// BehaviorCleanup (void | () => void | { destroy }) — the real return is
// a () => void; cast for callable ergonomics in tests.
const cleanup = endOfStream.setup({ state, context, config: {} }) as () => void;
return { state, context, cleanup };
}
@@ -9,6 +9,12 @@ import {
} from '../calculate-presentation-duration';
function makeState(initial: PresentationDurationState = {}): StateSignals<PresentationDurationState> {
// Tests stand up signals for all three state slots — though
// calculatePresentationDuration only declares `presentation` in its
// stateKeys (the other two are read defensively as optional fields
// contributed by other behaviors at composition time). The tests
// exercise the defensive read path by providing the optional signals
// directly.
return {
presentation: signal<MaybeResolvedPresentation | undefined>(initial.presentation),
selectedVideoTrackId: signal<string | undefined>(initial.selectedVideoTrackId),
@@ -16,6 +22,21 @@ function makeState(initial: PresentationDurationState = {}): StateSignals<Presen
};
}
function setupDuration(
state: StateSignals<PresentationDurationState>,
resolveDuration: PresentationDurationResolver
): () => void {
// calculatePresentationDuration uses a manual Behavior<> literal, so the
// public setup signature requires `context` even though the behavior
// doesn't consume it. Cleanup widens to BehaviorCleanup; cast for
// callable ergonomics.
return calculatePresentationDuration.setup({
state,
context: {},
config: { resolveDuration },
}) as () => void;
}
const mockPresentation = (overrides: Partial<Presentation> = {}): Presentation =>
({
id: 'pres-1',
@@ -30,7 +51,7 @@ describe('calculatePresentationDuration', () => {
const state = makeState();
const resolveDuration: PresentationDurationResolver = () => 120.5;
const cleanup = calculatePresentationDuration.setup({ state, config: { resolveDuration } });
const cleanup = setupDuration(state, resolveDuration);
state.presentation.set(mockPresentation());
@@ -45,7 +66,7 @@ describe('calculatePresentationDuration', () => {
const state = makeState();
const resolveDuration: PresentationDurationResolver = () => Number.POSITIVE_INFINITY;
const cleanup = calculatePresentationDuration.setup({ state, config: { resolveDuration } });
const cleanup = setupDuration(state, resolveDuration);
state.presentation.set(mockPresentation());
@@ -60,7 +81,7 @@ describe('calculatePresentationDuration', () => {
const state = makeState();
const resolveDuration = vi.fn<PresentationDurationResolver>(() => 60);
const cleanup = calculatePresentationDuration.setup({ state, config: { resolveDuration } });
const cleanup = setupDuration(state, resolveDuration);
state.presentation.set(mockPresentation());
state.selectedVideoTrackId.set('video-1');
@@ -84,7 +105,7 @@ describe('calculatePresentationDuration', () => {
const state = makeState();
const resolveDuration: PresentationDurationResolver = () => undefined;
const cleanup = calculatePresentationDuration.setup({ state, config: { resolveDuration } });
const cleanup = setupDuration(state, resolveDuration);
state.presentation.set(mockPresentation());
@@ -99,7 +120,7 @@ describe('calculatePresentationDuration', () => {
const state = makeState();
const resolveDuration: PresentationDurationResolver = () => Number.NaN;
const cleanup = calculatePresentationDuration.setup({ state, config: { resolveDuration } });
const cleanup = setupDuration(state, resolveDuration);
state.presentation.set(mockPresentation());
@@ -114,7 +135,7 @@ describe('calculatePresentationDuration', () => {
const state = makeState();
const resolveDuration = vi.fn<PresentationDurationResolver>().mockReturnValueOnce(0).mockReturnValueOnce(-5);
const cleanup = calculatePresentationDuration.setup({ state, config: { resolveDuration } });
const cleanup = setupDuration(state, resolveDuration);
state.presentation.set(mockPresentation());
state.selectedVideoTrackId.set('video-1');
@@ -130,7 +151,7 @@ describe('calculatePresentationDuration', () => {
const state = makeState({ presentation: mockPresentation({ duration: 60 }) });
const resolveDuration = vi.fn<PresentationDurationResolver>(() => 120);
const cleanup = calculatePresentationDuration.setup({ state, config: { resolveDuration } });
const cleanup = setupDuration(state, resolveDuration);
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -0,0 +1,185 @@
import type { Constructor, MixinReturn } from '@videojs/utils/types';
import type { Composition } from '../../../core/composition/create-composition';
import {
createHlsAudioOnlyEngine,
type SimpleHlsAudioOnlyEngineConfig,
type SimpleHlsAudioOnlyEngineContext,
type SimpleHlsAudioOnlyEngineSignals,
type SimpleHlsAudioOnlyEngineState,
} from './engine-audio-only';
export interface SimpleHlsAudioOnlyMediaProps {
src: string;
preload: '' | 'none' | 'metadata' | 'auto';
}
export const simpleHlsAudioOnlyMediaDefaultProps: SimpleHlsAudioOnlyMediaProps = {
src: '',
preload: '',
};
export interface SimpleHlsAudioOnlyMediaAPI extends SimpleHlsAudioOnlyMediaProps {
readonly engine: Composition<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext>;
attach(mediaElement: HTMLMediaElement): void;
detach(): void;
destroy(): void;
play(): Promise<void>;
}
/**
* Mixin that adds SPF audio-only HLS playback to any base class.
*
* Parallel to `SimpleHlsMediaMixin` with one substantive difference: the
* underlying engine is the audio-only variant (`createHlsAudioOnlyEngine`),
* which omits video and text-track behaviors. The src / preload / play()
* contract per the WHATWG HTML spec is identical to the default adapter.
*
* Selecting this adapter is the variant decision: instantiating
* `SimpleHlsAudioOnlyMediaElement` opts the consumer into audio-only
* delivery even when the source is a mixed-AV HLS manifest.
*
* @example
* class SimpleHlsAudioOnlyMedia extends SimpleHlsAudioOnlyMediaMixin(HTMLVideoElementHost) {}
*
* const media = new SimpleHlsAudioOnlyMedia();
* media.attach(document.querySelector('video'));
* media.src = 'https://stream.mux.com/abc123.m3u8';
*/
export function SimpleHlsAudioOnlyMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {
class SimpleHlsAudioOnlyMediaImpl extends BaseClass {
#engine: Composition<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext>;
#config: SimpleHlsAudioOnlyEngineConfig;
#signals!: SimpleHlsAudioOnlyEngineSignals;
#preload: '' | 'none' | 'metadata' | 'auto' = simpleHlsAudioOnlyMediaDefaultProps.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<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
return this.#engine;
}
// -------------------------------------------------------------------------
// Media element lifecycle
// -------------------------------------------------------------------------
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();
}
// -------------------------------------------------------------------------
// 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) {
this.#signals.state.preload.set(value);
}
}
// -------------------------------------------------------------------------
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
// -------------------------------------------------------------------------
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() — WHATWG §4.8.11.8
// -------------------------------------------------------------------------
play(): Promise<void> {
const mediaElement = this.#signals.context.mediaElement.get();
if (!mediaElement) {
return Promise.reject(new Error('SimpleHlsAudioOnlyMediaElement: no media element attached'));
}
this.#signals.state.loadActivated.set(true);
return mediaElement.play().catch((err: unknown) => {
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
// -------------------------------------------------------------------------
#createEngine(): Composition<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
return createHlsAudioOnlyEngine({
...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 SimpleHlsAudioOnlyMediaImpl as unknown as MixinReturn<Base, SimpleHlsAudioOnlyMediaAPI>;
}
/** Standalone SPF audio-only media adapter with no base class. */
export class SimpleHlsAudioOnlyMediaElement extends SimpleHlsAudioOnlyMediaMixin(class {}) {}
@@ -0,0 +1,168 @@
import {
type Composition,
type ContextSignals,
createComposition,
type StateSignals,
} from '../../../core/composition/create-composition';
import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals';
import type { BackBufferConfig } from '../../../media/buffer/back-buffer';
import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer';
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
import type { MaybeResolvedPresentation } from '../../../media/types';
import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection';
import type { SegmentLoaderActor } from '../../actors/dom/segment-loader';
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
import {
calculatePresentationDuration,
type PresentationDurationResolver,
} from '../../behaviors/calculate-presentation-duration';
import { endOfStream } from '../../behaviors/dom/end-of-stream';
import { loadAudioSegments } from '../../behaviors/dom/load-segments';
import { setupAudioBufferActors } from '../../behaviors/dom/setup-buffer-actors';
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
import { resolveAudioTrack } from '../../behaviors/resolve-track';
import { selectAudioTrack } from '../../behaviors/select-tracks';
import { syncPreload } from '../../behaviors/sync-preload';
// ============================================================================
// Audio-Only HLS Engine State & Context
// ============================================================================
/**
* State shape for the audio-only HLS playback engine.
*
* Subset of `SimpleHlsEngineState` covering only the slots written and read
* by audio-side behaviors. Video and text-track slots are absent
* subtractive composition removes the behaviors that declare them.
*/
export interface SimpleHlsAudioOnlyEngineState {
presentation?: MaybeResolvedPresentation;
preload?: 'auto' | 'metadata' | 'none';
selectedAudioTrackId?: string;
currentTime?: number;
loadActivated?: boolean;
}
/**
* Context shape for the audio-only HLS playback engine.
*
* Subset of `SimpleHlsEngineContext` covering only the platform objects and
* actor refs managed by audio-side behaviors.
*/
export interface SimpleHlsAudioOnlyEngineContext {
mediaElement?: HTMLMediaElement | undefined;
mediaSource?: MediaSource;
audioBufferActor?: SourceBufferActor;
audioSegmentLoaderActor?: SegmentLoaderActor;
}
export type SimpleHlsAudioOnlyEngineSignals = {
state: StateSignals<SimpleHlsAudioOnlyEngineState>;
context: ContextSignals<SimpleHlsAudioOnlyEngineContext>;
};
/**
* Configuration for the audio-only HLS playback engine.
*
* Subset of `SimpleHlsEngineConfig` video-quality, bandwidth-estimator,
* and text-track config fields are omitted (no behavior consumes them).
*/
export interface SimpleHlsAudioOnlyEngineConfig
extends ShareSignalsConfig<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
preferredAudioLanguage?: string;
resolveDuration?: PresentationDurationResolver;
parsePresentation?: ParsePresentation;
forwardBuffer?: Partial<ForwardBufferConfig>;
backBuffer?: Partial<BackBufferConfig>;
}
// ============================================================================
// Audio-Only HLS Playback Engine
// ============================================================================
const shareSignals = makeShareSignals<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext>();
/**
* Create an audio-only HLS playback engine.
*
* Subtractive composition variant of `createSimpleHlsEngine`: omits
* video-side behaviors (`resolveVideoTrack`, `switchVideoQuality`,
* `setupVideoBufferActors`, `loadVideoSegments`) and text-track behaviors
* (`selectTextTrack`, `resolveTextTrack`, `syncTextTracks`,
* `setupTextTrackActors`, `loadTextTrackSegments`). The remaining audio
* pipeline composes unchanged.
*
* Handles both truly audio-only HLS sources (no video stream-inf) and
* mixed-AV HLS sources where the audio rendition is selected and video /
* subtitle renditions are ignored at composition time. The variant decision
* is encoded by adapter choice; this engine does not branch on source
* shape.
*
* @example
* ```ts
* let signals: SimpleHlsAudioOnlyEngineSignals;
* const engine = createHlsAudioOnlyEngine({
* preferredAudioLanguage: 'en',
* onSignalsReady: (refs) => {
* signals = refs;
* },
* });
*
* signals.context.mediaElement.set(audioEl);
* signals.state.presentation.set({ url: 'https://example.com/stream.m3u8' });
* ```
*/
export function createHlsAudioOnlyEngine(
config: SimpleHlsAudioOnlyEngineConfig = {}
): Composition<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
const finalConfig = {
...config,
resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
};
return createComposition(
[
syncPreload,
trackLoadTriggers,
resolvePresentation,
// Track selection — audio only.
selectAudioTrack,
// Resolve selected tracks — audio only.
resolveAudioTrack,
// Presentation duration
calculatePresentationDuration,
// MSE setup. Single audio buffer; no video buffer to coordinate with,
// so the Firefox `mozHasAudio` registration ordering is moot here.
setupMediaSource,
updateMediaSourceDuration,
setupAudioBufferActors,
// Playback tracking
trackCurrentTime,
// Segment loading — audio only.
loadAudioSegments,
// End of stream coordination. `endOfStream` iterates buffer actors via
// `[videoBufferActor, audioBufferActor].filter(Boolean)` and reads
// `mediaSource.sourceBuffers` aggregately — composes unchanged with
// only audio in scope.
endOfStream,
// Adapter signal callback.
shareSignals,
],
{
config: finalConfig,
}
);
}
@@ -1,5 +1,11 @@
export type { SimpleHlsMediaAPI, SimpleHlsMediaProps } from './adapter';
export { SimpleHlsMediaElement, SimpleHlsMediaMixin, simpleHlsMediaDefaultProps } from './adapter';
export type { SimpleHlsAudioOnlyMediaAPI, SimpleHlsAudioOnlyMediaProps } from './adapter-audio-only';
export {
SimpleHlsAudioOnlyMediaElement,
SimpleHlsAudioOnlyMediaMixin,
simpleHlsAudioOnlyMediaDefaultProps,
} from './adapter-audio-only';
export type {
SimpleHlsEngineConfig,
SimpleHlsEngineContext,
@@ -7,3 +13,10 @@ export type {
SimpleHlsEngineState,
} from './engine';
export { createSimpleHlsEngine } from './engine';
export type {
SimpleHlsAudioOnlyEngineConfig,
SimpleHlsAudioOnlyEngineContext,
SimpleHlsAudioOnlyEngineSignals,
SimpleHlsAudioOnlyEngineState,
} from './engine-audio-only';
export { createHlsAudioOnlyEngine } from './engine-audio-only';
@@ -0,0 +1,268 @@
/**
* SimpleHlsAudioOnlyMediaElement adapter tests.
*
* Covers the HTMLMediaElement-compatible contract for src and play(), per the
* WHATWG HTML spec, for the audio-only HLS variant. Parallels
* adapter.test.ts semantics match (the variant differs in composition,
* not in adapter contract).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SimpleHlsAudioOnlyMediaElement } from '../adapter-audio-only';
describe('SimpleHlsAudioOnlyMediaElement', () => {
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 SimpleHlsAudioOnlyMediaElement();
expect(media.src).toBe('');
});
it('reflects the set value synchronously', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.src = 'https://example.com/v.m3u8';
expect(media.src).toBe('https://example.com/v.m3u8');
});
it('reflects the most recently set value', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
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 SimpleHlsAudioOnlyMediaElement();
media.src = 'https://example.com/v.m3u8';
media.src = '';
expect(media.src).toBe('');
});
it('synchronously updates engine presentation state when src is set', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.src = 'https://example.com/v.m3u8';
expect(media.engine.state.presentation.get()?.url).toBe('https://example.com/v.m3u8');
});
it('synchronously updates engine presentation state when src changes', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.src = 'https://example.com/v1.m3u8';
media.src = 'https://example.com/v2.m3u8';
expect(media.engine.state.presentation.get()?.url).toBe('https://example.com/v2.m3u8');
});
it('clears engine presentation state when src is set to empty string', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.src = 'https://example.com/v.m3u8';
media.src = '';
expect(media.engine.state.presentation.get()?.url).toBeFalsy();
});
});
// ---------------------------------------------------------------------------
// attach / detach
// ---------------------------------------------------------------------------
describe('attach / detach', () => {
it('exposes the engine immediately (created at construction, not on attach)', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
expect(media.engine).not.toBeNull();
});
it('reuses the same engine instance across attach calls', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
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 SimpleHlsAudioOnlyMediaElement();
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 SimpleHlsAudioOnlyMediaElement();
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 SimpleHlsAudioOnlyMediaElement();
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 SimpleHlsAudioOnlyMediaElement();
const el = document.createElement('video');
media.attach(el);
media.src = 'https://example.com/v1.m3u8';
expect(media.engine.context.mediaElement.get()).toBe(el);
});
it('sets mediaElement in owners when attached', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
const el = document.createElement('video');
media.attach(el);
expect(media.engine.context.mediaElement.get()).toBe(el);
});
it('clears mediaElement in owners when detached', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.attach(document.createElement('video'));
media.detach();
expect(media.engine.context.mediaElement.get()).toBeUndefined();
});
it('updates mediaElement when re-attached to a different element', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
const el1 = document.createElement('video');
const el2 = document.createElement('video');
media.attach(el1);
media.attach(el2);
expect(media.engine.context.mediaElement.get()).toBe(el2);
});
it('preserves src across attach/detach cycles', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
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 SimpleHlsAudioOnlyMediaElement();
media.src = 'https://example.com/v.m3u8';
media.attach(document.createElement('video'));
expect(media.engine.state.presentation.get()?.url).toBe('https://example.com/v.m3u8');
});
it('detach does not destroy the engine', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
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 SimpleHlsAudioOnlyMediaElement();
media.attach(document.createElement('video'));
const result = media.play();
expect(result).toBeInstanceOf(Promise);
result.catch(() => {});
});
it('sets loadActivated on engine state when called', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.attach(document.createElement('video'));
media.play().catch(() => {});
expect(media.engine.state.loadActivated.get()).toBe(true);
});
it('retries play() via loadstart when element has no src but adapter has one', async () => {
const media = new SimpleHlsAudioOnlyMediaElement();
const el = document.createElement('video');
media.attach(el);
media.src = 'https://example.com/v.m3u8';
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();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
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 SimpleHlsAudioOnlyMediaElement();
const el = document.createElement('video');
media.attach(el);
const err = new Error('autoplay policy');
el.play = () => Promise.reject(err);
await expect(media.play()).rejects.toThrow('autoplay policy');
});
});
// ---------------------------------------------------------------------------
// preload — synchronous IDL attribute (WHATWG §4.8.11.2)
// ---------------------------------------------------------------------------
describe('preload', () => {
it('returns empty string before any preload is set', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
expect(media.preload).toBe('');
});
it('reflects the set value synchronously', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.preload = 'auto';
expect(media.preload).toBe('auto');
});
it('updates engine state immediately when set', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.preload = 'none';
expect(media.engine.state.preload.get()).toBe('none');
});
it('survives src reassignment — explicit preload is preserved across engine recreation', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
media.preload = 'none';
media.src = 'https://example.com/v.m3u8';
expect(media.preload).toBe('none');
expect(media.engine.state.preload.get()).toBe('none');
});
});
// ---------------------------------------------------------------------------
// destroy()
// ---------------------------------------------------------------------------
describe('destroy()', () => {
it('destroys the underlying engine', () => {
const media = new SimpleHlsAudioOnlyMediaElement();
const spy = vi.spyOn(media.engine, 'destroy');
media.destroy();
expect(spy).toHaveBeenCalledOnce();
});
});
});
@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { snapshot } from '../../../../core/signals/primitives';
import { createHlsAudioOnlyEngine } from '../engine-audio-only';
// Mock appendSegment to succeed without real MP4 data
vi.mock('../../../../media/dom/mse/append-segment', () => ({
appendSegment: vi.fn().mockResolvedValue(undefined),
}));
describe('createHlsAudioOnlyEngine', () => {
let originalFetch: typeof globalThis.fetch;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
// Tests assert at actor-presence and state-shape level, not at "init
// segment appended" level — so unmocked init/segment URLs in the manifests
// are intentional. The fetch loop's reject path leaks a console.error in
// each test; suppress only the expected patterns so genuine failures still
// surface.
const expectedErrorPatterns = [
/Unexpected error in segment loader.*Unmocked URL/s,
/Failed to load text-track segment/,
];
beforeEach(() => {
originalFetch = globalThis.fetch;
const originalConsoleError = console.error.bind(console);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
const text = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ');
if (expectedErrorPatterns.some((p) => p.test(text))) return;
originalConsoleError(...args);
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
consoleErrorSpy.mockRestore();
});
it('creates engine with state, context, and destroy', () => {
const engine = createHlsAudioOnlyEngine();
expect(engine.state).toBeDefined();
expect(engine.context).toBeDefined();
expect(typeof engine.destroy).toBe('function');
engine.destroy();
});
it('does not seed bandwidthState (no ABR behavior subscribed at init)', () => {
const engine = createHlsAudioOnlyEngine();
const state = snapshot(engine.state) as Record<string, unknown>;
// bandwidthState slot may or may not exist depending on whether any
// composed behavior declares it; if it exists, it must not be seeded.
if ('bandwidthState' in state) {
expect(state.bandwidthState).toBeUndefined();
}
engine.destroy();
});
it('plays truly audio-only HLS source (parity with default-engine tolerance)', async () => {
const mockFetch = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
if (url.includes('playlist.m3u8')) {
return Promise.resolve(
new Response(`#EXTM3U
#EXT-X-VERSION:7
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",CHANNELS="2",URI="http://example.com/audio-en.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS="mp4a.40.2",AUDIO="audio"
http://example.com/audio-en.m3u8`)
);
}
if (url.includes('audio-en.m3u8')) {
return Promise.resolve(
new Response(`#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:10
#EXT-X-MAP:URI="http://example.com/init-audio.mp4"
#EXTINF:10.0,
http://example.com/audio-seg1.m4s
#EXT-X-ENDLIST`)
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
});
globalThis.fetch = mockFetch;
const engine = createHlsAudioOnlyEngine();
const mediaElement = document.createElement('video');
mediaElement.preload = 'auto';
engine.context.mediaElement.set(mediaElement);
engine.state.presentation.set({ url: 'http://example.com/playlist.m3u8' });
engine.state.preload.set('auto');
await vi.waitFor(
() => {
const state = snapshot(engine.state);
const owners = snapshot(engine.context);
expect(state.selectedAudioTrackId).toBeDefined();
expect(owners.audioBufferActor).toBeDefined();
expect(owners.mediaSource).toBeDefined();
expect(owners.mediaSource?.readyState).toBe('open');
},
{ timeout: 2000 }
);
engine.destroy();
});
it('plays mixed HLS source as audio-only (video tracks ignored)', async () => {
// Mixed AV manifest: audio rendition + video stream-inf. The variant
// should compose audio behaviors only — no video selection, no video
// buffer actor, no video segment loading.
const mockFetch = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
if (url.includes('playlist.m3u8')) {
return Promise.resolve(
new Response(`#EXTM3U
#EXT-X-VERSION:7
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",CHANNELS="2",URI="http://example.com/audio-en.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="avc1.42E01E,mp4a.40.2",AUDIO="audio",RESOLUTION=640x360
http://example.com/video-360p.m3u8`)
);
}
if (url.includes('audio-en.m3u8')) {
return Promise.resolve(
new Response(`#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:10
#EXT-X-MAP:URI="http://example.com/init-audio.mp4"
#EXTINF:10.0,
http://example.com/audio-seg1.m4s
#EXT-X-ENDLIST`)
);
}
// Video playlist is NOT expected to be fetched — fail loudly if it is.
if (url.includes('video-360p.m3u8')) {
throw new Error('Audio-only variant fetched the video media playlist');
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
});
globalThis.fetch = mockFetch;
const engine = createHlsAudioOnlyEngine();
const mediaElement = document.createElement('video');
mediaElement.preload = 'auto';
engine.context.mediaElement.set(mediaElement);
engine.state.presentation.set({ url: 'http://example.com/playlist.m3u8' });
engine.state.preload.set('auto');
await vi.waitFor(
() => {
const state = snapshot(engine.state) as Record<string, unknown>;
const owners = snapshot(engine.context) as Record<string, unknown>;
// Audio-side fully exercised
expect(state.selectedAudioTrackId).toBeDefined();
expect(owners.audioBufferActor).toBeDefined();
expect(owners.mediaSource).toBeDefined();
expect((owners.mediaSource as MediaSource).readyState).toBe('open');
// Video-side slots absent — no composed behavior in this variant
// declares them. Behaviors that read these slots defensively
// (`endOfStream` for videoBufferActor; `calculatePresentationDuration`
// for selectedVideoTrackId) treat them as optional context/state
// fields and don't leak the slot into the composition. Asserting
// absence catches regressions where a behavior re-introduces a
// video-side declaration.
expect('videoBufferActor' in owners).toBe(false);
expect('videoSegmentLoaderActor' in owners).toBe(false);
expect('selectedVideoTrackId' in state).toBe(false);
},
{ timeout: 2000 }
);
engine.destroy();
});
it('plays mixed HLS source with subtitles ignored', async () => {
// Mixed AV manifest with a subtitle rendition. Subtitle behaviors are
// subtracted in Phase 1, so no text-track machinery should be set up.
const mockFetch = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
if (url.includes('playlist.m3u8')) {
return Promise.resolve(
new Response(`#EXTM3U
#EXT-X-VERSION:7
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",CHANNELS="2",URI="http://example.com/audio-en.m3u8"
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="http://example.com/text-en.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="avc1.42E01E,mp4a.40.2",AUDIO="audio",SUBTITLES="subs",RESOLUTION=640x360
http://example.com/video-360p.m3u8`)
);
}
if (url.includes('audio-en.m3u8')) {
return Promise.resolve(
new Response(`#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:10
#EXT-X-MAP:URI="http://example.com/init-audio.mp4"
#EXTINF:10.0,
http://example.com/audio-seg1.m4s
#EXT-X-ENDLIST`)
);
}
if (url.includes('text-en.m3u8') || url.includes('video-360p.m3u8')) {
throw new Error(`Audio-only variant fetched a non-audio playlist: ${url}`);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
});
globalThis.fetch = mockFetch;
const engine = createHlsAudioOnlyEngine();
const mediaElement = document.createElement('video');
mediaElement.preload = 'auto';
engine.context.mediaElement.set(mediaElement);
engine.state.presentation.set({ url: 'http://example.com/playlist.m3u8' });
engine.state.preload.set('auto');
await vi.waitFor(
() => {
const state = snapshot(engine.state) as Record<string, unknown>;
const owners = snapshot(engine.context) as Record<string, unknown>;
expect(state.selectedAudioTrackId).toBeDefined();
expect(owners.audioBufferActor).toBeDefined();
// Text-track slots absent — Phase 1 subtracts all text-track
// behaviors, and no remaining behavior declares the slots.
expect('selectedTextTrackId' in state).toBe(false);
expect('textTracksActor' in owners).toBe(false);
expect('textTrackSegmentLoaderActor' in owners).toBe(false);
},
{ timeout: 2000 }
);
engine.destroy();
});
it('cleans up on destroy', () => {
const engine = createHlsAudioOnlyEngine();
expect(() => engine.destroy()).not.toThrow();
});
});
@@ -9,15 +9,34 @@ vi.mock('../../../../media/dom/mse/append-segment', () => ({
describe('createSimpleHlsEngine', () => {
let originalFetch: typeof globalThis.fetch;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
// Tests assert at actor-presence and state-shape level, not at "init
// segment appended" level — so unmocked init/segment URLs in the manifests
// are intentional. The fetch loop's reject path leaks a console.error in
// each test; suppress only the expected patterns so genuine failures still
// surface.
const expectedErrorPatterns = [
/Unexpected error in segment loader.*Unmocked URL/s,
/Failed to load text-track segment/,
];
beforeEach(() => {
// Save original fetch
originalFetch = globalThis.fetch;
const originalConsoleError = console.error.bind(console);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
const text = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ');
if (expectedErrorPatterns.some((p) => p.test(text))) return;
originalConsoleError(...args);
});
});
afterEach(() => {
// Restore original fetch
globalThis.fetch = originalFetch;
consoleErrorSpy.mockRestore();
});
it('creates engine with state, owners, and destroy', () => {
const engine = createSimpleHlsEngine();