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
@@ -0,0 +1,72 @@
import { Fragment, useEffect } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import {
closeKeyboardHelp,
dispatch,
formatChord,
helpLines,
toggleKeyboardHelp,
useActiveScopes,
useHelpConditionValues,
useKeyboardHelpOpen,
} from "../../lib/keymap";
// The app's one keyboard cheat sheet, plus the global `h` that opens it. Mounted once by App.
//
// It renders the shared SHORTCUTS table filtered to the scopes that are live right now — the Plex
// player's keys while you are in the player, the app-wide ones underneath — so there is a single
// answer to "what can I press here" and nothing to keep in sync. Before this, the Plex player had a
// cheat sheet nobody else did, listing an array hand-copied from its own handler.
//
// `h` rather than the conventional `?`: on a Hungarian layout `?` is a Shift chord, and `h` is the
// key this app's reader already has in their fingers from the player.
//
// Deliberately NOT a new overlay primitive: `Modal` already portals into
// `document.fullscreenElement ?? document.body` (so this works over a fullscreen player), registers
// a layer so Escape closes it before the player underneath, traps focus and handles browser Back.
export default function KeyboardHelp() {
const { t } = useTranslation();
const open = useKeyboardHelpOpen();
const scopes = useActiveScopes();
const conditions = useHelpConditionValues();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
dispatch(e, "global", {
help: (ev) => {
ev.preventDefault();
toggleKeyboardHelp();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
if (!open) return null;
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
// a promise the player does not keep.
const lines = helpLines(scopes).filter((l) => !l.when || !!conditions[l.when]);
return (
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
{lines.map((line) => (
<Fragment key={line.labelKey}>
<dt className="flex flex-wrap gap-1">
{line.chords.map((chord) => (
<kbd
key={chord}
className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{formatChord(chord)}
</kbd>
))}
</dt>
<dd className="text-muted">{t(line.labelKey)}</dd>
</Fragment>
))}
</dl>
</Modal>
);
}