mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
1462 lines
42 KiB
Markdown
1462 lines
42 KiB
Markdown
# Player API Implementation Plan
|
|
|
|
Implementation plan for the Player API, aligned with Store v2 architecture.
|
|
|
|
Parent issue: [#320](https://github.com/videojs/v10/issues/320)
|
|
|
|
## Phases
|
|
|
|
| # | Issue | Title | SP | Status |
|
|
|---|-------|-------|-----|--------|
|
|
| 1 | [#365](https://github.com/videojs/v10/issues/365) | Store v2 Foundation | 5 | ✅ |
|
|
| 2 | [#366](https://github.com/videojs/v10/issues/366) | PlayerTarget & Features | 3 | ✅ |
|
|
| 3 | [#367](https://github.com/videojs/v10/issues/367) | React Player API | 3 | ✅ |
|
|
| 4 | [#368](https://github.com/videojs/v10/issues/368) | HTML Player API | 5 | ✅ |
|
|
| 5 | [#369](https://github.com/videojs/v10/issues/369) | Cleanup & Documentation | 2 | 🔄 Partial (store/html cleanup done in #368) |
|
|
|
|
**Total: 18 SP**
|
|
|
|
## Overview
|
|
|
|
Transform the current store-based architecture into a feature-based player API with:
|
|
|
|
- **Single store** with `PlayerTarget = { media: Media, container: MediaContainer | null }`
|
|
- **Selector-based subscriptions** via `useStore(store, selector)` and `usePlayer(selector)`
|
|
- `createFeatureSelector(feature)` for type-safe feature state selection
|
|
- Pre-built selectors exported from `@videojs/core/dom` (`selectPlayback`, `selectVolume`, etc.)
|
|
- `createFeatureSelector(feature)` for type-safe feature state selection
|
|
- **Feature availability** via `FeatureAvailability` type (`'available' | 'unavailable' | 'unsupported'`)
|
|
- **Base player context** in `@videojs/react` and `@videojs/html` for UI primitives
|
|
- **`createPlayer()`** factory wraps base context with typed hooks
|
|
|
|
## Architecture
|
|
|
|
```
|
|
createPlayer({ features })
|
|
│
|
|
creates Store<PlayerTarget, State>
|
|
│
|
|
▼
|
|
┌───────────────────────┐
|
|
│ PlayerContext │
|
|
│ (base, untyped) │
|
|
│ │
|
|
│ store: AnyStore │
|
|
│ media: Media | null │
|
|
│ setMedia: ... │
|
|
└───────────────────────┘
|
|
│
|
|
UI primitives use base
|
|
usePlayer(selector)
|
|
useMedia()
|
|
|
|
PlayerTarget = {
|
|
media: Media, // extends HTMLMediaElement
|
|
container: MediaContainer | null // extends HTMLElement
|
|
}
|
|
|
|
Store<Target, State> = Simplify<{
|
|
state: State, // state snapshot for selectors
|
|
attach(target: Target): () => void,
|
|
subscribe(callback: () => void): () => void,
|
|
destroy(): void
|
|
} & State> // direct state access via intersection
|
|
|
|
PlayerContextValue = {
|
|
store: AnyStore, // base store type
|
|
media: Media | null,
|
|
setMedia: ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Store Package Changes
|
|
|
|
### Removed from `@videojs/store/react`
|
|
|
|
| Export | Reason |
|
|
|--------|--------|
|
|
| `createStore` | Replaced by `createPlayer` in `@videojs/react` |
|
|
| `useStoreContext` | Replaced by `usePlayerContext` in `@videojs/react` |
|
|
| `StoreContextProvider` | Replaced by player context |
|
|
| `CreateStoreConfig` | No longer needed |
|
|
| `CreateStoreResult` | No longer needed |
|
|
| `ProviderProps` | No longer needed |
|
|
|
|
### Kept in `@videojs/store/react`
|
|
|
|
| Export | Purpose |
|
|
|--------|---------|
|
|
| `useStore` | Hook to subscribe to store state |
|
|
| `useSelector` (new) | Selector-based subscription with shallowEqual |
|
|
|
|
### Removed from `@videojs/store/html`
|
|
|
|
| Export | Reason |
|
|
|--------|--------|
|
|
| `createStore` | Replaced by `createPlayer` in `@videojs/html` |
|
|
| `createProviderMixin` | Player-specific version in `@videojs/html` |
|
|
| `createContainerMixin` | Player-specific version in `@videojs/html` |
|
|
| `createStoreMixin` | Player-specific version in `@videojs/html` |
|
|
| `CreateStoreConfig` | No longer needed |
|
|
| `CreateStoreResult` | No longer needed |
|
|
| `CreateStoreHost` | No longer needed |
|
|
| `contextKey` | No longer needed |
|
|
| `StoreConsumer` | Player-specific |
|
|
| `StoreProvider` | Player-specific |
|
|
|
|
### Kept in `@videojs/store/html`
|
|
|
|
| Export | Purpose |
|
|
|--------|---------|
|
|
| `SubscriptionController` | Generic subscription management |
|
|
| `StoreController` | Subscribes to store state |
|
|
| `StoreAccessor` | Resolves store from direct instance or context |
|
|
| `StoreSource<Store>` | Type: store instance or context |
|
|
| `StoreAccessorHost` | Type: host requirements |
|
|
| `SubscriptionControllerHost` | Type: host requirements |
|
|
|
|
---
|
|
|
|
## Phase 1: Store Enhancements
|
|
|
|
### PR 1: feat(store): Store type refactor
|
|
|
|
Refactor Store type to use `Target` and `State` generics with intersection (`& State`) for direct access plus `state` property for selectors. Use `Simplify<>` for flattened IntelliSense. Simplify type helpers (remove `InferStoreFeatures`). Pass `store` to feature `attach()`.
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/store/src/core/store.ts
|
|
packages/store/src/core/types.ts (new or update)
|
|
packages/store/src/core/tests/store.test.ts
|
|
```
|
|
|
|
**Store type (intersection with state snapshot):**
|
|
|
|
```ts
|
|
import { Simplify } from '@videojs/utils/types';
|
|
|
|
/**
|
|
* Store type with direct state access via intersection.
|
|
* - Target: type for attach() parameter (default: unknown)
|
|
* - State: state merged onto store AND available via state property (default: Record<string, unknown>)
|
|
*/
|
|
type Store<Target = unknown, State = Record<string, unknown>> = Simplify<{
|
|
state: State;
|
|
attach(target: Target): () => void;
|
|
subscribe(callback: () => void): () => void;
|
|
destroy(): void;
|
|
} & State>;
|
|
|
|
/** Loose store type for contexts and base primitives. */
|
|
type AnyStore<Target = any> = Store<Target, any>;
|
|
|
|
/** Infer target type from store. */
|
|
type InferStoreTarget<S extends AnyStore> = S extends Store<infer T, any> ? T : never;
|
|
|
|
/** Infer state type from store. */
|
|
type InferStoreState<S extends AnyStore> = S extends Store<any, infer State> ? State : never;
|
|
|
|
// Note: No InferStoreFeatures - features are not stored in the type.
|
|
// State is inferred from features at createStore() call site only.
|
|
|
|
// Base store (uses defaults) - for features and primitives
|
|
// Store<unknown, Record<string, unknown>>
|
|
// Access: store.paused → unknown (intersection), store.state.paused → unknown (snapshot)
|
|
|
|
// Typed store from createStore
|
|
// Store<PlayerTarget, { paused: boolean; volume: number; ... }>
|
|
// Access: store.paused → boolean (intersection), store.state.paused → boolean (snapshot)
|
|
// Selectors always use store.state for consistency
|
|
```
|
|
|
|
**createStore infers from features:**
|
|
|
|
```ts
|
|
function createStore<const Features extends AnyFeature[]>(
|
|
config: { features: Features }
|
|
): Store<InferFeatureTarget<Features>, UnionFeatureState<Features>>;
|
|
|
|
// Usage
|
|
const store = createStore({ features: [playbackFeature, volumeFeature] });
|
|
// Type: Store<PlayerTarget, { paused: boolean; volume: number; ... }>
|
|
store.state.paused; // Access via state snapshot
|
|
|
|
// Features are NOT stored in Store type - only Target and State
|
|
// This simplifies type helpers and avoids complex inference chains
|
|
```
|
|
|
|
**Feature attach receives store:**
|
|
|
|
```ts
|
|
// Update feature attach signature
|
|
attach({ target, signal, set, store }: {
|
|
target: Target;
|
|
signal: AbortSignal;
|
|
set: (partial: Partial<State>) => void;
|
|
store: Store; // Base store for cross-feature access
|
|
}) {
|
|
// One-time read from state snapshot
|
|
const playback = selectPlayback(store.state);
|
|
|
|
// Reactive subscription
|
|
const unsubscribe = store.subscribe(() => {
|
|
const playback = selectPlayback(store.state);
|
|
// ...
|
|
});
|
|
signal.addEventListener('abort', unsubscribe);
|
|
}
|
|
|
|
---
|
|
|
|
### PR 2: feat(store): shallowEqual utility
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/store/src/core/shallow-equal.ts (new)
|
|
packages/store/src/core/index.ts
|
|
packages/store/src/core/tests/shallow-equal.test.ts (new)
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/store/src/core/shallow-equal.ts
|
|
export function shallowEqual<T>(a: T, b: T): boolean {
|
|
if (Object.is(a, b)) return true;
|
|
|
|
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
|
|
return false;
|
|
}
|
|
|
|
const keysA = Object.keys(a);
|
|
const keysB = Object.keys(b);
|
|
|
|
if (keysA.length !== keysB.length) return false;
|
|
|
|
for (const key of keysA) {
|
|
if (!Object.hasOwn(b, key) || !Object.is((a as any)[key], (b as any)[key])) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### PR 3: feat(store/react): useSelector hook
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/store/src/react/hooks/use-selector.ts (new)
|
|
packages/store/src/react/hooks/index.ts
|
|
packages/store/src/react/hooks/tests/use-selector.test.tsx (new)
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/store/src/react/hooks/use-selector.ts
|
|
import { useRef } from 'react';
|
|
import { useSyncExternalStore } from 'react';
|
|
import { shallowEqual } from '../../core/shallow-equal';
|
|
|
|
export function useSelector<S, R>(
|
|
subscribe: (cb: () => void) => () => void,
|
|
getSnapshot: () => S,
|
|
selector: (state: S) => R,
|
|
isEqual: (a: R, b: R) => boolean = shallowEqual
|
|
): R {
|
|
const cache = useRef<R | undefined>(undefined);
|
|
|
|
const getSelectedSnapshot = () => {
|
|
const next = selector(getSnapshot());
|
|
if (cache.current !== undefined && isEqual(cache.current, next)) {
|
|
return cache.current;
|
|
}
|
|
cache.current = next;
|
|
return next;
|
|
};
|
|
|
|
return useSyncExternalStore(subscribe, getSelectedSnapshot, getSelectedSnapshot);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### PR 4: feat(store/react): useStore selector overload
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/store/src/react/hooks/use-store.ts
|
|
packages/store/src/react/hooks/tests/use-store.test.tsx
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/store/src/react/hooks/use-store.ts
|
|
import type { AnyStore, InferStoreState } from '../../core/types';
|
|
import { shallowEqual } from '../../core/shallow-equal';
|
|
import { useSelector } from './use-selector';
|
|
|
|
export function useStore<S extends AnyStore>(store: S): InferStoreState<S>;
|
|
export function useStore<S extends AnyStore, R>(
|
|
store: S,
|
|
selector: (state: Record<string, unknown>) => R
|
|
): R;
|
|
export function useStore<S extends AnyStore, R>(
|
|
store: S,
|
|
selector?: (state: Record<string, unknown>) => R
|
|
): InferStoreState<S> | R {
|
|
const sub = (cb: () => void) => store.subscribe(cb);
|
|
const snap = () => store.state; // State snapshot
|
|
|
|
if (selector) {
|
|
return useSelector(sub, snap, selector, shallowEqual);
|
|
}
|
|
|
|
// No selector: return full state, re-render on any change
|
|
return useSelector(sub, snap, (s) => s, () => false) as InferStoreState<S>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### PR 5: feat(store): createFeatureSelector
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/store/src/core/feature-selector.ts (new)
|
|
packages/store/src/core/index.ts
|
|
packages/store/src/core/tests/feature-selector.test.ts (new)
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/store/src/core/feature-selector.ts
|
|
import type { AnyFeature, InferFeatureState, StateFactoryContext } from './feature';
|
|
import { StoreError } from './errors';
|
|
|
|
const stateContext: StateFactoryContext<unknown> = {
|
|
task: () => { throw new StoreError('NO_TARGET'); },
|
|
target: () => { throw new StoreError('NO_TARGET'); },
|
|
};
|
|
|
|
export function createFeatureSelector<F extends AnyFeature>(
|
|
feature: F
|
|
): (state: Record<string, unknown>) => InferFeatureState<F> | undefined {
|
|
const initialState = feature.state(stateContext);
|
|
const keys = Object.keys(initialState);
|
|
|
|
return (state) => {
|
|
if (!(keys[0] in state)) return undefined;
|
|
|
|
const result: Record<string, unknown> = {};
|
|
|
|
for (const key of keys) {
|
|
result[key] = state[key];
|
|
}
|
|
|
|
return result as InferFeatureState<F>;
|
|
};
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```ts
|
|
const selectPlayback = createFeatureSelector(playbackFeature);
|
|
|
|
function PlayButton() {
|
|
// usePlayer passes store.state to selector internally
|
|
const playback = usePlayer(selectPlayback);
|
|
// Type: { paused: boolean; ended: boolean; ... } | undefined
|
|
|
|
if (!playback) return null; // Feature not configured
|
|
|
|
playback.paused; // ✓ TypeScript knows
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Core Types
|
|
|
|
### PR 6: feat(core/dom): PlayerTarget and FeatureAvailability types
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/core/src/dom/types.ts (new)
|
|
packages/core/src/dom/index.ts
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/core/src/dom/types.ts
|
|
|
|
/** Media element contract. */
|
|
export interface Media extends HTMLMediaElement {}
|
|
|
|
/** Container element contract. */
|
|
export interface MediaContainer extends HTMLElement {}
|
|
|
|
/** Composite target for player features. */
|
|
export interface PlayerTarget {
|
|
media: Media;
|
|
container: MediaContainer | null;
|
|
}
|
|
|
|
/** Feature capability availability. */
|
|
export type FeatureAvailability = 'available' | 'unavailable' | 'unsupported';
|
|
```
|
|
|
|
---
|
|
|
|
### PR 7: refactor(core/dom): update features to use PlayerTarget
|
|
|
|
Update all features from `HTMLMediaElement` to `PlayerTarget`.
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/core/src/dom/store/features/playback.ts
|
|
packages/core/src/dom/store/features/volume.ts
|
|
packages/core/src/dom/store/features/time.ts
|
|
packages/core/src/dom/store/features/source.ts
|
|
packages/core/src/dom/store/features/buffer.ts
|
|
packages/core/src/dom/store/features/tests/*.test.ts
|
|
```
|
|
|
|
**Example change:**
|
|
|
|
```ts
|
|
// Before
|
|
const playbackFeature = defineFeature<HTMLMediaElement>()({
|
|
state: ({ task }) => ({ ... }),
|
|
attach({ target, signal, set }) {
|
|
listen(target, 'play', sync, { signal });
|
|
},
|
|
});
|
|
|
|
// After
|
|
const playbackFeature = defineFeature<PlayerTarget>()({
|
|
state: ({ task }) => ({ ... }),
|
|
attach({ target, signal, set }) {
|
|
listen(target.media, 'play', sync, { signal });
|
|
},
|
|
});
|
|
```
|
|
|
|
**Volume feature with availability:**
|
|
|
|
```ts
|
|
// packages/core/src/dom/store/features/volume.ts
|
|
import type { FeatureAvailability, PlayerTarget } from '../../types';
|
|
|
|
/** Check if volume can be programmatically set (fails on iOS Safari). */
|
|
function canSetVolume(media: HTMLMediaElement): FeatureAvailability {
|
|
try {
|
|
const original = media.volume;
|
|
media.volume = 0.5;
|
|
const canSet = media.volume === 0.5;
|
|
media.volume = original;
|
|
return canSet ? 'available' : 'unsupported';
|
|
} catch {
|
|
return 'unsupported';
|
|
}
|
|
}
|
|
|
|
const volumeFeature = defineFeature<PlayerTarget>()(({ task }) => ({
|
|
volume: 1,
|
|
muted: false,
|
|
volumeAvailability: 'unsupported' as FeatureAvailability, // Safe default
|
|
setVolume: task('setVolume', (volume: number, { target }) => {
|
|
target.media.volume = volume;
|
|
}),
|
|
setMuted: task('setMuted', (muted: boolean, { target }) => {
|
|
target.media.muted = muted;
|
|
}),
|
|
}), {
|
|
attach({ target, signal, set }) {
|
|
const { media } = target;
|
|
|
|
// Check platform capability
|
|
set({ volumeAvailability: canSetVolume(media) });
|
|
|
|
const sync = () => set({ volume: media.volume, muted: media.muted });
|
|
listen(media, 'volumechange', sync, { signal });
|
|
sync();
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### PR 8: feat(core/dom): feature bundles
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/core/src/dom/store/features/bundles.ts (new)
|
|
packages/core/src/dom/store/features/index.ts
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/core/src/dom/store/features/bundles.ts
|
|
import { playbackFeature } from './playback';
|
|
import { volumeFeature } from './volume';
|
|
import { timeFeature } from './time';
|
|
import { sourceFeature } from './source';
|
|
import { bufferFeature } from './buffer';
|
|
|
|
/** Base video player features. */
|
|
export const video = [
|
|
playbackFeature,
|
|
volumeFeature,
|
|
timeFeature,
|
|
sourceFeature,
|
|
bufferFeature,
|
|
] as const;
|
|
|
|
/** Base audio player features. */
|
|
export const audio = [
|
|
playbackFeature,
|
|
volumeFeature,
|
|
timeFeature,
|
|
sourceFeature,
|
|
bufferFeature,
|
|
] as const;
|
|
```
|
|
|
|
---
|
|
|
|
### PR 9: feat(core/dom): feature selectors
|
|
|
|
Pre-built selectors for standard features, exported from `@videojs/core/dom`.
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/core/src/dom/store/selectors.ts (new)
|
|
packages/core/src/dom/index.ts
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/core/src/dom/store/selectors.ts
|
|
import { createFeatureSelector } from '@videojs/store';
|
|
import { playbackFeature } from './features/playback';
|
|
import { volumeFeature } from './features/volume';
|
|
import { timeFeature } from './features/time';
|
|
import { sourceFeature } from './features/source';
|
|
import { bufferFeature } from './features/buffer';
|
|
|
|
export const selectPlayback = createFeatureSelector(playbackFeature);
|
|
export const selectVolume = createFeatureSelector(volumeFeature);
|
|
export const selectTime = createFeatureSelector(timeFeature);
|
|
export const selectSource = createFeatureSelector(sourceFeature);
|
|
export const selectBuffer = createFeatureSelector(bufferFeature);
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```tsx
|
|
import { selectPlayback, selectVolume } from '@videojs/core/dom';
|
|
|
|
function PlayButton() {
|
|
const playback = usePlayer(selectPlayback);
|
|
if (!playback) return null;
|
|
|
|
return (
|
|
<button onClick={playback.toggle}>
|
|
{playback.paused ? 'Play' : 'Pause'}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function VolumeSlider() {
|
|
const volume = usePlayer(selectVolume);
|
|
if (!volume) return null;
|
|
|
|
// Hide on platforms that don't support volume control
|
|
if (volume.volumeAvailability === 'unsupported') return null;
|
|
|
|
return (
|
|
<input
|
|
type="range"
|
|
value={volume.volume}
|
|
onChange={(e) => volume.setVolume(Number(e.target.value))}
|
|
disabled={volume.volumeAvailability !== 'available'}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: React Player API
|
|
|
|
### PR 10: feat(react): base player context
|
|
|
|
Base context for UI primitives — untyped, shared across all createPlayer instances.
|
|
|
|
> **Resolved:** Selectors define their own loose input type `(state: Record<string, unknown>) => R`. The `as any` cast inside `useStore()` is localized — external API is fully typed. Selector returns `FeatureState | undefined`, handling "feature not configured" case.
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/react/src/player/context.tsx (new)
|
|
packages/react/src/index.ts
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```tsx
|
|
// packages/react/src/player/context.tsx
|
|
import type { AnyStore } from '@videojs/store';
|
|
import type { Media, MediaContainer } from '@videojs/core/dom';
|
|
import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react';
|
|
import { createContext, useContext, useEffect, useRef } from 'react';
|
|
import { useStore } from '@videojs/store/react';
|
|
|
|
/** Player context value. Store is AnyStore for base primitives. */
|
|
export interface PlayerContextValue {
|
|
store: AnyStore;
|
|
media: Media | null;
|
|
setMedia: Dispatch<SetStateAction<Media | null>>;
|
|
}
|
|
|
|
/** Base context for player primitives. */
|
|
const PlayerContext = createContext<PlayerContextValue | null>(null);
|
|
|
|
/** Internal provider for player context. */
|
|
export function PlayerContextProvider({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: PlayerContextValue;
|
|
children: ReactNode;
|
|
}): ReactNode {
|
|
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>;
|
|
}
|
|
|
|
/** Access player context (internal, for primitives). */
|
|
export function usePlayerContext(): PlayerContextValue {
|
|
const ctx = useContext(PlayerContext);
|
|
if (!ctx) throw new Error('NO_STORE');
|
|
return ctx;
|
|
}
|
|
|
|
/** Container component - attaches media to store when registered. */
|
|
export function Container({
|
|
children,
|
|
as: Element = 'div',
|
|
className,
|
|
ref: refProp,
|
|
}: ContainerProps): ReactNode {
|
|
const { store, media } = usePlayerContext();
|
|
const internalRef = useRef<MediaContainer>(null);
|
|
const containerRef = refProp ?? internalRef;
|
|
|
|
useEffect(() => {
|
|
if (media) {
|
|
return store.attach({ media, container: containerRef.current });
|
|
}
|
|
}, [media, store, containerRef]);
|
|
|
|
return (
|
|
<Element ref={containerRef} className={className}>
|
|
{children}
|
|
</Element>
|
|
);
|
|
}
|
|
|
|
export interface ContainerProps {
|
|
children: ReactNode;
|
|
as?: keyof JSX.IntrinsicElements;
|
|
className?: string;
|
|
ref?: RefObject<MediaContainer>;
|
|
}
|
|
|
|
/** Access player state. Optionally pass selector for derived state. */
|
|
export function usePlayer(): Record<string, unknown>;
|
|
export function usePlayer<R>(selector: (state: Record<string, unknown>) => R): R;
|
|
export function usePlayer<R>(selector?: (state: Record<string, unknown>) => R): Record<string, unknown> | R {
|
|
const { store } = usePlayerContext();
|
|
// useStore passes store.state to selector internally
|
|
return useStore(store, selector as any);
|
|
}
|
|
|
|
/** Access current media element (may be null if not registered). */
|
|
export function useMedia(): Media | null {
|
|
const { media } = usePlayerContext();
|
|
return media;
|
|
}
|
|
|
|
/** Access the media attach setter for connecting a media element to the player. */
|
|
export function useMediaAttach(): Dispatch<SetStateAction<Media | null>> | undefined {
|
|
const ctx = useContext(PlayerContext);
|
|
return ctx?.setMedia;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### PR 11: feat(react): createPlayer factory and update Video
|
|
|
|
Factory that creates typed provider and hooks. Update existing Video component to use new context.
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/react/src/player/create-player.tsx (new)
|
|
packages/react/src/media/video.tsx (update - use useMediaAttach)
|
|
packages/react/src/index.ts
|
|
packages/react/src/player/tests/create-player.test.tsx (new)
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```tsx
|
|
// packages/react/src/player/create-player.tsx
|
|
import type { AnyFeature, Store, UnionFeatureState } from '@videojs/store';
|
|
import { createStore } from '@videojs/store';
|
|
import { useStore } from '@videojs/store/react';
|
|
import type { Media, MediaContainer, PlayerTarget } from '@videojs/core/dom';
|
|
import type { FC, ReactNode, RefObject } from 'react';
|
|
import { useContext, useEffect, useRef, useState } from 'react';
|
|
import { Container, PlayerContextProvider, usePlayerContext } from './context';
|
|
|
|
export interface CreatePlayerConfig<Features extends AnyFeature[]> {
|
|
features: Features;
|
|
displayName?: string;
|
|
}
|
|
|
|
export interface CreatePlayerResult<Features extends AnyFeature[]> {
|
|
Provider: FC<ProviderProps>;
|
|
Container: typeof Container; // Re-exported from context
|
|
usePlayer: UsePlayerHook<Features>;
|
|
}
|
|
|
|
export interface ProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
type UsePlayerHook<Features extends AnyFeature[]> = {
|
|
(): UnionFeatureState<Features>;
|
|
<R>(selector: (state: Record<string, unknown>) => R): R;
|
|
};
|
|
|
|
export function createPlayer<const Features extends AnyFeature<PlayerTarget>[]>(
|
|
config: CreatePlayerConfig<Features>
|
|
): CreatePlayerResult<Features> {
|
|
type State = UnionFeatureState<Features>;
|
|
|
|
function Provider({ children }: ProviderProps): ReactNode {
|
|
const [store] = useState(() => createStore<Features>({ features: config.features }));
|
|
const [media, setMedia] = useState<Media | null>(null);
|
|
|
|
useEffect(() => () => store.destroy(), [store]);
|
|
|
|
return (
|
|
<PlayerContextProvider value={{ store: store as any, media, setMedia }}>
|
|
{children}
|
|
</PlayerContextProvider>
|
|
);
|
|
}
|
|
|
|
if (config.displayName) {
|
|
Provider.displayName = `${config.displayName}.Provider`;
|
|
}
|
|
|
|
function usePlayer(): State;
|
|
function usePlayer<R>(selector: (state: Record<string, unknown>) => R): R;
|
|
function usePlayer<R>(selector?: (state: Record<string, unknown>) => R): State | R {
|
|
const { store } = usePlayerContext();
|
|
// useStore passes store.state to selector internally
|
|
return useStore(store, selector as any);
|
|
}
|
|
|
|
return {
|
|
Provider,
|
|
Container, // Re-exported from context module
|
|
usePlayer: usePlayer as UsePlayerHook<Features>,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Update existing Video component to use player context:**
|
|
|
|
```tsx
|
|
// packages/react/src/media/video.tsx
|
|
'use client';
|
|
|
|
import type { Ref, VideoHTMLAttributes } from 'react';
|
|
import { useCallback } from 'react';
|
|
import { useComposedRefs } from '../utils/use-composed-refs';
|
|
import { useMediaAttach } from '../player/context';
|
|
|
|
export interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
|
|
ref?: Ref<HTMLVideoElement> | React.RefObject<HTMLVideoElement>;
|
|
}
|
|
|
|
export function Video({ children, ref: refProp, ...props }: VideoProps): React.JSX.Element {
|
|
const setMedia = useMediaAttach();
|
|
|
|
const attachRef = useCallback(
|
|
(el: HTMLVideoElement): (() => void) | void => {
|
|
if (!el || !setMedia) return;
|
|
setMedia(el);
|
|
return () => setMedia(null);
|
|
},
|
|
[setMedia]
|
|
);
|
|
|
|
const ref = useComposedRefs(refProp, attachRef);
|
|
|
|
return (
|
|
<video ref={ref} {...props}>
|
|
{children}
|
|
</video>
|
|
);
|
|
}
|
|
|
|
export namespace Video {
|
|
export type Props = VideoProps;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: HTML Player API
|
|
|
|
### PR 12: feat(html): directory scaffold and MediaElement
|
|
|
|
Set up package structure with subpath exports and placeholder files.
|
|
|
|
**Directory structure:**
|
|
|
|
```
|
|
packages/html/src/
|
|
├── index.ts
|
|
├── ui/
|
|
│ ├── media-element.ts
|
|
│ └── video-skin.ts (placeholder)
|
|
├── skin/
|
|
│ └── modern.ts (placeholder)
|
|
├── feature/
|
|
│ └── video.ts (placeholder)
|
|
└── player/
|
|
├── context.ts
|
|
├── player-controller.ts
|
|
└── video.ts (placeholder)
|
|
```
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/html/src/ui/media-element.ts (new)
|
|
packages/html/src/ui/video-skin.ts (new, placeholder)
|
|
packages/html/src/skin/modern.ts (new, placeholder)
|
|
packages/html/src/feature/video.ts (new, placeholder)
|
|
packages/html/src/player/video.ts (new, placeholder)
|
|
packages/html/package.json (update exports, add @lit/context dependency)
|
|
packages/html/tsdown.config.ts (new)
|
|
```
|
|
|
|
**MediaElement:**
|
|
|
|
```ts
|
|
// packages/html/src/ui/media-element.ts
|
|
import { ReactiveElement } from '@lit/reactive-element';
|
|
|
|
/** Base class for media UI primitives. */
|
|
export class MediaElement extends ReactiveElement {
|
|
// Base class marker for media UI primitives
|
|
}
|
|
```
|
|
|
|
**Package exports and dependencies:**
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"@lit/context": "^1.1.0",
|
|
"@lit/reactive-element": "^2.1.2",
|
|
"@videojs/core": "workspace:*",
|
|
"@videojs/store": "workspace:*",
|
|
"@videojs/utils": "workspace:*"
|
|
},
|
|
"exports": {
|
|
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
"./ui/*": { "types": "./dist/ui/*.d.ts", "default": "./dist/ui/*.js" },
|
|
"./skin/*": { "types": "./dist/skin/*.d.ts", "default": "./dist/skin/*.js" },
|
|
"./feature/*": { "types": "./dist/feature/*.d.ts", "default": "./dist/feature/*.js" },
|
|
"./player/*": { "types": "./dist/player/*.d.ts", "default": "./dist/player/*.js" }
|
|
}
|
|
}
|
|
```
|
|
|
|
**tsdown.config.ts:**
|
|
|
|
```ts
|
|
import { defineConfig } from 'tsdown';
|
|
import { glob } from 'glob';
|
|
|
|
export default defineConfig({
|
|
entry: glob.sync('src/**/*.ts', { ignore: ['**/*.test.ts', '**/*.d.ts'] }),
|
|
format: 'esm',
|
|
dts: true,
|
|
clean: true,
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### PR 13: feat(html): base player context and controller
|
|
|
|
Base context and controller for UI primitives.
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/html/src/player/context.ts (new)
|
|
packages/html/src/player/player-controller.ts (new)
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/html/src/player/context.ts
|
|
import { createContext, ContextConsumer } from '@lit/context';
|
|
import type { ReactiveElement } from '@lit/reactive-element';
|
|
import type { AnyStore } from '@videojs/store';
|
|
import type { Constructor } from '@videojs/utils/types';
|
|
import type { Media, MediaContainer, PlayerTarget } from '@videojs/core/dom';
|
|
|
|
/** Player context value. Store is AnyStore for base primitives. */
|
|
export interface PlayerContextValue {
|
|
store: AnyStore;
|
|
media: Media | null;
|
|
}
|
|
|
|
/** Base context for player primitives. */
|
|
export const playerContext = createContext<PlayerContextValue>(Symbol('@videojs/player'));
|
|
|
|
/** Mixin that observes for media elements and attaches to store. */
|
|
export function ContainerMixin<Base extends Constructor<ReactiveElement>>(BaseClass: Base) {
|
|
return class extends BaseClass {
|
|
#media: Media | null = null;
|
|
#detach: (() => void) | null = null;
|
|
#observer: MutationObserver | null = null;
|
|
#consumer = new ContextConsumer(this, {
|
|
context: playerContext,
|
|
subscribe: true,
|
|
});
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
this.#observeMedia();
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.#observer?.disconnect();
|
|
this.#detach?.();
|
|
}
|
|
|
|
#observeMedia() {
|
|
this.#observer = new MutationObserver(() => this.#checkForMedia());
|
|
this.#observer.observe(this, { childList: true, subtree: true });
|
|
this.#checkForMedia();
|
|
}
|
|
|
|
#checkForMedia() {
|
|
const media = this.querySelector('video, audio') as Media | null;
|
|
if (media !== this.#media) {
|
|
this.#detach?.();
|
|
this.#media = media;
|
|
this.#attachMedia();
|
|
}
|
|
}
|
|
|
|
#attachMedia() {
|
|
const ctx = this.#consumer.value;
|
|
if (ctx && this.#media) {
|
|
this.#detach = ctx.store.attach({
|
|
media: this.#media,
|
|
container: this as unknown as MediaContainer,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// packages/html/src/player/player-controller.ts
|
|
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
|
import type { AnyStore, InferStoreState } from '@videojs/store';
|
|
import { shallowEqual } from '@videojs/store';
|
|
import { StoreAccessor, type StoreSource } from '@videojs/store/html';
|
|
|
|
export class PlayerController<S extends AnyStore, R = InferStoreState<S>>
|
|
implements ReactiveController
|
|
{
|
|
#host: ReactiveControllerHost;
|
|
#accessor: StoreAccessor<S>;
|
|
#selector?: (state: Record<string, unknown>) => R;
|
|
#cached?: R;
|
|
#unsubscribe?: () => void;
|
|
|
|
// Overload: with selector - subscribes, R from selector
|
|
constructor(
|
|
host: ReactiveControllerHost,
|
|
source: StoreSource<S>,
|
|
selector: (state: Record<string, unknown>) => R
|
|
);
|
|
// Overload: without selector - no subscription, R is full state
|
|
constructor(
|
|
host: ReactiveControllerHost,
|
|
source: StoreSource<S>
|
|
);
|
|
constructor(
|
|
host: ReactiveControllerHost,
|
|
source: StoreSource<S>,
|
|
selector?: (state: Record<string, unknown>) => R
|
|
) {
|
|
this.#host = host;
|
|
this.#accessor = new StoreAccessor(host, source);
|
|
this.#selector = selector;
|
|
host.addController(this);
|
|
}
|
|
|
|
get store(): S {
|
|
return this.#accessor.store;
|
|
}
|
|
|
|
get value(): R {
|
|
if (this.#selector) {
|
|
// With selector: return cached selected value
|
|
this.#cached ??= this.#selector(this.store.state);
|
|
return this.#cached;
|
|
}
|
|
// Without selector: return current full state snapshot
|
|
return this.store.state as R;
|
|
}
|
|
|
|
hostConnected(): void {
|
|
// Only subscribe if selector provided
|
|
if (this.#selector) {
|
|
const selector = this.#selector;
|
|
this.#cached = selector(this.store.state);
|
|
|
|
this.#unsubscribe = this.store.subscribe(() => {
|
|
const next = selector(this.store.state);
|
|
if (!shallowEqual(this.#cached, next)) {
|
|
this.#cached = next;
|
|
this.#host.requestUpdate();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
hostDisconnected(): void {
|
|
this.#unsubscribe?.();
|
|
this.#unsubscribe = undefined;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```ts
|
|
import { selectPlayback } from '@videojs/core/dom';
|
|
import { playerContext } from './context';
|
|
|
|
class MediaPlayButton extends MediaElement {
|
|
// With selector: subscribes, .value is PlaybackState | undefined
|
|
#ctrl = new PlayerController(this, playerContext, selectPlayback);
|
|
|
|
render() {
|
|
const playback = this.#ctrl.value;
|
|
if (!playback) return nothing;
|
|
|
|
return html`<button @click=${playback.toggle}>
|
|
${playback.paused ? 'Play' : 'Pause'}
|
|
</button>`;
|
|
}
|
|
}
|
|
|
|
class SomeOtherElement extends LitElement {
|
|
// Without selector: no subscription, .value is full state
|
|
#ctrl = new PlayerController(this, typedStore);
|
|
|
|
someMethod() {
|
|
// Get current state (snapshot, not reactive)
|
|
const state = this.#ctrl.value;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### PR 14: feat(html): createPlayer factory
|
|
|
|
Factory that creates typed mixins and controller.
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/html/src/player/create-player.ts (new)
|
|
packages/html/src/player/tests/create-player.test.ts (new)
|
|
```
|
|
|
|
**Implementation:**
|
|
|
|
```ts
|
|
// packages/html/src/player/create-player.ts
|
|
import type { AnyFeature, Store, UnionFeatureState } from '@videojs/store';
|
|
import { createStore } from '@videojs/store';
|
|
import { ContextProvider } from '@lit/context';
|
|
import type { ReactiveElement } from '@lit/reactive-element';
|
|
import type { Constructor } from '@videojs/utils/types';
|
|
import type { Media, MediaContainer, PlayerTarget } from '@videojs/core/dom';
|
|
import { playerContext, ContainerMixin, type PlayerContextValue } from './context';
|
|
import { PlayerController } from './player-controller';
|
|
|
|
export interface CreatePlayerConfig<Features extends AnyFeature[]> {
|
|
features: Features;
|
|
}
|
|
|
|
export interface CreatePlayerResult<Features extends AnyFeature[]> {
|
|
context: typeof playerContext;
|
|
create: () => Store<PlayerTarget, UnionFeatureState<Features>>;
|
|
PlayerController: typeof PlayerController;
|
|
ProviderMixin: <Base extends Constructor<ReactiveElement>>(base: Base) => Base;
|
|
ContainerMixin: typeof ContainerMixin;
|
|
}
|
|
|
|
export function createPlayer<const Features extends AnyFeature<PlayerTarget>[]>(
|
|
config: CreatePlayerConfig<Features>
|
|
): CreatePlayerResult<Features> {
|
|
type StoreType = Store<PlayerTarget, UnionFeatureState<Features>>;
|
|
|
|
const create = () => createStore({ features: config.features }) as StoreType;
|
|
|
|
function ProviderMixin<Base extends Constructor<ReactiveElement>>(BaseClass: Base) {
|
|
return class extends BaseClass {
|
|
#store: StoreType = create();
|
|
#media: Media | null = null;
|
|
#provider = new ContextProvider(this, {
|
|
context: playerContext,
|
|
initialValue: { store: this.#store as any, media: null },
|
|
});
|
|
|
|
get store(): StoreType {
|
|
return this.#store;
|
|
}
|
|
|
|
get media(): Media | null {
|
|
return this.#media;
|
|
}
|
|
|
|
set media(value: Media | null) {
|
|
this.#media = value;
|
|
this.#provider.setValue({ store: this.#store as any, media: value });
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.#store.destroy();
|
|
}
|
|
};
|
|
}
|
|
|
|
return {
|
|
context: playerContext,
|
|
create,
|
|
PlayerController,
|
|
ProviderMixin,
|
|
ContainerMixin,
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Cleanup
|
|
|
|
### PR 15: refactor(store): remove createStore from lit
|
|
|
|
Remove deprecated exports, update package indexes.
|
|
|
|
**Note:** React cleanup (`createStore`, `useStoreContext`, etc.) was done in Phase 3 (PR 11).
|
|
|
|
**Files:**
|
|
|
|
```
|
|
packages/store/src/lit/create-store.ts (delete)
|
|
packages/store/src/lit/mixins/provider-mixin.ts (delete)
|
|
packages/store/src/lit/mixins/container-mixin.ts (delete)
|
|
packages/store/src/lit/mixins/store-mixin.ts (delete)
|
|
packages/store/src/lit/mixins/index.ts (delete)
|
|
packages/store/src/lit/types.ts (delete)
|
|
packages/store/src/lit/index.ts (update)
|
|
packages/store/src/lit/tests/create-store.test.ts (delete)
|
|
```
|
|
|
|
**Updated exports:**
|
|
|
|
```ts
|
|
// packages/store/src/react/index.ts
|
|
export { useStore } from './hooks/use-store';
|
|
export { useSelector } from './hooks/use-selector';
|
|
```
|
|
|
|
```ts
|
|
// packages/store/src/lit/index.ts
|
|
export { StoreController, SubscriptionController } from './controllers';
|
|
export type { StoreSource, StoreAccessorHost } from './store-accessor';
|
|
export { StoreAccessor } from './store-accessor';
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6: Documentation
|
|
|
|
### PR 16: docs(rfc): update for revised architecture
|
|
|
|
Update RFC to reflect:
|
|
|
|
- Single store with `PlayerTarget`
|
|
- Base player context pattern
|
|
- Selector-based subscriptions with pre-built selectors (`selectPlayback`, etc.)
|
|
- `createFeatureSelector` for type-safe feature access
|
|
- `FeatureAvailability` type for platform capability detection
|
|
- Removal of `createStore` from platform bindings
|
|
- Remove `FeatureKey` pattern (not needed yet)
|
|
|
|
**Files:**
|
|
|
|
```
|
|
rfc/player-api/*.md
|
|
```
|
|
|
|
---
|
|
|
|
## Design Decisions
|
|
|
|
### Why Store Uses Intersection AND State Property
|
|
|
|
The `Store<Target, State>` type merges state via intersection AND exposes `state` property:
|
|
|
|
```ts
|
|
type Store<Target, State> = Simplify<{
|
|
state: State; // State snapshot for selectors
|
|
attach(target: Target): () => void;
|
|
subscribe(callback: () => void): () => void;
|
|
destroy(): void;
|
|
} & State>; // Direct access via intersection
|
|
```
|
|
|
|
**Why both intersection AND state property:**
|
|
|
|
- **Direct access** — `store.paused` works for quick reads (intersection)
|
|
- **Selectors use state** — `selector(store.state)` for consistent snapshot reference
|
|
- **Type inference** — `Simplify<>` flattens the type for better IntelliSense
|
|
- **Framework-agnostic** — All frameworks pass `store.state` to selectors
|
|
|
|
**Selector pattern:**
|
|
|
|
```ts
|
|
// Selectors take state (Record<string, unknown>), not store
|
|
const selectPlayback = (state: Record<string, unknown>) => ({ ... });
|
|
|
|
// Direct access for simple reads
|
|
if (store.paused) { ... }
|
|
|
|
// Selectors always use store.state
|
|
usePlayer(selectPlayback); // React hook passes store.state
|
|
controller.value; // Lit controller reads store.state
|
|
```
|
|
|
|
### Why Base Player Context
|
|
|
|
UI primitives (PlayButton, VolumeSlider, etc.) need store access without knowing which features are configured. The base context provides:
|
|
|
|
- `usePlayer()` — returns current state snapshot (untyped `Record<string, unknown>`)
|
|
- `usePlayer(selector)` — returns selected state via selector
|
|
- `useMedia()` — returns current media element
|
|
- `useMediaAttach()` — for Video/Audio primitives to attach
|
|
|
|
`createPlayer()` wraps this base with typed hooks for app code.
|
|
|
|
Selectors define their own loose input type `(state: Record<string, unknown>) => R`, so the `as any` cast inside `useStore()` is localized while the external API remains fully typed.
|
|
|
|
### Why Selector-Based Over Feature-Scoped Hooks
|
|
|
|
- Simpler mental model — one hook, one pattern
|
|
- No conditional hook calls
|
|
- `createFeatureSelector(feature)` provides type-safe feature selection
|
|
- Pre-built selectors exported from `@videojs/core/dom` (`selectPlayback`, `selectVolume`, etc.)
|
|
- Returns `T | undefined` to handle "feature not configured" case
|
|
- Matches industry patterns (Zustand, Redux Toolkit)
|
|
|
|
### Why Feature Availability
|
|
|
|
Platform capabilities vary (e.g., iOS Safari can't programmatically set volume). Instead of runtime errors or silent failures:
|
|
|
|
- Features expose `*Availability` state (`'available' | 'unavailable' | 'unsupported'`)
|
|
- Safe default is `'unsupported'` — updated in `attach()` after capability check
|
|
- State doesn't contain DOM — helper functions like `canSetVolume()` return availability
|
|
- UI primitives can hide/disable based on availability
|
|
|
|
### Why Selector Logic in Framework Bindings (Not Base)
|
|
|
|
Selector-based change detection (shallowEqual) lives in React hooks and Lit controllers, not in `store.subscribe()`:
|
|
|
|
- **Framework-agnostic base** — `store.subscribe(callback)` stays simple, fires on any state change
|
|
- **Svelte/Vue have their own solutions** — `derived()` and `computed()` handle this idiomatically
|
|
- **React/Lit need it** — no built-in derived state, so hooks/controllers handle shallowEqual
|
|
- **Direct state access available** — `selector(store.state)` for one-time reads without subscription
|
|
|
|
**Pattern:**
|
|
|
|
```ts
|
|
// Base store - simple callback, fires on any change
|
|
store.subscribe(() => {
|
|
const playback = selectPlayback(store.state);
|
|
// ...
|
|
});
|
|
|
|
// React/Lit - shallowEqual comparison on selector result
|
|
useStore(store, selectPlayback); // Only re-renders when playback state changes
|
|
```
|
|
|
|
### PlayerController Design (Lit)
|
|
|
|
`PlayerController` exposes both `.store` and `.value`:
|
|
|
|
- **`.store`** — direct store access for `attach()`, subscriptions, etc.
|
|
- **`.value`** — selected state (with selector) or full state snapshot (without)
|
|
- **With selector** — subscribes, calls `selector(store.state)` with shallowEqual comparison
|
|
- **Without selector** — no subscription, `.value` returns `store.state` snapshot
|
|
- **Overloads** — TypeScript knows return type based on whether selector provided
|
|
|
|
```ts
|
|
// With selector - subscribes, .value is selected state
|
|
#playback = new PlayerController(this, playerContext, selectPlayback);
|
|
this.#playback.value; // PlaybackState | undefined
|
|
|
|
// Without selector - no subscription, .value is full state snapshot
|
|
#ctrl = new PlayerController(this, playerContext);
|
|
this.#ctrl.value; // Current state snapshot (not reactive)
|
|
```
|
|
|
|
### Why Remove createStore from Platform Bindings
|
|
|
|
- Player API is the primary use case
|
|
- `createStore` in store/react was a thin wrapper
|
|
- Base player context provides what primitives need
|
|
- Reduces API surface and confusion
|
|
|
|
---
|
|
|
|
## Future Work
|
|
|
|
Items identified during planning but deferred from initial implementation.
|
|
|
|
### Side-Effect Registration System
|
|
|
|
The `/ui/*`, `/video/*`, `/audio/*`, `/background/*`, `/feature/*` exports provide scaffold for future registration patterns:
|
|
|
|
```ts
|
|
// User imports trigger registration
|
|
import '@videojs/html/ui/play-button';
|
|
import '@videojs/html/video/skin';
|
|
import '@videojs/html/feature/quality-selection';
|
|
```
|
|
|
|
**Deferred:** Requires design decisions on:
|
|
- Global registry vs player-scoped registration
|
|
- How features/UI/skins discover each other
|
|
- Lazy loading and code splitting strategy
|
|
|
|
### Feature Keys (`FeatureKey<F>`)
|
|
|
|
Typed symbols for feature identity:
|
|
|
|
```ts
|
|
const playbackKey: FeatureKey<typeof playbackFeature> = Symbol.for('@videojs/playback');
|
|
store.get(playbackKey); // Typed access without importing feature
|
|
```
|
|
|
|
**Deferred:**
|
|
- `createFeatureSelector` provides equivalent type-safe access
|
|
- Bundle size benefit is marginal for most use cases
|
|
- Adds API surface without clear advantage yet
|
|
|
|
### Additional Feature Availability Checks
|
|
|
|
Expand `FeatureAvailability` pattern to other features:
|
|
|
|
```ts
|
|
// Fullscreen availability (iframe restrictions, browser support)
|
|
fullscreen.fullscreenAvailability
|
|
|
|
// Picture-in-Picture availability
|
|
pip.pipAvailability
|
|
|
|
// AirPlay/Cast availability
|
|
cast.castAvailability
|
|
```
|
|
|
|
**Deferred:** Add as features are implemented.
|
|
|
|
### Streaming/Ads/Live Feature Bundles
|
|
|
|
Additional feature bundles beyond base `video`/`audio`:
|
|
|
|
```ts
|
|
features.streaming = [qualitySelection, audioTracks, textTracks];
|
|
features.ads = [adMarkers, adSkip, adCountdown];
|
|
features.live = [liveIndicator, seekToLive, dvr];
|
|
```
|
|
|
|
**Deferred:** Core features need stabilization first.
|
|
|
|
### Server-Side Rendering Support
|
|
|
|
SSR-safe patterns for React:
|
|
- Hydration-safe store initialization
|
|
- Server state serialization
|
|
- `useId()` for deterministic IDs
|
|
|
|
**Deferred:** Requires real-world SSR testing scenarios.
|
|
|
|
### DevTools Integration
|
|
|
|
Browser extension for debugging:
|
|
- Store state inspector
|
|
- Feature configuration viewer
|
|
- Request/task timeline
|
|
- Performance profiling
|
|
|
|
**Deferred:** Nice-to-have, not blocking core functionality.
|