From 33873bc4ae9864b43961a2da666f11de4218bde3 Mon Sep 17 00:00:00 2001 From: Santiago Puppo Date: Wed, 17 Jun 2026 11:36:02 -0300 Subject: [PATCH] feat(spf): Add maxResolution to SPF Background Video (#1654) --- .../spf-background-looping-video/index.html | 81 +++++-------- .../spf-background-looping-video/main.ts | 103 +++++++--------- .../spf/src/media/primitives/select-tracks.ts | 67 ++++++++--- .../primitives/tests/select-tracks.test.ts | 86 ++++++++++++- .../background-looping-video/adapter.ts | 39 ++++++ .../background-looping-video/engine.ts | 9 +- .../tests/adapter.test.ts | 113 ++++++++++++++++++ .../tests/engine.test.ts | 2 +- 8 files changed, 366 insertions(+), 134 deletions(-) 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 = {