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
+65 -62
View File
@@ -44,6 +44,7 @@ import {
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import {
useNativeControlsYield,
@@ -329,72 +330,74 @@ export default function PlayerModal({
};
}, []);
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(e)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
e.preventDefault();
setMaximized(false);
return;
}
onClose();
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
// still scroll that way, exactly like Space defers to a focused button. NOT while the video fills
// the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen
// there: deferring would scroll something invisible and leave the viewer with no volume control at
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
const volume = (e: KeyboardEvent, up: boolean) => {
const covered = isFullscreenRef.current || maximizedRef.current;
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
return;
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing =
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
toggleFullscreen();
} else if (e.key === " " || e.code === "Space") {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (typing || tag === "button" || tag === "a") return;
e.preventDefault();
togglePlay();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
// whenever the focused element can still scroll that way, exactly like Space defers to a
// focused button. NOT while the video fills the screen though — in fullscreen OR in our own
// maximise — because the card is behind/off-screen there: deferring would scroll something
// invisible and leave the viewer with no volume control at all. Both flags, not just
// fullscreen: since F stopped taking the browser's lock, maximise is the common case and
// checking only `isFullscreen` made the keys dead exactly where they matter most.
const up = e.key === "ArrowUp";
const covered = isFullscreenRef.current || maximizedRef.current;
if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return;
e.preventDefault();
nudgeVolume(up ? 5 : -5);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
// previous/next video in the queue (the feed's order or a playlist).
if (typing) return;
e.preventDefault();
const forward = e.key === "ArrowRight";
if (e.shiftKey) {
if (forward) goNext();
else goPrev();
} else {
nudgeSeek(forward ? 5 : -5);
}
}
e.preventDefault();
nudgeVolume(up ? 5 : -5);
};
const seek = (e: KeyboardEvent, secs: number) => {
e.preventDefault();
nudgeSeek(secs);
};
const onKey = (e: KeyboardEvent) => {
dispatch(e, "ytPlayer", {
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
ev.preventDefault();
setMaximized(false);
return;
}
onClose();
},
fullscreen: (ev) => {
ev.preventDefault();
toggleFullscreen();
},
playPause: (ev) => {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
},
volumeUp: (ev) => volume(ev, true),
volumeDown: (ev) => volume(ev, false),
seekBack: (ev) => seek(ev, -5),
seekFwd: (ev) => seek(ev, 5),
prevVideo: (ev) => {
ev.preventDefault();
goPrev();
},
nextVideo: (ev) => {
ev.preventDefault();
goNext();
},
});
};
window.addEventListener("keydown", onKey);
focusModal();
+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);
+72 -96
View File
@@ -53,6 +53,13 @@ import {
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import {
dispatch,
formatChord,
helpLines,
isActivationTarget,
isTypingTarget,
} from "../lib/keymap";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
@@ -617,81 +624,63 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
e.preventDefault();
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
};
const step = (e: KeyboardEvent, id: string | null | undefined) => {
e.preventDefault();
go(id);
};
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
wake(); // keyboard use also reveals the controls
switch (e.key) {
case " ":
// Keyboard use also reveals the controls — but typing in a player field is not "using the
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
// opts in), which is why this is a guard around `wake` rather than an early return.
if (!isTypingTarget(e)) wake();
dispatch(e, "plexPlayer", {
playPause: (ev) => {
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
// playback when the focus isn't on a control — matching PlayerModal's guard.
if (tag === "BUTTON" || tag === "A") return;
e.preventDefault();
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
break;
case "k":
e.preventDefault();
togglePlay();
break;
case "f":
toggleFs();
break;
case "ArrowLeft":
e.preventDefault();
if (e.shiftKey) go(prevId);
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowRight":
e.preventDefault();
if (e.shiftKey) go(nextId);
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowUp":
e.preventDefault();
},
fullscreen: () => toggleFs(),
seekBack: (ev) => seekBy(ev, -1, false),
seekFwd: (ev) => seekBy(ev, 1, false),
fineBack: (ev) => seekBy(ev, -1, true),
fineFwd: (ev) => seekBy(ev, 1, true),
prevEpisode: (ev) => step(ev, prevId),
nextEpisode: (ev) => step(ev, nextId),
volumeUp: (ev) => {
ev.preventDefault();
nudgeVolume(0.05);
break;
case "ArrowDown":
e.preventDefault();
},
volumeDown: (ev) => {
ev.preventDefault();
nudgeVolume(-0.05);
break;
case "m":
toggleMute();
break;
case "a":
case "A":
cycleAudio();
break;
case "s":
case "S":
cycleSubtitle();
break;
case "h":
case "H":
setHelpOpen((v) => !v);
break;
case "i":
case "I":
setInfoOpen((v) => !v);
break;
case "d":
case "D":
setStatsOpen((v) => !v);
break;
case "Backspace":
},
mute: () => toggleMute(),
audio: () => cycleAudio(),
subtitle: () => cycleSubtitle(),
help: () => setHelpOpen((v) => !v),
info: () => setInfoOpen((v) => !v),
stats: () => setStatsOpen((v) => !v),
back: (ev) => {
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
ev.preventDefault();
if (skipProgressRef.current != null)
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
else handleBack();
break;
case "Escape":
},
escape: (ev) => {
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
if (escapeHandled(e)) break;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);
@@ -701,8 +690,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else if (!fullscreenElement()) onClose();
break;
}
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@@ -1261,40 +1250,27 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<X className="w-5 h-5" />
</button>
</div>
{/* Rendered FROM the shared keymap, not from a copy of it beside the handler: the two
used to be edited separately, which is how a sheet ends up documenting a key that no
longer exists. `when: "episode"` rows only apply to an episode. */}
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
{(
[
[["Space", "K"], "plex.player.keys.playPause"],
[["←", "→"], "plex.player.keys.seek"],
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
...(detail?.kind === "episode"
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
: []),
[["↑", "↓"], "plex.player.keys.volume"],
[["M"], "plex.player.keys.mute"],
[["A"], "plex.player.keys.audio"],
[["S"], "plex.player.keys.subtitle"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["D"], "plex.player.keys.stats"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
).map(([keys, labelKey]) => (
<Fragment key={labelKey}>
<dt className="flex flex-wrap gap-1">
{keys.map((k) => (
<kbd
key={k}
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{k}
</kbd>
))}
</dt>
<dd className="text-white/80">{t(labelKey)}</dd>
</Fragment>
))}
{helpLines(["plexPlayer", "global"])
.filter((line) => line.when !== "episode" || detail?.kind === "episode")
.map((line) => (
<Fragment key={line.labelKey}>
<dt className="flex flex-wrap gap-1">
{line.chords.map((chord) => (
<kbd
key={chord}
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{formatChord(chord)}
</kbd>
))}
</dt>
<dd className="text-white/80">{t(line.labelKey)}</dd>
</Fragment>
))}
</dl>
</div>
</div>
+13 -15
View File
@@ -1,10 +1,12 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redo2, Undo2 } from "lucide-react";
import { dispatch } from "../../lib/keymap";
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
// undo source. The shortcuts ignore keystrokes while a text field is focused.
// undo source. The chords and the typing guard come from the shared keymap (`undo` scope), so
// they are listed in the help sheet with everything else instead of only living here.
export default function UndoToolbar({
canUndo,
canRedo,
@@ -20,22 +22,18 @@ export default function UndoToolbar({
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey)) return;
const tgt = e.target as HTMLElement | null;
if (tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable))
return;
const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) {
if (canUndo) {
e.preventDefault();
dispatch(e, "undo", {
undo: (ev) => {
if (!canUndo) return;
ev.preventDefault();
onUndo();
}
} else if (k === "y" || (k === "z" && e.shiftKey)) {
if (canRedo) {
e.preventDefault();
},
redo: (ev) => {
if (!canRedo) return;
ev.preventDefault();
onRedo();
}
}
},
});
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);