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:
@@ -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<S extends KeyScope> = Extract<Row, { scope: S }>["id"];
|
||||
export type ShortcutId<S extends KeyScope> = 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 `<select>` and `contentEditable`, so the same keystroke was a shortcut in
|
||||
@@ -169,7 +189,7 @@ export function dispatch<S extends KeyScope>(
|
||||
): boolean {
|
||||
const typing = isTypingTarget(e);
|
||||
for (const s of ROWS) {
|
||||
if (s.scope !== scope) continue;
|
||||
if (s.scope !== scope || s.handledLocally) continue;
|
||||
if (typing && !s.allowInInput) continue;
|
||||
if (!s.keys.some((k) => matches(e, k))) continue;
|
||||
(handlers as Record<string, (ev: KeyboardEvent) => void>)[s.id]?.(e);
|
||||
@@ -178,6 +198,66 @@ export function dispatch<S extends KeyScope>(
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Which scopes are live right now ───────────────────────────────────────────────────────────
|
||||
// A stack rather than a single value: surfaces nest (the Plex player opens over the Plex browser,
|
||||
// a reorderable playlist has both its page and an UndoToolbar). Innermost first, so the help sheet
|
||||
// leads with the surface you are actually in and trails with the app-wide keys.
|
||||
//
|
||||
// Push a scope only while its keys really DO something — `PlexBrowse` deactivates its own scope
|
||||
// while the player is up, because its Backspace handler no-ops there and a sheet promising "Back
|
||||
// one level" would be lying. That is the honest-states rule applied to documentation.
|
||||
const scopeStack = createStore<readonly KeyScope[]>([]);
|
||||
|
||||
/** Register `scope` as live for as long as this component is mounted and `active`. */
|
||||
export function useKeyScope(scope: KeyScope, active = true): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const token = { scope };
|
||||
tokens.push(token);
|
||||
scopeStack.set(tokens.map((t) => t.scope));
|
||||
return () => {
|
||||
tokens.splice(tokens.indexOf(token), 1);
|
||||
scopeStack.set(tokens.map((t) => t.scope));
|
||||
};
|
||||
}, [scope, active]);
|
||||
}
|
||||
|
||||
// Identity tokens, so unmounting removes THIS registration and not another instance of the same
|
||||
// scope that happens to sit earlier in the array.
|
||||
const tokens: { scope: KeyScope }[] = [];
|
||||
|
||||
/** The live scopes, innermost first, always ending in `global`. Re-renders on change. */
|
||||
export function useActiveScopes(): KeyScope[] {
|
||||
const stack = scopeStack.use();
|
||||
return [...stack].reverse().concat("global");
|
||||
}
|
||||
|
||||
// ── The keyboard help sheet ───────────────────────────────────────────────────────────────────
|
||||
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
|
||||
// there is exactly one answer to "what can I press here" instead of one per player.
|
||||
const helpOpen = createStore(false);
|
||||
|
||||
export const closeKeyboardHelp = (): void => helpOpen.set(false);
|
||||
export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
|
||||
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
||||
|
||||
/** Answers for the `when` conditions, published by the surface that knows them. */
|
||||
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
||||
const helpConditions = createStore<HelpConditions>({});
|
||||
|
||||
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
|
||||
* sheet lives in App and cannot ask, so the surface tells it. */
|
||||
export function useHelpConditions(conditions: HelpConditions): void {
|
||||
const episode = conditions.episode;
|
||||
useEffect(() => {
|
||||
helpConditions.set({ episode });
|
||||
return () => helpConditions.set({});
|
||||
}, [episode]);
|
||||
}
|
||||
|
||||
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
|
||||
|
||||
// ── Display ───────────────────────────────────────────────────────────────────────────────────
|
||||
const GLYPHS: Record<string, string> = {
|
||||
ArrowLeft: "←",
|
||||
@@ -185,6 +265,7 @@ const GLYPHS: Record<string, string> = {
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
Backspace: "⌫",
|
||||
ContextMenu: "Menu", // the dedicated menu key; "ContextMenu" is a DOM name, not a keycap
|
||||
Escape: "Esc",
|
||||
Shift: "⇧",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user