mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): background looping video (phase 1) (#1602)
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sandbox — SPF Background Looping Video</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family:
|
||||
'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#bg-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0.2) 35%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 720px;
|
||||
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);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 540px;
|
||||
margin: 0 0 24px;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.hero .links {
|
||||
font-size: 13px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.hero .links a {
|
||||
padding-bottom: 1px;
|
||||
margin-right: 18px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.hero .links a:hover {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.9);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Top-right diagnostic strip — surfaces the three Phase 1 review-focus
|
||||
signals: loadActivated, the picker result, and the absence of audio-
|
||||
side context. */
|
||||
.diag {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
min-width: 240px;
|
||||
padding: 12px 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.diag .diag-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.diag .row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.diag .row .key {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.diag .row .val {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.diag .row .val.ok {
|
||||
color: #7ee787;
|
||||
}
|
||||
|
||||
.diag .row .val.absent {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
/* Bottom-right control card — reviewer affordances + roadmap. */
|
||||
.controls {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
min-width: 260px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.controls .picker-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.rendition-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rendition-buttons button {
|
||||
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 {
|
||||
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;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="stage">
|
||||
<video id="bg-video" playsinline muted></video>
|
||||
<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
|
||||
picker is composed in. The SPF foundation for <code><mux-background-video></code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diag" id="diag">
|
||||
<div class="diag-title">Engine state</div>
|
||||
<div class="row"><span class="key">loadActivated</span><span class="val" id="diag-load">—</span></div>
|
||||
<div class="row"><span class="key">selected rendition</span><span class="val" id="diag-rendition">—</span></div>
|
||||
<div class="row"><span class="key">context keys</span><span class="val" id="diag-context">—</span></div>
|
||||
<div class="row"><span class="key">audio side</span><span class="val absent">subtracted</span></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label for="source-select">Source</label>
|
||||
<select id="source-select"></select>
|
||||
|
||||
<div class="picker-section">
|
||||
<label>Rendition</label>
|
||||
<div id="rendition-buttons" class="rendition-buttons"></div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,211 @@
|
||||
import '@app/styles.css';
|
||||
|
||||
// SPF Background Looping Video — Phase 1 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:
|
||||
// - loadActivated is true from frame 0 (no preload-gate or play-event needed)
|
||||
// - the picker selects the highest-resolution rendition by default
|
||||
// - 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.
|
||||
|
||||
import { SOURCES } from '@app/shared/sources';
|
||||
import { effect, snapshot } from '@videojs/spf';
|
||||
import type { BackgroundLoopingVideoEngineState } from '@videojs/spf/background-looping-video';
|
||||
import { BackgroundLoopingVideoMediaElement } from '@videojs/spf/background-looping-video';
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
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 diagLoad = document.getElementById('diag-load') as HTMLSpanElement;
|
||||
const diagRendition = document.getElementById('diag-rendition') as HTMLSpanElement;
|
||||
const diagContext = document.getElementById('diag-context') as HTMLSpanElement;
|
||||
|
||||
// ── Source picker ─────────────────────────────────────────────────────────────
|
||||
// The SPF MSE pipeline appends fMP4/CMAF segments directly (no MPEG-TS
|
||||
// transmuxing), so only fMP4 HLS sources play — exclude `.ts` and live.
|
||||
const HLS_SOURCE_IDS = (Object.keys(SOURCES) as Array<keyof typeof SOURCES>).filter((id) => {
|
||||
const source = SOURCES[id] as { type: string; subType?: string; live?: boolean };
|
||||
return source.type === 'hls' && source.subType === 'mp4' && !source.live;
|
||||
});
|
||||
const DEFAULT_ID = (HLS_SOURCE_IDS[0] ?? 'hls-1') as keyof typeof SOURCES;
|
||||
|
||||
for (const id of HLS_SOURCE_IDS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
option.textContent = SOURCES[id].label;
|
||||
if (id === DEFAULT_ID) option.selected = true;
|
||||
sourceSelect.appendChild(option);
|
||||
}
|
||||
|
||||
// ── Renditions ────────────────────────────────────────────────────────────────
|
||||
type MaybePresentation = BackgroundLoopingVideoEngineState['presentation'];
|
||||
|
||||
function videoTracksOf(presentation: MaybePresentation) {
|
||||
return presentation?.selectionSets?.find((s) => s.type === 'video')?.switchingSets[0]?.tracks ?? [];
|
||||
}
|
||||
|
||||
type VideoTrack = ReturnType<typeof videoTracksOf>[number];
|
||||
|
||||
// Sandbox-local stable rendition id. The engine regenerates track ids on every
|
||||
// parse, so a captured engine id is dead after a rebuild — this survives.
|
||||
function stableTrackId(track: VideoTrack): string {
|
||||
const w = 'width' in track && typeof track.width === 'number' ? track.width : 0;
|
||||
const h = 'height' in track && typeof track.height === 'number' ? track.height : 0;
|
||||
return `${w}x${h}@${track.bandwidth}`;
|
||||
}
|
||||
|
||||
function trackDimensions(track: VideoTrack): { w: number; h: number } {
|
||||
const w = 'width' in track && typeof track.width === 'number' ? track.width : 0;
|
||||
const h = 'height' in track && typeof track.height === 'number' ? track.height : 0;
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
// ── Adapter lifecycle ─────────────────────────────────────────────────────────
|
||||
type PickerMode = { kind: 'auto' } | { kind: 'manual'; stableId: string };
|
||||
|
||||
let currentSourceId: keyof typeof SOURCES = DEFAULT_ID;
|
||||
let pickerMode: PickerMode = { kind: 'auto' };
|
||||
let adapter!: BackgroundLoopingVideoMediaElement;
|
||||
let stopDiag: () => void = () => {};
|
||||
|
||||
function rebuildAdapter(): void {
|
||||
// Pause before teardown so the in-flight play() doesn't reject with
|
||||
// AbortError when the next engine swaps the MediaSource on this element.
|
||||
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);
|
||||
// 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;
|
||||
adapter.attach(video);
|
||||
|
||||
(window as any).adapter = adapter;
|
||||
stopDiag();
|
||||
stopDiag = attachDiagnostic();
|
||||
}
|
||||
|
||||
// `state` / `context` read `adapter` lazily — the `let` binding always
|
||||
// resolves to the current instance after a rebuild.
|
||||
(window as any).state = () => snapshot(adapter.engine.state);
|
||||
(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.
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Diagnostic strip + rendition picker ──────────────────────────────────────
|
||||
function formatBandwidth(bps: number): string {
|
||||
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
|
||||
return `${Math.round(bps / 1000)} Kbps`;
|
||||
}
|
||||
|
||||
function attachDiagnostic(): () => void {
|
||||
return effect(() => {
|
||||
const state = snapshot(adapter.engine.state);
|
||||
const context = snapshot(adapter.engine.context);
|
||||
|
||||
diagLoad.textContent = state.loadActivated ? 'true' : 'false';
|
||||
diagLoad.className = `val ${state.loadActivated ? 'ok' : ''}`;
|
||||
|
||||
const tracks = videoTracksOf(state.presentation);
|
||||
const selected = tracks.find((t) => t.id === state.selectedVideoTrackId);
|
||||
if (selected) {
|
||||
const { w, h } = trackDimensions(selected);
|
||||
const res = w && h ? `${w}x${h} ` : '';
|
||||
diagRendition.textContent = `${res}${formatBandwidth(selected.bandwidth)}`;
|
||||
diagRendition.className = 'val ok';
|
||||
} else {
|
||||
diagRendition.textContent = '—';
|
||||
diagRendition.className = 'val';
|
||||
}
|
||||
|
||||
// List the context keys present at runtime — the absence of any audio-side
|
||||
// actor key is the visible subtraction proof.
|
||||
const keys = Object.keys(context).filter((k) => (context as Record<string, unknown>)[k] !== undefined);
|
||||
diagContext.textContent = keys.length ? keys.join(', ') : '—';
|
||||
|
||||
renderRenditionButtons(tracks);
|
||||
});
|
||||
}
|
||||
|
||||
function renditionSignature(tracks: VideoTrack[]): string {
|
||||
const mode = pickerMode.kind === 'manual' ? `manual:${pickerMode.stableId}` : 'auto';
|
||||
return `${mode}#${tracks.map(stableTrackId).join(',')}`;
|
||||
}
|
||||
|
||||
function renderRenditionButtons(tracks: VideoTrack[]): void {
|
||||
const signature = renditionSignature(tracks);
|
||||
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.
|
||||
const sorted = [...tracks].sort((a, b) => {
|
||||
const da = trackDimensions(a);
|
||||
const db = trackDimensions(b);
|
||||
const areaA = da.w * da.h;
|
||||
const areaB = db.w * db.h;
|
||||
if (areaB !== areaA) return areaB - areaA;
|
||||
return b.bandwidth - a.bandwidth;
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"words": [
|
||||
"antfu",
|
||||
"demuxed",
|
||||
"multivariant",
|
||||
"nanostores",
|
||||
"noautohide",
|
||||
"noto",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
status: draft
|
||||
date: 2026-05-22
|
||||
definition: technical
|
||||
---
|
||||
|
||||
# Background-looping video
|
||||
|
||||
Engine variant for silent, autoplay-looping video on ambient/decorative
|
||||
surfaces (hero backgrounds, GIF-replacement loops, editorial previews).
|
||||
The Case-2 Player feature per
|
||||
[`../features/clusters.md` § Feature classification axes](../features/clusters.md#feature-classification-axes);
|
||||
ground-truth source the Mux [`mux-background-video`](https://github.com/muxinc/mux-background-video)
|
||||
product adapted to Video.js 10. Phase 1 is the SPF foundation for
|
||||
`<mux-background-video>` under parent epic
|
||||
[#873](https://github.com/videojs/v10/issues/873); the composition
|
||||
demonstrates SPF composability by *removing* behaviors from the standard
|
||||
playback engine rather than adding new logic.
|
||||
|
||||
Distinct from [`video-only-mode-override`](./video-only-mode-override.md):
|
||||
both subtract audio from mixed-source manifests, but background-looping-
|
||||
video also commits to single-rendition playback, loop semantics,
|
||||
autoplay-via-initial-state, and a product-shaped adapter — `mode-override`
|
||||
is the narrower "deliver video-only despite mixed source" use case.
|
||||
|
||||
## Status
|
||||
|
||||
Phase 1 implemented ([#1586](https://github.com/videojs/v10/issues/1586)):
|
||||
`createBackgroundLoopingVideoEngine`, `BackgroundLoopingVideoMediaElement`,
|
||||
and the `pickMaxResolutionVideoTrack` primitive ship under
|
||||
`@videojs/spf/background-looping-video`. Phases 2-3 (decorator composition
|
||||
of audio and preload) and Phase 4 (Video.js component shell — out of SPF
|
||||
scope) stay coarser.
|
||||
|
||||
## Phases
|
||||
|
||||
Adopts the parent-epic [#873](https://github.com/videojs/v10/issues/873)
|
||||
phase structure — phases map to discrete epic deliverables.
|
||||
|
||||
| Phase | What |
|
||||
|---|---|
|
||||
| **1 — Composition + adapter** ([#1586](https://github.com/videojs/v10/issues/1586)) | Subtractive composition removing audio, text, ABR, preload-monitoring, and play/seek-monitoring behaviors; adds `selectVideoTrack` with a max-resolution picker; seeds `loadActivated: true`; ships independent adapter parallel to `SimpleHlsMediaElement`. HLS multivariant source; native `mediaElement.loop = true`. |
|
||||
| **2 — `withAudio()` decoration** | Composes audio-side behaviors back in for surfaces needing audio (user-initiated unmute, audible ambient). Decorator shape TBD. |
|
||||
| **3 — `withPreload()` + optimizations** | Composes preload-state monitoring back in for lazy/viewport-gated tiles. Co-scoped with loop-around forward-buffer fetching, GPU/thermal-aware quality caps, and a sampling-strip alt-impl of `loadVideoSegments`. |
|
||||
| **4 — Video.js component** | Full `<mux-background-video>` integration. **Out of SPF scope** — adapter / consumer territory. |
|
||||
|
||||
## Composition specifics
|
||||
|
||||
Phase 1 uses three mechanisms — subtract, add, and alternative default
|
||||
configuration. Alternative-implementation buckets surface in Phase 3.
|
||||
|
||||
### Subtracted
|
||||
|
||||
From [`createSimpleHlsEngine`](../../../../packages/spf/src/playback/engines/hls/engine.ts):
|
||||
|
||||
- `syncPreload`, `trackLoadTriggers` — no preload-state monitoring or DOM `play`/`seeking` activation; replaced by `loadActivated: true` initial state.
|
||||
- `selectAudioTrack`, `selectTextTrack`, `resolveAudioTrack`, `resolveTextTrack`, `setupAudioBufferActors`, `loadAudioSegments` — no audio side.
|
||||
- `syncTextTracks`, `setupTextTrackActors`, `loadTextTrackSegments` — no text-track machinery.
|
||||
- `switchVideoQuality` — no ABR; commits to a single rendition for the session.
|
||||
|
||||
### Added
|
||||
|
||||
`selectVideoTrack` (from [`select-tracks.ts`](../../../../packages/spf/src/playback/behaviors/select-tracks.ts))
|
||||
with a config-provided `picker`, defaulting to `pickMaxResolutionVideoTrack`.
|
||||
Per `switchVideoQuality`'s docstring: *"Composing `selectVideoTrack` alone
|
||||
tree-shakes out the ABR code path."* — exactly the affordance Phase 1 wants.
|
||||
|
||||
### Alternative default configurations
|
||||
|
||||
- **`initialState.loadActivated: true`** *(Phase 1)* — seeds the composition into the post-preload-gate state from frame 0. Combined with the `syncPreload` / `trackLoadTriggers` subtractions, every downstream behavior that would otherwise gate on `!isBlockingPreload(preload) || loadActivated` sees `loadActivated` truthy from the start.
|
||||
- **`picker`** *(Phase 1)* — defaults to `pickMaxResolutionVideoTrack`; overridable for mobile/content-aware caps.
|
||||
- **Back-buffer tuning** *(Phase 3 candidate)* — larger back-buffer for gapless wrap-around (per [`buffer-management`](../features/buffer-management.md)'s `BackBufferConfig.keepSegments`).
|
||||
- **GPU/thermal-aware quality caps** *(Phase 3 candidate)* — shared concern with `video-only-mode-override`.
|
||||
|
||||
### Alternative implementations *(Phase 3 candidates)*
|
||||
|
||||
- **Sampling-strip `loadVideoSegments`** — the standard loader samples bandwidth into `state.bandwidthState` to feed ABR. With `switchVideoQuality` subtracted, no consumer reads it; sampling is harmless but wasted work. Per [`README.md` § Implementation note](./README.md#implementation-note-customizing-behaviors-for-use-cases), likely Path B (the sampling assumption is structurally tied to ABR).
|
||||
- **Loop-around forward-buffer fetching** — pre-fetch the wrap-around for gapless restart. See [`buffer-management`](../features/buffer-management.md) "What's not implemented".
|
||||
|
||||
## Customer-policy surface
|
||||
|
||||
Independent adapter parallel to `SimpleHlsMediaElement`:
|
||||
|
||||
```ts
|
||||
const bgPlayer = new BackgroundLoopingVideoMediaElement({ picker: maxResolutionPicker });
|
||||
bgPlayer.src = sourceUrl;
|
||||
bgPlayer.loop = true; // native HTMLMediaElement.loop
|
||||
bgPlayer.muted = true; // browser autoplay policy
|
||||
bgPlayer.play();
|
||||
```
|
||||
|
||||
Engine config: **`picker`** (TrackPicker, default max-resolution); later
|
||||
phases add `withAudio()` / `withPreload()` decorator hooks. Native `loop`
|
||||
/ `muted` live on the underlying media element. Adapter-layer concerns
|
||||
(autoplay-muted defaults, loop policy refinements, GPU/thermal caps, the
|
||||
Video.js component shell) live above the SPF engine.
|
||||
|
||||
## Variant-decision signal source
|
||||
|
||||
**Adapter-upfront.** Selecting `BackgroundLoopingVideoMediaElement` *is*
|
||||
the variant choice — no parser detection, no runtime config branch. Same
|
||||
resolution as [`video-only-mode-override`](./video-only-mode-override.md)
|
||||
and [`audio-only-mode-override`](./audio-only-mode-override.md): Case-2
|
||||
use cases resolve via adapter choice.
|
||||
|
||||
## Constituent features
|
||||
|
||||
Phase 1 baseline:
|
||||
|
||||
- **[`video-only-composition`](../features/video-only-composition.md)** — used at the *composition-mechanism* level; same audio-side subtraction pattern as the Case-1 feature, driven by adapter choice instead of source-shape detection. Plus further subtractions (text, ABR, preload).
|
||||
- **[`engine-adapter-integration`](../features/engine-adapter-integration.md)** — variant adapter parallels `SimpleHlsMediaElement` via the same `SimpleHlsMediaMixin` / `shareSignals` pattern.
|
||||
- **[`mse-mms-pipeline`](../features/mse-mms-pipeline.md)** — used as-is. Firefox `mozHasAudio=false` verification under subtractive-audio composition is **joint Phase 1 scope** with `video-only-mode-override` and the Case-1 `video-only-composition` feature.
|
||||
- **[`buffer-management`](../features/buffer-management.md)** — as-is in Phase 1; Phase 3 surfaces back-buffer tuning and loop-around forward-buffer fetching (the "loop-around buffer fetching" candidate in that feature's *What's not implemented* directly targets this use case).
|
||||
- **[`preload-modes`](../features/preload-modes.md)** — alternative initial state (`loadActivated: true`) plus subtraction of `syncPreload` + `trackLoadTriggers`. Semantic contract preserved; the variant just seeds the gate-passable state from composition time.
|
||||
|
||||
Subtracted (cross-link discipline):
|
||||
|
||||
- **[`video-abr`](../features/video-abr.md)** — single rendition for the session.
|
||||
- **[`multi-language-audio`](../features/multi-language-audio.md)** — audio fully subtracted.
|
||||
- **[`subtitles`](../features/subtitles.md)** — text fully subtracted; may resurface if a `withCaptions()`-style extension is scoped.
|
||||
|
||||
Phase 2 (decorations TBD): **[`audio-playback`](../features/audio-playback.md)**, **[`audio-abr`](../features/audio-abr.md)**.
|
||||
|
||||
## Likely cross-cutting impact
|
||||
|
||||
- **Shared engine factory.** Three use cases now want subtractive-audio composition (this, `video-only-mode-override`, Case-1 `video-only-composition`). Lean: shared factory at the subtractive-audio level, with this use case layering further subtractions (text, ABR, preload) and an initial-state override on top.
|
||||
- **Firefox `mozHasAudio` verification.** Joint scope with the two sibling cases — same mixed-source-with-audio-subtracted permutation.
|
||||
- **Adapter proliferation.** N+1 adapter parallel to `SimpleHlsMediaElement`; three adapters share the `SimpleHlsMediaMixin` / `shareSignals` pattern — cost is configuration surface, not architecture.
|
||||
- **`loadActivated: true` initial-state pattern.** Pioneered here. If a second use case wants the same shape, consider a shared `withAutoLoad()`-style helper or document treatment in [`preload-modes`](../features/preload-modes.md).
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Phase 2/3 decorator pattern shape.** Decorator on the engine factory? Composable-feature abstraction? Engine-config flags re-including subtracted behaviors? Resolves when Phase 2/3 are scoped.
|
||||
- **Shared engine factory.** Joint with `video-only-mode-override` and Case-1 `video-only-composition`. Lean: shared, with this case composing further subtractions.
|
||||
- **Sampling-strip alt-impl Path A vs B.** Likely Path B per [`README.md` § Implementation note](./README.md#implementation-note-customizing-behaviors-for-use-cases).
|
||||
- **GPU/thermal-aware quality caps boundary.** Engine-variant (compose a thermal-aware behavior) or adapter (cap the picker candidate set). Likely engine-variant given the product context.
|
||||
|
||||
Resolved Phase 1: ~~picker location~~ (`pickMaxResolutionVideoTrack` ships in [`media/primitives/select-tracks.ts`](../../../../packages/spf/src/media/primitives/select-tracks.ts) next to `pickFirstTrackId`); ~~adapter naming~~ (`BackgroundLoopingVideoMediaElement`; product-shell naming `<mux-background-video>` lives in the adapter layer).
|
||||
|
||||
## See also
|
||||
|
||||
- [`video-only-mode-override.md`](./video-only-mode-override.md) — peer use case; shares constituent features and Firefox `mozHasAudio` scope; differs in delivery-scenario specificity (this is the Mux-product-shaped variant with loop, single-rendition, autoplay-via-initial-state).
|
||||
- [`audio-only-mode-override.md`](./audio-only-mode-override.md) — inverse-axis sibling; shares adapter-upfront pattern and the shared-engine-factory open question.
|
||||
- [`README.md`](./README.md) — use-case-composition doc-type spec.
|
||||
- [`../features/clusters.md` § Composition vs Policy vs middle pattern](../features/clusters.md#composition-vs-policy-vs-middle-pattern) · [`../conventions/behaviors.md` § Inverse: behaviors that operate uniformly across tracks](../conventions/behaviors.md#inverse-behaviors-that-operate-uniformly-across-tracks).
|
||||
- [`../../../../packages/spf/docs/hls-engine.md`](../../../../packages/spf/docs/hls-engine.md) — HLS engine composition baseline the variant subtracts from.
|
||||
- [`select-tracks.ts`](../../../../packages/spf/src/playback/behaviors/select-tracks.ts) (add target) · [`engine.ts`](../../../../packages/spf/src/playback/engines/hls/engine.ts) (subtraction baseline).
|
||||
- [GitHub #1586](https://github.com/videojs/v10/issues/1586) (Phase 1) · [#873](https://github.com/videojs/v10/issues/873) (parent epic) · [`mux-background-video`](https://github.com/muxinc/mux-background-video) (prior art) · [SPF Epics Working Doc](https://www.notion.so/35f97a7f89d08123a13fecab1ca1cac4).
|
||||
@@ -32,6 +32,11 @@
|
||||
"types": "./dist/dev/hls.d.ts",
|
||||
"development": "./dist/dev/hls.js",
|
||||
"default": "./dist/default/hls.js"
|
||||
},
|
||||
"./background-looping-video": {
|
||||
"types": "./dist/dev/background-looping-video.d.ts",
|
||||
"development": "./dist/dev/background-looping-video.js",
|
||||
"default": "./dist/default/background-looping-video.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/default/index.js",
|
||||
|
||||
@@ -178,6 +178,34 @@ export function pickVideoTrack(presentation: Presentation, config: VideoSelectio
|
||||
return selected?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the video track with the highest resolution (width x height).
|
||||
*
|
||||
* Falls back to `bandwidth` when resolution metadata is missing.
|
||||
*
|
||||
* Pair with `selectVideoTrack`; compose `switchVideoQuality` instead
|
||||
* for runtime-adapted quality.
|
||||
*/
|
||||
export function pickMaxResolutionVideoTrack(presentation: MaybeResolvedPresentation): string | undefined {
|
||||
const videoSet = presentation.selectionSets?.find((set) => set.type === 'video') as VideoSelectionSet | undefined;
|
||||
const tracks = videoSet?.switchingSets[0]?.tracks;
|
||||
if (!tracks?.length) return undefined;
|
||||
|
||||
let bestId: string | undefined;
|
||||
let bestArea = -1;
|
||||
let bestBandwidth = -1;
|
||||
for (const track of tracks) {
|
||||
const area = track.width && track.height ? track.width * track.height : 0;
|
||||
const bandwidth = track.bandwidth ?? 0;
|
||||
if (area > bestArea || (area === bestArea && bandwidth > bestBandwidth)) {
|
||||
bestArea = area;
|
||||
bestBandwidth = bandwidth;
|
||||
bestId = track.id;
|
||||
}
|
||||
}
|
||||
return bestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick audio track.
|
||||
*
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
TextSelectionSet,
|
||||
VideoSelectionSet,
|
||||
} from '../../types';
|
||||
import { pickAudioTrack, pickTextTrack, pickVideoTrack } from '../select-tracks';
|
||||
import { pickAudioTrack, pickMaxResolutionVideoTrack, pickTextTrack, pickVideoTrack } from '../select-tracks';
|
||||
|
||||
// Helper to create a minimal presentation
|
||||
function createPresentation(config: {
|
||||
@@ -176,6 +176,103 @@ describe('pickVideoTrack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMaxResolutionVideoTrack', () => {
|
||||
it('selects the track with the highest width × height area', () => {
|
||||
const tracks: PartiallyResolvedVideoTrack[] = [
|
||||
{
|
||||
type: 'video',
|
||||
id: '360p',
|
||||
url: 'http://example.com/360p.m3u8',
|
||||
bandwidth: 500_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 640,
|
||||
height: 360,
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
id: '1080p',
|
||||
url: 'http://example.com/1080p.m3u8',
|
||||
bandwidth: 4_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
id: '720p',
|
||||
url: 'http://example.com/720p.m3u8',
|
||||
bandwidth: 2_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ video: tracks });
|
||||
expect(pickMaxResolutionVideoTrack(presentation)).toBe('1080p');
|
||||
});
|
||||
|
||||
it('falls back to bandwidth when resolution metadata is missing', () => {
|
||||
const tracks: PartiallyResolvedVideoTrack[] = [
|
||||
{
|
||||
type: 'video',
|
||||
id: 'low',
|
||||
url: 'http://example.com/low.m3u8',
|
||||
bandwidth: 500_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
id: 'high',
|
||||
url: 'http://example.com/high.m3u8',
|
||||
bandwidth: 4_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ video: tracks });
|
||||
expect(pickMaxResolutionVideoTrack(presentation)).toBe('high');
|
||||
});
|
||||
|
||||
it('breaks ties on equal resolution by bandwidth', () => {
|
||||
const tracks: PartiallyResolvedVideoTrack[] = [
|
||||
{
|
||||
type: 'video',
|
||||
id: '1080p-low',
|
||||
url: 'http://example.com/1080p-low.m3u8',
|
||||
bandwidth: 3_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
id: '1080p-high',
|
||||
url: 'http://example.com/1080p-high.m3u8',
|
||||
bandwidth: 6_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ video: tracks });
|
||||
expect(pickMaxResolutionVideoTrack(presentation)).toBe('1080p-high');
|
||||
});
|
||||
|
||||
it('returns undefined when no video tracks exist', () => {
|
||||
const presentation = createPresentation({ audio: [] });
|
||||
expect(pickMaxResolutionVideoTrack(presentation)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickAudioTrack', () => {
|
||||
it('selects first track when no preferences', () => {
|
||||
const tracks: PartiallyResolvedAudioTrack[] = [
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Constructor, MixinReturn } from '@videojs/utils/types';
|
||||
import type { Composition } from '../../../core/composition/create-composition';
|
||||
import {
|
||||
type BackgroundLoopingVideoEngineConfig,
|
||||
type BackgroundLoopingVideoEngineContext,
|
||||
type BackgroundLoopingVideoEngineSignals,
|
||||
type BackgroundLoopingVideoEngineState,
|
||||
createBackgroundLoopingVideoEngine,
|
||||
} from './engine';
|
||||
|
||||
export interface BackgroundLoopingVideoMediaProps {
|
||||
src: string;
|
||||
preload: '' | 'none' | 'metadata' | 'auto';
|
||||
loop: boolean;
|
||||
muted: boolean;
|
||||
autoplay: boolean;
|
||||
}
|
||||
|
||||
export const backgroundLoopingVideoMediaDefaultProps: BackgroundLoopingVideoMediaProps = {
|
||||
src: '',
|
||||
preload: 'auto',
|
||||
loop: true,
|
||||
muted: true,
|
||||
autoplay: true,
|
||||
};
|
||||
|
||||
export interface BackgroundLoopingVideoMediaAPI extends BackgroundLoopingVideoMediaProps {
|
||||
readonly engine: Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext>;
|
||||
attach(mediaElement: HTMLMediaElement): void;
|
||||
detach(): void;
|
||||
destroy(): void;
|
||||
play(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin that adds the background-looping-video SPF playback engine to any
|
||||
* base class.
|
||||
*
|
||||
* Implements the WHATWG HTML media element contract (`src`, `preload`,
|
||||
* `loop`, `muted`, `autoplay`, `play()`) so it can be dropped in anywhere a
|
||||
* media element API is expected. Compared to `SimpleHlsMediaMixin`, this
|
||||
* variant:
|
||||
*
|
||||
* - exposes `loop`, `muted`, and `autoplay` as adapter-owned native
|
||||
* passthroughs, all defaulting to `true` — the use case is silent
|
||||
* autoplay-looping video, so muted + autoplay satisfy browser autoplay
|
||||
* policies and loop is the defining behavior;
|
||||
* - drives the underlying engine with the background-looping-video
|
||||
* composition (single-rendition, video-only, autoplay-from-construction).
|
||||
*
|
||||
* A new engine is created on every src assignment — this fully tears down
|
||||
* all state, SourceBuffers, and in-flight requests from the previous
|
||||
* source before the next one begins. The media element reference is
|
||||
* preserved across src changes and re-applied to the new engine
|
||||
* automatically.
|
||||
*
|
||||
* @example
|
||||
* class BackgroundLoopingVideoMedia extends BackgroundLoopingVideoMediaMixin(HTMLVideoElementHost) {}
|
||||
*
|
||||
* const media = new BackgroundLoopingVideoMedia();
|
||||
* media.attach(document.querySelector('video'));
|
||||
* media.src = 'https://stream.mux.com/abc123.m3u8';
|
||||
* media.play();
|
||||
*/
|
||||
export function BackgroundLoopingVideoMediaMixin<Base extends Constructor<any>>(BaseClass: Base) {
|
||||
class BackgroundLoopingVideoMediaImpl extends BaseClass {
|
||||
#engine: Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext>;
|
||||
#config: BackgroundLoopingVideoEngineConfig;
|
||||
#signals!: BackgroundLoopingVideoEngineSignals;
|
||||
#preload: '' | 'none' | 'metadata' | 'auto' = backgroundLoopingVideoMediaDefaultProps.preload;
|
||||
#loop: boolean = backgroundLoopingVideoMediaDefaultProps.loop;
|
||||
#muted: boolean = backgroundLoopingVideoMediaDefaultProps.muted;
|
||||
#autoplay: boolean = backgroundLoopingVideoMediaDefaultProps.autoplay;
|
||||
|
||||
/** Pending loadstart listener from a deferred play() retry, if any. */
|
||||
#loadstartListener: (() => void) | null = null;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
const { config } = args?.[0] ?? {};
|
||||
this.#config = config;
|
||||
this.#engine = this.#createEngine();
|
||||
}
|
||||
|
||||
get engine(): Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
|
||||
return this.#engine;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Media element lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
attach(mediaElement: HTMLMediaElement): void {
|
||||
super.attach?.(mediaElement);
|
||||
// Apply adapter-owned native props before the engine takes over —
|
||||
// the underlying element needs `loop` / `muted` / `autoplay` set for
|
||||
// the use case's autoplay-looping semantics.
|
||||
mediaElement.loop = this.#loop;
|
||||
mediaElement.muted = this.#muted;
|
||||
mediaElement.autoplay = this.#autoplay;
|
||||
|
||||
this.#signals.context.mediaElement.set(mediaElement);
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.#cancelPendingPlay();
|
||||
this.#signals.context.mediaElement.set(undefined);
|
||||
super.detach?.();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#cancelPendingPlay();
|
||||
this.#engine.destroy();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// preload — synchronous IDL attribute (WHATWG §4.8.11.2)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get preload(): '' | 'none' | 'metadata' | 'auto' {
|
||||
return this.#preload;
|
||||
}
|
||||
|
||||
set preload(_value: '' | 'none' | 'metadata' | 'auto') {
|
||||
// Noop for this phase
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// loop / muted / autoplay — adapter-owned IDL attributes mirrored onto
|
||||
// the attached media element. The engine itself has no opinion on any
|
||||
// of them.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get loop(): boolean {
|
||||
return this.#loop;
|
||||
}
|
||||
|
||||
set loop(_value: boolean) {
|
||||
// Noop for this phase
|
||||
}
|
||||
|
||||
get muted(): boolean {
|
||||
return this.#muted;
|
||||
}
|
||||
|
||||
set muted(_value: boolean) {
|
||||
// Noop for this phase
|
||||
}
|
||||
|
||||
get autoplay(): boolean {
|
||||
return this.#autoplay;
|
||||
}
|
||||
|
||||
set autoplay(_value: boolean) {
|
||||
// Noop for this phase
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
|
||||
// Each assignment destroys the current engine and starts a fresh one,
|
||||
// matching the browser's load algorithm reset on src change.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get src(): string {
|
||||
return this.#signals.state.presentation.get()?.url ?? '';
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this.#cancelPendingPlay();
|
||||
|
||||
if (value) {
|
||||
this.#signals.state.presentation.set({ url: value });
|
||||
} else {
|
||||
this.#signals.state.presentation.set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// play() — WHATWG §4.8.11.8
|
||||
// Delegates to the attached media element's native play().
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async play(): Promise<void> {
|
||||
const mediaElement = this.#signals.context.mediaElement.get();
|
||||
if (!mediaElement) {
|
||||
return Promise.reject(new Error('BackgroundLoopingVideoMediaElement: no media element attached'));
|
||||
}
|
||||
|
||||
try {
|
||||
return await mediaElement.play();
|
||||
} catch (err) {
|
||||
// If we have a pending HLS source, the rejection may be because MSE
|
||||
// hasn't attached a blob URL yet. Wait for loadstart (src assigned
|
||||
// by MSE setup) and retry once.
|
||||
if (this.src) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const listener = () => {
|
||||
this.#loadstartListener = null;
|
||||
mediaElement.play().then(resolve, reject);
|
||||
};
|
||||
this.#loadstartListener = listener;
|
||||
mediaElement.addEventListener('loadstart', listener, { once: true });
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#createEngine(): Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
|
||||
return createBackgroundLoopingVideoEngine({
|
||||
...this.#config,
|
||||
onSignalsReady: (signals) => {
|
||||
this.#signals = signals;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#cancelPendingPlay(): void {
|
||||
if (!this.#loadstartListener) return;
|
||||
const mediaElement = this.#signals.context.mediaElement.get();
|
||||
mediaElement?.removeEventListener('loadstart', this.#loadstartListener);
|
||||
this.#loadstartListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
return BackgroundLoopingVideoMediaImpl as unknown as MixinReturn<Base, BackgroundLoopingVideoMediaAPI>;
|
||||
}
|
||||
|
||||
/** Standalone SPF background-looping-video adapter with no base class. */
|
||||
export class BackgroundLoopingVideoMediaElement extends BackgroundLoopingVideoMediaMixin(class {}) {}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
type Composition,
|
||||
type ContextSignals,
|
||||
createComposition,
|
||||
type StateSignals,
|
||||
} from '../../../core/composition/create-composition';
|
||||
import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals';
|
||||
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
|
||||
import { pickMaxResolutionVideoTrack, type TrackPicker } from '../../../media/primitives/select-tracks';
|
||||
import type { MaybeResolvedPresentation } from '../../../media/types';
|
||||
import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection';
|
||||
import type { SegmentLoaderActor } from '../../actors/dom/segment-loader';
|
||||
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
|
||||
import { calculatePresentationDuration } from '../../behaviors/calculate-presentation-duration';
|
||||
import { endOfStream } from '../../behaviors/dom/end-of-stream';
|
||||
import { loadVideoSegments } from '../../behaviors/dom/load-segments';
|
||||
import { setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
|
||||
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
|
||||
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { resolveVideoTrack } from '../../behaviors/resolve-track';
|
||||
import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks';
|
||||
|
||||
// ============================================================================
|
||||
// Background-looping-video engine state & context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* State shape for the background-looping-video playback engine.
|
||||
*
|
||||
* Narrower than `SimpleHlsEngineState`: audio/text track slots are absent
|
||||
* because their selection/resolution behaviors are subtracted. `bandwidthState`
|
||||
* is present because `setupVideoBufferActors` declares it and `loadVideoSegments`
|
||||
* samples into it (wasted work in this variant — a Phase 3 alt-impl will skip
|
||||
* sampling).
|
||||
*/
|
||||
export interface BackgroundLoopingVideoEngineState {
|
||||
/**
|
||||
* The presentation being played. A caller writes `{ url }`;
|
||||
* `resolvePresentation` parses the manifest and populates the rest.
|
||||
*/
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
preload?: 'auto' | 'metadata' | 'none';
|
||||
selectedVideoTrackId?: string;
|
||||
loadActivated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context shape for the background-looping-video engine.
|
||||
*/
|
||||
export interface BackgroundLoopingVideoEngineContext {
|
||||
mediaElement?: HTMLMediaElement | undefined;
|
||||
mediaSource?: MediaSource;
|
||||
videoBufferActor?: SourceBufferActor;
|
||||
videoSegmentLoaderActor?: SegmentLoaderActor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composition signal refs handed to `onSignalsReady` callers — the
|
||||
* canonical way to drive the engine externally (writes) or observe its
|
||||
* state (reads) without touching `composition.state` / `composition.context`
|
||||
* directly.
|
||||
*/
|
||||
export type BackgroundLoopingVideoEngineSignals = {
|
||||
state: StateSignals<BackgroundLoopingVideoEngineState>;
|
||||
context: ContextSignals<BackgroundLoopingVideoEngineContext>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for the background-looping-video engine.
|
||||
*
|
||||
* Each option is consumed by the appropriate behavior — the engine itself
|
||||
* has no config beyond what its behaviors read. Compared to
|
||||
* `SimpleHlsEngineConfig`, audio/text/ABR/bandwidth/quality knobs are
|
||||
* dropped: the variant subtracts the behaviors that read them.
|
||||
*/
|
||||
export interface BackgroundLoopingVideoEngineConfig
|
||||
extends ShareSignalsConfig<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
|
||||
/**
|
||||
* Track picker handed to `selectVideoTrack`. Default:
|
||||
* `pickMaxResolutionVideoTrack` — picks the highest-resolution variant on
|
||||
* presentation resolve and pins it for the session. Override for
|
||||
* mobile-aware or content-aware caps.
|
||||
*/
|
||||
picker?: TrackPicker<SelectVideoTrackConfig>;
|
||||
/**
|
||||
* Manifest parser handed to `resolvePresentation`. Defaults to the HLS
|
||||
* multivariant-playlist parser.
|
||||
*/
|
||||
parsePresentation?: ParsePresentation;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Background-looping-video playback engine
|
||||
// ============================================================================
|
||||
|
||||
const shareSignals = makeShareSignals<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext>();
|
||||
|
||||
/**
|
||||
* Create a background-looping-video playback engine.
|
||||
*
|
||||
* Subtractive composition over the HLS engine baseline:
|
||||
* audio-side, text-side, ABR-driven, preload-monitoring, and play/seek
|
||||
* load-trigger behaviors are removed. `selectVideoTrack` (with a
|
||||
* max-resolution picker by default) replaces `switchVideoQuality`, pinning
|
||||
* a single rendition for the session. The initial state seeds
|
||||
* `loadActivated: true` so the composition behaves as if preload has
|
||||
* already been activated — appropriate for ambient / hero / GIF-replacement
|
||||
* surfaces that should start loading the moment a src is set.
|
||||
*
|
||||
* Native `loop` / `muted` / `autoplay` are adapter concerns and live on
|
||||
* `BackgroundLoopingVideoMediaElement` rather than the engine.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* let signals: BackgroundLoopingVideoEngineSignals;
|
||||
* const engine = createBackgroundLoopingVideoEngine({
|
||||
* onSignalsReady: (refs) => {
|
||||
* signals = refs;
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* signals.context.mediaElement.set(videoEl);
|
||||
* signals.state.presentation.set({ url: 'https://example.com/stream.m3u8' });
|
||||
*
|
||||
* await engine.destroy();
|
||||
* ```
|
||||
*/
|
||||
export function createBackgroundLoopingVideoEngine(
|
||||
config: BackgroundLoopingVideoEngineConfig = {}
|
||||
): Composition<BackgroundLoopingVideoEngineState, BackgroundLoopingVideoEngineContext> {
|
||||
const finalConfig = {
|
||||
...config,
|
||||
picker: config.picker ?? pickMaxResolutionVideoTrack,
|
||||
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
|
||||
resolveDuration: getResolvedSelectedTrackDuration,
|
||||
};
|
||||
|
||||
return createComposition(
|
||||
[
|
||||
resolvePresentation,
|
||||
// Presentation duration
|
||||
calculatePresentationDuration,
|
||||
|
||||
// Track selection - pinned single-rendition pick on presentation resolve.
|
||||
selectVideoTrack,
|
||||
// Resolve selected video track (fetch its media playlist)
|
||||
resolveVideoTrack,
|
||||
// Segment loading — video-only.
|
||||
loadVideoSegments,
|
||||
|
||||
// MSE setup — video-only.
|
||||
setupMediaSource,
|
||||
updateMediaSourceDuration,
|
||||
setupVideoBufferActors,
|
||||
|
||||
// Playback tracking
|
||||
trackCurrentTime,
|
||||
|
||||
// End of stream coordination
|
||||
endOfStream,
|
||||
|
||||
// Behavior whose sole purpose is to expose signal refs via a callback
|
||||
// (e.g. to an adapter). Listed last so initial signal setup has run
|
||||
// before the callback fires.
|
||||
shareSignals,
|
||||
],
|
||||
{
|
||||
config: finalConfig,
|
||||
initialState: {
|
||||
// Note: Set to true until we add preload configuration
|
||||
loadActivated: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type { BackgroundLoopingVideoMediaAPI, BackgroundLoopingVideoMediaProps } from './adapter';
|
||||
export {
|
||||
BackgroundLoopingVideoMediaElement,
|
||||
BackgroundLoopingVideoMediaMixin,
|
||||
backgroundLoopingVideoMediaDefaultProps,
|
||||
} from './adapter';
|
||||
export type {
|
||||
BackgroundLoopingVideoEngineConfig,
|
||||
BackgroundLoopingVideoEngineContext,
|
||||
BackgroundLoopingVideoEngineSignals,
|
||||
BackgroundLoopingVideoEngineState,
|
||||
} from './engine';
|
||||
export { createBackgroundLoopingVideoEngine } from './engine';
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* BackgroundLoopingVideoMediaElement adapter tests.
|
||||
*
|
||||
* Covers the HTMLMediaElement-compatible contract for src, preload, loop,
|
||||
* muted, and play(). Adapter-shape parallels SimpleHlsMediaElement; the
|
||||
* tests focus on what diverges: the new adapter owns `loop` / `muted`
|
||||
* passthroughs and defaults both to true (autoplay-muted, looping).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BackgroundLoopingVideoMediaElement } from '../adapter';
|
||||
|
||||
describe('BackgroundLoopingVideoMediaElement', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => new Promise<Response>(() => {}))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('src', () => {
|
||||
it('returns empty string before any src is set', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
expect(media.src).toBe('');
|
||||
});
|
||||
|
||||
it('reflects the set value synchronously', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
expect(media.src).toBe('https://example.com/v.m3u8');
|
||||
});
|
||||
|
||||
it('synchronously updates engine presentation state when src is set', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
expect(media.engine.state.presentation.get()?.url).toBe('https://example.com/v.m3u8');
|
||||
});
|
||||
|
||||
it('clears engine presentation state when src is set to empty string', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
media.src = '';
|
||||
expect(media.engine.state.presentation.get()?.url).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach / detach', () => {
|
||||
it('exposes the engine immediately (created at construction)', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
expect(media.engine).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reuses the same engine instance across attach calls', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const engineBefore = media.engine;
|
||||
media.attach(document.createElement('video'));
|
||||
media.attach(document.createElement('video'));
|
||||
expect(media.engine).toBe(engineBefore);
|
||||
});
|
||||
|
||||
it('re-attaches the media element to the new engine when src changes', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.src = 'https://example.com/v1.m3u8';
|
||||
expect(media.engine.context.mediaElement.get()).toBe(el);
|
||||
});
|
||||
|
||||
it('sets mediaElement in context when attached', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
expect(media.engine.context.mediaElement.get()).toBe(el);
|
||||
});
|
||||
|
||||
it('clears mediaElement in context when detached', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
media.detach();
|
||||
expect(media.engine.context.mediaElement.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detach does not destroy the engine', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
const spy = vi.spyOn(media.engine, 'destroy');
|
||||
media.detach();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loop / muted defaults', () => {
|
||||
it('defaults loop to true', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
expect(media.loop).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults muted to true', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
expect(media.muted).toBe(true);
|
||||
});
|
||||
|
||||
it('applies loop / muted defaults to the media element on attach', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const el = document.createElement('video');
|
||||
// start the element in the opposite state so we can confirm attach overrides it
|
||||
el.loop = false;
|
||||
el.muted = false;
|
||||
media.attach(el);
|
||||
expect(el.loop).toBe(true);
|
||||
expect(el.muted).toBe(true);
|
||||
});
|
||||
|
||||
// attach modifies native props; changing src doesn't
|
||||
it('preserves loop / muted to the preserved element on src change', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
el.loop = false;
|
||||
el.muted = false;
|
||||
media.src = 'https://example.com/v.m3u8';
|
||||
expect(el.loop).toBe(false);
|
||||
expect(el.muted).toBe(false);
|
||||
});
|
||||
|
||||
// Skipped: `set loop` / `set muted` are noops in Phase 1 (the adapter
|
||||
// pins loop=true / muted=true for the autoplay-looping use case). These
|
||||
// assert functional setters — unskip when the setters are implemented.
|
||||
it.skip('mirrors loop changes onto the attached element', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.loop = false;
|
||||
expect(el.loop).toBe(false);
|
||||
expect(media.loop).toBe(false);
|
||||
});
|
||||
|
||||
it.skip('mirrors muted changes onto the attached element', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
media.muted = false;
|
||||
expect(el.muted).toBe(false);
|
||||
expect(media.muted).toBe(false);
|
||||
});
|
||||
|
||||
it.skip('stores loop / muted updates made before attach and applies them on attach', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
media.loop = false;
|
||||
media.muted = false;
|
||||
const el = document.createElement('video');
|
||||
media.attach(el);
|
||||
expect(el.loop).toBe(false);
|
||||
expect(el.muted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('play()', () => {
|
||||
it('returns a Promise', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
media.attach(document.createElement('video'));
|
||||
const result = media.play();
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
result.catch(() => {});
|
||||
});
|
||||
|
||||
it('rejects when no media element is attached', async () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
await expect(media.play()).rejects.toThrow('no media element attached');
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('destroys the underlying engine', () => {
|
||||
const media = new BackgroundLoopingVideoMediaElement();
|
||||
const spy = vi.spyOn(media.engine, 'destroy');
|
||||
media.destroy();
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* createBackgroundLoopingVideoEngine tests.
|
||||
*
|
||||
* The variant subtracts audio, text, ABR, and preload-monitoring behaviors
|
||||
* from the simple HLS engine, then seeds `loadActivated: true` so the
|
||||
* composition behaves as if preload has already been activated. These tests
|
||||
* confirm the seed, the absence of subtracted state slots, and the picker
|
||||
* configurability.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { snapshot } from '../../../../core/signals/primitives';
|
||||
import type { MaybeResolvedPresentation } from '../../../../media/types';
|
||||
import { createBackgroundLoopingVideoEngine } from '../engine';
|
||||
|
||||
vi.mock('../../../../media/dom/mse/append-segment', () => ({
|
||||
appendSegment: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('createBackgroundLoopingVideoEngine', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('creates an engine with state, context, and destroy()', () => {
|
||||
const engine = createBackgroundLoopingVideoEngine();
|
||||
|
||||
expect(engine.state).toBeDefined();
|
||||
expect(engine.context).toBeDefined();
|
||||
expect(typeof engine.destroy).toBe('function');
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('seeds loadActivated: true so preload gates pass from frame 0', () => {
|
||||
const engine = createBackgroundLoopingVideoEngine();
|
||||
expect(engine.state.loadActivated.get()).toBe(true);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('omits subtracted state slots — no audio/text/userVideoTrackSelection signals', () => {
|
||||
const engine = createBackgroundLoopingVideoEngine();
|
||||
const state = snapshot(engine.state) as Record<string, unknown>;
|
||||
|
||||
// selectedAudioTrackId is declared by calculatePresentationDuration so
|
||||
// its signal is created, but it stays undefined since no audio-selection
|
||||
// behavior is composed in.
|
||||
expect(state.selectedAudioTrackId).toBeUndefined();
|
||||
|
||||
// Text-track and userVideoTrackSelection signals must not exist —
|
||||
// no behavior in this composition declares them.
|
||||
expect('selectedTextTrackId' in state).toBe(false);
|
||||
expect('userVideoTrackSelection' in state).toBe(false);
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('omits subtracted context slots — no audio segment loader / text actors', () => {
|
||||
const engine = createBackgroundLoopingVideoEngine();
|
||||
const context = snapshot(engine.context) as Record<string, unknown>;
|
||||
|
||||
// `audioBufferActor` IS declared by `endOfStream` (cross-type EOS
|
||||
// coordination), so the signal exists — but no behavior in this
|
||||
// composition writes it, so it stays `undefined`.
|
||||
expect(context.audioBufferActor).toBeUndefined();
|
||||
|
||||
// The audio segment loader and both text-track actors aren't declared
|
||||
// by any behavior left in the composition — their signals don't exist.
|
||||
expect('audioSegmentLoaderActor' in context).toBe(false);
|
||||
expect('textTracksActor' in context).toBe(false);
|
||||
expect('textTrackSegmentLoaderActor' in context).toBe(false);
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('defaults the picker to pickMaxResolutionVideoTrack', async () => {
|
||||
const engine = createBackgroundLoopingVideoEngine();
|
||||
|
||||
const presentation: MaybeResolvedPresentation = {
|
||||
id: 'p',
|
||||
url: 'https://example.com/manifest.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'video-set',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'video-switching',
|
||||
type: 'video',
|
||||
tracks: [
|
||||
{
|
||||
type: 'video',
|
||||
id: '480p',
|
||||
url: 'https://example.com/480p.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
initialization: { url: 'init', byteRange: { offset: 0, length: 0 } },
|
||||
segments: [],
|
||||
startTime: 0,
|
||||
duration: 0,
|
||||
width: 854,
|
||||
height: 480,
|
||||
} as never,
|
||||
{
|
||||
type: 'video',
|
||||
id: '1080p',
|
||||
url: 'https://example.com/1080p.m3u8',
|
||||
bandwidth: 4_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.640028'],
|
||||
initialization: { url: 'init', byteRange: { offset: 0, length: 0 } },
|
||||
segments: [],
|
||||
startTime: 0,
|
||||
duration: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as never,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
engine.state.presentation.set(presentation);
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
expect(engine.state.selectedVideoTrackId.get()).toBe('1080p');
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('honors a custom picker override from config', async () => {
|
||||
const engine = createBackgroundLoopingVideoEngine({
|
||||
picker: () => 'forced-pick',
|
||||
});
|
||||
|
||||
const presentation: MaybeResolvedPresentation = {
|
||||
id: 'p',
|
||||
url: 'https://example.com/manifest.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'video-set',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'video-switching',
|
||||
type: 'video',
|
||||
tracks: [
|
||||
{
|
||||
type: 'video',
|
||||
id: '480p',
|
||||
url: 'https://example.com/480p.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
initialization: { url: 'init', byteRange: { offset: 0, length: 0 } },
|
||||
segments: [],
|
||||
startTime: 0,
|
||||
duration: 0,
|
||||
width: 854,
|
||||
height: 480,
|
||||
} as never,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
engine.state.presentation.set(presentation);
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
expect(engine.state.selectedVideoTrackId.get()).toBe('forced-pick');
|
||||
engine.destroy();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const createConfig = (mode: PackageBuildMode): UserConfig => ({
|
||||
index: 'src/index.ts',
|
||||
dom: 'src/dom.ts',
|
||||
hls: 'src/playback/engines/hls/index.ts',
|
||||
'background-looping-video': 'src/playback/engines/background-looping-video/index.ts',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user