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 // `` 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( e: KeyboardEvent, scope: S, id: Extract["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(); // 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( e: KeyboardEvent, scope: S, handlers: { readonly [K in ShortcutId]: (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 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([]); /** 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("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 boolean>(); export const setScopeCoveredProbe = (scope: KeyScope, probe: (() => boolean) | null): void => { if (probe) coveredProbes.set(scope, probe); else coveredProbes.delete(scope); }; const helpCovered = createStore>(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 => 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([]); 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({}); /** 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): 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 = { 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>; 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(); 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; }