Files
v10/.claude/plans/player-api-implementation.md
T

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) 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):

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. 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:

// 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.)
  • 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:

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 accessstore.paused works for quick reads (intersection)
  • Selectors use stateselector(store.state) for consistent snapshot reference
  • Type inferenceSimplify<> flattens the type for better IntelliSense
  • Framework-agnostic — All frameworks pass store.state to 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 (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 basestore.subscribe(callback) stays simple, fires on any state change
  • Svelte/Vue have their own solutionsderived() and computed() handle this idiomatically
  • React/Lit need it — no built-in derived state, so hooks/controllers handle shallowEqual
  • Direct state access availableselector(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 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
// 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:

// 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:

  • 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:

// 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.