+ Subtractive engine variant — audio, text tracks, ABR, and preload monitoring are composed out; a max-resolution
+ picker is composed in. The SPF foundation for <mux-background-video>.
+
+
+
+
+
Engine state
+
loadActivated—
+
selected rendition—
+
context keys—
+
audio sidesubtracted
+
+
+
+
+
+
+
+
+
+
+
+
+
Later-phase controls
+
P2withAudio() — unmute / audio tracks
+
P3withPreload() — preload mode
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/sandbox/templates/spf-background-looping-video/main.ts b/apps/sandbox/templates/spf-background-looping-video/main.ts
new file mode 100644
index 00000000..45735ed7
--- /dev/null
+++ b/apps/sandbox/templates/spf-background-looping-video/main.ts
@@ -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).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[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)[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);
+ }
+}
diff --git a/cspell.json b/cspell.json
index 29862795..a2957569 100644
--- a/cspell.json
+++ b/cspell.json
@@ -6,6 +6,7 @@
"words": [
"antfu",
"demuxed",
+ "multivariant",
"nanostores",
"noautohide",
"noto",
diff --git a/internal/design/spf/use-cases/background-looping-video.md b/internal/design/spf/use-cases/background-looping-video.md
new file mode 100644
index 00000000..175e31d3
--- /dev/null
+++ b/internal/design/spf/use-cases/background-looping-video.md
@@ -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
+`` 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 `` 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 `` 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).
diff --git a/packages/spf/package.json b/packages/spf/package.json
index ee984fb5..c33513ea 100644
--- a/packages/spf/package.json
+++ b/packages/spf/package.json
@@ -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",
diff --git a/packages/spf/src/media/primitives/select-tracks.ts b/packages/spf/src/media/primitives/select-tracks.ts
index 8775da03..a6c28932 100644
--- a/packages/spf/src/media/primitives/select-tracks.ts
+++ b/packages/spf/src/media/primitives/select-tracks.ts
@@ -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.
*
diff --git a/packages/spf/src/media/primitives/tests/select-tracks.test.ts b/packages/spf/src/media/primitives/tests/select-tracks.test.ts
index 2a450e3d..c0c16c6f 100644
--- a/packages/spf/src/media/primitives/tests/select-tracks.test.ts
+++ b/packages/spf/src/media/primitives/tests/select-tracks.test.ts
@@ -7,7 +7,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[] = [
diff --git a/packages/spf/src/playback/engines/background-looping-video/adapter.ts b/packages/spf/src/playback/engines/background-looping-video/adapter.ts
new file mode 100644
index 00000000..e7138beb
--- /dev/null
+++ b/packages/spf/src/playback/engines/background-looping-video/adapter.ts
@@ -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;
+ attach(mediaElement: HTMLMediaElement): void;
+ detach(): void;
+ destroy(): void;
+ play(): Promise;
+}
+
+/**
+ * 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>(BaseClass: Base) {
+ class BackgroundLoopingVideoMediaImpl extends BaseClass {
+ #engine: Composition;
+ #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 {
+ 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 {
+ 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((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 {
+ 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;
+}
+
+/** Standalone SPF background-looping-video adapter with no base class. */
+export class BackgroundLoopingVideoMediaElement extends BackgroundLoopingVideoMediaMixin(class {}) {}
diff --git a/packages/spf/src/playback/engines/background-looping-video/engine.ts b/packages/spf/src/playback/engines/background-looping-video/engine.ts
new file mode 100644
index 00000000..0d636836
--- /dev/null
+++ b/packages/spf/src/playback/engines/background-looping-video/engine.ts
@@ -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;
+ context: ContextSignals;
+};
+
+/**
+ * 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 {
+ /**
+ * 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;
+ /**
+ * Manifest parser handed to `resolvePresentation`. Defaults to the HLS
+ * multivariant-playlist parser.
+ */
+ parsePresentation?: ParsePresentation;
+}
+
+// ============================================================================
+// Background-looping-video playback engine
+// ============================================================================
+
+const shareSignals = makeShareSignals();
+
+/**
+ * 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 {
+ 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,
+ },
+ }
+ );
+}
diff --git a/packages/spf/src/playback/engines/background-looping-video/index.ts b/packages/spf/src/playback/engines/background-looping-video/index.ts
new file mode 100644
index 00000000..7de6dd1b
--- /dev/null
+++ b/packages/spf/src/playback/engines/background-looping-video/index.ts
@@ -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';
diff --git a/packages/spf/src/playback/engines/background-looping-video/tests/adapter.test.ts b/packages/spf/src/playback/engines/background-looping-video/tests/adapter.test.ts
new file mode 100644
index 00000000..8a16f51c
--- /dev/null
+++ b/packages/spf/src/playback/engines/background-looping-video/tests/adapter.test.ts
@@ -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(() => {}))
+ );
+ });
+
+ 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();
+ });
+ });
+});
diff --git a/packages/spf/src/playback/engines/background-looping-video/tests/engine.test.ts b/packages/spf/src/playback/engines/background-looping-video/tests/engine.test.ts
new file mode 100644
index 00000000..a68dbe0a
--- /dev/null
+++ b/packages/spf/src/playback/engines/background-looping-video/tests/engine.test.ts
@@ -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;
+
+ // 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;
+
+ // `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((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((resolve) => queueMicrotask(resolve));
+ expect(engine.state.selectedVideoTrackId.get()).toBe('forced-pick');
+ engine.destroy();
+ });
+});
diff --git a/packages/spf/tsdown.config.ts b/packages/spf/tsdown.config.ts
index b4416f4b..b4a93e47 100644
--- a/packages/spf/tsdown.config.ts
+++ b/packages/spf/tsdown.config.ts
@@ -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',
},
});