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
+3 -1
View File
@@ -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
+22 -19
View File
@@ -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<SortState> = 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
+73 -75
View File
@@ -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<string | null>(null);
const osdTimer = useRef<number | null>(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<number | null>(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) {
</div>
)}
{/* 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 && (
<div
className="pointer-events-none absolute inset-x-0 bottom-24 z-menu flex justify-center px-4"
aria-live="polite"
>
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{osd}
</div>
</div>
)}
{/* 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) && <BackClose onBack={backIntent} />}
{(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
<BackClose onBack={backIntent} />
)}
{/* Top bar */}
<div
@@ -1186,7 +1235,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
<Keyboard className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.download")} onClick={download}>
@@ -1225,57 +1274,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/>
)}
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
onClick={(e) => {
e.stopPropagation();
setHelpOpen(false);
}}
>
<div
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold">
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
</div>
<button
onClick={() => setHelpOpen(false)}
className="p-1 rounded-lg hover:bg-white/10"
aria-label={t("plex.player.close")}
>
<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">
{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>
)}
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && (
<div
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
//
// Contract of the app-wide cheat sheet: `h` opens it, it lists the SCOPES that are live (innermost
// first, global last), and it hides a `when` row whose condition the surface has not published.
// i18n is key-passthrough, so the asserted text is the i18n key.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import KeyboardHelp from "./KeyboardHelp";
import { closeKeyboardHelp, useHelpConditions, useKeyScope } from "../../lib/keymap";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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(<KeyboardHelp />);
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(<KeyboardHelp />);
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(
<>
<Surface />
<KeyboardHelp />
</>,
);
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(<KeyboardHelp />);
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(
<>
<Surface />
<KeyboardHelp />
</>,
);
pressH();
expect(descriptions()).not.toContain("keys.episode");
unmount();
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
render(
<>
<Surface episode />
<KeyboardHelp />
</>,
);
pressH();
expect(descriptions()).toContain("keys.episode");
});
});
@@ -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>
);
}
+2 -1
View File
@@ -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) {