mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
docs(rfc): player api design v2 (#358)
This commit is contained in:
+176
-116
@@ -1,163 +1,223 @@
|
||||
# Primitives API
|
||||
|
||||
Guide for library authors building UI primitives (like `<PlayButton>`, `<VolumeSlider>`).
|
||||
Guide for library authors building UI primitives (like `<media-play-button>`, `<media-slider>`).
|
||||
|
||||
## Problem
|
||||
|
||||
Primitives don't know which preset the user chose:
|
||||
Primitives don't know which features the user configured:
|
||||
|
||||
```tsx
|
||||
// Inside @videojs/react - shipped to users
|
||||
// Inside @videojs/react — shipped to users
|
||||
export function PlayButton() {
|
||||
const player = usePlayer(); // What type is this?
|
||||
// User might use presets.website or presets.background
|
||||
// We don't know if playbackFeature is included
|
||||
// User might use features.video or a custom subset
|
||||
// We don't know if playback feature is included
|
||||
}
|
||||
```
|
||||
|
||||
They need:
|
||||
|
||||
1. Loosely typed access to the player
|
||||
1. Access to the player store
|
||||
2. A way to check if a feature exists
|
||||
3. Type narrowing when the feature is present
|
||||
|
||||
## Solution: `hasFeature`, `getFeature`, `throwMissingFeature`
|
||||
## Solution: Feature Access Pattern
|
||||
|
||||
Three utilities for feature access:
|
||||
|
||||
| Function | Returns | Use case |
|
||||
| ----------------------- | ------------------------------------ | ---------------------------------- |
|
||||
| `hasFeature(player, f)` | `boolean` (type guard) | Conditional narrowing, `if` blocks |
|
||||
| `getFeature(player, f)` | Typed object, props `T \| undefined` | Direct access, optional chaining |
|
||||
| `throwMissingFeature` | `never` (throws) | Critical features, fail fast |
|
||||
|
||||
### `hasFeature` — Type Guard
|
||||
### React
|
||||
|
||||
```tsx
|
||||
import { features, hasFeature, throwMissingFeature, usePlayer } from '@videojs/react';
|
||||
import { usePlayer, features } from '@videojs/react';
|
||||
|
||||
export function PlayButton() {
|
||||
const player = usePlayer(); // UnknownPlayer - loosely typed
|
||||
const playback = usePlayer(features.playback);
|
||||
|
||||
if (!hasFeature(player, features.playback)) {
|
||||
throwMissingFeature(features.playback, { displayName: 'PlayButton' });
|
||||
}
|
||||
if (!playback) return null;
|
||||
|
||||
// TypeScript narrows: player.paused and player.play() are now typed
|
||||
return <button onClick={player.play}>{player.paused ? '▶' : '⏸'}</button>;
|
||||
// TypeScript knows playback is PlaybackSlice
|
||||
return (
|
||||
<button onClick={playback.paused ? playback.play : playback.pause}>
|
||||
{playback.paused ? 'Play' : 'Pause'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### `getFeature` — Direct Access
|
||||
### HTML
|
||||
|
||||
For optional features, use `getFeature` with safe access:
|
||||
```ts
|
||||
import { features, MediaElement, PlayerController } from '@videojs/html';
|
||||
|
||||
```tsx
|
||||
const volume = getFeature(player, features.volume);
|
||||
volume.setVolume?.(0.5); // Safe - no crash if undefined
|
||||
class MediaPlayButton extends MediaElement {
|
||||
#playback = new PlayerController(this, features.playback);
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('click', this.#handleClick);
|
||||
}
|
||||
|
||||
#handleClick = () => {
|
||||
this.#playback.value?.toggle();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### `StoreProxy<T>` Contract
|
||||
### FeatureKey
|
||||
|
||||
All proxies implement `StoreProxy<T>`, which holds a reference to the underlying store:
|
||||
Type carrier for feature keys:
|
||||
|
||||
```ts
|
||||
const STORE_SYMBOL: unique symbol;
|
||||
type FeatureKey<F extends Feature> = symbol & { __feature?: F };
|
||||
```
|
||||
|
||||
interface StoreProxy<T extends AnyStore = AnyStore> {
|
||||
readonly [STORE_SYMBOL]: T;
|
||||
[key: string]: unknown;
|
||||
Use with `store.get()` for typed access:
|
||||
|
||||
```ts
|
||||
import { playbackKey } from '@videojs/core/features';
|
||||
|
||||
const playback = store.get(playbackKey); // PlaybackSlice | undefined
|
||||
```
|
||||
|
||||
### InferFeatureSlice
|
||||
|
||||
Infer the slice type from a feature:
|
||||
|
||||
```ts
|
||||
type PlaybackSlice = InferFeatureSlice<typeof playbackFeature>;
|
||||
// { paused: boolean; ended: boolean; play(): void; pause(): void; toggle(): void }
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Required Feature
|
||||
|
||||
If a primitive requires a feature to function:
|
||||
|
||||
```tsx
|
||||
export function VolumeSlider() {
|
||||
const volume = usePlayer(features.volume);
|
||||
|
||||
// Return nothing if feature not available
|
||||
if (!volume) return null;
|
||||
|
||||
return <Slider value={volume.volume} onChange={volume.setVolume} />;
|
||||
}
|
||||
```
|
||||
|
||||
The index signature `[key: string]: unknown` allows any property access. After `hasFeature` narrows, explicit properties take precedence.
|
||||
### Optional Feature Enhancement
|
||||
|
||||
### Proxy Types
|
||||
|
||||
```ts
|
||||
interface UnknownPlayerStore extends Store<PlayerTarget, []> {}
|
||||
interface UnknownMediaStore extends Store<MediaTarget, []> {}
|
||||
|
||||
interface UnknownPlayer extends StoreProxy<UnknownPlayerStore> {}
|
||||
interface UnknownMedia extends StoreProxy<UnknownMediaStore> {}
|
||||
```
|
||||
|
||||
The `[]` for features means "no features statically typed" — the store has features at runtime, but TypeScript doesn't know which ones. Use `hasFeature` to narrow.
|
||||
|
||||
### Type Summary
|
||||
|
||||
| Type | Description |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| `StoreProxy<T>` | Base interface for all proxies |
|
||||
| `UnknownPlayer` | Player proxy with unknown features |
|
||||
| `UnknownMedia` | Media proxy with unknown features |
|
||||
| `UnknownPlayerStore` | Player store with unknown features |
|
||||
| `UnknownMediaStore` | Media store with unknown features |
|
||||
|
||||
### Creating Proxies
|
||||
|
||||
Internally, proxies are created from stores via `createProxy()`:
|
||||
|
||||
```ts
|
||||
import { createProxy } from '@videojs/store';
|
||||
|
||||
const store = createStore({ ... });
|
||||
const proxy = createProxy(store); // StoreProxy<typeof store>
|
||||
```
|
||||
|
||||
This is used internally by `createPlayer` and controllers. Library authors typically receive proxies via `usePlayer()` or `controller.value`.
|
||||
|
||||
### Via Controller (Lit/ReactiveElement)
|
||||
|
||||
Controllers expose the proxy via `.value`:
|
||||
|
||||
```ts
|
||||
const controller = new PlayerController(this);
|
||||
controller.value; // UnknownPlayer (tracked proxy)
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Both functions access `target[STORE_SYMBOL].features.has(feature.id)` at runtime:
|
||||
|
||||
- **`hasFeature`** — Type guard that narrows the proxy to include feature's state and requests
|
||||
- **`getFeature`** — Returns same proxy typed to feature, with properties as `T | undefined`
|
||||
|
||||
## Cross-Framework Consistency
|
||||
|
||||
The same API works in React and Lit:
|
||||
|
||||
| Concept | React | Lit/ReactiveElement |
|
||||
| -------------------- | ------------------------------------ | --------------------------------------- |
|
||||
| Loosely typed player | `usePlayer()` → `UnknownPlayer` | `controller.value` → `UnknownPlayer` |
|
||||
| Type guard | `hasFeature(player, feature)` | `hasFeature(controller.value, feature)` |
|
||||
| Direct access | `getFeature(player, feature)` | `getFeature(controller.value, feature)` |
|
||||
| Throw on missing | `throwMissingFeature(feature, opts)` | `throwMissingFeature(feature, opts)` |
|
||||
|
||||
### Package Exports
|
||||
|
||||
| Package | Exports |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `@videojs/store` | `createProxy`, `hasFeature`, `getFeature`, `throwMissingFeature`, `subscribe`, `StoreProxy` |
|
||||
| `@videojs/core/dom` | `UnknownPlayer`, `UnknownMedia`, `UnknownPlayerStore`, `UnknownMediaStore` |
|
||||
| `@videojs/react` | Re-exports above + `usePlayer`, `useMedia`, `createPlayer` |
|
||||
| `@videojs/html` | Re-exports above + `PlayerController`, `MediaController`, `createPlayer` |
|
||||
|
||||
## Example: Mixing Required and Optional
|
||||
If a primitive works without a feature but enhances with it:
|
||||
|
||||
```tsx
|
||||
export function TimeSlider() {
|
||||
const player = usePlayer();
|
||||
const time = usePlayer(features.time);
|
||||
const playback = usePlayer(features.playback);
|
||||
|
||||
// Required — throw if missing
|
||||
if (!hasFeature(player, features.time)) {
|
||||
throwMissingFeature(features.time, { displayName: 'TimeSlider' });
|
||||
}
|
||||
if (!time) return null;
|
||||
|
||||
// Optional — graceful degradation
|
||||
const playback = getFeature(player, features.playback);
|
||||
return <Slider onDragStart={playback.pause} onDragEnd={playback.play} />;
|
||||
return (
|
||||
<Slider
|
||||
value={time.currentTime}
|
||||
max={time.duration}
|
||||
onChange={time.seek}
|
||||
// Optional: pause during drag
|
||||
onDragStart={playback?.pause}
|
||||
onDragEnd={playback?.play}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Required Features
|
||||
|
||||
```tsx
|
||||
export function Controls() {
|
||||
const playback = usePlayer(features.playback);
|
||||
const volume = usePlayer(features.volume);
|
||||
const fullscreen = usePlayer(features.fullscreen);
|
||||
|
||||
// All required
|
||||
if (!playback || !volume || !fullscreen) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PlayButton playback={playback} />
|
||||
<VolumeSlider volume={volume} />
|
||||
<FullscreenButton fullscreen={fullscreen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Framework Consistency
|
||||
|
||||
Same pattern works in React and HTML:
|
||||
|
||||
| Concept | React | HTML |
|
||||
| --------------- | -------------------- | ------------------------------------------- |
|
||||
| Hook/Controller | `usePlayer(feature)` | `new PlayerController(this, feature)` |
|
||||
| Get slice | returns slice | `controller.value` |
|
||||
| Check existence | `if (!slice)` | `if (!slice)` |
|
||||
| Access state | `slice.paused` | `slice.paused` |
|
||||
| Call request | `slice.play()` | `slice.play()` |
|
||||
## Package Exports
|
||||
|
||||
### @videojs/store
|
||||
|
||||
```ts
|
||||
import { shallowEqual } from '@videojs/store';
|
||||
```
|
||||
|
||||
### @videojs/core
|
||||
|
||||
```ts
|
||||
import {
|
||||
features, createMediaFeature, createPlayerFeature,
|
||||
playbackKey, volumeKey, timeKey
|
||||
} from '@videojs/core/dom';
|
||||
```
|
||||
|
||||
### @videojs/react
|
||||
|
||||
```ts
|
||||
import { createPlayer, usePlayer, features } from '@videojs/react';
|
||||
```
|
||||
|
||||
### @videojs/html
|
||||
|
||||
```ts
|
||||
import { createPlayer, features, MediaElement, PlayerController } from '@videojs/html';
|
||||
```
|
||||
|
||||
## Feature Availability
|
||||
|
||||
Features may target capabilities the platform doesn't support.
|
||||
|
||||
```ts
|
||||
// iOS Safari doesn't allow programmatic volume control
|
||||
const volume = usePlayer(features.volume);
|
||||
volume?.volumeAvailability; // 'available' | 'unavailable' | 'unsupported'
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| `'unsupported'` | Platform can never do this (e.g., iOS volume) |
|
||||
| `'unavailable'` | Could work, not ready yet (e.g., waiting for manifest) |
|
||||
| `'available'` | Ready to use |
|
||||
|
||||
### Handling Unavailable Features
|
||||
|
||||
```tsx
|
||||
export function VolumeSlider() {
|
||||
const volume = usePlayer(features.volume);
|
||||
|
||||
if (!volume) return null;
|
||||
|
||||
// Hide if platform doesn't support volume control
|
||||
if (volume.volumeAvailability === 'unsupported') return null;
|
||||
|
||||
// Disable if temporarily unavailable
|
||||
const disabled = volume.volumeAvailability !== 'available';
|
||||
|
||||
return <Slider value={volume.volume} onChange={volume.setVolume} disabled={disabled} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user