feat(e10): one keyboard cheat sheet for the whole app

The Plex player had a shortcuts overlay nobody else did, so the YouTube
player's Space/F/arrows and the Plex browser's Backspace were documented
nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a
global `h` - not the conventional `?`, which is a Shift chord on a
Hungarian layout, while `h` is the key this app's reader already has in
their fingers from the player.

It renders the shared table filtered to the scopes that are LIVE: a
surface registers its scope with useKeyScope while mounted, and only
while its keys really do something - PlexBrowse deactivates its own
scope inside the player, where its Backspace no-ops, so the sheet cannot
promise a "Back one level" the handler declines to perform. Same rule
for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer
the player publishes because the sheet cannot ask.

No new overlay primitive, after checking: Modal already portals into
document.fullscreenElement ?? document.body (so this works over a
fullscreen player), registers an Escape layer, traps focus and handles
browser Back. Third time this epic that the thing was already built.

Shift+F10 is in the table as `handledLocally` - documented and sharing
the chord parser, but matched by useContextMenu on the focused row.
dispatch neither routes it nor demands a handler for it, so the
exhaustiveness check stays honest about what it actually covers.

Also: A/S have cycled the Plex audio and subtitle tracks since E5, but
silently - the only way to see where you landed was to open the tracks
menu, the exact round-trip the shortcut exists to avoid. cycleAudio and
cycleSubtitle now return the track they moved to, and the player flashes
it over the video.
This commit is contained in:
2026-09-05 21:15:42 +02:00
parent 12822a9e04
commit 86163958d6
10 changed files with 389 additions and 107 deletions
+85 -4
View File
@@ -1,3 +1,6 @@
import { useEffect } from "react";
import { createStore } from "./store";
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
// Before E10 S3 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
@@ -31,6 +34,10 @@ export interface Shortcut {
* help line with their chords side by side — that is how "← → Seek" stays a single row. */
readonly labelKey: string;
readonly when?: ShortcutWhen;
/** Matched at the element level rather than through `dispatch` — a row that is here to be
* DOCUMENTED and to share the chord parser, not to be routed. `dispatch` neither requires nor
* accepts a handler for it, so the exhaustiveness check stays honest about what it covers. */
readonly handledLocally?: boolean;
/** 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. */
@@ -42,7 +49,18 @@ export interface Shortcut {
// focused button reports `key === " "` inconsistently across engines).
export const SHORTCUTS = [
// --- global ---------------------------------------------------------------------------------
{ id: "contextMenu", scope: "global", keys: ["Shift+F10"], labelKey: "keys.contextMenu" },
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
// have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning.
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
// Matched by `useContextMenu` on the focused row, not by a window-level dispatch: which row the
// menu belongs to is a property of focus, not of the app.
{
id: "contextMenu",
scope: "global",
keys: ["Shift+F10", "ContextMenu"],
labelKey: "keys.contextMenu",
handledLocally: true,
},
// --- undo/redo (wherever an UndoToolbar is mounted) -----------------------------------------
{ id: "undo", scope: "undo", keys: ["Mod+z"], labelKey: "keys.undo" },
@@ -91,7 +109,6 @@ export const SHORTCUTS = [
{ 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
@@ -117,7 +134,10 @@ 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<S extends KeyScope> = Extract<Row, { scope: S }>["id"];
export type ShortcutId<S extends KeyScope> = Extract<
Row,
{ scope: S; handledLocally?: undefined }
>["id"];
/** True while focus sits somewhere that swallows plain keys. The ONE definition: before S3 the
* players disagreed about `<select>` and `contentEditable`, so the same keystroke was a shortcut in
@@ -169,7 +189,7 @@ export function dispatch<S extends KeyScope>(
): boolean {
const typing = isTypingTarget(e);
for (const s of ROWS) {
if (s.scope !== scope) continue;
if (s.scope !== scope || s.handledLocally) continue;
if (typing && !s.allowInInput) continue;
if (!s.keys.some((k) => matches(e, k))) continue;
(handlers as Record<string, (ev: KeyboardEvent) => void>)[s.id]?.(e);
@@ -178,6 +198,66 @@ export function dispatch<S extends KeyScope>(
return false;
}
// ── Which scopes are live right now ───────────────────────────────────────────────────────────
// A stack rather than a single value: surfaces nest (the Plex player opens over the Plex browser,
// a reorderable playlist has both its page and an UndoToolbar). Innermost first, so the help sheet
// leads with the surface you are actually in and trails with the app-wide keys.
//
// Push a scope only while its keys really DO something — `PlexBrowse` deactivates its own scope
// while the player is up, because its Backspace handler no-ops there and a sheet promising "Back
// one level" would be lying. That is the honest-states rule applied to documentation.
const scopeStack = createStore<readonly KeyScope[]>([]);
/** Register `scope` as live for as long as this component is mounted and `active`. */
export function useKeyScope(scope: KeyScope, active = true): void {
useEffect(() => {
if (!active) return;
const token = { scope };
tokens.push(token);
scopeStack.set(tokens.map((t) => t.scope));
return () => {
tokens.splice(tokens.indexOf(token), 1);
scopeStack.set(tokens.map((t) => t.scope));
};
}, [scope, active]);
}
// Identity tokens, so unmounting removes THIS registration and not another instance of the same
// scope that happens to sit earlier in the array.
const tokens: { scope: KeyScope }[] = [];
/** The live scopes, innermost first, always ending in `global`. Re-renders on change. */
export function useActiveScopes(): KeyScope[] {
const stack = scopeStack.use();
return [...stack].reverse().concat("global");
}
// ── The keyboard help sheet ───────────────────────────────────────────────────────────────────
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
// there is exactly one answer to "what can I press here" instead of one per player.
const helpOpen = createStore(false);
export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
/** Answers for the `when` conditions, published by the surface that knows them. */
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
const helpConditions = createStore<HelpConditions>({});
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
* sheet lives in App and cannot ask, so the surface tells it. */
export function useHelpConditions(conditions: HelpConditions): void {
const episode = conditions.episode;
useEffect(() => {
helpConditions.set({ episode });
return () => helpConditions.set({});
}, [episode]);
}
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
// ── Display ───────────────────────────────────────────────────────────────────────────────────
const GLYPHS: Record<string, string> = {
ArrowLeft: "←",
@@ -185,6 +265,7 @@ const GLYPHS: Record<string, string> = {
ArrowUp: "↑",
ArrowDown: "↓",
Backspace: "⌫",
ContextMenu: "Menu", // the dedicated menu key; "ContextMenu" is a DOM name, not a keycap
Escape: "Esc",
Shift: "⇧",
};