diff --git a/apps/sandbox/templates/spf-background-looping-video/index.html b/apps/sandbox/templates/spf-background-looping-video/index.html new file mode 100644 index 00000000..a9e58164 --- /dev/null +++ b/apps/sandbox/templates/spf-background-looping-video/index.html @@ -0,0 +1,287 @@ + + + + + + + Sandbox — SPF Background Looping Video + + + + +
+ +
+ +
+

SPF · Phase 1

+

Background Looping Video

+

+ 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', }, });