Files
v10/rfc/player-api/decisions.md
T

6.7 KiB

Design Decisions

Rationale behind Player API design choices.

Problem

Original direction used use-case presets as the primary abstraction:

<website-video-player>
  <website-frosted-video-skin>
    <hls-video src="...">
  </website-frosted-video-skin>
</website-video-player>

Issues

  1. Verbose for common case — most people just want "a video player"
  2. Opaque naming — "website" means nothing without context
  3. Mental model mismatch — Devs think "I need a video player... then adaptive... then ads", not "I need a news player"
  4. Preset/skin/media relationship unclear — If you use website preset but <hls-video>, does the skin show quality controls?
  5. Use-cases are just feature combos — News = default + ads. Why make it a separate concept?
  6. Feature/skin pairing unclear — Does streaming-video-skin strictly require streaming features?
  7. Default was hidden — "website" was default but not obvious
  8. Configuration vs composition — Presets are "pick one". Features are "add what you need". Composition scales better.
  9. Presets hid what's inside — "website" bundles player + features, not transparent
  10. Not self-documenting — User doesn't see what's included without checking docs
  11. Presets might make sense at a higher layer — e.g., Mux could bundle features as presets for their customers

Core Insight

Presets aren't the primary abstraction. Features are.

Player is simple. Features are additive. Skins adapt.

Features over Presets

Decision: Features are the primary abstraction, not presets.

Features match the mental model: start simple, add what you need.

Selectors (No Proxy Tracking)

Decision: Use explicit selectors for state subscriptions.

// Selector-based
const paused = usePlayer(features.playback, s => s.paused);

Rationale:

  • Explicit — You control what you subscribe to
  • Predictable — No magic tracking, clear performance characteristics
  • Standard — Matches Zustand, Redux Toolkit patterns

Trade-off: More ceremony than proxy tracking, but no surprises.

shallowEqual for Object Selectors

Decision: Object selector results compared with shallowEqual.

const state = usePlayer(s => ({ paused: s.paused, volume: s.volume }));
// Re-renders only when paused OR volume changes

Rationale:

  • Selectors often return objects for convenience
  • Without shallow comparison, new object = new reference = re-render
  • shallowEqual exported from @videojs/store for custom use

Feature Keys

Decision: Features can have custom symbol keys with type-carrying.

const PLAYBACK_KEY = Symbol.for('@videojs/playback');
export const playbackKey: FeatureKey<typeof playbackFeature> = PLAYBACK_KEY;

Rationale:

  • Smaller imports — Import just the key, not the feature definition
  • Cross-realmSymbol.for() works across module boundaries
  • Type inferenceFeatureKey<F> carries feature type for store.get()

usePlayer Overloads

Decision: Single hook with multiple overloads.

usePlayer(feature)           // Full feature slice
usePlayer(feature, selector) // Selected value from feature
usePlayer(selector)          // Selected value from all state

Rationale:

  • Single hook — Less API surface to learn
  • Progressive — Start simple, add selector for performance
  • Consistent — Same pattern for feature access and selection

Why no array support? Call usePlayer multiple times. Simpler, explicit.

const playback = usePlayer(features.playback);
const volume = usePlayer(features.volume);

Feature Returns Undefined, Not Throws

Decision: usePlayer(feature) returns Slice | undefined, doesn't throw.

Rationale:

  • Primitives need graceful handling — they don't know user's feature config
  • Throwing would break apps when features are misconfigured
  • Caller decides how to handle: return null, show fallback, throw themselves

store.get / store.has

Decision: Use store.get() and store.has() for feature access in feature context.

subscribe: ({ store }) => {
  const playback = store.get(features.playback);
  if (store.has('time')) { /* ... */ }
}

Rationale:

  • Map-like — Familiar pattern, .get() returns T | undefined
  • Unified — Same API for feature reference, key, or name
  • Cross-store — Abstracts which store a feature lives on

No Store Merge

Decision: Keep two stores internally (media + player).

Rationale:

  • Different targets (media element vs container)
  • Different attachment timing
  • store.get() abstracts cross-store access
  • Feature authors use same API regardless of store

Trade-off: Internal complexity, but hidden from users.

Naming

createPlayer (not createPlayerStore)

  • Users want a player, not a store
  • "Store" is implementation detail
  • Matches ecosystem: createContext, createRoot, createBrowserRouter

features (not slices)

  • "Slice" implies Redux mental model (store owns state, reducers modify)
  • Our pattern: target owns state, features observe and request
  • "Feature" matches user mental model: "I want the fullscreen feature"

MediaElement (not VjsElement)

  • Clearer purpose — base class for media UI primitives
  • No vjs- prefix in new naming convention

Element Naming (HTML)

Decision: No vjs- prefix. Pattern-based naming.

Layer Pattern Examples
Player <{mediatype}-player> <video-player>, <audio-player>
Skin <{mediatype}-skin> <video-skin>
Media <{source}-{mediatype}> <hls-video>, <dash-video>
UI <media-{component}> <media-play-button>

Rationale:

  • Self-documenting — element name tells you what it is
  • Consistent — every name ends in its object type
  • No vendor prefix — cleaner, less typing

Adaptive Skins

Decision: Default skin adapts to available features.

<video-skin>  <!-- shows quality menu only if streaming feature loaded -->

Rationale:

  • DX — import features, skin "just works"
  • Progressive — add features, UI adapts
  • Named skins for specific variants (minimal, cinematic)

Feature Bundles Are Sugar

Decision: Bundles like features.video are convenience, not required.

// These are equivalent:
features.video
[features.playback, features.volume, features.time, features.presentation, features.userActivity, /* ... */]

Rationale:

  • Bundles reduce API surface — fewer imports for common cases
  • Granular still available — full control when needed
  • Upgrade path — add to bundle, users get it automatically