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
<select> and contentEditable where the other two surfaces covered both.

lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own
listener and closures - PlexPlayer's refs are not worth re-plumbing -
but the chords, the modifier rules and the typing guard live in one
place, and dispatch() binds a scope's rows to handlers through a mapped
type, so a row without a handler (or a handler without a row) is a tsc
error rather than a comment someone forgets.

Matching is now exact on modifiers, which is what kills the Ctrl+A bug
and lets Shift+arrow be its own row beside the bare arrow. Escape opts
out of the typing guard (allowInInput) so it stays the way out of a
player's own subtitle-offset field.

The player's shortcut sheet renders helpLines() instead of a hand-copied
array beside the handler - the two used to be edited separately, which
is how a sheet ends up documenting a key that no longer exists. Labels
move to the new `keys` i18n namespace (EN + HU), and a test asserts
every row is named in both.
This commit is contained in:
2026-09-05 20:58:17 +02:00
parent 928eac4c89
commit 12822a9e04
10 changed files with 663 additions and 226 deletions
+24 -23
View File
@@ -39,6 +39,7 @@ import { usePlex, usePlexActions } from "./PlexProvider";
import { usePublishPlexView } from "./PlexViewBridge";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch } from "../lib/keymap";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -163,31 +164,31 @@ export default function PlexBrowse() {
}, [sub.view.kind]);
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
// and never hijack Backspace while typing in a field.
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here
// (its "player" view kind is deliberately absent from the list below); the typing guard and the
// chord itself come from the shared keymap.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Backspace") return;
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
if (
[
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
"collection",
].includes(sub.view.kind)
) {
e.preventDefault();
sub.back();
}
dispatch(e, "plexBrowse", {
back: (ev) => {
if (
![
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
"collection",
].includes(sub.view.kind)
)
return;
ev.preventDefault();
sub.back();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);