mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): background looping video (phase 1) (#1602)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user