From 12822a9e046a68fc880a2bce2336af769722c125 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 20:58:17 +0200 Subject: [PATCH] feat(e10): put every keyboard shortcut in one table Six surfaces each owned a private keydown listener with its own key literals and its own idea of "am I typing?", and they had drifted: PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A (select all) cycled the audio track, and its typing guard missed ` and `contentEditable`, which the other two surfaces already covered. The classic +// comment-vs-code drift this codebase keeps getting bitten by, one layer up. +// +// The fix is a TABLE, not a dispatcher. Each surface keeps its own listener and its own closures — +// `PlexPlayer`'s 1300 lines of refs are not worth re-plumbing — but the key literals, the modifier +// rules and the typing guard all live here, and `dispatch()` binds a scope's rows to handlers with +// a mapped type. So a row without a handler (or a handler without a row) is a **tsc error**, not a +// comment someone forgets: the parity is enforced by the gate, which is the house rule. +// +// It is also the source for the keyboard help sheet — every shortcut in the app is listed here, in +// one place, and the sheet renders this table rather than a hand-copied array beside it. + +/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface + * is mounted. The help sheet shows `global` plus the topmost registered scope. */ +export type KeyScope = "global" | "undo" | "ytPlayer" | "plexPlayer" | "plexBrowse"; + +/** Extra render condition for the help sheet (the shortcut itself is always live). */ +type ShortcutWhen = "episode"; + +export interface Shortcut { + /** Unique within its scope; the handler key `dispatch()` demands. */ + readonly id: string; + readonly scope: KeyScope; + /** Chords that trigger it, in the `matches()` mini-syntax. All are listed in the help sheet. */ + readonly keys: readonly string[]; + /** i18n key (the `keys` namespace) for the description. Rows SHARING a labelKey are shown as one + * help line with their chords side by side — that is how "← → Seek" stays a single row. */ + readonly labelKey: string; + readonly when?: ShortcutWhen; + /** Fires even while a text field has focus. Escape only: it is the universal "get me out of here", + * and a player whose Escape died inside its own subtitle-offset input is a trap. Every other + * shortcut stays out of the way of typing. */ + readonly allowInInput?: boolean; +} + +// Chord syntax: `Mod` = Ctrl on Windows/Linux, ⌘ on macOS. `Shift`/`Alt` are literal. The final +// segment is a `KeyboardEvent.key` name, except `Space` (which also accepts `code`, because a +// focused button reports `key === " "` inconsistently across engines). +export const SHORTCUTS = [ + // --- global --------------------------------------------------------------------------------- + { id: "contextMenu", scope: "global", keys: ["Shift+F10"], labelKey: "keys.contextMenu" }, + + // --- undo/redo (wherever an UndoToolbar is mounted) ----------------------------------------- + { id: "undo", scope: "undo", keys: ["Mod+z"], labelKey: "keys.undo" }, + { id: "redo", scope: "undo", keys: ["Mod+y", "Mod+Shift+z"], labelKey: "keys.redo" }, + + // --- the YouTube player modal --------------------------------------------------------------- + { id: "playPause", scope: "ytPlayer", keys: ["Space"], labelKey: "keys.playPause" }, + { id: "fullscreen", scope: "ytPlayer", keys: ["f"], labelKey: "keys.fullscreen" }, + { id: "seekBack", scope: "ytPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" }, + { id: "seekFwd", scope: "ytPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" }, + { id: "prevVideo", scope: "ytPlayer", keys: ["Shift+ArrowLeft"], labelKey: "keys.video" }, + { id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" }, + { id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" }, + { id: "volumeDown", scope: "ytPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" }, + { + id: "close", + scope: "ytPlayer", + keys: ["Escape"], + labelKey: "keys.closePlayer", + allowInInput: true, + }, + + // --- the Plex player ------------------------------------------------------------------------ + { id: "playPause", scope: "plexPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" }, + { id: "fullscreen", scope: "plexPlayer", keys: ["f"], labelKey: "keys.fullscreen" }, + { id: "seekBack", scope: "plexPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek" }, + { id: "seekFwd", scope: "plexPlayer", keys: ["ArrowRight"], labelKey: "keys.seek" }, + { id: "fineBack", scope: "plexPlayer", keys: ["Mod+ArrowLeft"], labelKey: "keys.fineSeek" }, + { id: "fineFwd", scope: "plexPlayer", keys: ["Mod+ArrowRight"], labelKey: "keys.fineSeek" }, + { + id: "prevEpisode", + scope: "plexPlayer", + keys: ["Shift+ArrowLeft"], + labelKey: "keys.episode", + when: "episode", + }, + { + id: "nextEpisode", + scope: "plexPlayer", + keys: ["Shift+ArrowRight"], + labelKey: "keys.episode", + when: "episode", + }, + { id: "volumeUp", scope: "plexPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" }, + { id: "volumeDown", scope: "plexPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" }, + { id: "mute", scope: "plexPlayer", keys: ["m"], labelKey: "keys.mute" }, + { id: "audio", scope: "plexPlayer", keys: ["a"], labelKey: "keys.audio" }, + { id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" }, + { id: "help", scope: "plexPlayer", keys: ["h"], labelKey: "keys.help" }, + { id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" }, + { id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" }, + // Two rows, one help line: ⌫ is the plain "stop & out", Escape is the layer-aware cascade (defer to + // anything above → cancel auto-skip → close the topmost player overlay → close). Sharing a labelKey + // keeps them a single "⌫ Esc Stop & back to feed" row in the sheet. + { id: "back", scope: "plexPlayer", keys: ["Backspace"], labelKey: "keys.back" }, + { + id: "escape", + scope: "plexPlayer", + keys: ["Escape"], + labelKey: "keys.back", + allowInInput: true, + }, + + // --- the Plex browser (drill-down) ---------------------------------------------------------- + { id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" }, +] as const satisfies readonly Shortcut[]; + +// `as const` keeps the ids literal (that is what makes `dispatch`'s handler map exhaustive), but it +// also narrows each row to its own shape — so the optional `when`/`allowInInput` vanish from rows +// that omit them. Iterate through this widened view; read ids through `Row`. +const ROWS: readonly Shortcut[] = SHORTCUTS; + +type Row = (typeof SHORTCUTS)[number]; +/** The shortcut ids defined for one scope — the exact handler keys `dispatch()` requires. */ +export type ShortcutId = Extract["id"]; + +/** True while focus sits somewhere that swallows plain keys. The ONE definition: before S3 the + * players disagreed about `