diff --git a/apps/sandbox/templates/spf-background-looping-video/index.html b/apps/sandbox/templates/spf-background-looping-video/index.html
index a9e58164..afccca25 100644
--- a/apps/sandbox/templates/spf-background-looping-video/index.html
+++ b/apps/sandbox/templates/spf-background-looping-video/index.html
@@ -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);
}
@@ -248,7 +223,6 @@
-
SPF · Phase 1
Background Looping Video
Subtractive engine variant — audio, text tracks, ABR, and preload monitoring are composed out; a max-resolution
@@ -269,14 +243,23 @@
-
-
+
+
+
-
-
Later-phase controls
-
P2withAudio() — unmute / audio tracks
-
P3withPreload() — preload mode
+
diff --git a/apps/sandbox/templates/spf-background-looping-video/main.ts b/apps/sandbox/templates/spf-background-looping-video/main.ts
index 45735ed7..40bcdf81 100644
--- a/apps/sandbox/templates/spf-background-looping-video/main.ts
+++ b/apps/sandbox/templates/spf-background-looping-video/main.ts
@@ -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)[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);
}
}
diff --git a/packages/spf/src/media/primitives/select-tracks.ts b/packages/spf/src/media/primitives/select-tracks.ts
index 2165020f..7e28b05e 100644
--- a/packages/spf/src/media/primitives/select-tracks.ts
+++ b/packages/spf/src/media/primitives/select-tracks.ts
@@ -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(
+ 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;
}
/**
diff --git a/packages/spf/src/media/primitives/tests/select-tracks.test.ts b/packages/spf/src/media/primitives/tests/select-tracks.test.ts
index 2acadfa6..398fcce8 100644
--- a/packages/spf/src/media/primitives/tests/select-tracks.test.ts
+++ b/packages/spf/src/media/primitives/tests/select-tracks.test.ts
@@ -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 `"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');
});
});
diff --git a/packages/spf/src/playback/engines/background-looping-video/adapter.ts b/packages/spf/src/playback/engines/background-looping-video/adapter.ts
index e7138beb..d04b664d 100644
--- a/packages/spf/src/playback/engines/background-looping-video/adapter.ts
+++ b/packages/spf/src/playback/engines/background-looping-video/adapter.ts
@@ -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>(
#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>(
const { config } = args?.[0] ?? {};
this.#config = config;
+
+ this.#maxResolution = config?.maxResolution;
this.#engine = this.#createEngine();
}
@@ -156,6 +167,27 @@ export function BackgroundLoopingVideoMediaMixin>(
// 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>(
// -------------------------------------------------------------------------
#createEngine(): Composition {
+ 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;
diff --git a/packages/spf/src/playback/engines/background-looping-video/engine.ts b/packages/spf/src/playback/engines/background-looping-video/engine.ts
index 0d636836..7db65e08 100644
--- a/packages/spf/src/playback/engines/background-looping-video/engine.ts
+++ b/packages/spf/src/playback/engines/background-looping-video/engine.ts
@@ -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 {
/**
* 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;
/**
@@ -132,7 +135,7 @@ export function createBackgroundLoopingVideoEngine(
): Composition {
const finalConfig = {
...config,
- picker: config.picker ?? pickMaxResolutionVideoTrack,
+ picker: config.picker ?? pickHighestResolutionVideoTrack,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
resolveDuration: getResolvedSelectedTrackDuration,
};
diff --git a/packages/spf/src/playback/engines/background-looping-video/tests/adapter.test.ts b/packages/spf/src/playback/engines/background-looping-video/tests/adapter.test.ts
index 8a16f51c..6a93d28f 100644
--- a/packages/spf/src/playback/engines/background-looping-video/tests/adapter.test.ts
+++ b/packages/spf/src/playback/engines/background-looping-video/tests/adapter.test.ts
@@ -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((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((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((resolve) => queueMicrotask(resolve));
+ media.engine.state.presentation.set(presentationWithFourTracks());
+ await new Promise((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((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;
+}
diff --git a/packages/spf/src/playback/engines/background-looping-video/tests/engine.test.ts b/packages/spf/src/playback/engines/background-looping-video/tests/engine.test.ts
index a68dbe0a..763786ed 100644
--- a/packages/spf/src/playback/engines/background-looping-video/tests/engine.test.ts
+++ b/packages/spf/src/playback/engines/background-looping-video/tests/engine.test.ts
@@ -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 = {