feat(e10): one keyboard cheat sheet for the whole app
The Plex player had a shortcuts overlay nobody else did, so the YouTube player's Space/F/arrows and the Plex browser's Backspace were documented nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a global `h` - not the conventional `?`, which is a Shift chord on a Hungarian layout, while `h` is the key this app's reader already has in their fingers from the player. It renders the shared table filtered to the scopes that are LIVE: a surface registers its scope with useKeyScope while mounted, and only while its keys really do something - PlexBrowse deactivates its own scope inside the player, where its Backspace no-ops, so the sheet cannot promise a "Back one level" the handler declines to perform. Same rule for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer the player publishes because the sheet cannot ask. No new overlay primitive, after checking: Modal already portals into document.fullscreenElement ?? document.body (so this works over a fullscreen player), registers an Escape layer, traps focus and handles browser Back. Third time this epic that the thing was already built. Shift+F10 is in the table as `handledLocally` - documented and sharing the chord parser, but matched by useContextMenu on the focused row. dispatch neither routes it nor demands a handler for it, so the exhaustiveness check stays honest about what it actually covers. Also: A/S have cycled the Plex audio and subtitle tracks since E5, but silently - the only way to see where you landed was to open the tracks menu, the exact round-trip the shortcut exists to avoid. cycleAudio and cycleSubtitle now return the track they moved to, and the player flashes it over the video.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import {
|
||||
@@ -55,10 +54,12 @@ import {
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import {
|
||||
dispatch,
|
||||
formatChord,
|
||||
helpLines,
|
||||
isActivationTarget,
|
||||
isTypingTarget,
|
||||
toggleKeyboardHelp,
|
||||
useHelpConditions,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyScope,
|
||||
} from "../lib/keymap";
|
||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
@@ -183,10 +184,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
},
|
||||
[tracksFade.ref],
|
||||
);
|
||||
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
|
||||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||||
// know an overlay is up before it steals a wheel scroll for volume.
|
||||
const helpOpen = useKeyboardHelpOpen();
|
||||
const helpOpenRef = useRef(false);
|
||||
helpOpenRef.current = helpOpen;
|
||||
|
||||
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
|
||||
// E5, but silently — you changed the track and the only way to see what you landed on was to open
|
||||
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
|
||||
// itself needs no OSD: it shows the selection where you clicked.
|
||||
const [osd, setOsd] = useState<string | null>(null);
|
||||
const osdTimer = useRef<number | null>(null);
|
||||
const flashOsd = useCallback((text: string) => {
|
||||
setOsd(text);
|
||||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||||
osdTimer.current = window.setTimeout(() => setOsd(null), 2200);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (osdTimer.current) window.clearTimeout(osdTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
@@ -584,15 +605,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
useEffect(() => setUpNextOpen(false), [id]);
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
|
||||
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||
// with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the
|
||||
// player. The arrow used to call onClose() unconditionally, so Back with the info panel open
|
||||
// jumped straight to the feed instead of just closing the panel. The shortcuts sheet is no longer
|
||||
// in this cascade: it is a Modal above us now, and owns its own Escape layer and Back-to-close.
|
||||
const handleBack = useCallback(() => {
|
||||
if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
@@ -610,6 +631,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
handleBack();
|
||||
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
|
||||
|
||||
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
|
||||
// episodes, so they are listed only inside a series).
|
||||
useKeyScope("plexPlayer");
|
||||
useHelpConditions({ episode: detail?.kind === "episode" });
|
||||
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
||||
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
@@ -661,9 +687,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
nudgeVolume(-0.05);
|
||||
},
|
||||
mute: () => toggleMute(),
|
||||
audio: () => cycleAudio(),
|
||||
subtitle: () => cycleSubtitle(),
|
||||
help: () => setHelpOpen((v) => !v),
|
||||
audio: () => {
|
||||
const ord = cycleAudio();
|
||||
if (ord === undefined) return; // one track (or none): nothing to say
|
||||
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
|
||||
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
|
||||
},
|
||||
subtitle: () => {
|
||||
const ord = cycleSubtitle();
|
||||
if (ord === undefined) return;
|
||||
const label =
|
||||
ord == null
|
||||
? t("plex.player.subOff")
|
||||
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
|
||||
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
|
||||
},
|
||||
info: () => setInfoOpen((v) => !v),
|
||||
stats: () => setStatsOpen((v) => !v),
|
||||
back: (ev) => {
|
||||
@@ -685,8 +723,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else if (!fullscreenElement()) onClose();
|
||||
@@ -704,6 +741,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
toggleMute,
|
||||
cycleAudio,
|
||||
cycleSubtitle,
|
||||
flashOsd,
|
||||
t,
|
||||
detail,
|
||||
prevId,
|
||||
nextId,
|
||||
@@ -835,15 +874,25 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
|
||||
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
|
||||
the whole point is confirming something you cannot otherwise see. */}
|
||||
{osd && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-24 z-menu flex justify-center px-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
|
||||
{osd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
|
||||
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
|
||||
{(menuOpen ||
|
||||
tracksOpen ||
|
||||
helpOpen ||
|
||||
infoOpen ||
|
||||
statsOpen ||
|
||||
upNextOpen ||
|
||||
skipProgress != null) && <BackClose onBack={backIntent} />}
|
||||
{(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
|
||||
<BackClose onBack={backIntent} />
|
||||
)}
|
||||
|
||||
{/* Top bar */}
|
||||
<div
|
||||
@@ -1186,7 +1235,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
|
||||
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||
@@ -1225,57 +1274,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||
{helpOpen && (
|
||||
<div
|
||||
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHelpOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="p-1 rounded-lg hover:bg-white/10"
|
||||
aria-label={t("plex.player.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
{helpLines(["plexPlayer", "global"])
|
||||
.filter((line) => line.when !== "episode" || detail?.kind === "episode")
|
||||
.map((line) => (
|
||||
<Fragment key={line.labelKey}>
|
||||
<dt className="flex flex-wrap gap-1">
|
||||
{line.chords.map((chord) => (
|
||||
<kbd
|
||||
key={chord}
|
||||
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{formatChord(chord)}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="text-white/80">{t(line.labelKey)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
|
||||
{infoOpen && detail && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user