From 86163958d6e19530b0ae422ccf20e76bb3249f56 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 21:15:42 +0200 Subject: [PATCH] feat(e10): one keyboard cheat sheet for the whole app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 4 + frontend/src/components/PlayerModal.tsx | 4 +- frontend/src/components/PlexBrowse.tsx | 41 ++--- frontend/src/components/PlexPlayer.tsx | 148 +++++++++--------- .../components/ui/KeyboardHelp.dom.test.tsx | 100 ++++++++++++ frontend/src/components/ui/KeyboardHelp.tsx | 72 +++++++++ frontend/src/components/ui/UndoToolbar.tsx | 3 +- frontend/src/lib/keymap.test.ts | 17 +- frontend/src/lib/keymap.ts | 89 ++++++++++- frontend/src/lib/plexPlayerHooks.ts | 18 ++- 10 files changed, 389 insertions(+), 107 deletions(-) create mode 100644 frontend/src/components/ui/KeyboardHelp.dom.test.tsx create mode 100644 frontend/src/components/ui/KeyboardHelp.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a61db11b..3252da5d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,6 +34,7 @@ import PageShell from "./components/ui/PageShell"; import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import Toaster from "./components/ui/Toaster"; +import KeyboardHelp from "./components/ui/KeyboardHelp"; import { StateMessage } from "./components/ui/QueryState"; import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; @@ -412,6 +413,9 @@ export default function App() { (collapsed or expanded) without tracking its width. */} + {/* The app-wide keyboard cheat sheet + the global `h` that opens it. Mounted once, outside + the page area, so every surface answers "what can I press here" the same way. */} + {meQuery.data && !meQuery.data.is_demo && } {aboutOpen && ( diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 6ad2391b..9f738691 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -44,7 +44,7 @@ import { } from "../lib/format"; import { renderDescription } from "../lib/descriptionLinks"; import { fullscreenElement } from "../lib/fullscreen"; -import { dispatch, isActivationTarget } from "../lib/keymap"; +import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; import { useNativeControlsYield, @@ -330,6 +330,8 @@ export default function PlayerModal({ }; }, []); + useKeyScope("ytPlayer"); + // 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 diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 7b9ee507..e30f4c2e 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -39,7 +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"; +import { dispatch, useKeyScope } from "../lib/keymap"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -65,6 +65,20 @@ const LASTWATCHED_SORT: NonNullable = Object.freeze({ // I looking at" control now, like the feed's Watch-state pill, not a rail widget). const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; +// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns +// its own Backspace (stop & back to the grid), and this handler must not fire underneath it. +const BACKABLE_VIEWS: readonly string[] = [ + "grid", + "playlists", + "collections", + "show", + "season", + "info", + "person", + "playlist", + "collection", +]; + export default function PlexBrowse() { const { t } = useTranslation(); const qc = useQueryClient(); @@ -164,27 +178,16 @@ 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 - // (its "player" view kind is deliberately absent from the list below); the typing guard and the - // chord itself come from the shared keymap. + // matching the browser/mouse Back; the chord and the typing guard come from the shared keymap. + // BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat + // sheet can never promise a "Back one level" the handler declines to perform. + const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind); + useKeyScope("plexBrowse", canStepBack); useEffect(() => { const onKey = (e: KeyboardEvent) => { dispatch(e, "plexBrowse", { back: (ev) => { - if ( - ![ - "grid", - "playlists", - "collections", - "show", - "season", - "info", - "person", - "playlist", - "collection", - ].includes(sub.view.kind) - ) - return; + if (!canStepBack) return; ev.preventDefault(); sub.back(); }, @@ -192,7 +195,7 @@ export default function PlexBrowse() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [sub]); + }, [sub, canStepBack]); // D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently // watched first); leaving restores the sort that was active before entering. Only the ENTER edge diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 32514acd..42404d1c 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -1,4 +1,4 @@ -import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -25,7 +25,6 @@ import { Square, Volume2, VolumeX, - X, } from "lucide-react"; import { api, HttpError } from "../lib/api"; import { @@ -55,10 +54,12 @@ import { import { fullscreenElement } from "../lib/fullscreen"; import { dispatch, - formatChord, - helpLines, isActivationTarget, isTypingTarget, + toggleKeyboardHelp, + useHelpConditions, + useKeyboardHelpOpen, + useKeyScope, } from "../lib/keymap"; import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; @@ -183,10 +184,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { }, [tracksFade.ref], ); - // Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar. - const [helpOpen, setHelpOpen] = useState(false); + // The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this + // player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to + // know an overlay is up before it steals a wheel scroll for volume. + const helpOpen = useKeyboardHelpOpen(); const helpOpenRef = useRef(false); helpOpenRef.current = helpOpen; + + // On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since + // E5, but silently — you changed the track and the only way to see what you landed on was to open + // the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu + // itself needs no OSD: it shows the selection where you clicked. + const [osd, setOsd] = useState(null); + const osdTimer = useRef(null); + const flashOsd = useCallback((text: string) => { + setOsd(text); + if (osdTimer.current) window.clearTimeout(osdTimer.current); + osdTimer.current = window.setTimeout(() => setOsd(null), 2200); + }, []); + useEffect( + () => () => { + if (osdTimer.current) window.clearTimeout(osdTimer.current); + }, + [], + ); // "I" toggles a rich info overlay (poster/cast/ratings) over the running video. const [infoOpen, setInfoOpen] = useState(false); const infoOpenRef = useRef(false); @@ -584,15 +605,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { useEffect(() => setUpNextOpen(false), [id]); // "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel - // off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info - // overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back - // with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel. + // off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the + // player. The arrow used to call onClose() unconditionally, so Back with the info panel open + // jumped straight to the feed instead of just closing the panel. The shortcuts sheet is no longer + // in this cascade: it is a Modal above us now, and owns its own Escape layer and Back-to-close. const handleBack = useCallback(() => { if (menuOpenRef.current) { setMenuOpen(false); setTracksOpen(false); - } else if (helpOpenRef.current) setHelpOpen(false); - else if (infoOpenRef.current) setInfoOpen(false); + } else if (infoOpenRef.current) setInfoOpen(false); else if (statsOpenRef.current) setStatsOpen(false); else if (upNextOpenRef.current) setUpNextOpen(false); else onClose(); @@ -610,6 +631,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { handleBack(); }, [handleBack, skipProgressRef, cancelAutoSkipRef]); + // Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step + // episodes, so they are listed only inside a series). + useKeyScope("plexPlayer"); + useHelpConditions({ episode: detail?.kind === "episode" }); + // This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own // settings/tracks menus included) takes Escape first and this player defers (R8 S3). const layerIdRef = useRef(null); @@ -661,9 +687,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { nudgeVolume(-0.05); }, mute: () => toggleMute(), - audio: () => cycleAudio(), - subtitle: () => cycleSubtitle(), - help: () => setHelpOpen((v) => !v), + audio: () => { + const ord = cycleAudio(); + if (ord === undefined) return; // one track (or none): nothing to say + const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label; + flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`); + }, + subtitle: () => { + const ord = cycleSubtitle(); + if (ord === undefined) return; + const label = + ord == null + ? t("plex.player.subOff") + : (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—"); + flashOsd(`${t("plex.player.subtitles")} · ${label}`); + }, info: () => setInfoOpen((v) => !v), stats: () => setStatsOpen((v) => !v), back: (ev) => { @@ -685,8 +723,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { else if (menuOpenRef.current) { setMenuOpen(false); setTracksOpen(false); - } else if (helpOpenRef.current) setHelpOpen(false); - else if (infoOpenRef.current) setInfoOpen(false); + } else if (infoOpenRef.current) setInfoOpen(false); else if (statsOpenRef.current) setStatsOpen(false); else if (upNextOpenRef.current) setUpNextOpen(false); else if (!fullscreenElement()) onClose(); @@ -704,6 +741,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { toggleMute, cycleAudio, cycleSubtitle, + flashOsd, + t, detail, prevId, nextId, @@ -835,15 +874,25 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { )} + {/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the + bar is showing, and inside the player element so it survives fullscreen. `aria-live` because + the whole point is confirming something you cannot otherwise see. */} + {osd && ( +
+
+ {osd} +
+
+ )} + {/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */} - {(menuOpen || - tracksOpen || - helpOpen || - infoOpen || - statsOpen || - upNextOpen || - skipProgress != null) && } + {(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && ( + + )} {/* Top bar */}
setInfoOpen((v) => !v)}> - setHelpOpen((v) => !v)}> + @@ -1225,57 +1274,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { /> )} - {/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */} - {helpOpen && ( -
{ - e.stopPropagation(); - setHelpOpen(false); - }} - > -
e.stopPropagation()} - > -
-
- {t("plex.player.help")} -
- -
- {/* 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. */} -
- {helpLines(["plexPlayer", "global"]) - .filter((line) => line.when !== "episode" || detail?.kind === "episode") - .map((line) => ( - -
- {line.chords.map((chord) => ( - - {formatChord(chord)} - - ))} -
-
{t(line.labelKey)}
-
- ))} -
-
-
- )} - {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */} {infoOpen && detail && (
{ + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +// The open flag is module state shared by the whole app (one sheet, opened from anywhere), so a +// test that leaves it open would flip the NEXT test's toggle into a close. +afterEach(() => { + cleanup(); + closeKeyboardHelp(); +}); + +/** A stand-in for a surface that registers a scope while mounted, the way the players do. */ +function Surface({ episode }: { episode?: boolean }) { + useKeyScope("plexPlayer"); + useHelpConditions({ episode: !!episode }); + return null; +} + +const pressH = () => + act(() => { + fireEvent.keyDown(document.body, { key: "h" }); + }); + +const descriptions = () => screen.getAllByRole("definition").map((el) => el.textContent); + +describe("KeyboardHelp", () => { + it("opens and closes on the global h", () => { + render(); + expect(screen.queryByText("keys.title")).toBeNull(); + pressH(); + expect(screen.getByText("keys.title")).toBeTruthy(); + pressH(); + expect(screen.queryByText("keys.title")).toBeNull(); + }); + + it("does not open while a field has focus", () => { + render(); + const input = document.createElement("input"); + document.body.append(input); + act(() => { + fireEvent.keyDown(input, { key: "h" }); + }); + expect(screen.queryByText("keys.title")).toBeNull(); + input.remove(); + }); + + it("leads with the live surface and trails with the app-wide keys", () => { + render( + <> + + + , + ); + pressH(); + const rows = descriptions(); + // A plexPlayer row first, the app-wide rows last — the reading order the reader wants. + expect(rows[0]).toBe("keys.playPause"); + expect(rows.slice(-2)).toEqual(["keys.help", "keys.contextMenu"]); + }); + + it("lists only the app-wide keys when no surface is registered", () => { + render(); + pressH(); + expect(descriptions()).toEqual(["keys.help", "keys.contextMenu"]); + }); + + // "Previous / next episode" inside a movie would be a promise the player does not keep. + it("hides a conditional row until its surface says it applies", () => { + const { unmount } = render( + <> + + + , + ); + pressH(); + expect(descriptions()).not.toContain("keys.episode"); + unmount(); + closeKeyboardHelp(); // the sheet outlives the tree it was opened from + + render( + <> + + + , + ); + pressH(); + expect(descriptions()).toContain("keys.episode"); + }); +}); diff --git a/frontend/src/components/ui/KeyboardHelp.tsx b/frontend/src/components/ui/KeyboardHelp.tsx new file mode 100644 index 00000000..ad90085b --- /dev/null +++ b/frontend/src/components/ui/KeyboardHelp.tsx @@ -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 ( + +
+ {lines.map((line) => ( + +
+ {line.chords.map((chord) => ( + + {formatChord(chord)} + + ))} +
+
{t(line.labelKey)}
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/ui/UndoToolbar.tsx b/frontend/src/components/ui/UndoToolbar.tsx index 947742f2..1c896473 100644 --- a/frontend/src/components/ui/UndoToolbar.tsx +++ b/frontend/src/components/ui/UndoToolbar.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Redo2, Undo2 } from "lucide-react"; -import { dispatch } from "../../lib/keymap"; +import { dispatch, useKeyScope } 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 @@ -19,6 +19,7 @@ export default function UndoToolbar({ onRedo: () => void; }) { const { t } = useTranslation(); + useKeyScope("undo"); useEffect(() => { function onKey(e: KeyboardEvent) { diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts index 8bfdc060..0cf903c5 100644 --- a/frontend/src/lib/keymap.test.ts +++ b/frontend/src/lib/keymap.test.ts @@ -101,7 +101,6 @@ describe("dispatch", () => { mute: noop, audio: () => calls.push("audio"), subtitle: noop, - help: noop, info: noop, stats: noop, back: () => calls.push("back"), @@ -137,6 +136,20 @@ describe("dispatch", () => { input.remove(); }); + // A `handledLocally` row is documentation + a shared chord, matched on the focused element by its + // own component (Shift+F10 belongs to whichever row has focus). `dispatch` must not claim it, and + // the mapped handler type must not demand one — this test is the runtime half of that. + it("does not route a locally-handled row", () => { + const calls: string[] = []; + // The handler map is the global scope's routable ids only — `contextMenu` is absent from it, + // which is the compile-time half of the same claim; tsc rejects passing one. + const handlers = { help: () => calls.push("help") }; + expect(dispatch(press({ key: "F10", shiftKey: true }), "global", handlers)).toBe(false); + expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false); + expect(dispatch(press({ key: "h" }), "global", handlers)).toBe(true); + expect(calls).toEqual(["help"]); + }); + it("ignores keys belonging to another scope", () => { const calls: string[] = []; // `m` (mute) is a plexPlayer key; the browse scope only owns Backspace. @@ -190,7 +203,7 @@ describe("helpLines", () => { it("orders scopes as asked, not as tabled", () => { const first = (scopes: Parameters[0]) => helpLines(scopes)[0]?.labelKey; expect(first(["plexBrowse", "global"])).toBe("keys.browseBack"); - expect(first(["global", "plexBrowse"])).toBe("keys.contextMenu"); + expect(first(["global", "plexBrowse"])).toBe("keys.help"); }); }); diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index b0f735b2..32380324 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -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 = Extract["id"]; +export type ShortcutId = 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 `