mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.7
parent
338020e1d5
commit
1a3cb45b29
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user