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:
@@ -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<typeof helpLines>[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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user