# 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 │ ▼ ┌───────────────────────┐ │ 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 = 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` | 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) */ type Store> = Simplify<{ state: State; attach(target: Target): () => void; subscribe(callback: () => void): () => void; destroy(): void; } & State>; /** Loose store type for contexts and base primitives. */ type AnyStore = Store; /** Infer target type from store. */ type InferStoreTarget = S extends Store ? T : never; /** Infer state type from store. */ type InferStoreState = S extends Store ? 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> // Access: store.paused → unknown (intersection), store.state.paused → unknown (snapshot) // Typed store from createStore // Store // Access: store.paused → boolean (intersection), store.state.paused → boolean (snapshot) // Selectors always use store.state for consistency ``` **createStore infers from features:** ```ts function createStore( config: { features: Features } ): Store, UnionFeatureState>; // Usage const store = createStore({ features: [playbackFeature, volumeFeature] }); // Type: Store 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) => 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(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( subscribe: (cb: () => void) => () => void, getSnapshot: () => S, selector: (state: S) => R, isEqual: (a: R, b: R) => boolean = shallowEqual ): R { const cache = useRef(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(store: S): InferStoreState; export function useStore( store: S, selector: (state: Record) => R ): R; export function useStore( store: S, selector?: (state: Record) => R ): InferStoreState | 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; } ``` --- ### 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 = { task: () => { throw new StoreError('NO_TARGET'); }, target: () => { throw new StoreError('NO_TARGET'); }, }; export function createFeatureSelector( feature: F ): (state: Record) => InferFeatureState | undefined { const initialState = feature.state(stateContext); const keys = Object.keys(initialState); return (state) => { if (!(keys[0] in state)) return undefined; const result: Record = {}; for (const key of keys) { result[key] = state[key]; } return result as InferFeatureState; }; } ``` **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()({ state: ({ task }) => ({ ... }), attach({ target, signal, set }) { listen(target, 'play', sync, { signal }); }, }); // After const playbackFeature = defineFeature()({ 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()(({ 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 ( ); } 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 ( 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) => 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>; } /** Base context for player primitives. */ const PlayerContext = createContext(null); /** Internal provider for player context. */ export function PlayerContextProvider({ value, children, }: { value: PlayerContextValue; children: ReactNode; }): ReactNode { return {children}; } /** 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(null); const containerRef = refProp ?? internalRef; useEffect(() => { if (media) { return store.attach({ media, container: containerRef.current }); } }, [media, store, containerRef]); return ( {children} ); } export interface ContainerProps { children: ReactNode; as?: keyof JSX.IntrinsicElements; className?: string; ref?: RefObject; } /** Access player state. Optionally pass selector for derived state. */ export function usePlayer(): Record; export function usePlayer(selector: (state: Record) => R): R; export function usePlayer(selector?: (state: Record) => R): Record | 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> | 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: Features; displayName?: string; } export interface CreatePlayerResult { Provider: FC; Container: typeof Container; // Re-exported from context usePlayer: UsePlayerHook; } export interface ProviderProps { children: ReactNode; } type UsePlayerHook = { (): UnionFeatureState; (selector: (state: Record) => R): R; }; export function createPlayer[]>( config: CreatePlayerConfig ): CreatePlayerResult { type State = UnionFeatureState; function Provider({ children }: ProviderProps): ReactNode { const [store] = useState(() => createStore({ features: config.features })); const [media, setMedia] = useState(null); useEffect(() => () => store.destroy(), [store]); return ( {children} ); } if (config.displayName) { Provider.displayName = `${config.displayName}.Provider`; } function usePlayer(): State; function usePlayer(selector: (state: Record) => R): R; function usePlayer(selector?: (state: Record) => 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, }; } ``` **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 { ref?: Ref | React.RefObject; } 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 ( ); } 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(Symbol('@videojs/player')); /** Mixin that observes for media elements and attaches to store. */ export function ContainerMixin>(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> implements ReactiveController { #host: ReactiveControllerHost; #accessor: StoreAccessor; #selector?: (state: Record) => R; #cached?: R; #unsubscribe?: () => void; // Overload: with selector - subscribes, R from selector constructor( host: ReactiveControllerHost, source: StoreSource, selector: (state: Record) => R ); // Overload: without selector - no subscription, R is full state constructor( host: ReactiveControllerHost, source: StoreSource ); constructor( host: ReactiveControllerHost, source: StoreSource, selector?: (state: Record) => 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``; } } 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: Features; } export interface CreatePlayerResult { context: typeof playerContext; create: () => Store>; PlayerController: typeof PlayerController; ProviderMixin: >(base: Base) => Base; ContainerMixin: typeof ContainerMixin; } export function createPlayer[]>( config: CreatePlayerConfig ): CreatePlayerResult { type StoreType = Store>; const create = () => createStore({ features: config.features }) as StoreType; function ProviderMixin>(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` type merges state via intersection AND exposes `state` property: ```ts type Store = 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), not store const selectPlayback = (state: Record) => ({ ... }); // 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`) - `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) => 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`) Typed symbols for feature identity: ```ts const playbackKey: FeatureKey = 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.