docs(rfc): primitives api & feature access (#307)

This commit is contained in:
rahim
2026-01-21 23:37:07 +11:00
committed by Rahim
parent f01a5fcf5c
commit 19f0d6af59
8 changed files with 335 additions and 480 deletions
-284
View File
@@ -1,284 +0,0 @@
# Using Slices
Slice-aware state access for primitives.
## Background
### What is a Slice?
A slice is a unit of state + behavior for a specific concern:
```ts
const volumeSlice = createSlice<HTMLMediaElement>()({
initialState: { volume: 1, muted: false, volumeAvailability: 'unsupported' },
getSnapshot: ({ target }) => ({ volume: target.volume, muted: target.muted, ... }),
subscribe: ({ target, update, signal }) => listen(target, 'volumechange', update, { signal }),
request: {
changeVolume: (volume, { target }) => { target.volume = volume; },
toggleMute: (_, { target }) => { target.muted = !target.muted; },
},
});
```
Stores are composed of slices. Slices are optional — users include what they need.
### Missing Slice vs Unavailable Capability
Two different concepts:
| Concept | Meaning | Detection | Cause |
| -------------------------- | -------------------------------------------- | -------------------------------------- | ---------------------------------------- |
| **Missing slice** | Store wasn't configured with this slice | `useSlice()` returns `undefined` | Developer didn't include slice in config |
| **Unavailable capability** | Slice exists but platform doesn't support it | `volumeAvailability === 'unsupported'` | Platform limitation (e.g., iOS volume) |
**Missing slice** is a composition/configuration issue. The primitive requires a slice that wasn't added to the store.
**Unavailable capability** is a platform limitation. The slice is configured, but the underlying media/platform can't perform the action (see `slice-availability.md`).
### Primitives Require Slices
UI primitives (PlayButton, VolumeSlider) need specific slices:
- VolumeSlider needs `volumeSlice`
- PlayButton needs `playbackSlice`
- TimeDisplay needs `timeSlice`
When a slice is missing, `useSlice` returns `undefined`. The primitive decides how to handle it — typically throwing `StoreError('MISSING_SLICE')`.
---
## API
### React
**Base hook** (explicit store):
```ts
import { useSlice } from '@videojs/store/react';
const volume = useSlice(store, volumeSlice, (ctx) => ctx.state.volume);
// Returns: number | undefined
```
**Factory-bound hook** (store from context):
```ts
const { useSlice } = createStore({ slices: [volumeSlice, playbackSlice] });
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
// Returns: number | undefined
```
### Lit
**Base controller** (explicit store):
```ts
import { SliceController } from '@videojs/store/lit';
#volume = new SliceController(this, store, volumeSlice, ctx => ctx.state.volume);
// this.#volume.value: number | undefined
```
**Factory-bound controller** (store from context):
```ts
const { SliceController } = createStore({ slices: [volumeSlice] });
#volume = new SliceController(this, volumeSlice, ctx => ctx.state.volume);
// this.#volume.value: number | undefined
```
### Selector
Always required. Receives slice context, returns selected value:
```ts
interface SliceContext<S extends AnySlice> {
state: InferSliceState<S>;
request: ResolveSliceRequestHandlers<S>;
}
// Select state
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
// Select request
const changeVolume = useSlice(volumeSlice, (ctx) => ctx.request.changeVolume);
// Derived values
const isSilent = useSlice(volumeSlice, (ctx) => ctx.state.muted || ctx.state.volume === 0);
```
### Subscription
Always subscribes when selector returns state (not a function). Request handlers are stable references — subscription is effectively a no-op for them.
---
## Usage in Primitives
```tsx
function VolumeSlider() {
const volume = useSlice(volumeSlice, (ctx) => ctx.state.volume);
const availability = useSlice(volumeSlice, (ctx) => ctx.state.volumeAvailability);
const changeVolume = useSlice(volumeSlice, (ctx) => ctx.request.changeVolume);
// 1. Slice not in store (composition error)
if (volume === undefined) {
throw new StoreError('MISSING_SLICE', 'VolumeSlider requires volumeSlice');
}
// 2. Platform doesn't support volume (iOS, etc.)
if (availability === 'unsupported') return null;
if (availability === 'unavailable') return <Slider disabled />;
// 3. Ready to use
return <Slider value={volume} onChange={changeVolume} />;
}
```
```ts
// Lit
class VolumeSlider extends LitElement {
#volume = new SliceController(this, volumeSlice, (ctx) => ctx.state.volume);
#availability = new SliceController(this, volumeSlice, (ctx) => ctx.state.volumeAvailability);
#changeVolume = new SliceController(this, volumeSlice, (ctx) => ctx.request.changeVolume);
render() {
const volume = this.#volume.value;
const availability = this.#availability.value;
if (volume === undefined) {
throw new StoreError('MISSING_SLICE', 'VolumeSlider requires volumeSlice');
}
if (availability === 'unsupported') return nothing;
if (availability === 'unavailable') return html`<vjs-slider disabled></vjs-slider>`;
return html`<vjs-slider value=${volume} @change=${this.#onChange}></vjs-slider>`;
}
#onChange = (e: CustomEvent) => this.#changeVolume.value?.(e.detail);
}
```
---
## Implementation
### Store: hasSlice
```ts
class Store {
#sliceIds: Set<symbol>;
constructor(config) {
this.#sliceIds = new Set(config.slices.map((s) => s.id));
}
hasSlice(slice: AnySlice): boolean {
return this.#sliceIds.has(slice.id);
}
}
```
### React: useSlice
```ts
function useSlice<S extends AnySlice, R>(
store: AnyStore,
slice: S,
selector: (ctx: SliceContext<S>) => R
): R | undefined {
// Check slice presence
if (!store.hasSlice(slice)) return undefined;
// Build context
const ctx: SliceContext<S> = {
state: store.state,
request: store.request,
};
const selected = selector(ctx);
// Subscribe if not a function (state vs request)
if (typeof selected !== 'function') {
return useSyncExternalStore(
(cb) => store.subscribe((state) => selector({ state, request: store.request }), cb),
() => selector(ctx)
);
}
return selected;
}
```
### Lit: SliceController
```ts
class SliceController<S extends AnySlice, R> implements ReactiveController {
#host: ReactiveControllerHost & HTMLElement;
#accessor: StoreAccessor;
#slice: S;
#selector: (ctx: SliceContext<S>) => R;
#value: R | undefined;
#unsubscribe = noop;
constructor(
host: ReactiveControllerHost & HTMLElement,
source: StoreSource,
slice: S,
selector: (ctx: SliceContext<S>) => R
) {
this.#host = host;
this.#slice = slice;
this.#selector = selector;
this.#accessor = new StoreAccessor(host, source, (store) => this.#connect(store));
host.addController(this);
}
get value(): R | undefined {
return this.#value;
}
hostConnected(): void {
this.#accessor.hostConnected();
}
hostDisconnected(): void {
this.#unsubscribe();
this.#unsubscribe = noop;
}
#connect(store: AnyStore): void {
if (!store.hasSlice(this.#slice)) {
this.#value = undefined;
return;
}
const ctx: SliceContext<S> = { state: store.state, request: store.request };
this.#value = this.#selector(ctx);
// Subscribe if not a function
if (typeof this.#value !== 'function') {
this.#unsubscribe = store.subscribe((state) => {
const newCtx = { state, request: store.request };
this.#value = this.#selector(newCtx);
this.#host.requestUpdate();
});
}
}
}
```
---
## Files to Create/Modify
- `packages/store/src/core/store.ts` — add `hasSlice` method
- `packages/store/src/core/errors.ts` — add `MISSING_SLICE` error code
- `packages/store/src/react/hooks/use-slice.ts` — base hook
- `packages/store/src/react/create-store.tsx` — factory-bound hook
- `packages/store/src/lit/controllers/slice-controller.ts` — base controller
- `packages/store/src/lit/create-store.ts` — factory-bound controller
- Tests for each
+4 -4
View File
@@ -83,16 +83,16 @@ rfc/
### Example
```bash
git checkout -b rfc/feature-accessor-design
git checkout -b rfc/player-api
# ... write RFC ...
git push -u origin rfc/feature-accessor-design
gh pr create --title "[RFC] Feature Accessor Design"
git push -u origin rfc/player-api
gh pr create --title "[RFC] Player API"
```
When the RFC is accepted and merged, the squash commit becomes:
```
docs(rfc): feature accessor design
docs(rfc): player api
```
## Relationship to Implementation Plans
@@ -1,22 +1,52 @@
# Slice Availability Design
---
status: draft
---
## The Problem
# Feature Availability Design
Slices may target capabilities the media doesn't support (e.g., `qualitySlice` on native `<video>`, `volumeSlice` on iOS).
## Problem
## Decisions
Features may target capabilities the platform doesn't support.
### Single Availability Type
```typescript
type Availability = 'available' | 'unavailable' | 'unsupported';
```ts
// iOS Safari doesn't allow programmatic volume control
request: {
setVolume: (vol, { target }) => {
target.volume = vol; // silently fails on iOS
},
}
```
| Value | Meaning |
| --------------- | ---------------------------------------------------------- |
| `'unsupported'` | Platform/target can never do this |
| `'unavailable'` | Could work, not ready yet (e.g., waiting for HLS manifest) |
| `'available'` | Ready to use |
UI needs to know: hide control? disable it? show it normally?
## Solution
Single availability state per feature:
```ts
type FeatureAvailability = '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 |
---
## Related: Missing Feature vs Unavailable Capability
See [player-api](./player-api/index.md) for feature access patterns.
| Concept | Cause | Detection |
| ---------------------- | ------------------- | --------------------------------- |
| Missing feature | Composition error | `hasFeature()` returns `false` |
| Unavailable capability | Platform limitation | `*Availability === 'unsupported'` |
---
## Implementation
### Naming Convention
@@ -34,10 +64,10 @@ Always start `'unsupported'` (pessimistic). Must be proven otherwise.
Use module-level cache + `update()` pattern. No API changes needed.
```typescript
let volumeSupportCache: Availability = 'unsupported';
```ts
let availability: FeatureAvailability = 'unsupported';
const volumeSlice = createSlice<HTMLMediaElement>()({
const volumeFeature = createFeature<HTMLMediaElement>()({
initialState: {
volume: 1,
volumeAvailability: 'unsupported',
@@ -45,7 +75,7 @@ const volumeSlice = createSlice<HTMLMediaElement>()({
getSnapshot: ({ target }) => ({
volume: target.volume,
volumeAvailability: volumeSupportCache,
volumeAvailability: availability,
}),
subscribe: ({ target, update, signal }) => {
@@ -54,14 +84,14 @@ const volumeSlice = createSlice<HTMLMediaElement>()({
// Async detection
canChangeVolume().then((supported) => {
if (signal.aborted) return;
volumeSupportCache = supported ? 'available' : 'unsupported';
availability = supported ? 'available' : 'unsupported';
update();
});
},
request: {
setVolume: {
guard: () => volumeSupportCache === 'available',
guard: () => availability === 'available',
handler: (vol, { target }) => {
target.volume = vol;
},
@@ -78,7 +108,11 @@ Guards receive `{ target, signal }`, not state. Check capability on target direc
```tsx
function VolumeSlider() {
const { volume, volumeAvailability } = useStore((s) => s);
const player = usePlayer();
if (!hasFeature(player, features.volume)) return null;
const { volume, volumeAvailability } = player;
if (volumeAvailability === 'unsupported') return null;
if (volumeAvailability === 'unavailable') return <Slider disabled />;
@@ -86,6 +120,8 @@ function VolumeSlider() {
}
```
---
## References
- Media Chrome uses similar pattern with `*Unavailable` properties
+40 -132
View File
@@ -25,16 +25,16 @@ Internal structure of the Player API.
│ state: paused, volume │ │ state: isFullscreen │
│ request: play, pause │ │ request: toggleFS │
└────────────────────────┘ └────────────────────────┘
target.media.getFeature()
┌───────────┴───────────┐
▼ ▼
Read media state Call media requests
(iOS fallback) (keyboard shortcuts)
getFeature(target.media, f)
┌───────────┴───────────┐
▼ ▼
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.
**Key insight:** Player Store's target includes a media proxy. This enables coordination without tight coupling — feature authors use the same flat API as component authors.
## Two Stores
@@ -84,20 +84,23 @@ Media features observe and control the `<video>` or `<audio>` element directly.
```ts
interface PlayerTarget {
container: HTMLElement;
media: Store<MediaTarget>;
media: UnknownMedia; // flat proxy, not store
}
```
Player features can:
- Control the container element (fullscreen, focus)
- Access media store for coordination
- Access media proxy for coordination (same flat API as components)
## Cross-Store Access
Player features access media via `target.media.getFeature()`.
Player features access media via `target.media` (a flat proxy). Use `hasFeature`/`getFeature` for type narrowing, and `subscribe` for reactive updates.
```ts
import * as media from '@videojs/core/dom/features/media';
import { getFeature, hasFeature, subscribe } from '@videojs/store';
const fullscreen = createPlayerFeature({
request: {
enterFullscreen: (_, { target }) => {
@@ -107,19 +110,25 @@ const fullscreen = createPlayerFeature({
return;
}
// iOS fallback — use media fullscreen
const mediaFS = target.media.getFeature(media.fullscreen);
mediaFS?.request.enterFullscreen();
// iOS fallback — use media fullscreen (flat access)
const mediaFS = getFeature(target.media, media.fullscreen);
mediaFS.enterFullscreen?.();
},
},
subscribe: ({ target, update, signal }) => {
// Subscribe to media fullscreen changes (iOS)
target.media.getFeature(media.fullscreen)?.subscribe((s) => s.isFullscreen, update, { signal });
if (hasFeature(target.media, media.fullscreen)) {
subscribe(target.media, (s) => s.isFullscreen, update, { signal });
}
},
});
```
## Feature Registry
Each store maintains `features: ReadonlyMap<symbol, AnyFeature>` keyed by `feature.id`. Used by `hasFeature()` — see [primitives.md](primitives.md).
## State Unification
`createPlayer` merges both stores into a unified API:
@@ -152,134 +161,32 @@ The proxy:
## 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
Proxy-based tracking (based on `SnapshotController`):
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
Works identically in React (`usePlayer()`) and Lit (`controller.value`).
## 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
```
| Path | Purpose |
| ---------------------------------------- | -------------------------------------------- |
| `packages/core/src/dom/features/media/` | Media features (playback, volume, time) |
| `packages/core/src/dom/features/player/` | Player features (fullscreen, keyboard, idle) |
| `packages/html/src/` | Lit player + presets/skins |
| `packages/react/src/` | React player + presets/skins |
## 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.
| Feature | Description |
| -------------- | ---------------------------------------------- |
| **Fullscreen** | Container fullscreen with iOS media fallback |
| **Idle** | Tracks user activity for auto-hide UI |
| **Keyboard** | Maps keys to requests (`Space``togglePlay`) |
| **Gestures** | Touch gestures (double-tap seek, swipe volume) |
## Progressive Complexity
@@ -299,3 +206,4 @@ Internal stores are implementation details until you author features.
- `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
- `hasFeature`, `getFeature`, `throwMissingFeature`, `subscribe` are framework-agnostic (from `@videojs/store`)
+36 -24
View File
@@ -207,36 +207,14 @@ function DebugPanel() {
### Two Stores (Not One)
**Decision:** Maintain two internal stores (Media Store + Player Store).
**Decision:** Maintain two internal stores (Media Store + Player Store). See [architecture.md](architecture.md) for details.
**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.
**Trade-off:** Two stores exist internally, but feature authors access media via `target.media` proxy — same flat API as components.
### 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
@@ -257,6 +235,40 @@ const bad = createPlayerFeature({
- Fail fast at creation, not at runtime access
- Clear error message: "Duplicate key 'play' found in state and requests"
## Primitives API
See [primitives.md](primitives.md) for types, examples, and package exports.
### `hasFeature`, `getFeature`, `throwMissingFeature`
**Decision:** Three utilities for feature access — type guard, optional access, and fail-fast.
**Rationale:**
- `hasFeature` — Standard TypeScript type guard pattern, narrows proxy in place
- `getFeature` — Properties as `T | undefined`, works with optional chaining
- `throwMissingFeature` — Surfaces misconfiguration immediately (silent `return null` hides bugs)
### `StoreProxy<T>` and `UnknownPlayer`
**Decision:** Generic `StoreProxy<T>` interface that all proxies implement.
**Rationale:**
- Preserves store type through the proxy
- Index signature `[key: string]: unknown` allows any property access
- Uses interfaces (not type aliases) for clearer hover hints
### `target.media` as Flat Proxy
**Decision:** `PlayerTarget.media` is an `UnknownMedia` proxy, not a store.
**Rationale:**
- Consistent API — feature authors and component authors use same flat access pattern
- No `.state`/`.request` namespacing to learn
- Simpler `hasFeature`/`getFeature` — only one signature (StoreProxy)
## Open Questions
### "In-between" Functionality
+3 -5
View File
@@ -5,6 +5,8 @@ Usage examples for React, HTML, and Lit.
> [!NOTE]
> Some examples here are for demonstration. In practice, UI primitives like `<PlayButton>`, `<VolumeSlider>`, etc. would be provided.
For primitive authoring examples (using `hasFeature`, `UnknownPlayer`, etc.), see [primitives.md](primitives.md).
## React
### Declarative (Skin)
@@ -48,11 +50,7 @@ function Controls() {
const player = usePlayer();
// Hook usage example only, you would use `PlayButton`
return (
<button onClick={player.paused ? player.play : player.pause}>
{player.paused ? 'Play' : 'Pause'}
</button>
);
return <button onClick={player.paused ? player.play : player.pause}>{player.paused ? 'Play' : 'Pause'}</button>;
}
```
+33 -11
View File
@@ -8,12 +8,13 @@ Unified API for Media and Container concerns. Two stores internally, one API for
## 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) |
| Document | Purpose |
| ---------------------------------- | ---------------------------------------- |
| [index.md](index.md) | Overview, quick start, surface API |
| [primitives.md](primitives.md) | Library author API (`hasFeature`, types) |
| [architecture.md](architecture.md) | Two-store architecture, internals |
| [decisions.md](decisions.md) | Design decisions and rationale |
| [examples.md](examples.md) | Usage examples (React, HTML, Lit) |
## Problem
@@ -115,9 +116,15 @@ createPlayer({
const {
Provider, // Creates both stores
Container, // Attaches container to player store
usePlayer, // Player state + requests (flattened)
useMedia, // Media state + requests (escape hatch)
usePlayer, // Player state + requests (flattened, typed to preset)
useMedia, // Media state + requests (escape hatch, typed to preset)
} = createPlayer(presets.website);
// Also exported from @videojs/react (for primitives):
// - usePlayer() → UnknownPlayer (loosely typed)
// - useMedia() → UnknownMedia (loosely typed)
// - hasFeature(player, feature) → type guard
// - getStore(player) → inferred store type
```
### Returns (HTML)
@@ -133,10 +140,16 @@ const {
ContainerMixin, // Container attachment
MediaProviderMixin, // Media store only (escape hatch)
// Controllers
PlayerController, // Player state + requests (like usePlayer)
// Controllers (typed to preset)
PlayerController, // .value for state + requests, .store for store access
MediaController, // Media state + requests (escape hatch)
} = createPlayer(presets.website);
// Also exported from @videojs/html (for primitives):
// - PlayerController → .value as UnknownPlayer, .store as UnknownPlayerStore
// - MediaController → .value as UnknownMedia, .store as UnknownMediaStore
// - hasFeature(player, feature) → type guard
// - getStore(player) → inferred store type
```
### Returns (HTML) — createMedia
@@ -287,8 +300,17 @@ State and requests share the same flat namespace. Follow this convention to avoi
Runtime validation throws if duplicate keys are detected.
## Primitives API
Building reusable UI primitives? See [primitives.md](primitives.md) for:
- `hasFeature()` type guard for feature detection
- `UnknownPlayer` / `UnknownMedia` loosely typed proxies
- `getStore()` for direct store access
- Cross-framework patterns (React + ReactiveElement)
## Related Docs
- [decisions.md](decisions.md) — Why these choices were made
- [architecture.md](architecture.md) — Two-store internals
- [architecture.md](architecture.md) — Two-store internals, feature registry
- [examples.md](examples.md) — Full usage examples
+163
View File
@@ -0,0 +1,163 @@
# Primitives API
Guide for library authors building UI primitives (like `<PlayButton>`, `<VolumeSlider>`).
## Problem
Primitives don't know which preset the user chose:
```tsx
// 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
}
```
They need:
1. Loosely typed access to the player
2. A way to check if a feature exists
3. Type narrowing when the feature is present
## Solution: `hasFeature`, `getFeature`, `throwMissingFeature`
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
```tsx
import { features, hasFeature, throwMissingFeature, usePlayer } from '@videojs/react';
export function PlayButton() {
const player = usePlayer(); // UnknownPlayer - loosely typed
if (!hasFeature(player, features.playback)) {
throwMissingFeature(features.playback, { displayName: 'PlayButton' });
}
// TypeScript narrows: player.paused and player.play() are now typed
return <button onClick={player.play}>{player.paused ? '▶' : '⏸'}</button>;
}
```
### `getFeature` — Direct Access
For optional features, use `getFeature` with safe access:
```tsx
const volume = getFeature(player, features.volume);
volume.setVolume?.(0.5); // Safe - no crash if undefined
```
## Types
### `StoreProxy<T>` Contract
All proxies implement `StoreProxy<T>`, which holds a reference to the underlying store:
```ts
const STORE_SYMBOL: unique symbol;
interface StoreProxy<T extends AnyStore = AnyStore> {
readonly [STORE_SYMBOL]: T;
[key: string]: unknown;
}
```
The index signature `[key: string]: unknown` allows any property access. After `hasFeature` narrows, explicit properties take precedence.
### 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
```tsx
export function TimeSlider() {
const player = usePlayer();
// Required — throw if missing
if (!hasFeature(player, features.time)) {
throwMissingFeature(features.time, { displayName: 'TimeSlider' });
}
// Optional — graceful degradation
const playback = getFeature(player, features.playback);
return <Slider onDragStart={playback.pause} onDragEnd={playback.play} />;
}
```