Files
v10/rfc/player-api/decisions.md
T

8.7 KiB

Design Decisions

Rationale behind Player API design choices.

Naming

createPlayer / createMedia (not createPlayerStore / createMediaStore)

Decision: Use createPlayer and createMedia — don't expose "store" in the primary API.

Rationale:

  • Users want a player, not a store
  • "Store" is an implementation detail — complexity should grow with use case
  • Progressive disclosure: simple concept first, internals when authoring features
  • Returns stay clean: Provider, usePlayer (not StoreProvider, usePlayerStore)

"But we're not creating a player?" — This matches React ecosystem conventions where create* means "create the infrastructure for X":

Factory Returns
createContext() { Provider, Consumer } — not "a context"
createRoot() { render, unmount } — not "a root"
createBrowserRouter() Config for <RouterProvider> — not "a router"
createTRPCReact() { Provider, hooks } — not "a tRPC"
createMachine() Machine definition — needs actor to run
createPlayer() { Provider, usePlayer } — infrastructure for a player

The pattern is established: create* returns building blocks, not a usable instance.

features (not slices)

Decision: Rename from "slices" to "features" everywhere (user-facing AND internal).

Rationale:

Our pattern is fundamentally different from Redux Toolkit slices:

Aspect Redux "slice" Our pattern
State ownership Store owns state Target owns state (HTMLMediaElement)
State mutation Reducers modify state Requests mutate target, snapshot reflects
Mental model "A slice of the store" "A feature I'm enabling"

Our concept is:

  • Observer/Adapter — binds store to external source of truth
  • Feature module — bundles related state + requests
  • Binding — connects reactive state to DOM element

"Feature" maps to user mental models:

  • "I want the fullscreen feature"
  • "Add the keyboard feature"
  • "This preset includes playback, volume, and time features"

Calling it "slice" creates false expectations from Redux users.

createPlayerFeature / createMediaFeature (not createSlice)

Decision: Use explicit factory names: createPlayerFeature and createMediaFeature.

Alternatives considered:

  • createFeature<PlayerTarget>() — generic with type parameter (rejected: curried call, less clear intent)
  • createFeature — simpler but ambiguous

Rationale:

  • Clearer intent — you know what you're creating
  • Simpler call signature — no curried type parameter
  • Matches createPlayer naming pattern
  • Explicit is better than implicit

Simple Provider Naming

Decision: Keep Provider, not PlayerProvider.

Rationale:

  • Scoped by createPlayer() call — context is already clear
  • Less ceremony for the common case
  • If someone needs multiple providers (rare), they can alias:
    const { Provider: VideoProvider } = createPlayer(...);
    

API Shape

Flat usePlayer Return

Decision: Return flattened object with state and requests at same level, no .state/.request namespaces.

// Before (considered)
const player = usePlayer();
player.state.paused;
player.request.play();

// After (chosen)
const player = usePlayer();
player.paused;
player.play();

Rationale:

  • Less nesting = less typing
  • Proxy-based tracking works at property access level
  • Naming convention prevents collisions (state = nouns, requests = verbs)

Trade-off: Requires runtime duplicate detection. If a feature defines state foo and another defines request foo, throw at creation time.

Proxy-Based Tracking (No Selectors)

Decision: Use proxy-based automatic tracking instead of selector functions.

// Before (selector approach)
const paused = usePlayer((s) => s.paused);

// After (proxy tracking)
const player = usePlayer();
player.paused; // accessing subscribes automatically

Rationale:

  • Simpler API — no selector functions to write
  • Automatic fine-grained subscriptions
  • Matches modern patterns (Valtio, MobX)
  • Works with flattened return (proxy tracks which properties accessed)

Implementation: Based on existing SnapshotController pattern using track().

PlayerController Uses .value

Decision: Access player state/requests via .value property (like SnapshotController).

class MyComponent extends VjsElement {
  #player = new PlayerController(this);

  render() {
    const { paused, play } = this.#player.value;
    // ...
  }
}

Rationale:

  • Consistent with SnapshotController API
  • .value returns tracking proxy
  • Property access during render auto-subscribes
  • No watch() method needed — proxy handles tracking

Keep Shorthand Config

Decision: Support both shorthand and config object forms.

// Shorthand — common case
createPlayer(presets.website);
createPlayer([features.playback, features.fullscreen]);

// Config object — extensibility
createPlayer({
  features: presets.website,
  // future: devTools, middleware, etc.
});

Rationale:

  • Shorthand is the 90% case — don't penalize it
  • Config object enables future extensibility without API changes
  • Progressive disclosure: simple → extensible

Feedback addressed: "Doing things too many ways can be rough" — but these aren't competing APIs, they're progressive complexity levels.

Escape Hatches

Keep useMedia

Decision: Keep useMedia as escape hatch for direct media access.

Rationale:

  • Progressive disclosure — 95% of users never need it
  • Costs nothing if unused
  • Saves advanced users when they need raw media state
  • Debugging scenarios: compare player vs media fullscreen state
function DebugPanel() {
  const player = usePlayer();
  const media = useMedia();

  // Player may abstract/transform media state
  // Sometimes you need the raw value
  console.log({ playerFS: player.isFullscreen, mediaFS: media.isFullscreen });
}

Architecture

Two Stores (Not One)

Decision: Maintain two internal stores (Media Store + Player Store). See architecture.md for details.

Trade-off: Two stores exist internally, but feature authors access media via target.media proxy — same flat API as components.

Container ≠ Provider

Decision: Container is purely UI attachment. Provider owns state.

Validation

Runtime Duplicate Key Detection

Decision: Throw at createPlayer time if state/request keys collide.

// This should throw
const bad = createPlayerFeature({
  initialState: { play: false }, // "play" as state
  request: { play: () => {} }, // "play" as request — collision!
});

Rationale:

  • Flat namespace requires disambiguation
  • Fail fast at creation, not at runtime access
  • Clear error message: "Duplicate key 'play' found in state and requests"

Primitives API

See primitives.md for types, examples, and package exports.

hasFeature, getFeature, throwMissingFeature

Decision: Three utilities for feature access — type guard, optional access, and fail-fast.

Rationale:

  • hasFeature — Standard TypeScript type guard pattern, narrows proxy in place
  • getFeature — Properties as T | undefined, works with optional chaining
  • throwMissingFeature — Surfaces misconfiguration immediately (silent return null hides bugs)

StoreProxy<T> and UnknownPlayer

Decision: Generic StoreProxy<T> interface that all proxies implement.

Rationale:

  • Preserves store type through the proxy
  • Index signature [key: string]: unknown allows any property access
  • Uses interfaces (not type aliases) for clearer hover hints

target.media as Flat Proxy

Decision: PlayerTarget.media is an UnknownMedia proxy, not a store.

Rationale:

  • Consistent API — feature authors and component authors use same flat access pattern
  • No .state/.request namespacing to learn
  • Simpler hasFeature/getFeature — only one signature (StoreProxy)

Open Questions

"In-between" Functionality

Where does functionality that's not clearly media or UI go?

Examples needed to clarify boundary.

Feature Author Experience

How many concepts must authors learn? Current: Target types, state, requests, subscribe pattern.

Is this the right level of complexity for the extension story?