feat(spf): background looping video (phase 1) (#1602)

This commit is contained in:
Santiago Puppo
2026-06-01 16:13:26 -03:00
committed by GitHub
parent 01a2115aab
commit 3741e8de12
13 changed files with 1570 additions and 1 deletions
@@ -178,6 +178,34 @@ export function pickVideoTrack(presentation: Presentation, config: VideoSelectio
return selected?.id;
}
/**
* Pick the video track with the highest resolution (width x height).
*
* Falls back to `bandwidth` when resolution metadata is missing.
*
* Pair with `selectVideoTrack`; compose `switchVideoQuality` instead
* for runtime-adapted quality.
*/
export function pickMaxResolutionVideoTrack(presentation: MaybeResolvedPresentation): string | undefined {
const videoSet = presentation.selectionSets?.find((set) => set.type === 'video') as VideoSelectionSet | undefined;
const tracks = videoSet?.switchingSets[0]?.tracks;
if (!tracks?.length) return undefined;
let bestId: string | undefined;
let bestArea = -1;
let bestBandwidth = -1;
for (const track of tracks) {
const area = track.width && track.height ? track.width * track.height : 0;
const bandwidth = track.bandwidth ?? 0;
if (area > bestArea || (area === bestArea && bandwidth > bestBandwidth)) {
bestArea = area;
bestBandwidth = bandwidth;
bestId = track.id;
}
}
return bestId;
}
/**
* Pick audio track.
*
@@ -7,7 +7,7 @@ import type {
TextSelectionSet,
VideoSelectionSet,
} from '../../types';
import { pickAudioTrack, pickTextTrack, pickVideoTrack } from '../select-tracks';
import { pickAudioTrack, pickMaxResolutionVideoTrack, pickTextTrack, pickVideoTrack } from '../select-tracks';
// Helper to create a minimal presentation
function createPresentation(config: {
@@ -176,6 +176,103 @@ describe('pickVideoTrack', () => {
});
});
describe('pickMaxResolutionVideoTrack', () => {
it('selects the track with the highest width × height area', () => {
const tracks: PartiallyResolvedVideoTrack[] = [
{
type: 'video',
id: '360p',
url: 'http://example.com/360p.m3u8',
bandwidth: 500_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
width: 640,
height: 360,
},
{
type: 'video',
id: '1080p',
url: 'http://example.com/1080p.m3u8',
bandwidth: 4_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
width: 1920,
height: 1080,
},
{
type: 'video',
id: '720p',
url: 'http://example.com/720p.m3u8',
bandwidth: 2_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
width: 1280,
height: 720,
},
];
const presentation = createPresentation({ video: tracks });
expect(pickMaxResolutionVideoTrack(presentation)).toBe('1080p');
});
it('falls back to bandwidth when resolution metadata is missing', () => {
const tracks: PartiallyResolvedVideoTrack[] = [
{
type: 'video',
id: 'low',
url: 'http://example.com/low.m3u8',
bandwidth: 500_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
},
{
type: 'video',
id: 'high',
url: 'http://example.com/high.m3u8',
bandwidth: 4_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
},
];
const presentation = createPresentation({ video: tracks });
expect(pickMaxResolutionVideoTrack(presentation)).toBe('high');
});
it('breaks ties on equal resolution by bandwidth', () => {
const tracks: PartiallyResolvedVideoTrack[] = [
{
type: 'video',
id: '1080p-low',
url: 'http://example.com/1080p-low.m3u8',
bandwidth: 3_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
width: 1920,
height: 1080,
},
{
type: 'video',
id: '1080p-high',
url: 'http://example.com/1080p-high.m3u8',
bandwidth: 6_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
width: 1920,
height: 1080,
},
];
const presentation = createPresentation({ video: tracks });
expect(pickMaxResolutionVideoTrack(presentation)).toBe('1080p-high');
});
it('returns undefined when no video tracks exist', () => {
const presentation = createPresentation({ audio: [] });
expect(pickMaxResolutionVideoTrack(presentation)).toBeUndefined();
});
});
describe('pickAudioTrack', () => {
it('selects first track when no preferences', () => {
const tracks: PartiallyResolvedAudioTrack[] = [
@@ -0,0 +1,235 @@
import type { Constructor, MixinReturn } from '@videojs/utils/types';
import type { Composition } from '../../../core/composition/create-composition';
import {
type BackgroundLoopingVideoEngineConfig,
type BackgroundLoopingVideoEngineContext,
type BackgroundLoopingVideoEngineSignals,
type BackgroundLoopingVideoEngineState,
createBackgroundLoopingVideoEngine,
} from './engine';
export interface BackgroundLoopingVideoMediaProps {
src: string;
preload: '' | 'none' | 'metadata' | 'auto';
loop: boolean;
muted: boolean;
autoplay: boolean;
}
export const backgroundLoopingVideoMediaDefaultProps: BackgroundLoopingVideoMediaProps = {
src: '',
preload: 'auto',
loop: true,
muted: true,
autoplay: true,
};
export interface BackgroundLoopingVideoMediaAPI extends BackgroundLoopingVideoMediaProps {
readonly engine: Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext>;
attach(mediaElement: HTMLMediaElement): void;
detach(): void;
destroy(): void;
play(): Promise<void>;
}
/**
* Mixin that adds the background-looping-video SPF playback engine to any
* base class.
*
* Implements the WHATWG HTML media element contract (`src`, `preload`,
* `loop`, `muted`, `autoplay`, `play()`) so it can be dropped in anywhere a
* media element API is expected. Compared to `SimpleHlsMediaMixin`, this
* variant:
*
* - exposes `loop`, `muted`, and `autoplay` as adapter-owned native
* passthroughs, all defaulting to `true` the use case is silent
* autoplay-looping video, so muted + autoplay satisfy browser autoplay
* policies and loop is the defining behavior;
* - drives the underlying engine with the background-looping-video
* composition (single-rendition, video-only, autoplay-from-construction).
*
* A new engine is created on every src assignment this fully tears down
* all state, SourceBuffers, and in-flight requests from the previous
* source before the next one begins. The media element reference is
* preserved across src changes and re-applied to the new engine
* automatically.
*
* @example
* class BackgroundLoopingVideoMedia extends BackgroundLoopingVideoMediaMixin(HTMLVideoElementHost) {}
*
* const media = new BackgroundLoopingVideoMedia();
* media.attach(document.querySelector('video'));
* media.src = 'https://stream.mux.com/abc123.m3u8';
* media.play();
*/
export function BackgroundLoopingVideoMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {
class BackgroundLoopingVideoMediaImpl extends BaseClass {
#engine: Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext>;
#config: BackgroundLoopingVideoEngineConfig;
#signals!: BackgroundLoopingVideoEngineSignals;
#preload: '' | 'none' | 'metadata' | 'auto' = backgroundLoopingVideoMediaDefaultProps.preload;
#loop: boolean = backgroundLoopingVideoMediaDefaultProps.loop;
#muted: boolean = backgroundLoopingVideoMediaDefaultProps.muted;
#autoplay: boolean = backgroundLoopingVideoMediaDefaultProps.autoplay;
/** 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<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
return this.#engine;
}
// -------------------------------------------------------------------------
// Media element lifecycle
// -------------------------------------------------------------------------
attach(mediaElement: HTMLMediaElement): void {
super.attach?.(mediaElement);
// Apply adapter-owned native props before the engine takes over —
// the underlying element needs `loop` / `muted` / `autoplay` set for
// the use case's autoplay-looping semantics.
mediaElement.loop = this.#loop;
mediaElement.muted = this.#muted;
mediaElement.autoplay = this.#autoplay;
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') {
// Noop for this phase
}
// -------------------------------------------------------------------------
// loop / muted / autoplay — adapter-owned IDL attributes mirrored onto
// the attached media element. The engine itself has no opinion on any
// of them.
// -------------------------------------------------------------------------
get loop(): boolean {
return this.#loop;
}
set loop(_value: boolean) {
// Noop for this phase
}
get muted(): boolean {
return this.#muted;
}
set muted(_value: boolean) {
// Noop for this phase
}
get autoplay(): boolean {
return this.#autoplay;
}
set autoplay(_value: boolean) {
// Noop for this phase
}
// -------------------------------------------------------------------------
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
// Each assignment destroys the current engine and starts a fresh one,
// matching the browser's load algorithm reset on src change.
// -------------------------------------------------------------------------
get src(): string {
return this.#signals.state.presentation.get()?.url ?? '';
}
set src(value: string) {
this.#cancelPendingPlay();
if (value) {
this.#signals.state.presentation.set({ url: value });
} else {
this.#signals.state.presentation.set(undefined);
}
}
// -------------------------------------------------------------------------
// play() — WHATWG §4.8.11.8
// Delegates to the attached media element's native play().
// -------------------------------------------------------------------------
async play(): Promise<void> {
const mediaElement = this.#signals.context.mediaElement.get();
if (!mediaElement) {
return Promise.reject(new Error('BackgroundLoopingVideoMediaElement: no media element attached'));
}
try {
return await mediaElement.play();
} catch (err) {
// If we have a pending HLS source, the rejection may be because MSE
// hasn't attached a blob URL yet. Wait for loadstart (src assigned
// by MSE setup) and retry once.
if (this.src) {
return new Promise<void>((resolve, reject) => {
const listener = () => {
this.#loadstartListener = null;
mediaElement.play().then(resolve, reject);
};
this.#loadstartListener = listener;
mediaElement.addEventListener('loadstart', listener, { once: true });
});
}
throw err;
}
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
#createEngine(): Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
return createBackgroundLoopingVideoEngine({
...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 BackgroundLoopingVideoMediaImpl as unknown as MixinReturn<Base, BackgroundLoopingVideoMediaAPI>;
}
/** Standalone SPF background-looping-video adapter with no base class. */
export class BackgroundLoopingVideoMediaElement extends BackgroundLoopingVideoMediaMixin(class {}) {}
@@ -0,0 +1,177 @@
import {
type Composition,
type ContextSignals,
createComposition,
type StateSignals,
} from '../../../core/composition/create-composition';
import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals';
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
import { pickMaxResolutionVideoTrack, type TrackPicker } from '../../../media/primitives/select-tracks';
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 } from '../../behaviors/calculate-presentation-duration';
import { endOfStream } from '../../behaviors/dom/end-of-stream';
import { loadVideoSegments } from '../../behaviors/dom/load-segments';
import { setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
import { resolveVideoTrack } from '../../behaviors/resolve-track';
import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks';
// ============================================================================
// Background-looping-video engine state & context
// ============================================================================
/**
* State shape for the background-looping-video playback engine.
*
* Narrower than `SimpleHlsEngineState`: audio/text track slots are absent
* because their selection/resolution behaviors are subtracted. `bandwidthState`
* is present because `setupVideoBufferActors` declares it and `loadVideoSegments`
* samples into it (wasted work in this variant a Phase 3 alt-impl will skip
* sampling).
*/
export interface BackgroundLoopingVideoEngineState {
/**
* The presentation being played. A caller writes `{ url }`;
* `resolvePresentation` parses the manifest and populates the rest.
*/
presentation?: MaybeResolvedPresentation;
preload?: 'auto' | 'metadata' | 'none';
selectedVideoTrackId?: string;
loadActivated?: boolean;
}
/**
* Context shape for the background-looping-video engine.
*/
export interface BackgroundLoopingVideoEngineContext {
mediaElement?: HTMLMediaElement | undefined;
mediaSource?: MediaSource;
videoBufferActor?: SourceBufferActor;
videoSegmentLoaderActor?: SegmentLoaderActor;
}
/**
* The composition signal refs handed to `onSignalsReady` callers the
* canonical way to drive the engine externally (writes) or observe its
* state (reads) without touching `composition.state` / `composition.context`
* directly.
*/
export type BackgroundLoopingVideoEngineSignals = {
state: StateSignals<BackgroundLoopingVideoEngineState>;
context: ContextSignals<BackgroundLoopingVideoEngineContext>;
};
/**
* Configuration for the background-looping-video engine.
*
* Each option is consumed by the appropriate behavior the engine itself
* has no config beyond what its behaviors read. Compared to
* `SimpleHlsEngineConfig`, audio/text/ABR/bandwidth/quality knobs are
* dropped: the variant subtracts the behaviors that read them.
*/
export interface BackgroundLoopingVideoEngineConfig
extends ShareSignalsConfig<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
/**
* Track picker handed to `selectVideoTrack`. Default:
* `pickMaxResolutionVideoTrack` picks the highest-resolution variant on
* presentation resolve and pins it for the session. Override for
* mobile-aware or content-aware caps.
*/
picker?: TrackPicker<SelectVideoTrackConfig>;
/**
* Manifest parser handed to `resolvePresentation`. Defaults to the HLS
* multivariant-playlist parser.
*/
parsePresentation?: ParsePresentation;
}
// ============================================================================
// Background-looping-video playback engine
// ============================================================================
const shareSignals = makeShareSignals<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext>();
/**
* Create a background-looping-video playback engine.
*
* Subtractive composition over the HLS engine baseline:
* audio-side, text-side, ABR-driven, preload-monitoring, and play/seek
* load-trigger behaviors are removed. `selectVideoTrack` (with a
* max-resolution picker by default) replaces `switchVideoQuality`, pinning
* a single rendition for the session. The initial state seeds
* `loadActivated: true` so the composition behaves as if preload has
* already been activated appropriate for ambient / hero / GIF-replacement
* surfaces that should start loading the moment a src is set.
*
* Native `loop` / `muted` / `autoplay` are adapter concerns and live on
* `BackgroundLoopingVideoMediaElement` rather than the engine.
*
* @example
* ```ts
* let signals: BackgroundLoopingVideoEngineSignals;
* const engine = createBackgroundLoopingVideoEngine({
* onSignalsReady: (refs) => {
* signals = refs;
* },
* });
*
* signals.context.mediaElement.set(videoEl);
* signals.state.presentation.set({ url: 'https://example.com/stream.m3u8' });
*
* await engine.destroy();
* ```
*/
export function createBackgroundLoopingVideoEngine(
config: BackgroundLoopingVideoEngineConfig = {}
): Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
const finalConfig = {
...config,
picker: config.picker ?? pickMaxResolutionVideoTrack,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
resolveDuration: getResolvedSelectedTrackDuration,
};
return createComposition(
[
resolvePresentation,
// Presentation duration
calculatePresentationDuration,
// Track selection - pinned single-rendition pick on presentation resolve.
selectVideoTrack,
// Resolve selected video track (fetch its media playlist)
resolveVideoTrack,
// Segment loading — video-only.
loadVideoSegments,
// MSE setup — video-only.
setupMediaSource,
updateMediaSourceDuration,
setupVideoBufferActors,
// Playback tracking
trackCurrentTime,
// End of stream coordination
endOfStream,
// Behavior whose sole purpose is to expose signal refs via a callback
// (e.g. to an adapter). Listed last so initial signal setup has run
// before the callback fires.
shareSignals,
],
{
config: finalConfig,
initialState: {
// Note: Set to true until we add preload configuration
loadActivated: true,
},
}
);
}
@@ -0,0 +1,13 @@
export type { BackgroundLoopingVideoMediaAPI, BackgroundLoopingVideoMediaProps } from './adapter';
export {
BackgroundLoopingVideoMediaElement,
BackgroundLoopingVideoMediaMixin,
backgroundLoopingVideoMediaDefaultProps,
} from './adapter';
export type {
BackgroundLoopingVideoEngineConfig,
BackgroundLoopingVideoEngineContext,
BackgroundLoopingVideoEngineSignals,
BackgroundLoopingVideoEngineState,
} from './engine';
export { createBackgroundLoopingVideoEngine } from './engine';
@@ -0,0 +1,184 @@
/**
* BackgroundLoopingVideoMediaElement adapter tests.
*
* Covers the HTMLMediaElement-compatible contract for src, preload, loop,
* muted, and play(). Adapter-shape parallels SimpleHlsMediaElement; the
* tests focus on what diverges: the new adapter owns `loop` / `muted`
* passthroughs and defaults both to true (autoplay-muted, looping).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BackgroundLoopingVideoMediaElement } from '../adapter';
describe('BackgroundLoopingVideoMediaElement', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise<Response>(() => {}))
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('src', () => {
it('returns empty string before any src is set', () => {
const media = new BackgroundLoopingVideoMediaElement();
expect(media.src).toBe('');
});
it('reflects the set value synchronously', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.src = 'https://example.com/v.m3u8';
expect(media.src).toBe('https://example.com/v.m3u8');
});
it('synchronously updates engine presentation state when src is set', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.src = 'https://example.com/v.m3u8';
expect(media.engine.state.presentation.get()?.url).toBe('https://example.com/v.m3u8');
});
it('clears engine presentation state when src is set to empty string', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.src = 'https://example.com/v.m3u8';
media.src = '';
expect(media.engine.state.presentation.get()?.url).toBeFalsy();
});
});
describe('attach / detach', () => {
it('exposes the engine immediately (created at construction)', () => {
const media = new BackgroundLoopingVideoMediaElement();
expect(media.engine).not.toBeNull();
});
it('reuses the same engine instance across attach calls', () => {
const media = new BackgroundLoopingVideoMediaElement();
const engineBefore = media.engine;
media.attach(document.createElement('video'));
media.attach(document.createElement('video'));
expect(media.engine).toBe(engineBefore);
});
it('re-attaches the media element to the new engine when src changes', () => {
const media = new BackgroundLoopingVideoMediaElement();
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 context when attached', () => {
const media = new BackgroundLoopingVideoMediaElement();
const el = document.createElement('video');
media.attach(el);
expect(media.engine.context.mediaElement.get()).toBe(el);
});
it('clears mediaElement in context when detached', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.attach(document.createElement('video'));
media.detach();
expect(media.engine.context.mediaElement.get()).toBeUndefined();
});
it('detach does not destroy the engine', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.attach(document.createElement('video'));
const spy = vi.spyOn(media.engine, 'destroy');
media.detach();
expect(spy).not.toHaveBeenCalled();
});
});
describe('loop / muted defaults', () => {
it('defaults loop to true', () => {
const media = new BackgroundLoopingVideoMediaElement();
expect(media.loop).toBe(true);
});
it('defaults muted to true', () => {
const media = new BackgroundLoopingVideoMediaElement();
expect(media.muted).toBe(true);
});
it('applies loop / muted defaults to the media element on attach', () => {
const media = new BackgroundLoopingVideoMediaElement();
const el = document.createElement('video');
// start the element in the opposite state so we can confirm attach overrides it
el.loop = false;
el.muted = false;
media.attach(el);
expect(el.loop).toBe(true);
expect(el.muted).toBe(true);
});
// attach modifies native props; changing src doesn't
it('preserves loop / muted to the preserved element on src change', () => {
const media = new BackgroundLoopingVideoMediaElement();
const el = document.createElement('video');
media.attach(el);
el.loop = false;
el.muted = false;
media.src = 'https://example.com/v.m3u8';
expect(el.loop).toBe(false);
expect(el.muted).toBe(false);
});
// Skipped: `set loop` / `set muted` are noops in Phase 1 (the adapter
// pins loop=true / muted=true for the autoplay-looping use case). These
// assert functional setters — unskip when the setters are implemented.
it.skip('mirrors loop changes onto the attached element', () => {
const media = new BackgroundLoopingVideoMediaElement();
const el = document.createElement('video');
media.attach(el);
media.loop = false;
expect(el.loop).toBe(false);
expect(media.loop).toBe(false);
});
it.skip('mirrors muted changes onto the attached element', () => {
const media = new BackgroundLoopingVideoMediaElement();
const el = document.createElement('video');
media.attach(el);
media.muted = false;
expect(el.muted).toBe(false);
expect(media.muted).toBe(false);
});
it.skip('stores loop / muted updates made before attach and applies them on attach', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.loop = false;
media.muted = false;
const el = document.createElement('video');
media.attach(el);
expect(el.loop).toBe(false);
expect(el.muted).toBe(false);
});
});
describe('play()', () => {
it('returns a Promise', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.attach(document.createElement('video'));
const result = media.play();
expect(result).toBeInstanceOf(Promise);
result.catch(() => {});
});
it('rejects when no media element is attached', async () => {
const media = new BackgroundLoopingVideoMediaElement();
await expect(media.play()).rejects.toThrow('no media element attached');
});
});
describe('destroy()', () => {
it('destroys the underlying engine', () => {
const media = new BackgroundLoopingVideoMediaElement();
const spy = vi.spyOn(media.engine, 'destroy');
media.destroy();
expect(spy).toHaveBeenCalledOnce();
});
});
});
@@ -0,0 +1,182 @@
/**
* createBackgroundLoopingVideoEngine tests.
*
* The variant subtracts audio, text, ABR, and preload-monitoring behaviors
* from the simple HLS engine, then seeds `loadActivated: true` so the
* composition behaves as if preload has already been activated. These tests
* confirm the seed, the absence of subtracted state slots, and the picker
* configurability.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { snapshot } from '../../../../core/signals/primitives';
import type { MaybeResolvedPresentation } from '../../../../media/types';
import { createBackgroundLoopingVideoEngine } from '../engine';
vi.mock('../../../../media/dom/mse/append-segment', () => ({
appendSegment: vi.fn().mockResolvedValue(undefined),
}));
describe('createBackgroundLoopingVideoEngine', () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('creates an engine with state, context, and destroy()', () => {
const engine = createBackgroundLoopingVideoEngine();
expect(engine.state).toBeDefined();
expect(engine.context).toBeDefined();
expect(typeof engine.destroy).toBe('function');
engine.destroy();
});
it('seeds loadActivated: true so preload gates pass from frame 0', () => {
const engine = createBackgroundLoopingVideoEngine();
expect(engine.state.loadActivated.get()).toBe(true);
engine.destroy();
});
it('omits subtracted state slots — no audio/text/userVideoTrackSelection signals', () => {
const engine = createBackgroundLoopingVideoEngine();
const state = snapshot(engine.state) as Record<string, unknown>;
// selectedAudioTrackId is declared by calculatePresentationDuration so
// its signal is created, but it stays undefined since no audio-selection
// behavior is composed in.
expect(state.selectedAudioTrackId).toBeUndefined();
// Text-track and userVideoTrackSelection signals must not exist —
// no behavior in this composition declares them.
expect('selectedTextTrackId' in state).toBe(false);
expect('userVideoTrackSelection' in state).toBe(false);
engine.destroy();
});
it('omits subtracted context slots — no audio segment loader / text actors', () => {
const engine = createBackgroundLoopingVideoEngine();
const context = snapshot(engine.context) as Record<string, unknown>;
// `audioBufferActor` IS declared by `endOfStream` (cross-type EOS
// coordination), so the signal exists — but no behavior in this
// composition writes it, so it stays `undefined`.
expect(context.audioBufferActor).toBeUndefined();
// The audio segment loader and both text-track actors aren't declared
// by any behavior left in the composition — their signals don't exist.
expect('audioSegmentLoaderActor' in context).toBe(false);
expect('textTracksActor' in context).toBe(false);
expect('textTrackSegmentLoaderActor' in context).toBe(false);
engine.destroy();
});
it('defaults the picker to pickMaxResolutionVideoTrack', async () => {
const engine = createBackgroundLoopingVideoEngine();
const presentation: MaybeResolvedPresentation = {
id: 'p',
url: 'https://example.com/manifest.m3u8',
startTime: 0,
selectionSets: [
{
id: 'video-set',
type: 'video',
switchingSets: [
{
id: 'video-switching',
type: 'video',
tracks: [
{
type: 'video',
id: '480p',
url: 'https://example.com/480p.m3u8',
bandwidth: 1_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
initialization: { url: 'init', byteRange: { offset: 0, length: 0 } },
segments: [],
startTime: 0,
duration: 0,
width: 854,
height: 480,
} as never,
{
type: 'video',
id: '1080p',
url: 'https://example.com/1080p.m3u8',
bandwidth: 4_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.640028'],
initialization: { url: 'init', byteRange: { offset: 0, length: 0 } },
segments: [],
startTime: 0,
duration: 0,
width: 1920,
height: 1080,
} as never,
],
},
],
},
],
};
engine.state.presentation.set(presentation);
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(engine.state.selectedVideoTrackId.get()).toBe('1080p');
engine.destroy();
});
it('honors a custom picker override from config', async () => {
const engine = createBackgroundLoopingVideoEngine({
picker: () => 'forced-pick',
});
const presentation: MaybeResolvedPresentation = {
id: 'p',
url: 'https://example.com/manifest.m3u8',
startTime: 0,
selectionSets: [
{
id: 'video-set',
type: 'video',
switchingSets: [
{
id: 'video-switching',
type: 'video',
tracks: [
{
type: 'video',
id: '480p',
url: 'https://example.com/480p.m3u8',
bandwidth: 1_000_000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
initialization: { url: 'init', byteRange: { offset: 0, length: 0 } },
segments: [],
startTime: 0,
duration: 0,
width: 854,
height: 480,
} as never,
],
},
],
},
],
};
engine.state.presentation.set(presentation);
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(engine.state.selectedVideoTrackId.get()).toBe('forced-pick');
engine.destroy();
});
});