feat(spf): background looping video (phase 1) (#1602)

This commit is contained in:
Santiago Puppo
2026-06-01 16:13:26 -03:00
committed by GitHub
parent 01a2115aab
commit 3741e8de12
13 changed files with 1570 additions and 1 deletions
@@ -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>&lt;mux-background-video&gt;</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);
}
}
+1
View File
@@ -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).
+5
View File
@@ -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();
});
});
+1
View File
@@ -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',
},
});