diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 73875a7d..6ad2391b 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -44,6 +44,7 @@ import { } from "../lib/format"; import { renderDescription } from "../lib/descriptionLinks"; import { fullscreenElement } from "../lib/fullscreen"; +import { dispatch, isActivationTarget } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; import { useNativeControlsYield, @@ -329,72 +330,74 @@ export default function PlayerModal({ }; }, []); - // Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock. + // Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer` + // scope) so the help sheet lists them; only the behaviour behind each id is here. // These fire only while focus is on our page, not inside the cross-origin player iframe — so // we focus the modal card on open and again on player-ready (autoplay can grab focus). useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - // Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an - // anchored popover): it takes the Escape and we do nothing — so we don't also step out of - // maximise or close the player underneath it. The consumed-event flag is the reliable signal - // (UX-8): the layer's `document` listener runs before ours and its close can pop it off the - // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. - if (escapeHandled(e)) return; - if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; - // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. - if (fullscreenElement()) return; - // Same for our own maximise: Esc steps back out of it before it closes the player. - if (maximizedRef.current) { - e.preventDefault(); - setMaximized(false); - return; - } - onClose(); + // Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen, + // where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description) + // and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can + // still scroll that way, exactly like Space defers to a focused button. NOT while the video fills + // the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen + // there: deferring would scroll something invisible and leave the viewer with no volume control at + // all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the + // common case and checking only `isFullscreen` made the keys dead exactly where they matter most. + const volume = (e: KeyboardEvent, up: boolean) => { + const covered = isFullscreenRef.current || maximizedRef.current; + if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1)) return; - } - const el = document.activeElement as HTMLElement | null; - const tag = (el?.tagName || "").toLowerCase(); - const typing = - tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable; - if (e.key === "f" || e.key === "F") { - if (typing) return; - e.preventDefault(); - toggleFullscreen(); - } else if (e.key === " " || e.code === "Space") { - // Let Space activate a focused button/link; otherwise toggle playback (and stop the - // page from scrolling). - if (typing || tag === "button" || tag === "a") return; - e.preventDefault(); - togglePlay(); - } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { - // Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives - // fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls - // (a long description) and Up/Down is how you scroll it from the keyboard — so defer - // whenever the focused element can still scroll that way, exactly like Space defers to a - // focused button. NOT while the video fills the screen though — in fullscreen OR in our own - // maximise — because the card is behind/off-screen there: deferring would scroll something - // invisible and leave the viewer with no volume control at all. Both flags, not just - // fullscreen: since F stopped taking the browser's lock, maximise is the common case and - // checking only `isFullscreen` made the keys dead exactly where they matter most. - const up = e.key === "ArrowUp"; - const covered = isFullscreenRef.current || maximizedRef.current; - if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return; - e.preventDefault(); - nudgeVolume(up ? 5 : -5); - } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { - // Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the - // previous/next video in the queue (the feed's order or a playlist). - if (typing) return; - e.preventDefault(); - const forward = e.key === "ArrowRight"; - if (e.shiftKey) { - if (forward) goNext(); - else goPrev(); - } else { - nudgeSeek(forward ? 5 : -5); - } - } + e.preventDefault(); + nudgeVolume(up ? 5 : -5); + }; + const seek = (e: KeyboardEvent, secs: number) => { + e.preventDefault(); + nudgeSeek(secs); + }; + const onKey = (e: KeyboardEvent) => { + dispatch(e, "ytPlayer", { + close: (ev) => { + // Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an + // anchored popover): it takes the Escape and we do nothing — so we don't also step out of + // maximise or close the player underneath it. The consumed-event flag is the reliable signal + // (UX-8): the layer's `document` listener runs before ours and its close can pop it off the + // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. + if (escapeHandled(ev)) return; + if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; + // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. + if (fullscreenElement()) return; + // Same for our own maximise: Esc steps back out of it before it closes the player. + if (maximizedRef.current) { + ev.preventDefault(); + setMaximized(false); + return; + } + onClose(); + }, + fullscreen: (ev) => { + ev.preventDefault(); + toggleFullscreen(); + }, + playPause: (ev) => { + // Let Space activate a focused button/link; otherwise toggle playback (and stop the + // page from scrolling). + if (isActivationTarget(ev)) return; + ev.preventDefault(); + togglePlay(); + }, + volumeUp: (ev) => volume(ev, true), + volumeDown: (ev) => volume(ev, false), + seekBack: (ev) => seek(ev, -5), + seekFwd: (ev) => seek(ev, 5), + prevVideo: (ev) => { + ev.preventDefault(); + goPrev(); + }, + nextVideo: (ev) => { + ev.preventDefault(); + goNext(); + }, + }); }; window.addEventListener("keydown", onKey); focusModal(); diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index f4ed08ea..7b9ee507 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -39,6 +39,7 @@ import { usePlex, usePlexActions } from "./PlexProvider"; import { usePublishPlexView } from "./PlexViewBridge"; import { EpisodeCard, PlexPosterCard } from "./plexCards"; import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; +import { dispatch } from "../lib/keymap"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -163,31 +164,31 @@ export default function PlexBrowse() { }, [sub.view.kind]); // Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist), - // matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here; - // and never hijack Backspace while typing in a field. + // matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here + // (its "player" view kind is deliberately absent from the list below); the typing guard and the + // chord itself come from the shared keymap. useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key !== "Backspace") return; - const el = document.activeElement as HTMLElement | null; - const tag = (el?.tagName || "").toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable) - return; - if ( - [ - "grid", - "playlists", - "collections", - "show", - "season", - "info", - "person", - "playlist", - "collection", - ].includes(sub.view.kind) - ) { - e.preventDefault(); - sub.back(); - } + dispatch(e, "plexBrowse", { + back: (ev) => { + if ( + ![ + "grid", + "playlists", + "collections", + "show", + "season", + "info", + "person", + "playlist", + "collection", + ].includes(sub.view.kind) + ) + return; + ev.preventDefault(); + sub.back(); + }, + }); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 61124c86..32514acd 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -53,6 +53,13 @@ import { useWallClock, } from "../lib/plexPlayerHooks"; import { fullscreenElement } from "../lib/fullscreen"; +import { + dispatch, + formatChord, + helpLines, + isActivationTarget, + isTypingTarget, +} from "../lib/keymap"; import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; @@ -617,81 +624,63 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // --- keyboard -------------------------------------------------------------------------------- useEffect(() => { + const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => { + e.preventDefault(); + seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current)); + }; + const step = (e: KeyboardEvent, id: string | null | undefined) => { + e.preventDefault(); + go(id); + }; const onKey = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement)?.tagName; - if (tag === "INPUT" || tag === "TEXTAREA") return; - wake(); // keyboard use also reveals the controls - switch (e.key) { - case " ": + // Keyboard use also reveals the controls — but typing in a player field is not "using the + // remote", and `dispatch` still lets Escape through from inside a field (the only key that + // opts in), which is why this is a guard around `wake` rather than an early return. + if (!isTypingTarget(e)) wake(); + dispatch(e, "plexPlayer", { + playPause: (ev) => { // Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle - // playback when the focus isn't on a control — matching PlayerModal's guard. - if (tag === "BUTTON" || tag === "A") return; - e.preventDefault(); + // playback when the focus isn't on a control — the same shared rule PlayerModal uses. + if (isActivationTarget(ev)) return; + ev.preventDefault(); togglePlay(); - break; - case "k": - e.preventDefault(); - togglePlay(); - break; - case "f": - toggleFs(); - break; - case "ArrowLeft": - e.preventDefault(); - if (e.shiftKey) go(prevId); - else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current)); - break; - case "ArrowRight": - e.preventDefault(); - if (e.shiftKey) go(nextId); - else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current)); - break; - case "ArrowUp": - e.preventDefault(); + }, + fullscreen: () => toggleFs(), + seekBack: (ev) => seekBy(ev, -1, false), + seekFwd: (ev) => seekBy(ev, 1, false), + fineBack: (ev) => seekBy(ev, -1, true), + fineFwd: (ev) => seekBy(ev, 1, true), + prevEpisode: (ev) => step(ev, prevId), + nextEpisode: (ev) => step(ev, nextId), + volumeUp: (ev) => { + ev.preventDefault(); nudgeVolume(0.05); - break; - case "ArrowDown": - e.preventDefault(); + }, + volumeDown: (ev) => { + ev.preventDefault(); nudgeVolume(-0.05); - break; - case "m": - toggleMute(); - break; - case "a": - case "A": - cycleAudio(); - break; - case "s": - case "S": - cycleSubtitle(); - break; - case "h": - case "H": - setHelpOpen((v) => !v); - break; - case "i": - case "I": - setInfoOpen((v) => !v); - break; - case "d": - case "D": - setStatsOpen((v) => !v); - break; - case "Backspace": + }, + mute: () => toggleMute(), + audio: () => cycleAudio(), + subtitle: () => cycleSubtitle(), + help: () => setHelpOpen((v) => !v), + info: () => setInfoOpen((v) => !v), + stats: () => setStatsOpen((v) => !v), + back: (ev) => { // HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade). - e.preventDefault(); + ev.preventDefault(); if (skipProgressRef.current != null) cancelAutoSkipRef.current(); // cancel auto-skip, don't go back else handleBack(); - break; - case "Escape": + }, + escape: (ev) => { // Defer to any layer opened above the player (Modal or anchored popover) — let it take the // Escape, not us. A player MENU registers as a layer too, so it closes first and the next // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the // consumed-event flag first (UX-8): a layer's `document` listener runs before this `window` // one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it. - if (escapeHandled(e)) break; - if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break; + if (escapeHandled(ev)) return; + if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; if (skipProgressRef.current != null) cancelAutoSkipRef.current(); else if (menuOpenRef.current) { setMenuOpen(false); @@ -701,8 +690,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { else if (statsOpenRef.current) setStatsOpen(false); else if (upNextOpenRef.current) setUpNextOpen(false); else if (!fullscreenElement()) onClose(); - break; - } + }, + }); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); @@ -1261,40 +1250,27 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { + {/* Rendered FROM the shared keymap, not from a copy of it beside the handler: the two + used to be edited separately, which is how a sheet ends up documenting a key that no + longer exists. `when: "episode"` rows only apply to an episode. */}
- {( - [ - [["Space", "K"], "plex.player.keys.playPause"], - [["←", "→"], "plex.player.keys.seek"], - [["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"], - ...(detail?.kind === "episode" - ? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]] - : []), - [["↑", "↓"], "plex.player.keys.volume"], - [["M"], "plex.player.keys.mute"], - [["A"], "plex.player.keys.audio"], - [["S"], "plex.player.keys.subtitle"], - [["F"], "plex.player.keys.fullscreen"], - [["I"], "plex.player.keys.info"], - [["D"], "plex.player.keys.stats"], - [["⌫", "Esc"], "plex.player.keys.back"], - [["H"], "plex.player.keys.help"], - ] as [string[], string][] - ).map(([keys, labelKey]) => ( - -
- {keys.map((k) => ( - - {k} - - ))} -
-
{t(labelKey)}
-
- ))} + {helpLines(["plexPlayer", "global"]) + .filter((line) => line.when !== "episode" || detail?.kind === "episode") + .map((line) => ( + +
+ {line.chords.map((chord) => ( + + {formatChord(chord)} + + ))} +
+
{t(line.labelKey)}
+
+ ))}
diff --git a/frontend/src/components/ui/UndoToolbar.tsx b/frontend/src/components/ui/UndoToolbar.tsx index 979b4aad..947742f2 100644 --- a/frontend/src/components/ui/UndoToolbar.tsx +++ b/frontend/src/components/ui/UndoToolbar.tsx @@ -1,10 +1,12 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Redo2, Undo2 } from "lucide-react"; +import { dispatch } from "../../lib/keymap"; // Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z // (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any -// undo source. The shortcuts ignore keystrokes while a text field is focused. +// undo source. The chords and the typing guard come from the shared keymap (`undo` scope), so +// they are listed in the help sheet with everything else instead of only living here. export default function UndoToolbar({ canUndo, canRedo, @@ -20,22 +22,18 @@ export default function UndoToolbar({ useEffect(() => { function onKey(e: KeyboardEvent) { - if (!(e.ctrlKey || e.metaKey)) return; - const tgt = e.target as HTMLElement | null; - if (tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable)) - return; - const k = e.key.toLowerCase(); - if (k === "z" && !e.shiftKey) { - if (canUndo) { - e.preventDefault(); + dispatch(e, "undo", { + undo: (ev) => { + if (!canUndo) return; + ev.preventDefault(); onUndo(); - } - } else if (k === "y" || (k === "z" && e.shiftKey)) { - if (canRedo) { - e.preventDefault(); + }, + redo: (ev) => { + if (!canRedo) return; + ev.preventDefault(); onRedo(); - } - } + }, + }); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json new file mode 100644 index 00000000..2df9013e --- /dev/null +++ b/frontend/src/i18n/locales/en/keys.json @@ -0,0 +1,23 @@ +{ + "title": "Keyboard shortcuts", + "contextMenu": "Open the context menu", + "undo": "Undo", + "redo": "Redo", + "playPause": "Play / pause", + "fullscreen": "Fullscreen", + "seek": "Seek 10 seconds", + "seek5": "Seek 5 seconds", + "fineSeek": "Fine seek", + "episode": "Previous / next episode", + "video": "Previous / next video", + "volume": "Volume up / down", + "mute": "Mute", + "audio": "Cycle audio track", + "subtitle": "Cycle subtitle", + "help": "Show this help", + "info": "Media info", + "stats": "Stats for nerds", + "back": "Stop & back to feed", + "browseBack": "Back one level", + "closePlayer": "Close the player" +} diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 1458913a..5e8d4b04 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -186,21 +186,6 @@ "stop": "Stop", "help": "Keyboard shortcuts", "infoBtn": "Media info", - "keys": { - "playPause": "Play / pause", - "seek": "Seek 10 seconds", - "episode": "Previous / next episode", - "volume": "Volume up / down", - "mute": "Mute", - "fullscreen": "Fullscreen", - "info": "Media info", - "back": "Stop & back to feed", - "help": "Show this help", - "audio": "Cycle audio track", - "subtitle": "Cycle subtitle", - "fineSeek": "Fine seek", - "stats": "Stats for nerds" - }, "upNext": { "title": "Up next", "playNow": "Play now", diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json new file mode 100644 index 00000000..8de1dd82 --- /dev/null +++ b/frontend/src/i18n/locales/hu/keys.json @@ -0,0 +1,23 @@ +{ + "title": "Billentyűparancsok", + "contextMenu": "Helyi menü megnyitása", + "undo": "Visszavonás", + "redo": "Újra", + "playPause": "Lejátszás / szünet", + "fullscreen": "Teljes képernyő", + "seek": "Tekerés 10 másodperc", + "seek5": "Tekerés 5 másodperc", + "fineSeek": "Finom léptetés", + "episode": "Előző / következő rész", + "video": "Előző / következő videó", + "volume": "Hangerő fel / le", + "mute": "Némítás", + "audio": "Hangsáv váltása", + "subtitle": "Felirat váltása", + "help": "Súgó megjelenítése", + "info": "Média infó", + "stats": "Statisztika (nerdöknek)", + "back": "Leállítás és vissza a feedre", + "browseBack": "Vissza egy szintet", + "closePlayer": "Lejátszó bezárása" +} diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 84490c4b..e4d555f5 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -186,21 +186,6 @@ "stop": "Leállítás", "help": "Billentyűparancsok", "infoBtn": "Média infó", - "keys": { - "playPause": "Lejátszás / szünet", - "seek": "Tekerés 10 másodperc", - "episode": "Előző / következő rész", - "volume": "Hangerő fel / le", - "mute": "Némítás", - "fullscreen": "Teljes képernyő", - "info": "Média infó", - "back": "Leállítás és vissza a feedre", - "help": "Súgó megjelenítése", - "audio": "Hangsáv váltása", - "subtitle": "Felirat váltása", - "fineSeek": "Finom léptetés", - "stats": "Statisztika (nerdöknek)" - }, "upNext": { "title": "Következik", "playNow": "Lejátszás most", diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts new file mode 100644 index 00000000..8bfdc060 --- /dev/null +++ b/frontend/src/lib/keymap.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import en from "../i18n/locales/en/keys.json"; +import hu from "../i18n/locales/hu/keys.json"; +import { + SHORTCUTS, + dispatch, + formatChord, + helpLines, + isActivationTarget, + isTypingTarget, + matches, +} from "./keymap"; + +/** A keydown as it arrives from a real focus target (jsdom only fills `e.target` on dispatch). */ +function press(init: KeyboardEventInit, on?: HTMLElement): KeyboardEvent { + const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + (on ?? document.body).dispatchEvent(e); + return e; +} + +describe("matches", () => { + it("matches a plain letter case-insensitively", () => { + expect(matches(press({ key: "a" }), "a")).toBe(true); + expect(matches(press({ key: "A" }), "a")).toBe(true); + }); + + // The bug this table was written to kill: `case "a"` fired on Ctrl+A, so select-all cycled the + // Plex audio track. A chord without `Mod` must REJECT a modified key, not ignore the modifier. + it("rejects a modified key for an unmodified chord", () => { + expect(matches(press({ key: "a", ctrlKey: true }), "a")).toBe(false); + expect(matches(press({ key: "a", metaKey: true }), "a")).toBe(false); + expect(matches(press({ key: "ArrowLeft", shiftKey: true }), "ArrowLeft")).toBe(false); + }); + + it("treats Mod as Ctrl or Cmd", () => { + expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true); + expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true); + expect(matches(press({ key: "k" }), "Mod+k")).toBe(false); + }); + + it("distinguishes Shift chords from their bare key", () => { + expect(matches(press({ key: "ArrowLeft", shiftKey: true }), "Shift+ArrowLeft")).toBe(true); + expect(matches(press({ key: "ArrowLeft" }), "Shift+ArrowLeft")).toBe(false); + expect(matches(press({ key: "z", ctrlKey: true, shiftKey: true }), "Mod+Shift+z")).toBe(true); + expect(matches(press({ key: "z", ctrlKey: true }), "Mod+Shift+z")).toBe(false); + }); + + it("accepts Space by key or by code", () => { + expect(matches(press({ key: " " }), "Space")).toBe(true); + expect(matches(press({ key: "Unidentified", code: "Space" }), "Space")).toBe(true); + }); +}); + +describe("isTypingTarget", () => { + it("covers every field kind, not just input/textarea", () => { + for (const tag of ["input", "textarea", "select"] as const) { + const el = document.createElement(tag); + document.body.append(el); + expect(isTypingTarget(press({ key: "a" }, el))).toBe(true); + el.remove(); + } + const div = document.createElement("div"); + div.contentEditable = "true"; + // jsdom does not implement the contentEditable→isContentEditable reflection. + Object.defineProperty(div, "isContentEditable", { value: true }); + document.body.append(div); + expect(isTypingTarget(press({ key: "a" }, div))).toBe(true); + div.remove(); + }); + + it("is false on ordinary content", () => { + expect(isTypingTarget(press({ key: "a" }))).toBe(false); + }); +}); + +describe("isActivationTarget", () => { + it("is true for the controls Space activates natively", () => { + const btn = document.createElement("button"); + document.body.append(btn); + expect(isActivationTarget(press({ key: " " }, btn))).toBe(true); + btn.remove(); + expect(isActivationTarget(press({ key: " " }))).toBe(false); + }); +}); + +describe("dispatch", () => { + const noop = () => {}; + const plexHandlers = (calls: string[]) => + ({ + playPause: () => calls.push("playPause"), + fullscreen: () => calls.push("fullscreen"), + seekBack: () => calls.push("seekBack"), + seekFwd: () => calls.push("seekFwd"), + fineBack: () => calls.push("fineBack"), + fineFwd: () => calls.push("fineFwd"), + prevEpisode: () => calls.push("prevEpisode"), + nextEpisode: () => calls.push("nextEpisode"), + volumeUp: noop, + volumeDown: noop, + mute: noop, + audio: () => calls.push("audio"), + subtitle: noop, + help: noop, + info: noop, + stats: noop, + back: () => calls.push("back"), + escape: () => calls.push("escape"), + }) as const; + + it("routes each arrow variant to its own id", () => { + const calls: string[] = []; + const h = plexHandlers(calls); + dispatch(press({ key: "ArrowLeft" }), "plexPlayer", h); + dispatch(press({ key: "ArrowLeft", ctrlKey: true }), "plexPlayer", h); + dispatch(press({ key: "ArrowLeft", shiftKey: true }), "plexPlayer", h); + expect(calls).toEqual(["seekBack", "fineBack", "prevEpisode"]); + }); + + it("does not fire an ordinary shortcut while typing", () => { + const calls: string[] = []; + const input = document.createElement("input"); + document.body.append(input); + expect(dispatch(press({ key: "a" }, input), "plexPlayer", plexHandlers(calls))).toBe(false); + expect(calls).toEqual([]); + input.remove(); + }); + + // Escape is the one opt-in: a player whose Escape died inside its own subtitle-offset field + // would be a trap, so `allowInInput` lets it through where every other key is swallowed. + it("still fires Escape from inside a field", () => { + const calls: string[] = []; + const input = document.createElement("input"); + document.body.append(input); + expect(dispatch(press({ key: "Escape" }, input), "plexPlayer", plexHandlers(calls))).toBe(true); + expect(calls).toEqual(["escape"]); + input.remove(); + }); + + it("ignores keys belonging to another scope", () => { + const calls: string[] = []; + // `m` (mute) is a plexPlayer key; the browse scope only owns Backspace. + expect(dispatch(press({ key: "m" }), "plexBrowse", { back: () => calls.push("back") })).toBe( + false, + ); + expect(calls).toEqual([]); + }); +}); + +describe("the table itself", () => { + // `dispatch` looks a row's handler up by id, so a duplicate would silently shadow the second one. + it("has no duplicate id within a scope", () => { + const seen = new Set(); + for (const s of SHORTCUTS) { + const key = `${s.scope}:${s.id}`; + expect(seen.has(key), `duplicate ${key}`).toBe(false); + seen.add(key); + } + }); + + // The done-condition of E10 S3: every shortcut in the app is listed in one place AND named in + // both languages. A row whose label is missing would render as a raw i18n key in the help sheet. + it("names every shortcut in both languages", () => { + for (const s of SHORTCUTS) { + const [ns, key] = s.labelKey.split("."); + expect(ns, `${s.id} must live in the keys namespace`).toBe("keys"); + expect(en, `en is missing ${s.labelKey}`).toHaveProperty(key!); + expect(hu, `hu is missing ${s.labelKey}`).toHaveProperty(key!); + } + }); +}); + +describe("helpLines", () => { + it("groups rows that share a description into one line", () => { + const back = helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.back"); + expect(back?.chords).toEqual(["Backspace", "Escape"]); + }); + + it("carries the episode-only condition through so the sheet can filter it", () => { + const episode = helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.episode"); + expect(episode?.when).toBe("episode"); + expect(helpLines(["plexPlayer"]).find((l) => l.labelKey === "keys.mute")?.when).toBeUndefined(); + }); + + it("returns only the requested scopes", () => { + expect(helpLines(["plexBrowse"]).map((l) => l.labelKey)).toEqual(["keys.browseBack"]); + }); + + // A surface's own keys are what its reader came for; the app-wide ones are context underneath. + it("orders scopes as asked, not as tabled", () => { + const first = (scopes: Parameters[0]) => helpLines(scopes)[0]?.labelKey; + expect(first(["plexBrowse", "global"])).toBe("keys.browseBack"); + expect(first(["global", "plexBrowse"])).toBe("keys.contextMenu"); + }); +}); + +describe("formatChord", () => { + it("renders glyphs and upper-cases the letter", () => { + expect(formatChord("ArrowLeft")).toBe("←"); + expect(formatChord("Backspace")).toBe("⌫"); + expect(formatChord("Shift+ArrowRight")).toBe("⇧ →"); + expect(formatChord("k")).toBe("K"); + expect(formatChord("Space")).toBe("Space"); + }); +}); diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts new file mode 100644 index 00000000..b0f735b2 --- /dev/null +++ b/frontend/src/lib/keymap.ts @@ -0,0 +1,238 @@ +// ── 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 +// 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"; +} + +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); +} + +/** 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). `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). */ +export function dispatch( + e: KeyboardEvent, + scope: S, + handlers: { readonly [K in ShortcutId]: (e: KeyboardEvent) => void }, +): boolean { + const typing = isTypingTarget(e); + for (const s of ROWS) { + if (s.scope !== scope) continue; + if (typing && !s.allowInInput) continue; + if (!s.keys.some((k) => matches(e, k))) continue; + (handlers as Record void>)[s.id]?.(e); + return true; + } + return false; +} + +// ── Display ─────────────────────────────────────────────────────────────────────────────────── +const GLYPHS: Record = { + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + Backspace: "⌫", + Escape: "Esc", + Shift: "⇧", +}; + +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). */ +export function formatChord(chord: string): string { + return chord + .split("+") + .map((part, i, all) => { + if (part.toLowerCase() === "mod") return isMac() ? "⌘" : "Ctrl"; + if (i < all.length - 1) return GLYPHS[part] ?? part; + return GLYPHS[part] ?? (part.length === 1 ? part.toUpperCase() : part); + }) + .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. 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[]): HelpLine[] { + const out: HelpLine[] = []; + const byLabel = new Map(); + for (const scope of scopes) { + for (const s of ROWS) { + if (s.scope !== scope) 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; +}