mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 13:48:14 +00:00
feat(spf): Add maxResolution to SPF Background Video (#1654)
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user