42 KiB
Player API Implementation Plan
Implementation plan for the Player API, aligned with Store v2 architecture.
Parent issue: #320
Phases
| # | Issue | Title | SP | Status |
|---|---|---|---|---|
| 1 | #365 | Store v2 Foundation | 5 | ✅ |
| 2 | #366 | PlayerTarget & Features | 3 | ✅ |
| 3 | #367 | React Player API | 3 | ✅ |
| 4 | #368 | HTML Player API | 5 | ✅ |
| 5 | #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)andusePlayer(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
FeatureAvailabilitytype ('available' | 'unavailable' | 'unsupported') - Base player context in
@videojs/reactand@videojs/htmlfor 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):
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:
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:
// 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:
// 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:
// 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:
// 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:
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:
// 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:
// 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:
// 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:
// 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:
// 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:
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. Theas anycast insideuseStore()is localized — external API is fully typed. Selector returnsFeatureState | undefined, handling "feature not configured" case.
Files:
packages/react/src/player/context.tsx (new)
packages/react/src/index.ts
Implementation:
// 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:
// 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:
// 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:
// 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:
{
"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:
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:
// 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,
});
}
}
};
}
// 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:
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:
// 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:
// packages/store/src/react/index.ts
export { useStore } from './hooks/use-store';
export { useSelector } from './hooks/use-selector';
// 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.) createFeatureSelectorfor type-safe feature accessFeatureAvailabilitytype for platform capability detection- Removal of
createStorefrom platform bindings - Remove
FeatureKeypattern (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:
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.pausedworks 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.stateto selectors
Selector pattern:
// 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 (untypedRecord<string, unknown>)usePlayer(selector)— returns selected state via selectoruseMedia()— returns current media elementuseMediaAttach()— 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 | undefinedto 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
*Availabilitystate ('available' | 'unavailable' | 'unsupported') - Safe default is
'unsupported'— updated inattach()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()andcomputed()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:
// 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 forattach(), 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,
.valuereturnsstore.statesnapshot - Overloads — TypeScript knows return type based on whether selector provided
// 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
createStorein 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:
// 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:
const playbackKey: FeatureKey<typeof playbackFeature> = Symbol.for('@videojs/playback');
store.get(playbackKey); // Typed access without importing feature
Deferred:
createFeatureSelectorprovides 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:
// 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:
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.