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. */}