mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
docs(rfc): primitives api & feature access (#307)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user