feat(spf): Add maxResolution to SPF Background Video (#1654)

This commit is contained in:
Santiago Puppo
2026-06-17 11:36:02 -03:00
committed by GitHub
parent 47aca0f798
commit 33873bc4ae
8 changed files with 366 additions and 134 deletions
@@ -50,14 +50,6 @@
padding: 0 8vw;
}
.hero .eyebrow {
margin: 0 0 12px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.18em;
opacity: 0.7;
}
.hero h1 {
margin: 0 0 16px;
font-size: clamp(36px, 6vw, 72px);
@@ -185,59 +177,42 @@
gap: 4px;
}
.rendition-buttons button {
.rendition-buttons .rendition {
width: 100%;
padding: 6px 8px;
font: inherit;
font-size: 12px;
color: inherit;
text-align: left;
cursor: pointer;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.rendition-buttons button:hover {
background: rgba(255, 255, 255, 0.12);
}
.rendition-buttons button.selected {
.rendition-buttons .rendition.selected {
color: #7ee787;
background: rgba(126, 231, 135, 0.12);
border-color: rgba(126, 231, 135, 0.4);
}
.controls .roadmap {
padding-top: 12px;
margin-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.controls .roadmap-title {
margin-bottom: 8px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
opacity: 0.5;
}
.controls .roadmap-item {
display: flex;
gap: 8px;
align-items: baseline;
.controls .load-btn {
width: 100%;
padding: 8px 10px;
margin-top: 10px;
font: inherit;
font-size: 12px;
line-height: 1.6;
opacity: 0.55;
font-weight: 600;
color: inherit;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: pointer;
background: rgba(126, 231, 135, 0.16);
border: 1px solid rgba(126, 231, 135, 0.4);
border-radius: 4px;
}
.controls .roadmap-item .phase {
flex-shrink: 0;
padding: 1px 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
.controls .load-btn:hover {
background: rgba(126, 231, 135, 0.24);
}
</style>
</head>
@@ -248,7 +223,6 @@
<div class="scrim"></div>
<div class="hero">
<p class="eyebrow">SPF · Phase 1</p>
<h1>Background Looping Video</h1>
<p>
Subtractive engine variant — audio, text tracks, ABR, and preload monitoring are composed out; a max-resolution
@@ -269,14 +243,23 @@
<select id="source-select"></select>
<div class="picker-section">
<label>Rendition</label>
<div id="rendition-buttons" class="rendition-buttons"></div>
<label for="max-resolution-select">maxResolution</label>
<select id="max-resolution-select">
<option value="">— none —</option>
<option value="240p">240p</option>
<option value="360p">360p</option>
<option value="480p">480p</option>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="1440p">1440p</option>
<option value="2160p">2160p</option>
</select>
<button type="button" id="load-btn" class="load-btn">Load</button>
</div>
<div class="roadmap">
<div class="roadmap-title">Later-phase controls</div>
<div class="roadmap-item"><span class="phase">P2</span>withAudio() — unmute / audio tracks</div>
<div class="roadmap-item"><span class="phase">P3</span>withPreload() — preload mode</div>
<div class="picker-section">
<label>Rendition</label>
<div id="rendition-buttons" class="rendition-buttons"></div>
</div>
</div>
</div>
@@ -1,22 +1,21 @@
import '@app/styles.css';
// SPF Background Looping Video — Phase 1 demo
// SPF Background Looping Video — sandbox demo
// http://localhost:5173/spf-background-looping-video/
//
// Drives the Phase 1 `BackgroundLoopingVideoMediaElement` adapter (the SPF
// surface added in this PR). The diagnostic strip surfaces three signals
// reviewers should verify:
// Drives `BackgroundLoopingVideoMediaElement`. The diagnostic strip
// surfaces three signals reviewers should verify:
// - loadActivated is true from frame 0 (no preload-gate or play-event needed)
// - the picker selects the highest-resolution rendition by default
// - the picker honors `maxResolution` (defaults to the highest variant)
// - audio-side actors are absent from the engine context (subtraction proof)
//
// Rendition switching: the engine's own track ids come from generateId() and
// are regenerated on every manifest parse, so they don't survive the engine
// rebuild a switch triggers. This demo identifies renditions by a stable id
// derived from dimensions + bandwidth, and maps it back to the fresh engine
// id via a config `picker` on each rebuild. "Auto" passes no picker (engine
// default = max-resolution). The video is paused before teardown and `src` is
// set before `attach` so the in-flight play() doesn't reject with AbortError.
// Rendition selection is driven by `maxResolution` on the adapter. The
// rendition list is read-only; it shows the available tracks and
// highlights the one the picker chose. Changing `maxResolution` just
// stashes the new value — clicking Load reassigns `src`, which cycles
// the presentation through `unresolved → resolved` and re-fires the
// closure picker (so the new cap takes effect on the live engine,
// without a rebuild).
import { SOURCES } from '@app/shared/sources';
import { effect, snapshot } from '@videojs/spf';
@@ -27,6 +26,8 @@ import { BackgroundLoopingVideoMediaElement } from '@videojs/spf/background-loop
const video = document.getElementById('bg-video') as HTMLVideoElement;
const sourceSelect = document.getElementById('source-select') as HTMLSelectElement;
const renditionButtons = document.getElementById('rendition-buttons') as HTMLDivElement;
const maxResolutionSelect = document.getElementById('max-resolution-select') as HTMLSelectElement;
const loadBtn = document.getElementById('load-btn') as HTMLButtonElement;
const diagLoad = document.getElementById('diag-load') as HTMLSpanElement;
const diagRendition = document.getElementById('diag-rendition') as HTMLSpanElement;
const diagContext = document.getElementById('diag-context') as HTMLSpanElement;
@@ -72,10 +73,8 @@ function trackDimensions(track: VideoTrack): { w: number; h: number } {
}
// ── Adapter lifecycle ─────────────────────────────────────────────────────────
type PickerMode = { kind: 'auto' } | { kind: 'manual'; stableId: string };
let currentSourceId: keyof typeof SOURCES = DEFAULT_ID;
let pickerMode: PickerMode = { kind: 'auto' };
let currentMaxResolution: string | undefined;
let adapter!: BackgroundLoopingVideoMediaElement;
let stopDiag: () => void = () => {};
@@ -85,14 +84,7 @@ function rebuildAdapter(): void {
video.pause();
adapter?.destroy();
// Manual: a picker that maps our stable id to the fresh engine id in the
// newly-parsed presentation. Auto: no picker → engine default (max-res).
const stableId = pickerMode.kind === 'manual' ? pickerMode.stableId : undefined;
const picker = stableId
? (presentation: MaybePresentation) => videoTracksOf(presentation).find((t) => stableTrackId(t) === stableId)?.id
: undefined;
adapter = new BackgroundLoopingVideoMediaElement(picker ? { config: { picker } } : undefined);
adapter = new BackgroundLoopingVideoMediaElement({ config: { maxResolution: currentMaxResolution } });
// src before attach: the engine starts resolving the presentation before
// play() (called inside attach) runs, so no teardown races the play promise.
adapter.src = SOURCES[currentSourceId].url;
@@ -109,24 +101,31 @@ function rebuildAdapter(): void {
(window as any).context = () => snapshot(adapter.engine.context);
// The diagnostic effect re-fires on every state change (currentTime ticks,
// segment loads), but the button list only depends on the track set and the
// selected mode. Skip the DOM rebuild when neither changed — otherwise every
// tick wipes hover/focus and churns nodes.
// segment loads), but the list only depends on the track set + the
// current selection. Skip DOM rebuild when neither changed.
let lastRenditionSignature = '';
rebuildAdapter();
sourceSelect.addEventListener('change', () => {
currentSourceId = sourceSelect.value as keyof typeof SOURCES;
// Renditions differ across sources — reset to auto.
pickerMode = { kind: 'auto' };
rebuildAdapter();
});
function setPickerMode(mode: PickerMode): void {
pickerMode = mode;
rebuildAdapter();
}
maxResolutionSelect.addEventListener('change', () => {
currentMaxResolution = maxResolutionSelect.value || undefined;
// Closure picker reads `#maxResolution` at pick time, so the setter
// just stashes the value. Use the Load button to reassign src and
// force a re-pick.
adapter.maxResolution = currentMaxResolution;
});
loadBtn.addEventListener('click', () => {
// Reassign src to cycle the presentation through
// `unresolved → resolved`. That re-fires the picker, which reads the
// current `maxResolution` via the adapter's closure.
adapter.src = SOURCES[currentSourceId].url;
});
// ── Diagnostic strip + rendition picker ──────────────────────────────────────
function formatBandwidth(bps: number): string {
@@ -159,32 +158,23 @@ function attachDiagnostic(): () => void {
const keys = Object.keys(context).filter((k) => (context as Record<string, unknown>)[k] !== undefined);
diagContext.textContent = keys.length ? keys.join(', ') : '—';
renderRenditionButtons(tracks);
renderRenditionList(tracks, state.selectedVideoTrackId);
});
}
function renditionSignature(tracks: VideoTrack[]): string {
const mode = pickerMode.kind === 'manual' ? `manual:${pickerMode.stableId}` : 'auto';
return `${mode}#${tracks.map(stableTrackId).join(',')}`;
function renditionSignature(tracks: VideoTrack[], selectedId: string | undefined): string {
return `${selectedId ?? '—'}#${tracks.map(stableTrackId).join(',')}`;
}
function renderRenditionButtons(tracks: VideoTrack[]): void {
const signature = renditionSignature(tracks);
function renderRenditionList(tracks: VideoTrack[], selectedId: string | undefined): void {
const signature = renditionSignature(tracks, selectedId);
if (signature === lastRenditionSignature) return;
lastRenditionSignature = signature;
renditionButtons.innerHTML = '';
const autoBtn = document.createElement('button');
autoBtn.type = 'button';
autoBtn.textContent = 'Auto · max resolution';
if (pickerMode.kind === 'auto') autoBtn.classList.add('selected');
autoBtn.addEventListener('click', () => setPickerMode({ kind: 'auto' }));
renditionButtons.appendChild(autoBtn);
if (tracks.length === 0) return;
// Sort by area desc so the picker reads top-down high-to-low.
// Sort by area desc so the list reads top-down high-to-low.
const sorted = [...tracks].sort((a, b) => {
const da = trackDimensions(a);
const db = trackDimensions(b);
@@ -195,17 +185,14 @@ function renderRenditionButtons(tracks: VideoTrack[]): void {
});
for (const track of sorted) {
const id = stableTrackId(track);
const { w, h } = trackDimensions(track);
const btn = document.createElement('button');
btn.type = 'button';
const res = w && h ? `${w}x${h} · ` : '';
btn.textContent = `${res}${formatBandwidth(track.bandwidth)}`;
btn.title = id;
if (pickerMode.kind === 'manual' && pickerMode.stableId === id) {
btn.classList.add('selected');
}
btn.addEventListener('click', () => setPickerMode({ kind: 'manual', stableId: id }));
renditionButtons.appendChild(btn);
const row = document.createElement('div');
row.className = 'rendition';
const tier = h ? `${h}p` : '';
const dims = w && h ? ` · ${w}x${h}` : '';
row.textContent = `${tier} - ${formatBandwidth(track.bandwidth)}${dims}`;
row.title = stableTrackId(track);
if (track.id === selectedId) row.classList.add('selected');
renditionButtons.appendChild(row);
}
}
@@ -179,31 +179,64 @@ export function pickVideoTrack(
}
/**
* Pick the video track with the highest resolution (width x height).
* Translates a "max resolution" into a total total pixel area
* for comparisons with video track resolutions with an assumed
* 16:9 ratio.
*
* Falls back to `bandwidth` when resolution metadata is missing.
* Example: "720p" translates to a 921600 pixel area.
*
* Because 720 * 1280 = 720 * (720 * (16/9) ) = 921_600
*
* Accepts:
* - string with the format '{height}p'. ('720p')
* - bare number, interpreted as pixel area. (921_600)
* - anything else will translate to `+Infinity`, meaning no cap specified
*/
export function maxResolutionToPixelArea(value: string | number | undefined): number {
if (value === undefined || value === null) return Number.POSITIVE_INFINITY;
if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? value : Number.POSITIVE_INFINITY;
const match = value.trim().match(/^(\d+)p?$/i);
if (!match) return Number.POSITIVE_INFINITY;
const height = Number(match[1]);
if (!(Number.isFinite(height) && height > 0)) return Number.POSITIVE_INFINITY;
return (height * height * 16) / 9;
}
type RankableTrack = { id: string; width?: number; height?: number; bandwidth?: number };
/**
* Pick the track with the highest pixel area at or below `maxPixelArea`.
* Falls back to the lowest track when nothing satisfies the cap (the
* lowest of the above-cap set is the closest to the cap from above).
* Tiebreak on bandwidth. Missing dimensions are treated as area `0`.
*/
export function pickTrackUnderPixelArea<T extends RankableTrack>(
tracks: readonly T[],
maxPixelArea: number = Number.POSITIVE_INFINITY
): T | undefined {
if (tracks.length === 0) return undefined;
// Sort descending by pixel area, bandwidth as tiebreaker. List sizes
// are small (HLS variant counts) — no need to optimize past a sort.
const sorted = [...tracks].sort(
(a, b) =>
(b.width ?? 0) * (b.height ?? 0) - (a.width ?? 0) * (a.height ?? 0) || (b.bandwidth ?? 0) - (a.bandwidth ?? 0)
);
return sorted.find((t) => (t.width ?? 0) * (t.height ?? 0) <= maxPixelArea) ?? sorted[sorted.length - 1];
}
/**
* Pick the video track with the highest pixel area.
*
* Pair with `selectVideoTrack`; compose `switchVideoQuality` instead
* for runtime-adapted quality.
*/
export function pickMaxResolutionVideoTrack(presentation: MaybeResolvedPresentation): string | undefined {
export function pickHighestResolutionVideoTrack(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;
return pickTrackUnderPixelArea(tracks)?.id;
}
/**
@@ -7,7 +7,14 @@ import type {
TextSelectionSet,
VideoSelectionSet,
} from '../../types';
import { pickAudioTrack, pickMaxResolutionVideoTrack, pickTextTrack, pickVideoTrack } from '../select-tracks';
import {
maxResolutionToPixelArea,
pickAudioTrack,
pickHighestResolutionVideoTrack,
pickTextTrack,
pickTrackUnderPixelArea,
pickVideoTrack,
} from '../select-tracks';
// Helper to create a minimal presentation
function createPresentation(config: {
@@ -175,7 +182,7 @@ describe('pickVideoTrack', () => {
});
});
describe('pickMaxResolutionVideoTrack', () => {
describe('pickHighestResolutionVideoTrack', () => {
it('selects the track with the highest width × height area', () => {
const tracks: PartiallyResolvedVideoTrack[] = [
{
@@ -211,7 +218,7 @@ describe('pickMaxResolutionVideoTrack', () => {
];
const presentation = createPresentation({ video: tracks });
expect(pickMaxResolutionVideoTrack(presentation)).toBe('1080p');
expect(pickHighestResolutionVideoTrack(presentation)).toBe('1080p');
});
it('falls back to bandwidth when resolution metadata is missing', () => {
@@ -235,7 +242,7 @@ describe('pickMaxResolutionVideoTrack', () => {
];
const presentation = createPresentation({ video: tracks });
expect(pickMaxResolutionVideoTrack(presentation)).toBe('high');
expect(pickHighestResolutionVideoTrack(presentation)).toBe('high');
});
it('breaks ties on equal resolution by bandwidth', () => {
@@ -263,12 +270,79 @@ describe('pickMaxResolutionVideoTrack', () => {
];
const presentation = createPresentation({ video: tracks });
expect(pickMaxResolutionVideoTrack(presentation)).toBe('1080p-high');
expect(pickHighestResolutionVideoTrack(presentation)).toBe('1080p-high');
});
it('returns undefined when no video tracks exist', () => {
const presentation = createPresentation({ audio: [] });
expect(pickMaxResolutionVideoTrack(presentation)).toBeUndefined();
expect(pickHighestResolutionVideoTrack(presentation)).toBeUndefined();
});
});
describe('maxResolutionToPixelArea', () => {
it('translates `"<n>p"` strings to 16:9 pixel area', () => {
expect(maxResolutionToPixelArea('720p')).toBe(720 * 1280);
expect(maxResolutionToPixelArea('1080P')).toBe(1080 * 1920);
expect(maxResolutionToPixelArea('1440p')).toBe(1440 * 2560);
expect(maxResolutionToPixelArea('2160p')).toBe(2160 * 3840);
});
it('treats bare numeric strings as height (16:9 area)', () => {
expect(maxResolutionToPixelArea('720')).toBe(720 * 1280);
});
it('treats bare numbers as a pixel-area cap', () => {
expect(maxResolutionToPixelArea(921_600)).toBe(921_600);
});
it('returns +Infinity for unrecognized inputs (no cap)', () => {
expect(maxResolutionToPixelArea(undefined)).toBe(Number.POSITIVE_INFINITY);
expect(maxResolutionToPixelArea('garbage')).toBe(Number.POSITIVE_INFINITY);
expect(maxResolutionToPixelArea('-720')).toBe(Number.POSITIVE_INFINITY);
expect(maxResolutionToPixelArea(0)).toBe(Number.POSITIVE_INFINITY);
expect(maxResolutionToPixelArea(-1)).toBe(Number.POSITIVE_INFINITY);
});
});
describe('pickTrackUnderPixelArea', () => {
const tracks = [
{ id: '360p', width: 640, height: 360, bandwidth: 500_000 },
{ id: '720p', width: 1280, height: 720, bandwidth: 2_000_000 },
{ id: '1080p', width: 1920, height: 1080, bandwidth: 4_000_000 },
{ id: '1440p', width: 2560, height: 1440, bandwidth: 8_000_000 },
];
it('returns undefined for an empty list', () => {
expect(pickTrackUnderPixelArea([])).toBeUndefined();
});
it('picks the highest-area track when no cap is provided', () => {
expect(pickTrackUnderPixelArea(tracks)?.id).toBe('1440p');
});
it('picks the highest track at or below the cap', () => {
expect(pickTrackUnderPixelArea(tracks, 1280 * 720)?.id).toBe('720p');
expect(pickTrackUnderPixelArea(tracks, 1920 * 1080)?.id).toBe('1080p');
});
it('falls back to the lowest track when the cap excludes everything', () => {
expect(pickTrackUnderPixelArea(tracks, 100)?.id).toBe('360p');
});
it('tiebreaks on bandwidth at equal pixel area', () => {
const ties = [
{ id: '1080p-low', width: 1920, height: 1080, bandwidth: 3_000_000 },
{ id: '1080p-high', width: 1920, height: 1080, bandwidth: 6_000_000 },
];
expect(pickTrackUnderPixelArea(ties)?.id).toBe('1080p-high');
});
it('treats missing dimensions as area 0', () => {
const mixed = [
{ id: 'unknown', bandwidth: 1_000_000 },
{ id: '720p', width: 1280, height: 720, bandwidth: 2_000_000 },
];
expect(pickTrackUnderPixelArea(mixed)?.id).toBe('720p');
});
});
@@ -1,5 +1,11 @@
import type { Constructor, MixinReturn } from '@videojs/utils/types';
import type { Composition } from '../../../core/composition/create-composition';
import {
maxResolutionToPixelArea,
pickTrackUnderPixelArea,
type TrackPicker,
} from '../../../media/primitives/select-tracks';
import type { VideoSelectionSet } from '../../../media/types';
import {
type BackgroundLoopingVideoEngineConfig,
type BackgroundLoopingVideoEngineContext,
@@ -14,6 +20,7 @@ export interface BackgroundLoopingVideoMediaProps {
loop: boolean;
muted: boolean;
autoplay: boolean;
maxResolution: string | number | undefined;
}
export const backgroundLoopingVideoMediaDefaultProps: BackgroundLoopingVideoMediaProps = {
@@ -22,6 +29,7 @@ export const backgroundLoopingVideoMediaDefaultProps: BackgroundLoopingVideoMedi
loop: true,
muted: true,
autoplay: true,
maxResolution: undefined,
};
export interface BackgroundLoopingVideoMediaAPI extends BackgroundLoopingVideoMediaProps {
@@ -71,6 +79,7 @@ export function BackgroundLoopingVideoMediaMixin<Base extends Constructor<any>>(
#loop: boolean = backgroundLoopingVideoMediaDefaultProps.loop;
#muted: boolean = backgroundLoopingVideoMediaDefaultProps.muted;
#autoplay: boolean = backgroundLoopingVideoMediaDefaultProps.autoplay;
#maxResolution: string | number | undefined;
/** Pending loadstart listener from a deferred play() retry, if any. */
#loadstartListener: (() => void) | null = null;
@@ -80,6 +89,8 @@ export function BackgroundLoopingVideoMediaMixin<Base extends Constructor<any>>(
const { config } = args?.[0] ?? {};
this.#config = config;
this.#maxResolution = config?.maxResolution;
this.#engine = this.#createEngine();
}
@@ -156,6 +167,27 @@ export function BackgroundLoopingVideoMediaMixin<Base extends Constructor<any>>(
// Noop for this phase
}
// -------------------------------------------------------------------------
// maxResolution — adapter-owned cap on the picked rendition. The engine's
// closure picker (see `#createEngine`) reads this field at pick time, so
// setter writes take effect on the next `presentation-resolved` transition
// without an engine rebuild.
// -------------------------------------------------------------------------
get maxResolution(): string | number | undefined {
return this.#maxResolution;
}
/**
* Set the cap. Accepts `"720p"` / `"1080p"` etc., a bare number
* (interpreted as pixel area), or `undefined` to clear. Unrecognized
* values are treated as no cap.
*/
set maxResolution(value: string | number | undefined) {
if (value === this.#maxResolution) return;
this.#maxResolution = value;
}
// -------------------------------------------------------------------------
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
// Each assignment destroys the current engine and starts a fresh one,
@@ -212,7 +244,14 @@ export function BackgroundLoopingVideoMediaMixin<Base extends Constructor<any>>(
// -------------------------------------------------------------------------
#createEngine(): Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
const adapterPicker: TrackPicker = (presentation) => {
const videoSet = presentation.selectionSets?.find((s) => s.type === 'video') as VideoSelectionSet | undefined;
const tracks = videoSet?.switchingSets[0]?.tracks ?? [];
return pickTrackUnderPixelArea(tracks, maxResolutionToPixelArea(this.#maxResolution))?.id;
};
return createBackgroundLoopingVideoEngine({
picker: adapterPicker,
...this.#config,
onSignalsReady: (signals) => {
this.#signals = signals;
@@ -6,7 +6,7 @@ import {
} 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 { pickHighestResolutionVideoTrack, 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';
@@ -79,9 +79,12 @@ export interface BackgroundLoopingVideoEngineConfig
extends ShareSignalsConfig<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
/**
* Track picker handed to `selectVideoTrack`. Default:
* `pickMaxResolutionVideoTrack` picks the highest-resolution variant on
* `pickHighestResolutionVideoTrack` picks the highest-resolution variant on
* presentation resolve and pins it for the session. Override for
* mobile-aware or content-aware caps.
*
* Adapters (e.g. `BackgroundLoopingVideoMediaElement`) install their own
* picker; this default applies when the engine is constructed directly.
*/
picker?: TrackPicker<SelectVideoTrackConfig>;
/**
@@ -132,7 +135,7 @@ export function createBackgroundLoopingVideoEngine(
): Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
const finalConfig = {
...config,
picker: config.picker ?? pickMaxResolutionVideoTrack,
picker: config.picker ?? pickHighestResolutionVideoTrack,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
resolveDuration: getResolvedSelectedTrackDuration,
};
@@ -7,6 +7,7 @@
* passthroughs and defaults both to true (autoplay-muted, looping).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MaybeResolvedPresentation } from '../../../../media/types';
import { BackgroundLoopingVideoMediaElement } from '../adapter';
describe('BackgroundLoopingVideoMediaElement', () => {
@@ -181,4 +182,116 @@ describe('BackgroundLoopingVideoMediaElement', () => {
expect(spy).toHaveBeenCalledOnce();
});
});
describe('maxResolution', () => {
it('defaults to undefined', () => {
const media = new BackgroundLoopingVideoMediaElement();
expect(media.maxResolution).toBeUndefined();
});
it('reflects the value passed via constructor config', () => {
const media = new BackgroundLoopingVideoMediaElement({
config: { maxResolution: '720p' },
});
expect(media.maxResolution).toBe('720p');
});
it('reflects setter writes', () => {
const media = new BackgroundLoopingVideoMediaElement();
media.maxResolution = '1080p';
expect(media.maxResolution).toBe('1080p');
});
it('does not rebuild the engine on setter writes — closure picker reads the field live', () => {
const media = new BackgroundLoopingVideoMediaElement();
const firstEngine = media.engine;
media.maxResolution = '720p';
expect(media.engine).toBe(firstEngine);
});
// Pre-resolved 4-track presentation used by the closure-picker assertions
// below. Setting it on `engine.state.presentation` drives the composition
// through `presentation-unresolved → presentation-resolved`, which is
// what fires the picker.
const presentationWithFourTracks = (): MaybeResolvedPresentation => ({
id: 'p',
url: 'https://example.com/manifest.m3u8',
startTime: 0,
selectionSets: [
{
id: 'video-set',
type: 'video',
switchingSets: [
{
id: 'video-switching',
type: 'video',
tracks: [
videoTrack('360p', 640, 360, 500_000),
videoTrack('720p', 1280, 720, 2_000_000),
videoTrack('1080p', 1920, 1080, 4_000_000),
videoTrack('1440p', 2560, 1440, 8_000_000),
],
},
],
},
],
});
it('the closure picker reads maxResolution at pick time', async () => {
// Construct without a cap. If the closure captured at creation, the
// pick would use `undefined` (→ 1440p). It uses the current field instead.
const media = new BackgroundLoopingVideoMediaElement();
media.maxResolution = '720p';
media.engine.state.presentation.set(presentationWithFourTracks());
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(media.engine.state.selectedVideoTrackId.get()).toBe('720p');
media.destroy();
});
it('setter writes are reflected on the next presentation cycle', async () => {
const media = new BackgroundLoopingVideoMediaElement();
media.engine.state.presentation.set(presentationWithFourTracks());
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(media.engine.state.selectedVideoTrackId.get()).toBe('1440p');
media.maxResolution = '720p';
// Cycle the presentation through unresolved → resolved so the picker
// re-fires on entry. The microtask between the two sets lets the
// reactor's monitor observe the transition and run exit cleanup
// (which clears selectedVideoTrackId) before re-entry runs the picker.
media.engine.state.presentation.set(undefined);
await new Promise<void>((resolve) => queueMicrotask(resolve));
media.engine.state.presentation.set(presentationWithFourTracks());
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(media.engine.state.selectedVideoTrackId.get()).toBe('720p');
media.destroy();
});
it('honors a user-supplied picker, overriding the closure default', async () => {
const media = new BackgroundLoopingVideoMediaElement({
config: { maxResolution: '720p', picker: () => '1440p' },
});
media.engine.state.presentation.set(presentationWithFourTracks());
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(media.engine.state.selectedVideoTrackId.get()).toBe('1440p');
media.destroy();
});
});
});
function videoTrack(id: string, width: number, height: number, bandwidth: number) {
return {
type: 'video' as const,
id,
url: `https://example.com/${id}.m3u8`,
bandwidth,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
initialization: { url: 'init', byteRange: { offset: 0, length: 0 } },
segments: [],
startTime: 0,
duration: 0,
width,
height,
} as never;
}
@@ -78,7 +78,7 @@ describe('createBackgroundLoopingVideoEngine', () => {
engine.destroy();
});
it('defaults the picker to pickMaxResolutionVideoTrack', async () => {
it('defaults the picker to pickHighestResolutionVideoTrack', async () => {
const engine = createBackgroundLoopingVideoEngine();
const presentation: MaybeResolvedPresentation = {