docs(plan): player api design (#300)

This commit is contained in:
rahim
2026-01-20 12:11:40 +11:00
committed by GitHub
parent c48c626211
commit fb51163d8c
4 changed files with 1328 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
# Architecture
Internal structure of the Player API.
## Overview
```
createPlayer()
config: presets.website | { features: [...] }
filters by feature.type
┌───────────────┴───────────────┐
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ createStore() │ │ createStore() │
│ type: 'media' │ │ type: 'player' │
└────────────────────────┘ └────────────────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Media Store │◄─────│ Player Store │
│ target: MediaTarget │ │ target: PlayerTarget │
│ │ │ │
│ state: paused, volume │ │ state: isFullscreen │
│ request: play, pause │ │ request: toggleFS │
└────────────────────────┘ └────────────────────────┘
target.media.getFeature()
┌───────────┴───────────┐
▼ ▼
Read media state Call media requests
(iOS fallback) (keyboard shortcuts)
```
**Key insight:** Player Store's target includes a reference to the Media Store. This enables coordination without tight coupling.
## Two Stores
### Why Two Stores
| Reason | Explanation |
| --------------------- | --------------------------------------------------------------------------------------------------- |
| **Different targets** | Media features target `MediaTarget`. Player features target `PlayerTarget`. |
| **Attachment timing** | `<Video>` and `<Container>` mount at different times, possibly different tree locations. |
| **Config dependency** | Player features configure against typed media store. Media store must exist first. |
| **Observability** | Player→media interactions go through store. Enables debugging, tracing, request queuing. |
| **Standalone media** | Headless player, audio-only, programmatic control. Media store works alone. |
| **Type safety** | Player features declare required media capabilities. TypeScript catches mismatches at compile time. |
### Feature Types
Features are discriminated by `type`:
```ts
interface MediaFeature {
type: 'media';
// ...
}
interface PlayerFeature {
type: 'player';
// ...
}
```
`createPlayer` filters features by type, builds both stores, returns unified API.
## Targets
### MediaTarget
```ts
interface MediaTarget {
element: HTMLMediaElement;
}
```
Media features observe and control the `<video>` or `<audio>` element directly.
### PlayerTarget
```ts
interface PlayerTarget {
container: HTMLElement;
media: Store<MediaTarget>;
}
```
Player features can:
- Control the container element (fullscreen, focus)
- Access media store for coordination
## Cross-Store Access
Player features access media via `target.media.getFeature()`.
```ts
const fullscreen = createPlayerFeature({
request: {
enterFullscreen: (_, { target }) => {
// Try container fullscreen
if (document.fullscreenEnabled) {
target.container.requestFullscreen();
return;
}
// iOS fallback — use media fullscreen
const mediaFS = target.media.getFeature(media.fullscreen);
mediaFS?.request.enterFullscreen();
},
},
subscribe: ({ target, update, signal }) => {
// Subscribe to media fullscreen changes (iOS)
target.media.getFeature(media.fullscreen)?.subscribe((s) => s.isFullscreen, update, { signal });
},
});
```
## State Unification
`createPlayer` merges both stores into a unified API:
```
┌─────────────────────────────────────────────────────────┐
│ usePlayer() │
│ │
│ Media State Player State Requests │
│ ─────────── ──────────── ──────── │
│ paused isFullscreen play() │
│ volume isIdle pause() │
│ currentTime ... setVolume() │
│ ... toggleFS() │
│ ... │
└─────────────────────────────────────────────────────────┘
Flattened via Proxy
┌─────────────┴─────────────┐
▼ ▼
Media Store Player Store
```
The proxy:
1. Merges state from both stores
2. Merges requests from both stores
3. Tracks property access for fine-grained subscriptions
## Reactive System
### Proxy-Based Tracking
Based on `SnapshotController` pattern:
```ts
// React
function Controls() {
const player = usePlayer();
// Accessing player.paused:
// 1. Returns current value
// 2. Tracks that this component uses "paused"
// 3. Re-renders when paused changes
return <button>{player.paused ? 'Play' : 'Pause'}</button>;
}
```
```ts
// Lit
class Controls extends VjsElement {
#player = new PlayerController(this);
render() {
// Accessing #player.value.paused:
// 1. Returns tracking proxy
// 2. Tracks "paused" access
// 3. Triggers requestUpdate() when paused changes
const { paused } = this.#player.value;
return html`<button>${paused ? 'Play' : 'Pause'}</button>`;
}
}
```
### Tracking Lifecycle
1. **Access** — Property access during render is tracked
2. **Subscribe** — Tracker subscribes to changes on accessed keys
3. **Update** — On change, trigger re-render
4. **Next** — After render, finalize tracked keys for next cycle
## File Structure
```
packages/html/src/
├── create-player.ts
└── presets/
└── website/
├── index.ts # preset features
└── skins/
└── frosted/
├── index.ts
└── define.ts
packages/react/src/
├── create-player.tsx
└── presets/
└── website/
├── index.ts
└── skins/
└── frosted/
packages/core/src/dom/
├── features/
│ ├── media/ # media features
│ │ ├── playback.ts
│ │ ├── volume.ts
│ │ └── time.ts
│ └── player/ # player features
│ ├── fullscreen.ts
│ ├── keyboard.ts
│ └── idle.ts
└── index.ts
```
## Player Features
### Fullscreen
```ts
export const fullscreen = createPlayerFeature({
initialState: {
isFullscreen: false,
fullscreenTarget: null as 'container' | 'media' | null,
},
getSnapshot: ({ target }) => {
const containerFS = document.fullscreenElement === target.container;
const mediaFS = target.media.getFeature(media.fullscreen)?.state.isFullscreen;
return {
isFullscreen: containerFS || mediaFS || false,
fullscreenTarget: containerFS ? 'container' : mediaFS ? 'media' : null,
};
},
subscribe: ({ target, update, signal }) => {
// Container fullscreen
listen(document, 'fullscreenchange', update, { signal });
// iOS: media fullscreen
target.media.getFeature(media.fullscreen)?.subscribe((s) => s.isFullscreen, update, { signal });
},
request: {
enterFullscreen: (_, { target }) => {
// container.requestFullscreen() || media fallback
},
exitFullscreen: (_, { target }) => {
// document.exitFullscreen() || media fallback
},
toggleFullscreen: (_, { target, state }) => {
// state.isFullscreen ? exit : enter
},
},
});
```
**Notes:**
- iOS Safari lacks container fullscreen — falls back to `media.fullscreen` feature
- `fullscreenTarget` indicates which element is fullscreen
### Other Features
**Idle** — Tracks user activity. Resets on `pointermove`, `pointerdown`, `keydown`. Optionally resets when media plays.
**Keyboard** — Keyboard shortcuts. Maps keys to requests (e.g., `Space``togglePlay`, `f``toggleFullscreen`).
**Gestures** — Touch gestures. Double-tap seek, swipe volume, pinch zoom.
## Progressive Complexity
| Level | Example | Sees internal stores? |
| --------------- | ----------------------------------------------- | --------------------- |
| Use skin | `<FrostedSkin>` | No |
| Use preset | `createPlayer(presets.website)` | No |
| Custom features | `createPlayer([...presets.website, myFeature])` | No |
| Use hooks | `usePlayer()` | No |
| Write feature | `createPlayerFeature({ ... })` | Yes (target.media) |
Internal stores are implementation details until you author features.
## Constraints
- Player features live in `@videojs/core/dom`
- `createPlayer` lives in `@videojs/html` and `@videojs/react`
- Skins are tied to presets — stores don't extend from skins
- Two stores internally, one API externally
+272
View File
@@ -0,0 +1,272 @@
# Design Decisions
Rationale behind Player API design choices.
## Naming
### `createPlayer` / `createMedia` (not `createPlayerStore` / `createMediaStore`)
**Decision:** Use `createPlayer` and `createMedia` — don't expose "store" in the primary API.
**Rationale:**
- Users want a player, not a store
- "Store" is an implementation detail — complexity should grow with use case
- Progressive disclosure: simple concept first, internals when authoring features
- Returns stay clean: `Provider`, `usePlayer` (not `StoreProvider`, `usePlayerStore`)
**"But we're not creating a player?"** — This matches React ecosystem conventions where `create*` means "create the infrastructure for X":
| Factory | Returns |
| ----------------------- | ------------------------------------------------------- |
| `createContext()` | `{ Provider, Consumer }` — not "a context" |
| `createRoot()` | `{ render, unmount }` — not "a root" |
| `createBrowserRouter()` | Config for `<RouterProvider>` — not "a router" |
| `createTRPCReact()` | `{ Provider, hooks }` — not "a tRPC" |
| `createMachine()` | Machine definition — needs actor to run |
| **`createPlayer()`** | `{ Provider, usePlayer }` — infrastructure for a player |
The pattern is established: `create*` returns building blocks, not a usable instance.
### `features` (not `slices`)
**Decision:** Rename from "slices" to "features" everywhere (user-facing AND internal).
**Rationale:**
Our pattern is fundamentally different from Redux Toolkit slices:
| Aspect | Redux "slice" | Our pattern |
| --------------- | ---------------------- | ----------------------------------------- |
| State ownership | Store owns state | Target owns state (HTMLMediaElement) |
| State mutation | Reducers modify state | Requests mutate target, snapshot reflects |
| Mental model | "A slice of the store" | "A feature I'm enabling" |
Our concept is:
- **Observer/Adapter** — binds store to external source of truth
- **Feature module** — bundles related state + requests
- **Binding** — connects reactive state to DOM element
"Feature" maps to user mental models:
- "I want the fullscreen feature"
- "Add the keyboard feature"
- "This preset includes playback, volume, and time features"
Calling it "slice" creates false expectations from Redux users.
### `createPlayerFeature` / `createMediaFeature` (not `createSlice`)
**Decision:** Use explicit factory names: `createPlayerFeature` and `createMediaFeature`.
**Alternatives considered:**
- `createFeature<PlayerTarget>()` — generic with type parameter (rejected: curried call, less clear intent)
- `createFeature` — simpler but ambiguous
**Rationale:**
- Clearer intent — you know what you're creating
- Simpler call signature — no curried type parameter
- Matches `createPlayer` naming pattern
- Explicit is better than implicit
### Simple Provider Naming
**Decision:** Keep `Provider`, not `PlayerProvider`.
**Rationale:**
- Scoped by `createPlayer()` call — context is already clear
- Less ceremony for the common case
- If someone needs multiple providers (rare), they can alias:
```ts
const { Provider: VideoProvider } = createPlayer(...);
```
## API Shape
### Flat `usePlayer` Return
**Decision:** Return flattened object with state and requests at same level, no `.state`/`.request` namespaces.
```tsx
// Before (considered)
const player = usePlayer();
player.state.paused;
player.request.play();
// After (chosen)
const player = usePlayer();
player.paused;
player.play();
```
**Rationale:**
- Less nesting = less typing
- Proxy-based tracking works at property access level
- Naming convention prevents collisions (state = nouns, requests = verbs)
**Trade-off:** Requires runtime duplicate detection. If a feature defines state `foo` and another defines request `foo`, throw at creation time.
### Proxy-Based Tracking (No Selectors)
**Decision:** Use proxy-based automatic tracking instead of selector functions.
```tsx
// Before (selector approach)
const paused = usePlayer((s) => s.paused);
// After (proxy tracking)
const player = usePlayer();
player.paused; // accessing subscribes automatically
```
**Rationale:**
- Simpler API — no selector functions to write
- Automatic fine-grained subscriptions
- Matches modern patterns (Valtio, MobX)
- Works with flattened return (proxy tracks which properties accessed)
**Implementation:** Based on existing `SnapshotController` pattern using `track()`.
### PlayerController Uses `.value`
**Decision:** Access player state/requests via `.value` property (like `SnapshotController`).
```ts
class MyComponent extends VjsElement {
#player = new PlayerController(this);
render() {
const { paused, play } = this.#player.value;
// ...
}
}
```
**Rationale:**
- Consistent with `SnapshotController` API
- `.value` returns tracking proxy
- Property access during render auto-subscribes
- No `watch()` method needed — proxy handles tracking
### Keep Shorthand Config
**Decision:** Support both shorthand and config object forms.
```ts
// Shorthand — common case
createPlayer(presets.website);
createPlayer([features.playback, features.fullscreen]);
// Config object — extensibility
createPlayer({
features: presets.website,
// future: devTools, middleware, etc.
});
```
**Rationale:**
- Shorthand is the 90% case — don't penalize it
- Config object enables future extensibility without API changes
- Progressive disclosure: simple → extensible
**Feedback addressed:** "Doing things too many ways can be rough" — but these aren't competing APIs, they're progressive complexity levels.
## Escape Hatches
### Keep `useMedia`
**Decision:** Keep `useMedia` as escape hatch for direct media access.
**Rationale:**
- Progressive disclosure — 95% of users never need it
- Costs nothing if unused
- Saves advanced users when they need raw media state
- Debugging scenarios: compare player vs media fullscreen state
```tsx
function DebugPanel() {
const player = usePlayer();
const media = useMedia();
// Player may abstract/transform media state
// Sometimes you need the raw value
console.log({ playerFS: player.isFullscreen, mediaFS: media.isFullscreen });
}
```
## Architecture
### Two Stores (Not One)
**Decision:** Maintain two internal stores (Media Store + Player Store).
**Rationale:**
- **Different targets:** Media features target `HTMLMediaElement`. Player features target container element.
- **Different attachment timing:** `<Video>` and `<Container>` mount at different times.
- **Config dependency:** Player features configure against typed media store. Media store must exist first.
- **Observability:** Player→media interactions go through store. Enables debugging, tracing, request queuing.
- **Standalone media:** Headless player, audio-only, programmatic control. Media store works alone.
**Trade-off:** Feature authors navigate two stores, but most extend only player store.
### Container ≠ Provider
**Decision:** Container is purely UI attachment. Provider owns state.
```tsx
<Provider>
{' '}
{/* state lives here (both stores) */}
<Skin>
{' '}
{/* UI only, no store creation */}
<Video /> {/* media */}
</Skin>
</Provider>
```
Container inside skin just attaches to existing store — doesn't provide one.
## Validation
### Runtime Duplicate Key Detection
**Decision:** Throw at `createPlayer` time if state/request keys collide.
```ts
// This should throw
const bad = createPlayerFeature({
initialState: { play: false }, // "play" as state
request: { play: () => {} }, // "play" as request — collision!
});
```
**Rationale:**
- Flat namespace requires disambiguation
- Fail fast at creation, not at runtime access
- Clear error message: "Duplicate key 'play' found in state and requests"
## Open Questions
### "In-between" Functionality
Where does functionality that's not clearly media or UI go?
Examples needed to clarify boundary.
### Feature Author Experience
How many concepts must authors learn? Current: Target types, state, requests, subscribe pattern.
Is this the right level of complexity for the extension story?
+465
View File
@@ -0,0 +1,465 @@
# Examples
Usage examples for React, HTML, and Lit.
## React
### Declarative (Skin)
```tsx
import { createPlayer, presets, Video } from '@videojs/react';
import { FrostedSkin } from '@videojs/react/presets/website';
const { Provider } = createPlayer(presets.website);
function App() {
return (
<Provider>
<FrostedSkin>
<Video src="video.mp4" />
</FrostedSkin>
</Provider>
);
}
```
### Custom Player (Preset)
```tsx
import { createPlayer, presets, Video } from '@videojs/react';
const { Provider, Container, usePlayer } = createPlayer(presets.website);
function App() {
return (
<Provider>
<Container>
<Video src="video.mp4" />
<Controls />
</Container>
</Provider>
);
}
function Controls() {
const player = usePlayer();
return (
<div className="controls">
<button onClick={player.paused ? player.play : player.pause}>{player.paused ? 'Play' : 'Pause'}</button>
<button onClick={player.toggleFullscreen}>{player.isFullscreen ? 'Exit' : 'Fullscreen'}</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={player.volume}
onChange={(e) => player.setVolume(Number(e.target.value))}
/>
</div>
);
}
```
### Extended Preset
```tsx
import { createPlayer, createPlayerFeature, features, presets, Video } from '@videojs/react';
// Custom analytics feature
const analytics = createPlayerFeature({
initialState: { events: [] as string[] },
getSnapshot: ({ initialState }) => initialState,
subscribe: ({ target, update }) => {
target.media.subscribe(
(s) => s.paused,
(paused) => {
console.log(paused ? 'paused' : 'playing');
update();
}
);
},
request: {
trackEvent: (name: string) => {
console.log('Track:', name);
},
},
});
const { Provider, Container, usePlayer } = createPlayer({
features: [...presets.website, analytics],
});
function App() {
return (
<Provider>
<Container>
<Video src="video.mp4" />
<Controls />
</Container>
</Provider>
);
}
function Controls() {
const player = usePlayer();
const handlePlay = () => {
player.play();
player.trackEvent('play_clicked');
};
return <button onClick={handlePlay}>Play</button>;
}
```
### Media Escape Hatch
```tsx
import { createPlayer, presets } from '@videojs/react';
const { usePlayer, useMedia } = createPlayer(presets.website);
function DebugPanel() {
const player = usePlayer();
const media = useMedia();
return (
<pre>
{JSON.stringify(
{
// Player state (preferred)
isFullscreen: player.isFullscreen,
// Media state directly (escape hatch)
mediaFullscreen: media.isFullscreen,
readyState: media.readyState,
networkState: media.networkState,
},
null,
2
)}
</pre>
);
}
```
### Headless (No UI)
```tsx
import { createMedia, features } from '@videojs/react';
const { Provider, useMedia } = createMedia([features.playback, features.time]);
function AudioPlayer() {
const media = useMedia();
// Programmatic control, no UI
useEffect(() => {
if (media.currentTime > 30) {
media.pause();
}
}, [media.currentTime]);
return <audio src="podcast.mp3" />;
}
```
## HTML
### Declarative (Skin)
```html
<script type="module" src="@videojs/html/presets/website/skins/frosted.js"></script>
<vjs-website-provider>
<vjs-frosted-skin>
<video src="video.mp4"></video>
</vjs-frosted-skin>
</vjs-website-provider>
```
Import registers both provider and skin — zero config.
### Custom Provider
```ts
import { createPlayer, presets } from '@videojs/html';
const { ProviderElement } = createPlayer(presets.website);
customElements.define('my-website-provider', ProviderElement);
```
```html
<my-website-provider>
<video src="video.mp4"></video>
</my-website-provider>
```
### Extended Preset
```ts
import { createPlayer, features, presets } from '@videojs/html';
const { ProviderElement } = createPlayer({
features: [...presets.background, features.keyboard],
});
customElements.define('vjs-background-provider', ProviderElement);
```
### Split Provider/Container
When media element and fullscreen target need different DOM locations.
```ts
import { createPlayer, presets, VjsElement } from '@videojs/html';
const { ProviderMixin, ContainerMixin } = createPlayer(presets.website);
class MediaProviderElement extends ProviderMixin(VjsElement) {}
class MediaContainerElement extends ContainerMixin(VjsElement) {}
customElements.define('my-provider', MediaProviderElement);
customElements.define('my-container', MediaContainerElement);
```
```html
<my-provider>
<video src="video.mp4"></video>
<my-container>
<my-controls></my-controls>
</my-container>
</my-provider>
```
### Headless (No UI)
```ts
import { createMedia, features, VjsElement } from '@videojs/html';
const { ProviderMixin, MediaController } = createMedia([features.playback, features.time]);
class VjsAudioController extends ProviderMixin(VjsElement) {
// Programmatic control, no UI
}
customElements.define('vjs-audio-controller', VjsAudioController);
```
## Lit
### Custom Play Button
```ts
import { html } from 'lit';
import { createPlayer, presets, VjsElement } from '@videojs/html';
const { PlayerController } = createPlayer(presets.website);
class VjsPlayButton extends VjsElement {
#player = new PlayerController(this);
render() {
const { paused, play, pause } = this.#player.value;
return html`
<button class="play-button" @click=${paused ? play : pause} aria-label=${paused ? 'Play' : 'Pause'}>
${paused ? 'Play' : 'Pause'}
</button>
`;
}
}
customElements.define('vjs-play-button', VjsPlayButton);
```
### Volume Slider
```ts
import { html } from 'lit';
import { createPlayer, presets, VjsElement } from '@videojs/html';
const { PlayerController } = createPlayer(presets.website);
class VjsVolumeSlider extends VjsElement {
#player = new PlayerController(this);
render() {
const { volume, muted, setVolume, toggleMute } = this.#player.value;
return html`
<div class="volume-control">
<button @click=${toggleMute} aria-label=${muted ? 'Unmute' : 'Mute'}>${muted ? 'Unmuted' : 'Muted'}</button>
<input
type="range"
min="0"
max="1"
step="0.05"
.value=${String(volume)}
@input=${(e: Event) => setVolume(Number((e.target as HTMLInputElement).value))}
aria-label="Volume"
/>
</div>
`;
}
}
customElements.define('vjs-volume-slider', VjsVolumeSlider);
```
### Time Display
```ts
import { html } from 'lit';
import { createPlayer, presets, VjsElement } from '@videojs/html';
const { PlayerController } = createPlayer(presets.website);
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
class VjsTimeDisplay extends VjsElement {
#player = new PlayerController(this);
render() {
const { currentTime, duration } = this.#player.value;
return html` <span class="time-display"> ${formatTime(currentTime)} / ${formatTime(duration)} </span> `;
}
}
customElements.define('vjs-time-display', VjsTimeDisplay);
```
### Fullscreen Button
```ts
import { html } from 'lit';
import { createPlayer, presets, VjsElement } from '@videojs/html';
const { PlayerController } = createPlayer(presets.website);
class VjsFullscreenButton extends VjsElement {
#player = new PlayerController(this);
render() {
const { isFullscreen, toggleFullscreen } = this.#player.value;
return html`
<button
class="fullscreen-button"
@click=${toggleFullscreen}
aria-label=${isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
${isFullscreen ? 'Exit' : 'Fullscreen'}
</button>
`;
}
}
customElements.define('vjs-fullscreen-button', VjsFullscreenButton);
```
### Complete Controls Bar
```ts
import { css, html } from 'lit';
import { createPlayer, presets, VjsElement } from '@videojs/html';
const { PlayerController } = createPlayer(presets.website);
class VjsControlsBar extends VjsElement {
static styles = css`
:host {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.7);
}
`;
#player = new PlayerController(this);
render() {
const { paused, volume, currentTime, duration, isFullscreen, play, pause, setVolume, seek, toggleFullscreen } =
this.#player.value;
return html`
<button @click=${paused ? play : pause}>${paused ? 'Play' : 'Pause'}</button>
<input
type="range"
min="0"
max=${duration}
.value=${String(currentTime)}
@input=${(e: Event) => seek(Number((e.target as HTMLInputElement).value))}
/>
<span>${this.#formatTime(currentTime)} / ${this.#formatTime(duration)}</span>
<input
type="range"
min="0"
max="1"
step="0.05"
.value=${String(volume)}
@input=${(e: Event) => setVolume(Number((e.target as HTMLInputElement).value))}
/>
<button @click=${toggleFullscreen}>${isFullscreen ? 'Exit FS' : 'FS'}</button>
`;
}
#formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
}
customElements.define('vjs-controls-bar', VjsControlsBar);
```
## Skins
### Using a Skin (React)
```tsx
import { createPlayer, presets } from '@videojs/react';
import { FrostedSkin } from '@videojs/react/presets/website';
const { Provider } = createPlayer(presets.streaming);
// Skins work with any preset that has the required features
<Provider>
<FrostedSkin>
<Video src="video.mp4" />
</FrostedSkin>
</Provider>;
```
### Skin Structure
```
packages/react/src/
└── presets/
└── website/
├── index.ts # preset features
└── skins/
└── frosted/
├── index.ts # FrostedSkin component
└── ui/ # UI components only
```
Skins are tied to presets — they assume certain features are available.
+290
View File
@@ -0,0 +1,290 @@
# Player API Design
Unified API for Media and Container concerns. Two stores internally, one API for users.
## Contents
| Document | Purpose |
| ---------------------------------- | ---------------------------------- |
| [index.md](index.md) | Overview, quick start, surface API |
| [decisions.md](decisions.md) | Design decisions and rationale |
| [architecture.md](architecture.md) | Two-store architecture, internals |
| [examples.md](examples.md) | Usage examples (React, HTML, Lit) |
## Problem
Two concerns, one player:
1. **Media** — play, pause, volume, time. Owned by `<video>`.
2. **Container** — fullscreen, keyboard, gestures, idle. Owned by the player UI wrapper.
Different targets, different lifecycles. Internally, they need separate stores.
But users want one API. This doc defines:
- How `createPlayer` exposes a unified API in React and HTML
- What it returns — hooks, controllers, providers
- How presets bundle features for common use cases
- How to extend with custom features
## Quick Start
### React
```tsx
import { createPlayer, presets } from '@videojs/react';
const { Provider, Container, usePlayer } = createPlayer(presets.website);
function App() {
return (
<Provider>
<Container>
<Video src="video.mp4" />
<Controls />
</Container>
</Provider>
);
}
function Controls() {
const player = usePlayer();
// Flat access — state and requests on same object
// Proxy-based tracking — accessing .paused subscribes automatically
return <button onClick={player.paused ? player.play : player.pause}>{player.paused ? 'Play' : 'Pause'}</button>;
}
```
### HTML / Lit
```ts
import { createPlayer, presets, VjsElement } from '@videojs/html';
const { ProviderElement, PlayerController } = createPlayer(presets.website);
customElements.define('vjs-website-provider', ProviderElement);
class VjsPlayButton extends VjsElement {
#player = new PlayerController(this);
render() {
// .value returns tracking proxy — accessing .paused subscribes
const { paused, play, pause } = this.#player.value;
return html` <button @click=${paused ? play : pause}>${paused ? 'Play' : 'Pause'}</button> `;
}
}
```
### Declarative (Skin)
```html
<script type="module" src="@videojs/html/presets/website/skins/frosted.js"></script>
<vjs-website-provider>
<vjs-frosted-skin>
<video src="video.mp4"></video>
</vjs-frosted-skin>
</vjs-website-provider>
```
## Surface API
### createPlayer
```ts
// Shorthand — preset or feature array
createPlayer(presets.website);
createPlayer([features.playback, features.fullscreen]);
// Config object — extensible
createPlayer({
features: presets.website,
// future: devTools, middleware, etc.
});
```
### Returns (React)
```ts
const {
Provider, // Creates both stores
Container, // Attaches container to player store
usePlayer, // Player state + requests (flattened)
useMedia, // Media state + requests (escape hatch)
} = createPlayer(presets.website);
```
### Returns (HTML)
```ts
const {
// Ready-to-use elements
ProviderElement, // Both stores, auto-attaches media
ContainerElement, // Attaches container to player store
// Mixins (customization)
ProviderMixin, // Both stores
ContainerMixin, // Container attachment
MediaProviderMixin, // Media store only (escape hatch)
// Controllers
PlayerController, // Player state + requests (like usePlayer)
MediaController, // Media state + requests (escape hatch)
} = createPlayer(presets.website);
```
### Returns (HTML) — createMedia
```ts
const {
ProviderElement, // Ready-to-use element
ProviderMixin, // Customization
MediaController, // Media state + requests
} = createMedia([features.playback, features.time]);
```
### usePlayer (React)
```tsx
function Controls() {
const player = usePlayer();
// State — proxy tracks access, subscribes automatically
player.paused;
player.volume;
player.isFullscreen;
// Requests — call directly
player.play();
player.pause();
player.setVolume(0.5);
player.toggleFullscreen();
}
```
### PlayerController (HTML/Lit)
```ts
class VjsControls extends VjsElement {
#player = new PlayerController(this);
render() {
const { paused, isFullscreen, play, pause, toggleFullscreen } = this.#player.value;
return html`
<button @click=${paused ? play : pause} />
<button @click=${toggleFullscreen} />
`;
}
}
```
### useMedia (Escape Hatch)
Direct media access. Rarely needed — use when player features don't expose what you need.
```tsx
function DebugPanel() {
const media = useMedia();
// Direct media state (bypasses player layer)
return <pre>{JSON.stringify({ readyState: media.readyState })}</pre>;
}
```
## Features
Features bundle related state and requests. Include only what you need.
```ts
import { features } from '@videojs/react';
features.playback; // play, pause, ended
features.volume; // volume, muted
features.time; // currentTime, duration, seeking
features.fullscreen; // isFullscreen, enter/exit
features.keyboard; // keyboard shortcuts
features.idle; // idle detection
features.gestures; // touch gestures
```
### Presets
Presets are curated feature collections. Pick one, get the right features.
```ts
createPlayer(presets.website); // full-featured
createPlayer(presets.background); // minimal (autoplay, loop)
```
| Preset | Use Case |
| -------------------- | ------------------------------------ |
| `presets.website` | Default website player **(default)** |
| `presets.background` | Background/hero video |
| `presets.news` | Article embeds |
| `presets.creator` | Creator platforms (YouTube) |
| `presets.swipe` | Short-form video (TikTok) |
| `presets.streaming` | Streaming apps (Netflix) |
| `presets.live` | Interactive live (Twitch) |
### Extending Presets
```ts
createPlayer({
features: [...presets.background, features.keyboard],
});
```
### Creating Features
```ts
import { createPlayerFeature } from '@videojs/react';
const analytics = createPlayerFeature({
initialState: { events: [] },
getSnapshot: ({ target, initialState }) => initialState,
subscribe: ({ target, update, signal }) => {
target.media.subscribe(
(s) => s.paused,
() => {
track(target.media.state.paused ? 'pause' : 'play');
update();
},
{ signal }
);
},
request: {
trackEvent: (event, { target }) => {
// Access media state and requests
target.media.state.paused;
target.media.request.play();
},
},
});
createPlayer({
features: [...presets.website, analytics],
});
```
## Naming Convention
State and requests share the same flat namespace. Follow this convention to avoid collisions:
| Type | Convention | Examples |
| -------- | ----------------------------------- | ------------------------------------------------------------- |
| State | Nouns, adjectives, past participles | `paused`, `volume`, `muted`, `isFullscreen`, `currentTime` |
| Requests | Verbs, imperative | `play`, `pause`, `setVolume`, `toggleMute`, `enterFullscreen` |
Runtime validation throws if duplicate keys are detected.
## Related Docs
- [decisions.md](decisions.md) — Why these choices were made
- [architecture.md](architecture.md) — Two-store internals
- [examples.md](examples.md) — Full usage examples