Files
siftlode/frontend/src/lib/keymap.ts
T
peter 84db570951 fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
2026-10-25 04:01:43 +01:00

716 lines
37 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. The one exception is a list INSIDE the topmost layer, with the
* focus in it (`dispatch`'s `inTopLayer`, decided by lib/listNav). */
export type KeyScope =
| "global"
| "page"
| "search"
| "list"
| "listDelete"
| "undo"
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "mediaPlayer"
| "fileViewer"
| "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" | "video";
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",
},
// "g d" is the retired Downloads page's chord: what it held is in Files now, so it goes there too.
{ id: "goFiles", scope: "page", keys: ["g l", "g d"], labelKey: "keys.goFiles", page: "files" },
{ 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" },
// Clip mode (the clip panel): `c` opens / closes it, `[` and `]` mark its ends at the playhead
// (opening it when it is shut).
{ id: "clip", scope: "ytPlayer", keys: ["c"], labelKey: "keys.clip" },
{ id: "markIn", scope: "ytPlayer", keys: ["["], labelKey: "keys.clipMark" },
{ id: "markOut", scope: "ytPlayer", keys: ["]"], labelKey: "keys.clipMark" },
{
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" },
{ id: "clip", scope: "plexPlayer", keys: ["c"], labelKey: "keys.clip" },
{ id: "markIn", scope: "plexPlayer", keys: ["["], labelKey: "keys.clipMark" },
{ id: "markOut", scope: "plexPlayer", keys: ["]"], labelKey: "keys.clipMark" },
// 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,
},
// --- a player on the browser's own media element (the library's, a shared link's) -----------
// The basic set (`lib/mediaKeys.ts`): the element's own controls carry the rest. The step is the
// YouTube player's 5 s, the other short-form player; Escape is the dialog's.
{ id: "playPause", scope: "mediaPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "seekBack", scope: "mediaPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "mediaPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
// An audio file has no picture to fill the screen with: the key does nothing there, so the sheet
// does not promise it.
{
id: "fullscreen",
scope: "mediaPlayer",
keys: ["f"],
labelKey: "keys.fullscreen",
when: "video",
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Files viewer: through the list's files that are not media ---------------------------
{ id: "prevFile", scope: "fileViewer", keys: ["ArrowLeft"], labelKey: "keys.file" },
{ id: "nextFile", scope: "fileViewer", keys: ["ArrowRight"], labelKey: "keys.file" },
// --- 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";
}
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
function isSymbol(name: string): boolean {
return [...name].length === 1 && !/[\p{L}\p{N}\s]/u.test(name);
}
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());
// A symbol the layout types through AltGr (the HU `[` is AltGr+F, Ctrl+Alt on Windows) is that
// symbol: a bare-symbol row answers it. A letter or a Mod row never does — AltGr+K is not Ctrl+K.
if (mods.length === 0 && isSymbol(key) && e.getModifierState("AltGraph"))
return keyMatches(e, key);
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. `inTopLayer` is the one way past it: the caller has checked that the
* key acts inside that topmost layer, not on the page behind (a list in the player). */
export function dispatch<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
{ inTopLayer = false }: { inTopLayer?: boolean } = {},
): 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() && !inTopLayer;
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);
// What the sheet was opened OVER: the page, a layer that owns the keyboard (a player, a dialog), or
// such a layer with a list inside it (the player's related videos). The page-level keys are quiet over
// a layer, so the sheet must not list them — except the list keys, which walk a list inside the
// topmost layer (see `dispatch`'s `inTopLayer`). The sheet 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.)
export type HelpOver = "page" | "layer" | "layerWithList";
const helpOverLayer = createStore<HelpOver>("page");
// Whether the topmost layer holds a list the keyboard walks. The lists are listNav's, which imports
// this module and so cannot be imported back: it hands the question in when it loads.
let listInTopLayer: () => boolean = () => false;
export const setListInTopLayerProbe = (probe: () => boolean): void => {
listInTopLayer = probe;
};
// A surface whose keys wait while a layer sits above it (the YouTube player under its related
// drawer or a dialog: every key but Escape is the layer's then; the file viewer under its menu or
// Share dialog). Its rows would promise keys that do
// nothing, so the sheet leaves the scope out — asked at the same moment as `helpOverLayer`.
const coveredProbes = new Map<KeyScope, () => boolean>();
export const setScopeCoveredProbe = (scope: KeyScope, probe: (() => boolean) | null): void => {
if (probe) coveredProbes.set(scope, probe);
else coveredProbes.delete(scope);
};
const helpCovered = createStore<ReadonlySet<KeyScope>>(new Set());
export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => {
const next = !helpOpen.get();
if (next) {
helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer");
helpCovered.set(new Set([...coveredProbes].filter(([, covered]) => covered()).map(([s]) => s)));
}
helpOpen.set(next);
};
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
/** What the sheet was opened over (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): HelpOver => helpOverLayer.useValue();
/** The surfaces a layer covered when the sheet opened (see `setScopeCoveredProbe`). */
export const useKeyboardHelpCovered = (): ReadonlySet<KeyScope> => helpCovered.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
* library's: is it a video?). The sheet lives in App and cannot ask, so the surface tells it. */
export function useHelpConditions(conditions: HelpConditions): void {
const { episode, video } = conditions;
useEffect(() => {
helpConditions.set({ episode, video });
return () => helpConditions.set({});
}, [episode, video]);
}
// ── 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;
}