Files
siftlode/frontend/src/lib/keymap.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

652 lines
33 KiB
TypeScript

import { useEffect, useEffectEvent, type RefObject } from "react";
import { anyBlockingLayer, escapeHandled } from "./layerStack";
import { createStore } from "./store";
import type { Page } from "./urlState";
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
// Earlier, 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
// checking modifiers (so Ctrl+A, select-all, cycled the audio track) and its typing guard missed
// `<select>` and `contentEditable`, which the other two surfaces already covered. The classic
// comment-vs-code drift this codebase keeps getting bitten by, one layer up.
//
// The fix is a TABLE, not a dispatcher. Each surface keeps its own listener and its own closures —
// `PlexPlayer`'s 1300 lines of refs are not worth re-plumbing — but the key literals, the modifier
// rules and the typing guard all live here, and `dispatch()` binds a scope's rows to handlers with
// a mapped type. So a row without a handler (or a handler without a row) is a **tsc error**, not a
// comment someone forgets: the parity is enforced by the gate, which is the house rule.
//
// It is also the source for the keyboard help sheet — every shortcut in the app is listed here, in
// one place, and the sheet renders this table rather than a hand-copied array beside it.
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
* is mounted. The help sheet shows `global` plus the registered scopes.
*
* `page`, `search` and `list` are the PAGE-LEVEL scopes: they act on the page underneath
* every overlay, so `dispatch` keeps them quiet while a layer that owns the keyboard is open. That is
* also what keeps them clear of the players' bare letters — the Plex player's `s` and `k` only ever
* run with the player's own layer up. */
export type KeyScope =
| "global"
| "page"
| "search"
| "list"
| "listDelete"
| "undo"
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
"page",
"search",
"list",
"listDelete",
]);
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
export interface Shortcut {
/** Unique within its scope; the handler key `dispatch()` demands. */
readonly id: string;
readonly scope: KeyScope;
/** Chords that trigger it, in the `matches()` mini-syntax. All are listed in the help sheet. */
readonly keys: readonly string[];
/** i18n key (the `keys` namespace) for the description. Rows SHARING a labelKey are shown as one
* 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. */
readonly allowInInput?: boolean;
/** The module a `g` chord jumps to. The sheet lists the row only while that module is available
* to this account, and the handler refuses it otherwise — a gated module has no page to land on. */
readonly page?: Page;
}
// Chord syntax: `Mod` = Ctrl on Windows/Linux, ⌘ on macOS. `Shift`/`Alt` are literal. The final
// segment is a `KeyboardEvent.key` name, except `Space` (which also accepts `code`, because a
// focused button reports `key === " "` inconsistently across engines). A SPACE separates the two
// steps of a sequence: `g h` is `g`, then `h` within SEQUENCE_MS.
//
// Owner rule: no new binding may need a modifier on the Hungarian QWERTZ layout — so no `/`
// or `?` (both Shift chords there). Letters, digits, `-`, `,`, `.`, Esc/Enter/arrows only. `keyMatches`
// compares `e.key`, so a letter follows the printed legend (the HU Z/Y swap included).
export const SHORTCUTS = [
// --- global ---------------------------------------------------------------------------------
// `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.
// `allowInInput` because the palette AUTOFOCUSES its search field: without it the typing guard ate
// the second Mod+K, so the toggle only ever fired one way and Chrome took the chord instead.
// Its handler preventDefaults, so nothing leaks to the browser either way.
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
// Shift+W and not a bare `w`: the Plex player binds bare letters (a s d f h i k m) and `w`
// sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
// and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
{ id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
// --- page-level: jump to a module (quiet under an overlay) -----------------------------------
// English initials, because the Hungarian ones collide (Letöltések / Lejátszási listák). `g h` is
// Shift+W's modifier-free twin and shares its help line. Admin pages stay on the palette.
{ id: "goHome", scope: "page", keys: ["g h"], labelKey: "keys.home", page: "home" },
{ id: "goFeed", scope: "page", keys: ["g f"], labelKey: "keys.goFeed", page: "feed" },
{ id: "goChannels", scope: "page", keys: ["g c"], labelKey: "keys.goChannels", page: "channels" },
{
id: "goPlaylists",
scope: "page",
keys: ["g p"],
labelKey: "keys.goPlaylists",
page: "playlists",
},
{ id: "goPlex", scope: "page", keys: ["g x"], labelKey: "keys.goPlex", page: "plex" },
{
id: "goNotifications",
scope: "page",
keys: ["g n"],
labelKey: "keys.goNotifications",
page: "notifications",
},
{
id: "goDownloads",
scope: "page",
keys: ["g d"],
labelKey: "keys.goDownloads",
page: "downloads",
},
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
// --- the module's search box (wherever a SearchBar is mounted) -------------------------------
{ id: "focus", scope: "search", keys: ["s"], labelKey: "keys.search" },
// --- list / grid navigation (wherever a list registers with `useListNav`) --------------------
{ id: "next", scope: "list", keys: ["j"], labelKey: "keys.listStep" },
{ id: "prev", scope: "list", keys: ["k"], labelKey: "keys.listStep" },
{ id: "left", scope: "list", keys: ["ArrowLeft"], labelKey: "keys.gridMove" },
{ id: "right", scope: "list", keys: ["ArrowRight"], labelKey: "keys.gridMove" },
{ id: "up", scope: "list", keys: ["ArrowUp"], labelKey: "keys.gridMove" },
{ id: "down", scope: "list", keys: ["ArrowDown"], labelKey: "keys.gridMove" },
{ id: "open", scope: "list", keys: ["Enter"], labelKey: "keys.open" },
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
// — where nothing answers the key. Same honest-states rule `plexBrowse` obeys.
{
id: "contextMenu",
scope: "contextMenu",
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" },
{ id: "redo", scope: "undo", keys: ["Mod+y", "Mod+Shift+z"], labelKey: "keys.redo" },
// --- the YouTube player modal ---------------------------------------------------------------
{ id: "playPause", scope: "ytPlayer", keys: ["Space"], labelKey: "keys.playPause" },
{ id: "fullscreen", scope: "ytPlayer", keys: ["f"], labelKey: "keys.fullscreen" },
{ id: "seekBack", scope: "ytPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "ytPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
{ id: "prevVideo", scope: "ytPlayer", keys: ["Shift+ArrowLeft"], labelKey: "keys.video" },
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
{ id: "volumeDown", scope: "ytPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" },
{
id: "close",
scope: "ytPlayer",
keys: ["Escape"],
labelKey: "keys.closePlayer",
allowInInput: true,
},
// --- the Plex player ------------------------------------------------------------------------
{ id: "playPause", scope: "plexPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "fullscreen", scope: "plexPlayer", keys: ["f"], labelKey: "keys.fullscreen" },
{ id: "seekBack", scope: "plexPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek" },
{ id: "seekFwd", scope: "plexPlayer", keys: ["ArrowRight"], labelKey: "keys.seek" },
{ id: "fineBack", scope: "plexPlayer", keys: ["Mod+ArrowLeft"], labelKey: "keys.fineSeek" },
{ id: "fineFwd", scope: "plexPlayer", keys: ["Mod+ArrowRight"], labelKey: "keys.fineSeek" },
{
id: "prevEpisode",
scope: "plexPlayer",
keys: ["Shift+ArrowLeft"],
labelKey: "keys.episode",
when: "episode",
},
{
id: "nextEpisode",
scope: "plexPlayer",
keys: ["Shift+ArrowRight"],
labelKey: "keys.episode",
when: "episode",
},
{ id: "volumeUp", scope: "plexPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
{ id: "volumeDown", scope: "plexPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" },
{ 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: "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
// anything above → cancel auto-skip → close the topmost player overlay → close). Sharing a labelKey
// keeps them a single "⌫ Esc Stop & go back" row in the sheet.
{ id: "back", scope: "plexPlayer", keys: ["Backspace"], labelKey: "keys.back" },
{
id: "escape",
scope: "plexPlayer",
keys: ["Escape"],
labelKey: "keys.back",
allowInInput: true,
},
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
// `as const` keeps the ids literal (that is what makes `dispatch`'s handler map exhaustive), but it
// also narrows each row to its own shape — so the optional `when`/`allowInInput` vanish from rows
// that omit them. Iterate through this widened view; read ids through `Row`.
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; handledLocally?: undefined }
>["id"];
/** True while focus sits somewhere that swallows plain keys. The ONE definition: before it the
* players disagreed about `<select>` and `contentEditable`, so the same keystroke was a shortcut in
* one and text in the other. Reads `e.target` (the focused node for a keydown) and falls back to
* `document.activeElement` for synthetic events that carry no target. */
export function isTypingTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
if (!el) return false;
const tag = (el.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
}
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
export function isActivationTarget(e: KeyboardEvent): boolean {
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
const tag = (el?.tagName || "").toLowerCase();
return tag === "button" || tag === "a";
}
function keyMatches(e: KeyboardEvent, name: string): boolean {
if (name === "Space") return e.key === " " || e.code === "Space";
if (name.length === 1) return e.key.toLowerCase() === name.toLowerCase();
return e.key === name;
}
/** Match one chord EXACTLY, modifiers included. Exactness is the point: `case "a"` used to fire on
* Ctrl+A too, so select-all cycled the Plex audio track. It also means Shift+H is not `h` — a
* narrowing from the old `case "h": case "H":`, and the reason Shift chords can now be their own
* rows (Shift+← steps an episode without ← also seeking). */
export function matches(e: KeyboardEvent, chord: string): boolean {
const parts = chord.split("+");
const key = parts[parts.length - 1] ?? "";
const mods = parts.slice(0, -1).map((m) => m.toLowerCase());
if ((e.ctrlKey || e.metaKey) !== mods.includes("mod")) return false;
if (e.shiftKey !== mods.includes("shift")) return false;
if (e.altKey !== mods.includes("alt")) return false;
return keyMatches(e, key);
}
/** Test an event against ONE row's chords, by id. The escape hatch for a `handledLocally` row: it is
* matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window),
* but it must still go through `matches` or the table's `keys` become decoration. They had: the
* context menu hand-rolled `e.key === "F10" && e.shiftKey`, so Ctrl+Shift+F10 opened it too and
* editing the row changed the help sheet and nothing else.
*
* Keyed on scope AND id, both as literal types: ids are only unique WITHIN a scope (`back` is on
* both players), so a bare `ROWS.find(s => s.id === id)` would answer for whichever came first, and
* a typo'd string would silently never match. */
export function matchesShortcut<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
id: Extract<Row, { scope: S }>["id"],
): boolean {
const row = ROWS.find((s) => s.scope === scope && s.id === id);
return !!row?.keys.some((k) => matches(e, k));
}
// ── Two-step sequences (`g h`) ─────────────────────────────────────────────────────────────────
// Each scope keeps its own window listener, so a sequence cannot live in one listener's closure: the
// `h` that completes `g h` also reaches the global listener (where `h` opens the sheet) and every
// other live scope. So the pending first step is module state, and it belongs to the scope that
// started it — every OTHER scope ignores the next key while it is pending, and the owner stamps the
// event once it has resolved it, so a scope whose listener runs after the owner's ignores it too.
// The stamp is the layerStack Escape trick: every listener in one dispatch gets the same object.
const SEQUENCE_MS = 1500;
let pending: { scope: KeyScope; key: string; at: number } | null = null;
const sequenceEvents = new WeakSet<KeyboardEvent>();
// A modifier's own keydown (reaching for Shift) neither completes nor breaks a sequence.
const MODIFIER_KEYS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
function liveSequence(e: KeyboardEvent): typeof pending {
if (!pending) return null;
if (Date.now() - pending.at >= SEQUENCE_MS) {
pending = null;
return null;
}
if (MODIFIER_KEYS.has(e.key)) return null;
// A chord with a modifier cannot be a second step (no binding may need one): it abandons the
// sequence and is matched as itself, or `g` then Mod+K would open the browser's search, not ours.
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
pending = null;
return null;
}
return pending;
}
const stepsOf = (chord: string): string[] => chord.split(" ");
/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides
* whether to `preventDefault`, because only it knows if it actually acted) or a sequence's first
* step was taken. `handlers` is a mapped type over the scope's ids, so the table and the
* implementation cannot drift without tsc saying so. Skipped while typing unless the row opts in
* with `allowInInput` (Escape, and only Escape), on a key a focused widget already handled, and — for a page-level scope — while a layer that
* owns the keyboard is open. The rule lives here rather than in each listener so a new page-level
* surface cannot forget it. */
export function dispatch<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
): boolean {
if (sequenceEvents.has(e)) return false;
// A key a focused widget already handled is not a shortcut: every listener here is on `window`,
// so a tablist or a keyboard drag (dnd-kit, on the document) has acted on it and prevented its
// default first. The player's settings tabs switched tab AND sought the video 10 s (seen in UAT).
if (e.defaultPrevented) return false;
// An Escape a layer already took (a menu that just closed) is spent: a page or a player reacting to
// it too is the double close the layer stack exists to prevent.
if (e.key === "Escape" && escapeHandled(e)) return false;
const live = liveSequence(e);
if (live && live.scope !== scope) return false;
const quiet = isPageLevelScope(scope) && anyBlockingLayer();
const typing = isTypingTarget(e);
const run = (id: string) => (handlers as Record<string, (ev: KeyboardEvent) => void>)[id]?.(e);
if (live) {
// This key ends the sequence either way: it completes a row, or it breaks the attempt.
pending = null;
sequenceEvents.add(e);
if (quiet || typing) return false;
for (const s of ROWS) {
if (s.scope !== scope || s.handledLocally) continue;
const hit = s.keys.some((k) => {
const [first, second] = stepsOf(k);
return second !== undefined && first === live.key && matches(e, second);
});
if (hit) {
run(s.id);
return true;
}
}
return false;
}
if (quiet) return false;
for (const s of ROWS) {
if (s.scope !== scope || s.handledLocally) continue;
if (typing && !s.allowInInput) continue;
if (!s.keys.some((k) => stepsOf(k).length === 1 && matches(e, k))) continue;
run(s.id);
return true;
}
if (typing) return false;
// Nothing matched outright — does this key open one of the scope's sequences?
for (const s of ROWS) {
if (s.scope !== scope || s.handledLocally) continue;
const first = s.keys.map(stepsOf).find((st) => st.length === 2 && matches(e, st[0]!))?.[0];
if (first !== undefined) {
pending = { scope, key: first, at: Date.now() };
sequenceEvents.add(e);
return true;
}
}
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.useValue();
// DEDUPED: a scope can be registered by more than one live component (the Playlists page and its
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key.
// Innermost registration wins the ordering.
const live = [...new Set([...stack].reverse())];
// The page-level scopes go in a FIXED order after the surfaces, not in registration order: App
// registers `page` in its own effect, which React runs after every child's — so by mount order
// the module jumps would head the sheet, above the list keys of the page you are looking at.
const surfaces = live.filter((s) => !isPageLevelScope(s));
const pageLevel = (["list", "listDelete", "search", "page"] as const).filter((s) =>
live.includes(s),
);
return [...surfaces, ...pageLevel, "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);
// Whether the sheet was opened OVER a layer that owns the keyboard (a player, a dialog). The page-level
// keys are quiet there, so the sheet must not list them — and it cannot ask the layer stack itself
// once it is showing, because its own Modal is a layer too. So the answer is taken at the moment of
// opening, before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
const helpOverLayer = createStore(false);
export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => {
const next = !helpOpen.get();
if (next) helpOverLayer.set(anyBlockingLayer());
helpOpen.set(next);
};
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.useValue();
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
const navigablePages = createStore<readonly Page[]>([]);
export const useNavigablePages = (): readonly Page[] => navigablePages.useValue();
// ── The command palette ───────────────────────────────────────────────────────────────────────
const paletteOpen = createStore(false);
export const closePalette = (): void => paletteOpen.set(false);
/** Open it from a visible control (the header's key cap / launcher), not only from Ctrl/Cmd+K. */
export const openPalette = (): void => paletteOpen.set(true);
export const usePaletteOpen = (): boolean => paletteOpen.useValue();
/** Bind the `global` and `page` scopes, once, for the whole app. It lives here rather than inside
* either overlay because both are opened by it: a listener that sat in one of them would have to
* route a key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything.
* App calls this; the overlays are pure renderers of their store.
*
* `pages` is the set of modules this account can reach (a `g` chord to anything else is a no-op).
*
* `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
* Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
* browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
* popped the overlay open by itself the moment the signed-in shell mounted. */
export function useGlobalKeymap(
enabled: boolean,
onNavigate: (page: Page) => void,
pages: readonly Page[],
): void {
// The page jumps are the global keys whose action lives OUTSIDE this file — navigation is a
// provider and `lib/` may not import a component — so App hands it in. An effect event, so a fresh
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
const navigate = useEffectEvent(onNavigate);
useKeyScope("page", enabled);
const pagesKey = pages.join(",");
useEffect(() => {
navigablePages.set(pagesKey ? (pagesKey.split(",") as Page[]) : []);
}, [pagesKey]);
useEffect(() => {
if (!enabled) {
// Leaving a stale `true` behind would spring the overlay open on the next mount.
helpOpen.set(false);
paletteOpen.set(false);
return;
}
// Built from the rows, so a new `g` row needs no second edit here — the row's `page` IS the action.
const pageHandlers = Object.fromEntries(
ROWS.filter((s) => s.scope === "page").map((s) => [
s.id,
(ev: KeyboardEvent) => {
if (!s.page || !navigablePages.get().includes(s.page)) return;
ev.preventDefault();
navigate(s.page);
},
]),
) as { readonly [K in ShortcutId<"page">]: (e: KeyboardEvent) => void };
const onKey = (e: KeyboardEvent) => {
// Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
// history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
// way, on auto-repeat that is ~30 push/back pairs a second, which Chrome starts dropping while
// our own depth bookkeeping keeps counting them — a desynced stack whose next browser Back
// walks out of the app. Holding a key is not a second press.
if (e.repeat) return;
const handled = dispatch(e, "global", {
help: (ev) => {
ev.preventDefault();
toggleKeyboardHelp();
},
palette: (ev) => {
// Mod+K is the browser's "focus the search bar" in a few builds; we want it.
ev.preventDefault();
paletteOpen.set((v) => !v);
},
home: (ev) => {
ev.preventDefault();
navigate("home");
},
});
if (!handled) dispatch(e, "page", pageHandlers);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [enabled]);
}
/** 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]);
}
// ── The module's search box ───────────────────────────────────────────────────────────────────
/** Bind `s` to "put the caret in this search box" while the box is mounted. The one SearchBar in the
* header calls this; the text is selected so the next keystrokes replace the old query (an arrow key
* keeps it). `preventDefault` keeps the `s` itself out of the field it just focused. */
export function useSearchFocusKey(input: RefObject<HTMLInputElement | null>): void {
useKeyScope("search");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.repeat) return;
dispatch(e, "search", {
focus: (ev) => {
const el = input.current;
if (!el) return;
ev.preventDefault();
el.focus();
el.select();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [input]);
}
export const useHelpConditionValues = (): HelpConditions => helpConditions.useValue();
// ── Display ───────────────────────────────────────────────────────────────────────────────────
const GLYPHS: Record<string, string> = {
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
Backspace: "⌫",
Escape: "Esc",
Shift: "⇧",
};
/** The keys a keycap names with a WORD rather than a legend every layout prints alike (Esc, Ctrl,
* Enter): the caller supplies them in the reader's language — a Hungarian sheet said "Space". */
export type KeycapWords = Readonly<Record<"Space" | "ContextMenu", string>>;
const isMac = (): boolean =>
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent);
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac), and a
* sequence step by step: `g h` → `G H`. */
export function formatChord(chord: string, words: KeycapWords): string {
const name = (part: string): string | undefined =>
GLYPHS[part] ?? words[part as keyof KeycapWords];
return stepsOf(chord)
.map((step) =>
step
.split("+")
.map((part, i, all) => {
if (part.toLowerCase() === "mod") return isMac() ? "⌘" : "Ctrl";
if (i < all.length - 1) return name(part) ?? part;
return name(part) ?? (part.length === 1 ? part.toUpperCase() : part);
})
.join(" "),
)
.join(" ");
}
/** One help-sheet line: every chord that shares a description, in table order. */
export interface HelpLine {
labelKey: string;
chords: string[];
when?: ShortcutWhen;
}
/** The shortcuts of `scopes`, grouped into help lines by description (with `pages`, the `g` chords
* are limited to those modules). Scopes come out in the order
* ASKED FOR, rows within a scope in table order — so a surface passing its own scope first heads its
* sheet with its own keys and leaves the app-wide ones underneath, where they read as context. */
export function helpLines(scopes: readonly KeyScope[], pages?: readonly Page[]): HelpLine[] {
const out: HelpLine[] = [];
const byLabel = new Map<string, HelpLine>();
for (const scope of scopes) {
for (const s of ROWS) {
if (s.scope !== scope) continue;
// A `g` chord to a module this account cannot reach is left out ROW by row, not line by line:
// `g h` shares its line with Shift+W, which must stay.
if (s.page && pages && !pages.includes(s.page)) continue;
const existing = byLabel.get(s.labelKey);
if (existing) {
existing.chords.push(...s.keys);
continue;
}
const line: HelpLine = {
labelKey: s.labelKey,
chords: [...s.keys],
...(s.when && { when: s.when }),
};
byLabel.set(s.labelKey, line);
out.push(line);
}
}
return out;
}