From 73cb4a7f0ed78d81ba32f51f9442beb08a4bc983 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 21 Apr 2026 16:28:40 -0700 Subject: [PATCH 1/3] docs(design): add design doc for live presets Made-with: Cursor --- internal/design/ui/live-presets.md | 184 +++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 internal/design/ui/live-presets.md diff --git a/internal/design/ui/live-presets.md b/internal/design/ui/live-presets.md new file mode 100644 index 000000000..21ce870e6 --- /dev/null +++ b/internal/design/ui/live-presets.md @@ -0,0 +1,184 @@ +--- +status: decided +date: 2026-04-21 +--- + +# Live Presets + +Dedicated `live-video` and `live-audio` presets for live-capable playback, alongside the existing `video` and `audio` presets for on-demand-only playback. + +## Decision + +Ship live playback as **separate presets** — `live-video` and `live-audio` — each pairing a live-capable feature set with a skin that adapts to both live and on-demand sources. We do **not** add live-capable state or UI to the existing `video` / `audio` presets. + +Two things diverge from the base presets: + +1. **Feature arrays include `streamTypeFeature`.** `liveVideoFeatures` / `liveAudioFeatures` are supersets of `videoFeatures` / `audioFeatures` with the stream-type slice appended. Base presets omit it entirely. +2. **Skins branch on `streamType`.** Live skins handle both live and on-demand sources within a single component, switching the time region when `streamType === 'live'`. Base skins assume on-demand and never read `streamType`. + +Runtime source switching between live and VOD is a supported flow of the live presets. + +## Context + +`streamTypeFeature` exposes `streamType: 'on-demand' | 'live' | 'unknown'` in the player store. 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.) + +Not every app needs this. Most VOD-only integrations don't want to pay for stream-type state or live UI code. And because the live skin needs to handle both modes anyway — a live-capable player may be pointed at a VOD replay, or a source may switch between the two — "live-aware" and "VOD-only" are two meaningfully different products. This doc records the split. + +## Shape + +### Feature arrays + +Base presets stay as today. Live presets are supersets with `streamTypeFeature` appended: + +```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: LiveVideoFeatures = [...videoFeatures, streamTypeFeature]; + +export const audioFeatures: AudioFeatures = [ + playbackFeature, + playbackRateFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + errorFeature, +]; + +export const liveAudioFeatures: LiveAudioFeatures = [...audioFeatures, streamTypeFeature]; +``` + +Types diverge to match. `LiveVideoFeatures` / `LiveAudioFeatures` extend their base arrays with the stream-type slice: + +```ts +export type LiveVideoFeatures = [...VideoFeatures, PlayerFeature]; +export type LiveAudioFeatures = [...AudioFeatures, PlayerFeature]; +``` + +### 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 adapts to both modes + +A live skin is a VOD skin plus a conditional time region: + +```tsx +const streamType = usePlayer(selectStreamType); + +// ... + +{streamType === 'live' ? ( + +) : ( + +)} +``` + +While `streamType === 'unknown'` the skin shows a neutral placeholder (no duration, no live badge) until detection resolves. This avoids the VOD → live snap that would happen if we optimistically rendered one mode and flipped after manifest load. + +### 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 + +- **Branch the existing `video` / `audio` skins on `streamType`** — Add `streamTypeFeature` to the base feature arrays and swap `.media-time-controls` inside the single skin when `streamType === 'live'`. Pattern already used for `volumeAvailability === 'unsupported'` (see `VolumePopover` in `packages/react/src/presets/video/skin.tsx:60`). + +## Rationale + +Live-as-separate-preset wins on four axes: + +1. **VOD players don't pay for live.** The dominant case is on-demand. Keeping `streamTypeFeature` out of `videoFeatures` / `audioFeatures` means the VOD store has no `streamType` slice, the VOD skin has no live-branch code, and bundles for VOD-only apps omit both. A branching approach forces every player to carry the stream-type event plumbing and the live-UI tree, even when the source is known to be VOD. + +2. **Explicit opt-in matches author intent.** Authors typically know at embed time whether their product plays live content (event pages, channel pages, DVR UIs). Picking `live-video` is a one-word signal that live is a supported path — clearer than "use `video` and set some prop" and more discoverable than "`video` happens to work if the source is live." It also localises the "does this handle live?" question to preset choice, not to skin internals. + +3. **Fork-template clarity.** Skins are designed to be copied and modified. A live-dedicated skin gives live-app authors a starting point that already wires up `streamType`, live-edge controls, and the VOD fallback — no conditional helpers to grow, no dead branches to delete. A VOD-app author forking `VideoSkin` gets a tree with zero live concepts to strip out. + +4. **Headroom for divergence.** Live UI tends to accumulate bespoke affordances — latency indicator, "behind live" badge, DVR-aware scrubber interactions, live chat/reactions slot, low-latency toggle. Each of those lands naturally inside `LiveVideoSkin` without leaking concepts into `VideoSkin` or swelling the shared store's state shape. + +Secondary wins: + +- **Targetable visual regression tests.** `apps/e2e/tests/visual/video-skin.spec.ts` already snapshots per skin; live gets its own snapshot, and the VOD snapshot stays stable without a `streamType` harness. +- **Smaller type surface in the common store.** `VideoPlayerStore` / `AudioPlayerStore` keep the same state shape they have today. Adding `streamType` to the base would ripple into every consumer of those types. + +### What we give up vs. branching + +- **The live skin still has to handle `unknown → live` internally.** The flicker problem doesn't disappear just because live is a separate preset; it just moves inside the live skin, which we address with a neutral placeholder during `unknown`. +- **Runtime mode switching is live-only.** A base-preset player can't flip into live UI if its source changes. Authors who need that pick a live preset up front. +- **Live skins are a superset to maintain.** Most of the file is the same JSX; only the time region branches. + +### Trade-offs + +| Gain | Cost | +| ---------------------------------------------------- | ------------------------------------------------------------- | +| VOD-only apps ship no stream-type state or live UI | Live skins carry both branches and a neutral `unknown` state | +| Live capability is an explicit, single author choice | Two more React skins + four HTML skin elements to maintain | +| Base store shape stays minimal and stable | Shared skin idioms must be kept in sync across trees | +| Room to add live-only affordances without churn | Base presets can't recover if a source unexpectedly goes live | + +## Consequences + +- In `@videojs/core/dom`: + - Add `liveVideoFeatures` and `liveAudioFeatures` exports in `packages/core/src/dom/store/features/presets.ts`, each derived from the base array plus `streamTypeFeature`. + - Update the type aliases in `packages/core/src/dom/media/types.ts` so `LiveVideoFeatures` / `LiveAudioFeatures` reflect the appended slice. + - Remove `streamTypeFeature` from any base-array usages (it stays a standalone export for custom compositions). +- 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. + - Each live skin reads `selectStreamType` and branches the time region between live-edge and on-demand controls; `unknown` renders a neutral placeholder. +- 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. +- `selectStreamType` remains a core export; its existing contract (return `undefined` when the feature isn't configured) covers the base-preset case naturally — base-preset consumers that call it get `undefined` and render as VOD. + +## Open Questions + +1. **Neutral `unknown` rendering.** Exact shape of the placeholder during `streamType === 'unknown'` — whether it matches the live layout's footprint, the VOD layout's footprint, or a minimal third shape that avoids any layout shift when it resolves. + +## 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-capable) rather than a runtime-only toggle. From 825428f9668c243cf499a3e8117cc2f2f4864421 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 21 Apr 2026 21:37:44 -0700 Subject: [PATCH 2/3] docs(design): scope live presets to live-only playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrow the live preset design to live-only skins that render live UI unconditionally — no streamType branching, no in-skin VOD fallback, no unknown-state flash on first paint. Drop streamTypeFeature from liveVideoFeatures / liveAudioFeatures for now; add it back additively when a live-only affordance actually needs it. Made-with: Cursor --- internal/design/ui/live-presets.md | 106 ++++++++++++++--------------- 1 file changed, 52 insertions(+), 54 deletions(-) diff --git a/internal/design/ui/live-presets.md b/internal/design/ui/live-presets.md index 21ce870e6..65242375e 100644 --- a/internal/design/ui/live-presets.md +++ b/internal/design/ui/live-presets.md @@ -5,18 +5,16 @@ date: 2026-04-21 # Live Presets -Dedicated `live-video` and `live-audio` presets for live-capable playback, alongside the existing `video` and `audio` presets for on-demand-only playback. +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 presets** — `live-video` and `live-audio` — each pairing a live-capable feature set with a skin that adapts to both live and on-demand sources. We do **not** add live-capable state or UI to the existing `video` / `audio` presets. +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**: -Two things diverge from the base presets: +- 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. -1. **Feature arrays include `streamTypeFeature`.** `liveVideoFeatures` / `liveAudioFeatures` are supersets of `videoFeatures` / `audioFeatures` with the stream-type slice appended. Base presets omit it entirely. -2. **Skins branch on `streamType`.** Live skins handle both live and on-demand sources within a single component, switching the time region when `streamType === 'live'`. Base skins assume on-demand and never read `streamType`. - -Runtime source switching between live and VOD is a supported flow of the live presets. +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 @@ -27,13 +25,15 @@ Runtime source switching between live and VOD is a supported flow of the live pr - 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.) -Not every app needs this. Most VOD-only integrations don't want to pay for stream-type state or live UI code. And because the live skin needs to handle both modes anyway — a live-capable player may be pointed at a VOD replay, or a source may switch between the two — "live-aware" and "VOD-only" are two meaningfully different products. This doc records the split. +Not every app needs this. Most VOD-only integrations don't want to pay for live UI code. And apps that do play live content almost always know that at embed time — an event page, a channel page, a DVR UI. "Live-aware" and "VOD-only" are two meaningfully different products, and authors pick one at integration time. + +This doc records both the split (live vs. VOD as separate presets) and the narrower commitment inside the live presets (live-only, no in-skin VOD fallback). ## Shape ### Feature arrays -Base presets stay as today. Live presets are supersets with `streamTypeFeature` appended: +Base presets stay as today. Live presets start identical in capability and will diverge as live-only needs emerge: ```ts // packages/core/src/dom/store/features/presets.ts @@ -52,7 +52,7 @@ export const videoFeatures: VideoFeatures = [ errorFeature, ]; -export const liveVideoFeatures: LiveVideoFeatures = [...videoFeatures, streamTypeFeature]; +export const liveVideoFeatures: VideoFeatures = videoFeatures; export const audioFeatures: AudioFeatures = [ playbackFeature, @@ -64,15 +64,10 @@ export const audioFeatures: AudioFeatures = [ errorFeature, ]; -export const liveAudioFeatures: LiveAudioFeatures = [...audioFeatures, streamTypeFeature]; +export const liveAudioFeatures: AudioFeatures = audioFeatures; ``` -Types diverge to match. `LiveVideoFeatures` / `LiveAudioFeatures` extend their base arrays with the stream-type slice: - -```ts -export type LiveVideoFeatures = [...VideoFeatures, PlayerFeature]; -export type LiveAudioFeatures = [...AudioFeatures, PlayerFeature]; -``` +`streamTypeFeature` is intentionally **not** included yet. 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`. ### Preset packages @@ -97,23 +92,19 @@ export { MinimalLiveVideoSkinTailwindElement } from '../define/live-video/minima `live-audio` mirrors this. -### Live skin adapts to both modes +### Live skin is live-only -A live skin is a VOD skin plus a conditional time region: +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 -const streamType = usePlayer(selectStreamType); - -// ... - -{streamType === 'live' ? ( - -) : ( - -)} +// packages/react/src/presets/live-video/skin.tsx +
+ + +
``` -While `streamType === 'unknown'` the skin shows a neutral placeholder (no duration, no live badge) until detection resolves. This avoids the VOD → live snap that would happen if we optimistically rendered one mode and flipped after manifest load. +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 @@ -121,58 +112,65 @@ While `streamType === 'unknown'` the skin shows a neutral placeholder (no durati ## Alternatives Considered -- **Branch the existing `video` / `audio` skins on `streamType`** — Add `streamTypeFeature` to the base feature arrays and swap `.media-time-controls` inside the single skin when `streamType === 'live'`. Pattern already used for `volumeAvailability === 'unsupported'` (see `VolumePopover` in `packages/react/src/presets/video/skin.tsx:60`). +- **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 -Live-as-separate-preset wins on four axes: +Live-only presets win on four axes: + +1. **VOD players don't pay for live.** The dominant case is on-demand. Keeping `streamTypeFeature` and the live UI tree out of `videoFeatures` / `audioFeatures` means the VOD store has no `streamType` slice, the VOD skin has no live-branch code, and bundles for VOD-only apps omit both. A shared-skin approach forces every player to carry the stream-type event plumbing and the live-UI tree, even when the source is known to be VOD. + +2. **Explicit opt-in matches author intent.** Authors typically know at embed time whether their product plays live or VOD content. 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." It localises the "what kind of player is this?" question to preset choice, not to skin internals or runtime state. -1. **VOD players don't pay for live.** The dominant case is on-demand. Keeping `streamTypeFeature` out of `videoFeatures` / `audioFeatures` means the VOD store has no `streamType` slice, the VOD skin has no live-branch code, and bundles for VOD-only apps omit both. A branching approach forces every player to carry the stream-type event plumbing and the live-UI tree, even when the source is known to be VOD. +3. **No unknown-state flash.** If the live skin branched on `streamType`, every load would start in `unknown` and resolve to `live` (or `on-demand`) after manifest detection. Either we render a neutral placeholder during that window — introducing 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. -2. **Explicit opt-in matches author intent.** Authors typically know at embed time whether their product plays live content (event pages, channel pages, DVR UIs). Picking `live-video` is a one-word signal that live is a supported path — clearer than "use `video` and set some prop" and more discoverable than "`video` happens to work if the source is live." It also localises the "does this handle live?" question to preset choice, not to skin internals. +4. **Fork-template clarity.** Skins are designed to be copied and modified. A live-dedicated, live-only skin gives live-app authors a starting point with only live concepts in the tree — no conditional helpers to grow, no `unknown` placeholder to design around, no dead VOD branches to delete. A VOD-app author forking `VideoSkin` gets the same treatment from the other side. -3. **Fork-template clarity.** Skins are designed to be copied and modified. A live-dedicated skin gives live-app authors a starting point that already wires up `streamType`, live-edge controls, and the VOD fallback — no conditional helpers to grow, no dead branches to delete. A VOD-app author forking `VideoSkin` gets a tree with zero live concepts to strip out. +5. **Headroom for divergence.** Live UI tends to accumulate bespoke affordances — latency indicator, "behind live" badge, DVR-aware scrubber interactions, live chat/reactions slot, low-latency toggle. Each of those lands naturally inside `LiveVideoSkin` without leaking concepts into `VideoSkin` or swelling the shared store's state shape. -4. **Headroom for divergence.** Live UI tends to accumulate bespoke affordances — latency indicator, "behind live" badge, DVR-aware scrubber interactions, live chat/reactions slot, low-latency toggle. Each of those lands naturally inside `LiveVideoSkin` without leaking concepts into `VideoSkin` or swelling the shared store's state shape. +6. **Easy to add, hard to remove.** If we ship live-only now and later find authors commonly need one preset that gracefully handles `live` → VOD replay or source swaps across modes, we can add `streamTypeFeature` to the live feature array and branch the skin — an additive change. Going the other way (shipping a dual-mode live skin, then deciding the unknown-state handling and dead code aren't worth it) is a visible breaking change for anyone who was relying on the VOD path. Secondary wins: - **Targetable visual regression tests.** `apps/e2e/tests/visual/video-skin.spec.ts` already snapshots per skin; live gets its own snapshot, and the VOD snapshot stays stable without a `streamType` harness. -- **Smaller type surface in the common store.** `VideoPlayerStore` / `AudioPlayerStore` keep the same state shape they have today. Adding `streamType` to the base would ripple into every consumer of those types. +- **Smaller type surface in the common store.** `VideoPlayerStore` / `AudioPlayerStore` keep the same state shape they have today. Adding `streamType` to the base (or to the live preset before we need it) would ripple into every consumer of those types. -### What we give up vs. branching +### What we give up -- **The live skin still has to handle `unknown → live` internally.** The flicker problem doesn't disappear just because live is a separate preset; it just moves inside the live skin, which we address with a neutral placeholder during `unknown`. -- **Runtime mode switching is live-only.** A base-preset player can't flip into live UI if its source changes. Authors who need that pick a live preset up front. -- **Live skins are a superset to maintain.** Most of the file is the same JSX; only the time region branches. +- **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 that need to handle both modes in one player either wait for a future iteration of the live preset 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. That's explicitly deferred; we'll revisit when a concrete product need surfaces. ### Trade-offs -| Gain | Cost | -| ---------------------------------------------------- | ------------------------------------------------------------- | -| VOD-only apps ship no stream-type state or live UI | Live skins carry both branches and a neutral `unknown` state | -| Live capability is an explicit, single author choice | Two more React skins + four HTML skin elements to maintain | -| Base store shape stays minimal and stable | Shared skin idioms must be kept in sync across trees | -| Room to add live-only affordances without churn | Base presets can't recover if a source unexpectedly goes live | +| 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 no VOD branch or unknown state | 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`, each derived from the base array plus `streamTypeFeature`. - - Update the type aliases in `packages/core/src/dom/media/types.ts` so `LiveVideoFeatures` / `LiveAudioFeatures` reflect the appended slice. - - Remove `streamTypeFeature` from any base-array usages (it stays a standalone export for custom compositions). + - Add `liveVideoFeatures` and `liveAudioFeatures` exports in `packages/core/src/dom/store/features/presets.ts`. Initially they alias `videoFeatures` / `audioFeatures` respectively — no `streamTypeFeature` yet. + - `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. - - Each live skin reads `selectStreamType` and branches the time region between live-edge and on-demand controls; `unknown` renders a neutral placeholder. + - 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. -- `selectStreamType` remains a core export; its existing contract (return `undefined` when the feature isn't configured) covers the base-preset case naturally — base-preset consumers that call it get `undefined` and render as VOD. +- 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. **Neutral `unknown` rendering.** Exact shape of the placeholder during `streamType === 'unknown'` — whether it matches the live layout's footprint, the VOD layout's footprint, or a minimal third shape that avoids any layout shift when it resolves. +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. **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 @@ -181,4 +179,4 @@ Secondary wins: - **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-capable) rather than a runtime-only toggle. +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. From 463a462ad4bd2f41e030975ac8739e3bc5bd22d9 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Wed, 22 Apr 2026 10:50:44 -0700 Subject: [PATCH 3/3] docs(design): incorporate live presets review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the rationale into two sections — one for the preset split, one for the live-only skin — and lead each with its actual driver. - Split rationale: filesize. VOD is the dominant case; every byte the shared preset carries for live is paid by VOD-only apps that don't use it. Enumerates what a shared preset forces onto the VOD bundle (streamType slice, live UI tree, branching logic). - Live-only rationale: smaller live bundle — no time slider, thumbnail previews, seek buttons, or remaining-time display. A dual-mode skin can't ship smaller, only differently shaped. - Drop playbackRateFeature from liveVideoFeatures / liveAudioFeatures; speed controls aren't meaningful for live. - Add a note that no live-specific timeFeature is needed — duration already resolves to seekable.end(last) for live. - Flag textTrackFeature as the clearest follow-up feature split (captions vs chapters vs thumbnails) in Open Question #2. - Tighten Context and cut AI-verbosity throughout. Addresses review feedback from @heff on #1395. Made-with: Cursor --- internal/design/ui/live-presets.md | 98 +++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/internal/design/ui/live-presets.md b/internal/design/ui/live-presets.md index 65242375e..a5aaa2a60 100644 --- a/internal/design/ui/live-presets.md +++ b/internal/design/ui/live-presets.md @@ -18,22 +18,27 @@ Two presets, two jobs: base presets play VOD, live presets play live. A preset i ## Context -`streamTypeFeature` exposes `streamType: 'on-demand' | 'live' | 'unknown'` in the player store. Live playback needs visibly different UI from VOD: +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.) -Not every app needs this. Most VOD-only integrations don't want to pay for live UI code. And apps that do play live content almost always know that at embed time — an event page, a channel page, a DVR UI. "Live-aware" and "VOD-only" are two meaningfully different products, and authors pick one at integration time. +`streamTypeFeature` exposes `streamType: 'on-demand' | 'live' | 'unknown'` and could drive this at runtime inside one shared skin. We're choosing not to. -This doc records both the split (live vs. VOD as separate presets) and the narrower commitment inside the live presets (live-only, no in-skin VOD fallback). +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 identical in capability and will diverge as live-only needs emerge: +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 @@ -52,7 +57,19 @@ export const videoFeatures: VideoFeatures = [ errorFeature, ]; -export const liveVideoFeatures: VideoFeatures = videoFeatures; +export const liveVideoFeatures: VideoFeatures = [ + playbackFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + fullscreenFeature, + pipFeature, + remotePlaybackFeature, + controlsFeature, + textTrackFeature, + errorFeature, +]; export const audioFeatures: AudioFeatures = [ playbackFeature, @@ -64,10 +81,21 @@ export const audioFeatures: AudioFeatures = [ errorFeature, ]; -export const liveAudioFeatures: AudioFeatures = audioFeatures; +export const liveAudioFeatures: AudioFeatures = [ + playbackFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + errorFeature, +]; ``` -`streamTypeFeature` is intentionally **not** included yet. 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`. +`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 @@ -116,46 +144,57 @@ Pointing a `live-video` preset at a VOD source produces the live skin on top of - **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 +## 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. -Live-only presets win on four axes: +A shared `video` preset covering both modes forces every VOD player to ship: -1. **VOD players don't pay for live.** The dominant case is on-demand. Keeping `streamTypeFeature` and the live UI tree out of `videoFeatures` / `audioFeatures` means the VOD store has no `streamType` slice, the VOD skin has no live-branch code, and bundles for VOD-only apps omit both. A shared-skin approach forces every player to carry the stream-type event plumbing and the live-UI tree, even when the source is known to be VOD. +- 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). -2. **Explicit opt-in matches author intent.** Authors typically know at embed time whether their product plays live or VOD content. 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." It localises the "what kind of player is this?" question to preset choice, not to skin internals or runtime state. +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. -3. **No unknown-state flash.** If the live skin branched on `streamType`, every load would start in `unknown` and resolve to `live` (or `on-demand`) after manifest detection. Either we render a neutral placeholder during that window — introducing 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. +Secondary wins: -4. **Fork-template clarity.** Skins are designed to be copied and modified. A live-dedicated, live-only skin gives live-app authors a starting point with only live concepts in the tree — no conditional helpers to grow, no `unknown` placeholder to design around, no dead VOD branches to delete. A VOD-app author forking `VideoSkin` gets the same treatment from the other side. +- **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. -5. **Headroom for divergence.** Live UI tends to accumulate bespoke affordances — latency indicator, "behind live" badge, DVR-aware scrubber interactions, live chat/reactions slot, low-latency toggle. Each of those lands naturally inside `LiveVideoSkin` without leaking concepts into `VideoSkin` or swelling the shared store's state shape. +## Rationale: Live preset renders live UI only -6. **Easy to add, hard to remove.** If we ship live-only now and later find authors commonly need one preset that gracefully handles `live` → VOD replay or source swaps across modes, we can add `streamTypeFeature` to the live feature array and branch the skin — an additive change. Going the other way (shipping a dual-mode live skin, then deciding the unknown-state handling and dead code aren't worth it) is a visible breaking change for anyone who was relying on the VOD path. +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. -Secondary wins: +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. -- **Targetable visual regression tests.** `apps/e2e/tests/visual/video-skin.spec.ts` already snapshots per skin; live gets its own snapshot, and the VOD snapshot stays stable without a `streamType` harness. -- **Smaller type surface in the common store.** `VideoPlayerStore` / `AudioPlayerStore` keep the same state shape they have today. Adding `streamType` to the base (or to the live preset before we need it) would ripple into every consumer of those types. +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 that need to handle both modes in one player either wait for a future iteration of the live preset 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. That's explicitly deferred; we'll revisit when a concrete product need surfaces. +- **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 no VOD branch or unknown state | 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 | +| 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 alias `videoFeatures` / `audioFeatures` respectively — no `streamTypeFeature` yet. + - 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`: @@ -170,7 +209,8 @@ Secondary wins: ## 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. **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. +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