8.8 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
- Verbose for common case — most people just want "a video player"
- Opaque naming — "website" means nothing without context
- Mental model mismatch — Devs think "I need a video player... then adaptive... then ads", not "I need a news player"
- Preset/skin/media relationship unclear — If you use
websitepreset but<hls-video>, does the skin show quality controls? - Use-cases are just feature combos — News = default + ads. Why make it a separate concept?
- Feature/skin pairing unclear — Does
streaming-video-skinstrictly require streaming features? - Default was hidden — "website" was default but not obvious
- Configuration vs composition — Presets are "pick one". Features are "add what you need". Composition scales better.
- Presets hid what's inside — "website" bundles player + features, not transparent
- Not self-documenting — User doesn't see what's included without checking docs
- 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.
Single Store with PlayerTarget
Decision: Use a single store with composite PlayerTarget.
interface PlayerTarget {
media: Media; // HTMLMediaElement
container: MediaContainer | null; // Container element (optional)
}
Why Single Store
| Reason | Explanation |
|---|---|
| Simpler mental model | One store, one target, one subscription flow |
| Unified state | All state in one place, selectors work across all slices |
| Easier debugging | Single state tree to inspect |
| Composite target | PlayerTarget gives features access to both media and container |
| Optional container | container: null for headless/audio-only use cases |
Trade-off
Features that only need the media element still receive the full PlayerTarget. This is acceptable because:
- Type narrowing via
target.mediais straightforward - No runtime overhead — just property access
- Consistent API for all features
Selector-Based Access
Decision: Use explicit selectors for state subscriptions.
// Selector-based
const playback = usePlayer(selectPlayback);
const paused = usePlayer((s) => s.paused);
Why Selectors over Feature-Scoped Hooks
| Reason | Explanation |
|---|---|
| Simpler API | One hook pattern, not multiple hook variants |
| Matches ecosystem | Zustand, Redux Toolkit use same pattern |
| Explicit | You control what you subscribe to |
| Composable | Selectors can derive, combine, filter |
| No magic | No proxy tracking, clear performance characteristics |
Trade-off: Requires importing selectors separately from @videojs/core/dom.
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
shallowEqualexported from@videojs/storefor custom use
Selector Returns Undefined, Not Throws
Decision: Selectors return T | undefined, don't throw.
Rationale:
- Primitives need graceful handling — they don't know user's slice config
- Throwing would break apps when slices are misconfigured
- Caller decides how to handle: return null, show fallback, throw themselves
const playback = usePlayer(selectPlayback);
if (!playback) return null; // Graceful handling
createPlayer Factory
Decision: Use createPlayer() factory that returns typed infrastructure.
const { Provider, Container, usePlayer } = createPlayer({
features: [...features.video],
});
Why Factory
| Reason | Explanation |
|---|---|
| Type inference | Return types inferred from features array |
| Scoped context | Each createPlayer call creates isolated context |
| Explicit config | Features declared upfront, not discovered at runtime |
| Tree-shakeable | Unused features not bundled |
Why Not Global Registration
Global registration (like side-effect imports) works for HTML but doesn't fit React's component model. The factory provides:
- Type safety from features to hooks
- Multiple players with different features
- Clear dependency graph
PlayerElement vs Mixins
Decision: Provide both PlayerElement (simple) and mixins (advanced).
// Simple — complete player element
customElements.define('video-player', PlayerElement);
// Advanced — custom behavior
class MyPlayer extends PlayerMixin(MediaElement) {}
Why Both
| Use Case | Solution | When to Use |
|---|---|---|
| Standard player | PlayerElement |
Most cases, just works |
| Custom behavior | PlayerMixin |
Need to extend lifecycle, add logic |
| Split concerns | ProviderMixin + ContainerMixin |
Media and controls in different DOM locations |
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
- "Feature" matches user mental model: "I want the fullscreen feature"
- Slices are the implementation, features are the concept
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
[playbackSlice, volumeSlice, timeSlice, sourceSlice, bufferSlice]
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
Future Work Deferred
Some patterns were considered but deferred:
Feature Keys
const playbackKey: FeatureKey<typeof playbackSlice> = PLAYBACK_KEY;
store.get(playbackKey); // Typed access without importing slice
Deferred: createSelector provides equivalent type-safe access without the complexity of symbol-based keys.
store.get / store.has
store.get(feature); // Direct feature access
store.has(feature); // Check if feature exists
Deferred: Selectors provide the same capability with better composition and TypeScript inference.