--- 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) | | `>` | > key, no modifiers (on US keyboards, produced by Shift+.) | | `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. - **Implicit modifiers (Shift, Alt)** — For single non-letter character keys (`>`, `<`, `?`, `!`, etc.), `shiftKey` and `altKey` are ignored during matching. These characters require Shift on some layouts (US: `Shift+.` produces `>`), Alt/Option on others (some Mac layouts: `Option+Shift` produces `>`), or neither (some European layouts: `>` is an unshifted key). Explicitly specifying `Shift+>` or `Alt+>` still requires that modifier to be held. `ctrlKey` and `metaKey` remain strict — they are intentional modifier shortcuts, never layout artifacts. - `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), `