mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
docs(plan): player api design (#300)
This commit is contained in:
@@ -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
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user