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,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");
});
});