mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
285 lines
8.7 KiB
Markdown
285 lines
8.7 KiB
Markdown
# 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:
|
|
```ts
|
|
const { Provider: VideoProvider } = createPlayer(...);
|
|
```
|
|
|
|
## API Shape
|
|
|
|
### Flat `usePlayer` Return
|
|
|
|
**Decision:** Return flattened object with state and requests at same level, no `.state`/`.request` namespaces.
|
|
|
|
```tsx
|
|
// 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.
|
|
|
|
```tsx
|
|
// 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`).
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```tsx
|
|
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](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.
|
|
|
|
```ts
|
|
// 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](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?
|