Skip to content

Conversation

@mihar-22
Copy link
Member

@mihar-22 mihar-22 commented Jan 8, 2026

closes #301

Adds planning document for the Player API Design — coordinating Media and Container concerns.

Summary

  • Dual-store coordination pattern for Media + Container concerns
  • createPlayerStore() factory that composes both stores
  • Player slices with cross-store access to media

Goals

  • Composability — Build players by combining independent pieces
  • Tree-shaking — Bundle only what you use
  • Extensibility — Extend without forking
  • Progressive complexity — From skin to fully custom

Document

  • .claude/plans/player-api-design.md (view)

This is an RFC for team review. See linked issue #301 for problem statement.

@mihar-22 mihar-22 changed the title Player Architecture Plans [RFC]: Player Architecture Plans Jan 8, 2026
@mihar-22 mihar-22 changed the title [RFC]: Player Architecture Plans [RFC] Player Architecture Plans Jan 8, 2026
@mihar-22 mihar-22 changed the title [RFC] Player Architecture Plans [RFC] Player & Provider Architecture Jan 8, 2026
@mihar-22 mihar-22 changed the title [RFC] Player & Provider Architecture [RFC] Player & Media Architecture Jan 8, 2026
@mihar-22 mihar-22 marked this pull request as ready for review January 8, 2026 14:01
@mihar-22 mihar-22 mentioned this pull request Jan 8, 2026
32 tasks
@vercel
Copy link

vercel bot commented Jan 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

4 Skipped Deployments
Project Deployment Review Updated (UTC)
vjs-10-demo-html Ignored Ignored Preview Jan 16, 2026 6:54am
vjs-10-demo-next Ignored Ignored Preview Jan 16, 2026 6:54am
vjs-10-demo-react Ignored Ignored Preview Jan 16, 2026 6:54am
vjs-10-website Ignored Ignored Preview Jan 16, 2026 6:54am

Review with Vercel Agent

Copy link
Member

@heff heff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting nitpicky now but it's bring up some important details.

There's a bunch of naming details we need to nail down. I called out in this comment I think we need to not use the term "Media" so generally in the naming of things here, but it doesn't look anything changed.

We need make the HTML side match the Getting Started guide more.
There's details in here that still aren't up to date with some of our later conversations, like the container living inside the skin, and the Media needing to be a child of the skin.
I think this is where diffing with the Getting Started guide will help.

@mihar-22 mihar-22 changed the title [RFC] Player Architecture [RFC] Player API Architecture Jan 15, 2026
@mihar-22 mihar-22 changed the title [RFC] Player API Architecture [RFC] Player API Design Jan 15, 2026
Comment on lines +26 to +27
const paused = usePlayer((s) => s.paused);
const { play, pause } = usePlayer().request;
Copy link
Collaborator

@decepulis decepulis Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without understanding underlying architecture, it's not initially obvious to me why we have two different patterns for state and requests. (I mean, I know you can also usePlayer().state, so, we have three patterns!)

I'd love to get an intuitive understanding of why we did it this way so that we can build an intuition in the docs.
Or, if it's not too late, maybe we could talk some more about APIs like usePlayerRequest and usePlayerState. I see they've been considered before, but, since I'm tuning in at the end, I'm not sure why they were discarded 😬

Copy link
Member Author

@mihar-22 mihar-22 Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Welcome, @decepulis!

Why

I originally had hooks separated by concern, but after discussing with Heff, we decided to simplify.

One hook = less to think about.

With separate hooks, users have to decide: "Do I need state or request? Both? Which import?" With usePlayer, there's one answer to "how do I interact with the player?"

Zustand works this way: useBearStore gives you state and actions together, not useBearState and useBearActions.

We aren't "flat" like Zustand but the separation in the return (state, request) is intentional atm — state = snapshot (read), request = method (write). Different mental models, but accessed from one place. You learn one hook, then discover the shape.

Important to callout difference between .state and selector:

usePlayer().state // this doesn't trigger re-renders (static)
usePlayer(s => s.paused) // this does (reactive)

❓ That said, answering your question here does raise a question: could it just be flat? I do like player.play() over request.play(), leans more into users not having to know state vs. request. One answer is the split avoids naming collisions. State keys and request methods are separate objects on slices, so they can share names without conflict. Whether that's worth the extra nesting is worth discussing.

Conflicted

I'm still torn between usePlayer and usePlayerStore. Leaning toward usePlayerStore for ecosystem familiarity (Zustand uses useBearStore, etc.), but there's a valid argument for concise — and the factory is already createPlayerStore, so "Store" is assumed.

Questions

  • Does one hook with multiple return shapes feel like too much to hold in your head at once? Would "flat" be more appealing player = usePlayer() and then player.* for state/requests?
  • Is there a library or pattern you've used where the separate hooks felt clearer? What makes usePlayerState and usePlayerStore appealing to you?

Copy link
Collaborator

@decepulis decepulis Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still torn between usePlayer and usePlayerStore

Honestly? You're not gonna get a strong opinion from me. I have a mild opinion towards usePlayer since I didn't encounter the concept of a store until I started working with Svelte, so, I don't want to assume that word is going to mean anything to a React user. But, like, we're already using the term in the factory. So. Idk. I'm not gonna be much help here.

Does one hook with multiple return shapes feel like too much to hold in your head at once?
Is there a library or pattern you've used where the separate hooks felt clearer? What makes usePlayerState and usePlayerStore appealing to you?

Note

If folks can learn zustand, they can learn this pattern, too. This conversation is still helpful -- No matter what we decide, I'll want to explain this well in the docs -- but ultimately, these concerns are non-blocking for me.

My ideal world would be const { paused, play } = usePlayer(). But of course, that world doesn't exist. First of all, because it doesn't offer the option between reactive and non-reactive state. (And I'm not sure there's a clean way to offer that†.) And second of all, because of namespace collisions.

Given those constraints, what would I rather see? I feel friction remembering 1. that there are three types of things you get from the store (unavoidable) and 2. the three syntaxes of the overloaded function (avoidable).

One solution? Three functions. usePlayerState, usePlayerCurrent††, and usePlayerRequest. Sure, you still have to make a decision, but at least you don't have to remember syntax.

But you would still have to remember three function names. So... what if...

const { state, current, request } = usePlayer() // ††
const reactivePaused = state(s => s.paused)

Then, the three modes are unavoidable and self-documenting.

Or we might encourage the shorthands

const play = usePlayer().request.play
const reactivePaused = usePlayer().state(s => s.paused)
const notReactivePaused = usePlayer().current.paused

It's still not ideal. It's still weird that state() is a function and request isn't. But. Whatever.

† claude says this is possible with some proxy magic, like in valtio. I'm not familiar with js proxies, so, I haven't wrapped my mind around this yet. Nor do I have to, because the namespace collision concern still remains.

†† is the non-reactive state more like a ref, in that it changes over time without triggering re-renders, or more like a snapshot, where it's read once at render-time? This matters in event handlers. If the value changes over time, we could call it ref or current. If it doesn't, maybe snapshot


class VjsPlayButton extends VjsElement {
// Selector subscribes — triggers requestUpdate() when paused changes
#paused = new PlayerController(this, (s) => s.paused);
Copy link
Collaborator

@luwes luwes Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#paused = new PlayerController(this, (s) => s.paused);

Is there an alternative? Something like:
Creating a new instance for each subscription doesn't feel right.

#player = new PlayerController(this);
#paused = this.#controller.observe((s) => s.paused)

Copy link
Member Author

@mihar-22 mihar-22 Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes. I like it.

I was thinking the following could help with that, but still sucks if you need both subscription and then request methods.

new PlayerController(this, s => ({ paused: s.paused, ended: s.ended }));

observe sounds good, we could also do .select or .watch (align with lit signals) naming:

#player = new PlayerController(this); // state, request, watch

// Watch
#paused = this.#player.watch(s => s.paused);

// Read
this.#paused.value;

// Remove selector from here
new PlayerController(this, s => s);

Could be subscribe as well to align with the store, but it looks funny and you usually expect a unsubscribe function returned, not value.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, watch() sounds good

### createPlayerStore

```ts
// Shorthand — preset or slice array
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the name "slice" set in stone? If not, it would be helpful to have some explanation of what a "slice" is, and what it's a slice of. After reading the entire doc I get general idea that these are feature sets, but I was only able to draw that conclusion after carefully reading all of the code examples. Thinking of them as features made things click for me, with presets being pre-bundled collections of features, etc. Are "slices" a design pattern specific to this new architecture? Doing a search for slice, specifically in a JS/TS context, only brings up the references to Array|String|TypedArray.prototype.slice. If this is an established pattern, can we get some external links?

Copy link
Member Author

@mihar-22 mihar-22 Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, Casey!

I've been having so many background conversations, and exploring all over that I forgot the obvious. First-time reader experience. Sorry about that.

I've gone ahead and added TOC, intro to those concepts you called out, and link to our store package. Let me know if I can help unpack anything.

👉 Updated

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the term "slice" caught me, too. Is there a term we could use that might have more intrinsic meaning? How do you think about the term "feature"?

```ts
// No selector — read state, make requests, no subscription
#player = new PlayerController(this);
this.#player.state.paused; // current value (may be stale if not subscribed)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope when accessing this with the whole prop chain this.#player.state.paused it is fresh.
Why would it not be?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm torn on this one Wes, because there's some real trade-offs.

💪 Benefits of live state:

  • Single source of truth - Target IS the state, we're just reading it
  • Always correct - Never lying about what the target has right now
  • Forgives bad slice wiring - If subscribe misses an event, state still works (note: subscribers are now out of sync which is bad)
  • Predictable - No wondering "is this fresh?" - it always is

Making state truly live has trade-offs:

  • Side-effectful reads - Reads should be pure. Live state that syncs makes reads have side effects. Footgun for debugging, testing, and reasoning about code.
  • Calls getSnapshot() on every access (perf cost if used in render paths, harder to avoid in React).
  • Need separate snapshot property for React (useSyncExternalStore needs reference stability). Two properties with different behaviors = confusing API
  • Inconsistency or race condition, subscriber callback receives cached state, but store.state returns live. Could see different values.
  • Testing harder - Can't easily mock/control what state returns without controlling target

😐 Current design (cached, event-driven) feels like right trade-off:

  • HTMLMediaElement fires events for property changes (volumechange, pause, etc.)
  • Staleness window is non-existent between property change and event dispatch (event dispatch and store patch is sync)
  • Simpler mental model, better perf

Options:

  1. Keep current - trust event wiring
  2. Add explicit getter or method for live reads when needed
  3. Make state live + add snapshot for cached (confusing)

getSnapshot: A function that returns a snapshot of the data in the store that’s needed by the component. While the store has not changed, repeated calls to getSnapshot must return the same value. If the store changes and the returned value is different (as compared by Object.is), React re-renders the component.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooh, yes I meant fresh in the sense the state is synced on all events. not always getting it live from the target.

| React | Lit |
| ---------------------------- | ------------------------------------------- |
| `usePlayer()` | `new PlayerController(this)` |
| `usePlayer(s => s.paused)` | `new PlayerController(this, s => s.paused)` |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same concern, hope we can use a cached controller, call a method to subscribe.

Comment on lines +108 to +114
<script type="module" src="@videojs/html/presets/website/skins/frosted.js"></script>

<vjs-website-provider>
<vjs-frosted-skin>
<video src="video.mp4"></video>
</vjs-frosted-skin>
</vjs-website-provider>
Copy link
Member Author

@mihar-22 mihar-22 Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@heff, questions:

  1. Since the import path makes it clear we're importing from the preset, should we consider just having vjs-frosted-skin without the provider?

  2. Issue: related to my comment in Notion-ish... my concern is having minimal/frosted skins per preset will lead to a lot of confusion. In this case, how do we differentiate skin element names per preset? vjs-website-frosted-skin starting to sound silly to me.

I can see a world where there's just a single default skin per preset: vjs-website-skin.

What's your thoughts here?

Copy link
Member Author

@mihar-22 mihar-22 Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another argument in favour of simplicity is that Video.js and Plyr did really well because the default path was obvious. No decision required. No browsing skins. No feature comparisons.

<!-- Preset has both your player configuration and skin setup here -->
<script type="module" src="@videojs/html/presets/website.js"></script>

<vjs-website-skin>
  <video src="video.mp4"></video>
</vjs-website-skin>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing I realized... if we allow multiple skins per preset, the store has to support the skin with the most features. So what does "minimal" actually mean? Minimal UI components? Feels confusing to me.

Preset, features, and skin should be the same thing. If you want streaming features with fewer controls, that's eject plus build your own. One preset, one skin, one bundle.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe let's discuss this one IRL first? Prob best I move this conversation to a separate RFC for Presets.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure we don't want to structure skins per / under preset. The way we did it in the MC themes seems more natural. The skin might support a feature / state slice but it's not a requirement.

I can see a world where there's just a single default skin per preset: vjs-website-skin.

Why are we coupling preset to skin so tightly?

Comment on lines +139 to +140
createPlayerStore(presets.website);
createPlayerStore([slices.playback, slices.fullscreen]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, I'd prefer

Suggested change
createPlayerStore(presets.website);
createPlayerStore([slices.playback, slices.fullscreen]);
createPlayerStore({ slices: presets.website });
createPlayerStore({ slices: [slices.playback, slices.fullscreen] });

Just feels less-magic. When the time comes to expand, folks know what's what and where to go.
Weak preference, though. I see the value in shorthands, too.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it's an option below.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. To clarify, I'm proposing not having a shorthand! It's more verbose, but also more instructional. Might be a worthwhile trade-off.

Comment on lines +338 to +343
import { FrostedSkin } from '@videojs/react/presets/website';

const { Provider } = createPlayerStore(presets.website);

<Provider>
<FrostedSkin>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing we'll want to have some logic in the skin to check for the presence of the required slices and warn if something's missing and fail silently. I'm sure you had something in another doc for this but I thought I'd raise it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's worth bundling the preset slices and related skins together to avoid that or would there ever be a scenario where we don't want to do that?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked the same question in an internal conversation!

lol and then about an hour later, I answered my own question like this:

What if the user wants to add more slices? Or what if the user wants custom controls outside the chrome of the player?

Conceivably we could pass a config object to the skin so that users could add additional slices to address the former concern. We could have the skin detect if it’s in a provider (or pass some withoutProvider prop) for the latter concern.

But. Neither of those solutions feels particularly better than what we have here.

@heff and @mihar-22 agreed with my train of thought, there.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! My concern was really around a mismatch of slices vs UI but perhaps it's just a case of docs and warning users about it at run time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discovery: Player API Design

7 participants