feat(e10): put every keyboard shortcut in one table
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 where the other two surfaces covered both. lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own listener and closures - PlexPlayer's refs are not worth re-plumbing - but the chords, the modifier rules and the typing guard live in one place, and dispatch() binds a scope's rows to handlers through a mapped type, so a row without a handler (or a handler without a row) is a tsc error rather than a comment someone forgets. Matching is now exact on modifiers, which is what kills the Ctrl+A bug and lets Shift+arrow be its own row beside the bare arrow. Escape opts out of the typing guard (allowInInput) so it stays the way out of a player's own subtitle-offset field. The player's shortcut sheet renders helpLines() instead of a hand-copied array beside the handler - the two used to be edited separately, which is how a sheet ends up documenting a key that no longer exists. Labels move to the new `keys` i18n namespace (EN + HU), and a test asserts every row is named in both.
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user