diff --git a/internal/design/ui/live-presets.md b/internal/design/ui/live-presets.md new file mode 100644 index 000000000..a5aaa2a60 --- /dev/null +++ b/internal/design/ui/live-presets.md @@ -0,0 +1,222 @@ +--- +status: decided +date: 2026-04-21 +--- + +# Live Presets + +Dedicated `live-video` and `live-audio` presets for **live-only** playback, alongside the existing `video` and `audio` presets for on-demand-only playback. + +## Decision + +Ship live playback as **separate, live-only presets** — `live-video` and `live-audio`. Each pairs a live-capable feature set with a skin that renders live UI unconditionally. We do **not**: + +- Add live-capable state or UI to the existing `video` / `audio` presets. +- Branch the live skins on `streamType` to cover VOD inside the same preset. + +Two presets, two jobs: base presets play VOD, live presets play live. A preset is a commitment to one mode, not a runtime toggle between both. If a live preset ever needs to cover VOD sources too, we can layer that in later — but we're not paying the complexity up front before we've felt the friction. + +## Context + +Live playback needs visibly different UI from VOD: + +- The duration display and standard scrubbing affordances disappear or change +- A live indicator / "jump to live edge" button takes their place +- The time slider, if shown at all, represents a DVR window, not `[0, duration]` +- Live-only affordances are likely to grow (latency badge, "behind live" state, chat slot, etc.) + +`streamTypeFeature` exposes `streamType: 'on-demand' | 'live' | 'unknown'` and could drive this at runtime inside one shared skin. We're choosing not to. + +Authors almost always know at embed time whether their product plays live or VOD — an event page, a channel page, a DVR UI. "Live-aware" and "VOD-only" are two meaningfully different products, and the vast majority of `video` / `audio` integrations are VOD-only. + +This doc records two decisions, with separate rationales below: + +1. **Split** — live and VOD ship as separate presets. +2. **Live-only** — the live preset's skin renders live UI unconditionally, with no in-skin VOD fallback. + +## Shape + +### Feature arrays + +Base presets stay as today. Live presets start close in capability and will diverge further as live-only needs emerge. `playbackRateFeature` is dropped — speed controls aren't meaningful for live playback, where the user is always pinned to (or chasing) the live edge. + +```ts +// packages/core/src/dom/store/features/presets.ts +export const videoFeatures: VideoFeatures = [ + playbackFeature, + playbackRateFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + fullscreenFeature, + pipFeature, + remotePlaybackFeature, + controlsFeature, + textTrackFeature, + errorFeature, +]; + +export const liveVideoFeatures: VideoFeatures = [ + playbackFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + fullscreenFeature, + pipFeature, + remotePlaybackFeature, + controlsFeature, + textTrackFeature, + errorFeature, +]; + +export const audioFeatures: AudioFeatures = [ + playbackFeature, + playbackRateFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + errorFeature, +]; + +export const liveAudioFeatures: AudioFeatures = [ + playbackFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + errorFeature, +]; +``` + +`streamTypeFeature` is intentionally **not** included. The live skin assumes live, so it doesn't need runtime detection. When a live-only affordance actually needs stream-type state (e.g., surface an error if a VOD source is loaded, or drive a "behind live" badge derived from seekable + streamType), it's an additive, non-breaking change to append it to `liveVideoFeatures` / `liveAudioFeatures`. + +No live-specific time feature either. `timeFeature` already resolves `duration` to `seekable.end(last)` when the native duration is `Infinity`, which is all the live skin needs for now. If `currentTime` or other time values need live-specific handling later, that's also additive. + +Beyond `playbackRateFeature`, the live feature arrays start as near-copies of their VOD counterparts. That's a conservative starting point, not a commitment that live needs every remaining feature. Further bundle wins for live-only are expected to come primarily from the skin (smaller DOM, fewer subscribers — see below). Trimming additional features from the live arrays is a follow-up once the skins land and we can measure. + +### Preset packages + +```ts +// packages/react/src/presets/live-video/index.ts +export { liveVideoFeatures } from '@videojs/core/dom'; +export { LiveVideo, type LiveVideoProps } from '@/media/live-video'; +export * from './skin'; +export * from './skin.tailwind'; +export * from './minimal-skin'; +export * from './minimal-skin.tailwind'; +``` + +```ts +// packages/html/src/presets/live-video.ts +export { liveVideoFeatures } from '@videojs/core/dom'; +export { LiveVideoSkinElement } from '../define/live-video/skin'; +export { LiveVideoSkinTailwindElement } from '../define/live-video/skin.tailwind'; +export { MinimalLiveVideoSkinElement } from '../define/live-video/minimal-skin'; +export { MinimalLiveVideoSkinTailwindElement } from '../define/live-video/minimal-skin.tailwind'; +``` + +`live-audio` mirrors this. + +### Live skin is live-only + +The live skin renders live controls unconditionally — a live indicator, a "jump to live edge" button, and (where applicable) a DVR-window slider derived from `seekable`. It does not read `streamType`, and it does not attempt to recover to a VOD layout when the source turns out to be on-demand: + +```tsx +// packages/react/src/presets/live-video/skin.tsx +
+ + +
+``` + +Pointing a `live-video` preset at a VOD source produces the live skin on top of a VOD stream — working playback, but with live affordances that don't match the content. That's an intentional degradation, not a supported configuration — the mirror of pointing `video` at a live source. + +### Base skin is VOD-only + +`VideoSkin` / `AudioSkin` never read `streamType` (and can't — the slice isn't in the store). They unconditionally render the on-demand time region. Pointing a `video` preset at a live source produces working playback with a duration of `Infinity` (or `seekable.end(last)` once `timeFeature` resolves it) and the standard scrubber on the seekable window, with no live-edge affordance. That's an intentional degradation, not a supported configuration — authors who expect live sources use a live preset. + +## Alternatives Considered + +- **Live skin branches on `streamType` to cover VOD too.** Ship `liveVideoFeatures = [...videoFeatures, streamTypeFeature]` and render live vs. on-demand time controls inside the live skin based on detection, with a neutral placeholder while `streamType === 'unknown'`. Supports runtime source switches between live and VOD within one preset. +- **Branch the existing `video` / `audio` skins on `streamType`.** One preset per medium, one skin that covers both modes by reading `streamType`. Pattern already used for `volumeAvailability === 'unsupported'` (see `VolumePopover` in `packages/react/src/presets/video/skin.tsx:60`). +- **Single preset, runtime-selected skin.** Keep one `video` / `audio` preset but have it mount a different skin tree based on `streamType`. Hides the split from the author but retains the unknown-state and dead-code problems. + +## Rationale: Split live from VOD + +The dominant case is VOD — the vast majority of `video` / `audio` integrations never play live. Every byte the VOD preset carries for a capability it doesn't use is a byte the dominant case pays for something it doesn't get. + +A shared `video` preset covering both modes forces every VOD player to ship: + +- The `streamType` state slice and the events, subscribers, and predicate helpers that maintain it. +- The live UI tree — live indicator, jump-to-live-edge button, DVR slider — rendered conditionally but present in the bundle. +- The branching logic itself (detection, unknown-state handling, transitions). + +A separate `live-video` preset keeps that cost with the apps that actually use live. The VOD store has no `streamType` slice; `VideoSkin` has no live branches; nothing in the default import path references live code. The bundle-size report already separates `/video/skin` from a future `/live-video/skin` entry — the split is visible and measurable. + +Secondary wins: + +- **Explicit opt-in matches author intent.** Picking `live-video` is a one-word signal that live is the supported path — clearer than "use `video` and set some prop" and more discoverable than "`video` happens to work if the source is live." +- **Smaller type surface in the common store.** `VideoPlayerStore` / `AudioPlayerStore` keep their current state shape. Adding `streamType` to the base ripples into every consumer of those types. +- **Targetable visual regression tests.** Each skin snapshots independently; the VOD snapshot stays stable without a `streamType` harness. + +## Rationale: Live preset renders live UI only + +Given we're shipping a dedicated live preset, it could still support VOD internally — branch the live skin on `streamType` and fall back to VOD controls when the source isn't live. We're not doing that either. + +1. **Smaller live bundle.** Live-only (especially non-DVR) unlocks a meaningfully smaller UI: no time slider, no thumbnail previews, no seek buttons, no remaining-time display. A dual-mode live skin has to ship all of those for the VOD branch, plus the branching. Live-only gets to be genuinely smaller, not just differently shaped. + +2. **No unknown-state flash.** A dual-mode live skin starts every load in `streamType === 'unknown'` and resolves to `live` (or `on-demand`) after manifest detection. Either we render a neutral placeholder during that window — a third UI state to design and test — or we pick a default and flip visibly once detection completes. A live-only skin has one shape from first paint. + +3. **Fork-template clarity.** Skins are reference implementations authors copy and mutate. A live-only skin gives live-app authors a starting point with only live concepts in the tree — no conditional helpers, no `unknown` placeholder, no dead VOD branches to delete. VOD authors forking `VideoSkin` get the same treatment from the other side. + +4. **Headroom for divergence.** Live UI tends to accumulate bespoke affordances — latency indicator, "behind live" badge, DVR-aware scrubber interactions, live chat/reactions slot. Each lands naturally inside `LiveVideoSkin` without leaking concepts into the dual-mode branching. + +5. **Easy to add, hard to remove.** If we later find authors commonly need one preset that handles live → VOD replay or cross-mode source swaps, we can add `streamTypeFeature` and branch the skin — additive. Shipping a dual-mode skin and later deciding the unknown-state handling and dead code aren't worth it is a breaking change for anyone on the VOD path. + +### What we give up + +- **Runtime mode switching.** Neither preset adapts if its source's stream type changes. A live preset pointed at a VOD replay shows live UI over a VOD stream; a VOD preset pointed at a live source shows VOD UI over a live stream. Authors needing both modes in one player either wait for a future iteration or compose their own. +- **A story for live replay / DVR archive flows.** Some products want a single embed that starts live and keeps playing after the stream ends, or that plays back a recorded segment inside the same UI as the live stream. Deferred until a concrete product need surfaces. + +### Trade-offs + +| Gain | Cost | +| -------------------------------------------------- | ------------------------------------------------------ | +| VOD-only apps ship no stream-type state or live UI | Source-type switches at runtime aren't covered | +| Live-only apps ship a smaller live-only UI | Live replay / post-stream VOD flows aren't served yet | +| First paint has a single, correct UI shape | Two more React skins + four HTML skin elements to ship | +| Easy to layer dual-mode support on later if needed | Shared skin idioms must be kept in sync across trees | +| Room to add live-only affordances without churn | Authors must know their mode at preset-selection time | + +## Consequences + +- In `@videojs/core/dom`: + - Add `liveVideoFeatures` and `liveAudioFeatures` exports in `packages/core/src/dom/store/features/presets.ts`. Initially they match `videoFeatures` / `audioFeatures` minus `playbackRateFeature`, and without `streamTypeFeature`. + - `streamTypeFeature` stays a standalone export for custom compositions; it's not wired into any preset feature array. + - No change to `VideoFeatures` / `AudioFeatures` or the derived store types. +- In `@videojs/react`: + - Add `packages/react/src/presets/live-video/` with `skin.tsx`, `skin.tailwind.tsx`, `minimal-skin.tsx`, `minimal-skin.tailwind.tsx`. + - Add `packages/react/src/presets/live-audio/` mirroring the above. + - Live skins render live controls unconditionally; they do not import `selectStreamType` and do not branch on stream type. +- In `@videojs/html`: + - Add `packages/html/src/presets/live-video.ts` and `packages/html/src/presets/live-audio.ts`, plus the corresponding `define/live-video/*` and `define/live-audio/*` custom element wrappers. +- Site API reference pages for the new presets; update the presets overview to list live variants and clarify that the base presets are VOD-only and the live presets are live-only. +- `selectStreamType` remains a core export; its existing contract (return `undefined` when the feature isn't configured) covers both presets naturally — neither preset configures `streamTypeFeature` today, so callers get `undefined`. + +## Open Questions + +1. **When to add `streamTypeFeature` to the live feature arrays.** What signal tells us we're paying for live-only's minimalism — authors writing the same "is this VOD?" guard themselves, Mux-specific mis-configurations we want to surface as an error, or something else? Worth revisiting after the first few live integrations. +2. **Further trimming the live feature array.** `playbackRateFeature` is dropped on day one. The clearest next candidate is splitting `textTrackFeature` — it bundles captions (needed for live) with chapters and thumbnail cues (VOD-only). A `captionsFeature` / `chaptersFeature` / `thumbnailsFeature` split lets live take captions only and drop the cue iteration and `` DOM-walking that exists to populate the others. Decide once the first live skins are in-tree and we can measure actual subscribers. +3. **Live replay / post-stream transitions.** Some live flows end with a VOD archive of the stream. Whether that's served by a future dual-mode live preset, by swapping presets at transition time, or by a third preset entirely is explicitly deferred. + +## Prior Art + +- **Media Chrome** — No distinct live layout preset; authors compose `` alongside or instead of `` / `` in their own control bar. Effectively forces every integrator to solve this composition problem themselves. +- **Vidstack** — Ships a `LiveIndicator` / `LiveButton` primitive and expects the author-provided layout to swap them in for the time display. Similar to Media Chrome — no separate live layout preset. +- **Video.js v8** — Adds a `vjs-live` body class and hides `.vjs-remaining-time` / `.vjs-duration` via CSS when the stream is live; shows `` instead. Single skin, CSS-driven branching — one preset serving both modes. +- **Plyr** — Hides the progress/time controls and shows a "Live" badge when duration is not finite. Single skin, JS-driven branching. + +Our choice is the least common in the ecosystem but aligns with the fork-template philosophy of this project: presets are full reference implementations authors copy and mutate, and the split reflects a real product distinction (VOD-only vs. live-only) rather than a runtime-only toggle. We're also biased toward starting narrower than the alternatives — a live skin that covers live only is an easy thing to extend if demand proves us wrong, and a hard thing to retract once shipped.