diff --git a/internal/design/ui/hotkey.md b/internal/design/ui/hotkey.md new file mode 100644 index 00000000..ad94ca42 --- /dev/null +++ b/internal/design/ui/hotkey.md @@ -0,0 +1,377 @@ +--- +status: draft +date: 2026-04-06 +--- + +# Hotkey + +Declarative keyboard shortcuts for media controls. + +## Problem + +Video players need keyboard shortcuts for common actions — play/pause, seek, volume, fullscreen, mute, captions. Users expect them. Every major player ships them. Video.js 10 currently has none. + +Requirements: + +- Declarative HTML-first API — configure via markup, not script +- Configurable key bindings with modifier support +- Input safety — shortcuts must not fire while typing in text fields +- Container-scoped by default, with document-scope opt-in +- Tree-shakeable — unused bindings don't ship +- Cross-framework — same core logic powers HTML and React +- Accessible — controls can discover and announce their bound shortcuts + +## API + +### Component + +A single generic `` component with `keys` and `action` props. + +Each element declares one binding. To bind multiple keys to the same action, use multiple elements. This avoids delimiter ambiguity — `+` is already the modifier separator. + +#### HTML + +```html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +#### React + +```tsx + + + + + +``` + +`keys` (not `key`) avoids collision with React's reserved `key` prop. + +#### Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `keys` | `string` | — | Key pattern to match. Required. | +| `action` | `string` | — | Hotkey action to execute. Required. | +| `value` | `number` | — | Numeric argument for the action (e.g., seek offset, volume step). Sign indicates direction. | +| `disabled` | `boolean` | `false` | Disables this binding. | +| `target` | `'player' \| 'document'` | `'player'` | Where to listen for key events. | +| `event` | `'keydown' \| 'keyup'` | `'keydown'` | Which keyboard event to listen on. | + +The `value` prop is intentionally generic — typing it precisely per action would require discriminated unions that add complexity for marginal safety. The underlying factory functions provide precise types. + +Invalid actions are no-ops with a `__DEV__` warning. + +#### Actions + +Actions are hotkey-specific behaviors — not 1:1 mirrors of store methods. The store exposes `play()`, `pause()`, `seek(time)`, `setVolume(level)`, `setPlaybackRate(rate)`, `toggleMuted()`, `requestFullscreen()`, `exitFullscreen()`, `toggleSubtitles()`, `requestPictureInPicture()`, `exitPictureInPicture()`. Hotkey actions wrap these with the input logic users expect from keyboard shortcuts. + +**Toggle actions** — no `value`: + +| Action | Behavior | +|---|---| +| `togglePaused` | Toggle between play and pause. | +| `toggleMuted` | Toggle mute state. | +| `toggleFullscreen` | Toggle fullscreen mode. | +| `toggleSubtitles` | Toggle captions/subtitles visibility. | +| `togglePiP` | Toggle picture-in-picture mode. | + +**Relative actions** — `value` is a signed offset applied to current state: + +| Action | Behavior | +|---|---| +| `seekStep` | Seek by `value` seconds from current time. Positive = forward, negative = backward. | +| `volumeStep` | Adjust volume by `value` (0–1 scale). Positive = louder, negative = quieter. | + +**Discrete step actions** — step through the `playbackRates` array, no `value`: + +| Action | Behavior | +|---|---| +| `speedUp` | Step to the next higher rate in `playbackRates`. Wraps to lowest if at highest. | +| `speedDown` | Step to the next lower rate in `playbackRates`. Wraps to highest if at lowest. | + +**Percentage seek:** + +| Action | Behavior | +|---|---| +| `seekToPercent` | Jump to a percentage of duration. If `value` is set, uses it directly (0–100). If `value` is omitted, derives from the key — digit keys produce `digit × 10` (e.g., `3` → 30%). No-op if the key isn't a digit and no `value` is set. | + +### Key matching + +Key patterns use `KeyboardEvent.key` values — layout-dependent, mnemonic ("K for play", "F for fullscreen"). This is consistent with the existing keyboard handling in the slider and button factories. + +**Format:** `[Modifier+]...Key` + +| Pattern | Matches | +|---|---| +| `k` | K key, no modifiers | +| `Space` | Space bar | +| `ArrowLeft` | Left arrow | +| `0-9` | Any digit key (0 through 9) | +| `Shift+>` | Shift + > (the shifted character, not the unshifted key) | +| `Mod+k` | Cmd+K on macOS, Ctrl+K elsewhere | +| `Ctrl+Shift+f` | Ctrl + Shift + F | + +**Ranges:** + +`0-9` matches any single digit key. The range expands at registration time into individual bindings — one per key in the range. The matched key is available to the action handler, which is how `seekToPercent` knows which digit was pressed. + +Only `0-9` is supported. Arbitrary ranges (e.g., `a-z`) are not — there's no use case for them in media shortcuts. + +**Modifiers:** + +| Name | Maps to | +|---|---| +| `Shift` | `event.shiftKey` | +| `Ctrl` | `event.ctrlKey` | +| `Alt` | `event.altKey` | +| `Meta` | `event.metaKey` | +| `Mod` | `event.metaKey` on macOS, `event.ctrlKey` elsewhere | + +**Rules:** + +- Patterns are parsed into `{ modifiers, key }` at registration time — no parsing per event. +- `event.key` is compared case-insensitively. +- **Exact modifier matching** — all specified modifiers must be active, all unspecified must be inactive. `k` does not fire when Ctrl+K is pressed. +- `Mod` resolves at parse time based on platform detection (`navigator.userAgentData?.platform` with `navigator.platform` fallback). +- IME composition input is filtered — events where `event.key === 'Unidentified'` are skipped. + +### Conflict resolution + +When multiple `` elements could match the same key event: + +**Specificity** — A binding with more modifiers takes priority. `Shift+ArrowLeft` beats `ArrowLeft` when Shift is held. + +**DOM order** — Among equal-specificity bindings, the first registered fires. This matches how the browser resolves duplicate event listeners. + +**One fires** — After a match, remaining bindings for that event are skipped. + +**Input safety** — Single-key shortcuts (no modifiers) are suppressed when the event target is editable: `` (text types), `