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:
@@ -34,6 +34,7 @@ import PageShell from "./components/ui/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import { StateMessage } from "./components/ui/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
@@ -412,6 +413,9 @@ export default function App() {
|
||||
(collapsed or expanded) without tracking its width. */}
|
||||
<Toaster />
|
||||
</div>
|
||||
{/* The app-wide keyboard cheat sheet + the global `h` that opens it. Mounted once, outside
|
||||
the page area, so every surface answers "what can I press here" the same way. */}
|
||||
<KeyboardHelp />
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget } from "../lib/keymap";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import {
|
||||
useNativeControlsYield,
|
||||
@@ -330,6 +330,8 @@ export default function PlayerModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useKeyScope("ytPlayer");
|
||||
|
||||
// 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
|
||||
|
||||
@@ -39,7 +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";
|
||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -65,6 +65,20 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
|
||||
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
|
||||
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
|
||||
const BACKABLE_VIEWS: readonly string[] = [
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
];
|
||||
|
||||
export default function PlexBrowse() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -164,27 +178,16 @@ 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
|
||||
// (its "player" view kind is deliberately absent from the list below); the typing guard and the
|
||||
// chord itself come from the shared keymap.
|
||||
// matching the browser/mouse Back; the chord and the typing guard come from the shared keymap.
|
||||
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
|
||||
// sheet can never promise a "Back one level" the handler declines to perform.
|
||||
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
|
||||
useKeyScope("plexBrowse", canStepBack);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "plexBrowse", {
|
||||
back: (ev) => {
|
||||
if (
|
||||
![
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
].includes(sub.view.kind)
|
||||
)
|
||||
return;
|
||||
if (!canStepBack) return;
|
||||
ev.preventDefault();
|
||||
sub.back();
|
||||
},
|
||||
@@ -192,7 +195,7 @@ export default function PlexBrowse() {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [sub]);
|
||||
}, [sub, canStepBack]);
|
||||
|
||||
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the app-wide cheat sheet: `h` opens it, it lists the SCOPES that are live (innermost
|
||||
// first, global last), and it hides a `when` row whose condition the surface has not published.
|
||||
// i18n is key-passthrough, so the asserted text is the i18n key.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import KeyboardHelp from "./KeyboardHelp";
|
||||
import { closeKeyboardHelp, useHelpConditions, useKeyScope } from "../../lib/keymap";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
// The open flag is module state shared by the whole app (one sheet, opened from anywhere), so a
|
||||
// test that leaves it open would flip the NEXT test's toggle into a close.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeKeyboardHelp();
|
||||
});
|
||||
|
||||
/** A stand-in for a surface that registers a scope while mounted, the way the players do. */
|
||||
function Surface({ episode }: { episode?: boolean }) {
|
||||
useKeyScope("plexPlayer");
|
||||
useHelpConditions({ episode: !!episode });
|
||||
return null;
|
||||
}
|
||||
|
||||
const pressH = () =>
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: "h" });
|
||||
});
|
||||
|
||||
const descriptions = () => screen.getAllByRole("definition").map((el) => el.textContent);
|
||||
|
||||
describe("KeyboardHelp", () => {
|
||||
it("opens and closes on the global h", () => {
|
||||
render(<KeyboardHelp />);
|
||||
expect(screen.queryByText("keys.title")).toBeNull();
|
||||
pressH();
|
||||
expect(screen.getByText("keys.title")).toBeTruthy();
|
||||
pressH();
|
||||
expect(screen.queryByText("keys.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open while a field has focus", () => {
|
||||
render(<KeyboardHelp />);
|
||||
const input = document.createElement("input");
|
||||
document.body.append(input);
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, { key: "h" });
|
||||
});
|
||||
expect(screen.queryByText("keys.title")).toBeNull();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("leads with the live surface and trails with the app-wide keys", () => {
|
||||
render(
|
||||
<>
|
||||
<Surface />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
pressH();
|
||||
const rows = descriptions();
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||
expect(rows[0]).toBe("keys.playPause");
|
||||
expect(rows.slice(-2)).toEqual(["keys.help", "keys.contextMenu"]);
|
||||
});
|
||||
|
||||
it("lists only the app-wide keys when no surface is registered", () => {
|
||||
render(<KeyboardHelp />);
|
||||
pressH();
|
||||
expect(descriptions()).toEqual(["keys.help", "keys.contextMenu"]);
|
||||
});
|
||||
|
||||
// "Previous / next episode" inside a movie would be a promise the player does not keep.
|
||||
it("hides a conditional row until its surface says it applies", () => {
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<Surface />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).not.toContain("keys.episode");
|
||||
unmount();
|
||||
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
|
||||
|
||||
render(
|
||||
<>
|
||||
<Surface episode />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.episode");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import {
|
||||
closeKeyboardHelp,
|
||||
dispatch,
|
||||
formatChord,
|
||||
helpLines,
|
||||
toggleKeyboardHelp,
|
||||
useActiveScopes,
|
||||
useHelpConditionValues,
|
||||
useKeyboardHelpOpen,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
// The app's one keyboard cheat sheet, plus the global `h` that opens it. Mounted once by App.
|
||||
//
|
||||
// It renders the shared SHORTCUTS table filtered to the scopes that are live right now — the Plex
|
||||
// player's keys while you are in the player, the app-wide ones underneath — so there is a single
|
||||
// answer to "what can I press here" and nothing to keep in sync. Before this, the Plex player had a
|
||||
// cheat sheet nobody else did, listing an array hand-copied from its own handler.
|
||||
//
|
||||
// `h` rather than the conventional `?`: on a Hungarian layout `?` is a Shift chord, and `h` is the
|
||||
// key this app's reader already has in their fingers from the player.
|
||||
//
|
||||
// Deliberately NOT a new overlay primitive: `Modal` already portals into
|
||||
// `document.fullscreenElement ?? document.body` (so this works over a fullscreen player), registers
|
||||
// a layer so Escape closes it before the player underneath, traps focus and handles browser Back.
|
||||
export default function KeyboardHelp() {
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardHelpOpen();
|
||||
const scopes = useActiveScopes();
|
||||
const conditions = useHelpConditionValues();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "global", {
|
||||
help: (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleKeyboardHelp();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
|
||||
// a promise the player does not keep.
|
||||
const lines = helpLines(scopes).filter((l) => !l.when || !!conditions[l.when]);
|
||||
return (
|
||||
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
{lines.map((line) => (
|
||||
<Fragment key={line.labelKey}>
|
||||
<dt className="flex flex-wrap gap-1">
|
||||
{line.chords.map((chord) => (
|
||||
<kbd
|
||||
key={chord}
|
||||
className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{formatChord(chord)}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="text-muted">{t(line.labelKey)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { dispatch } from "../../lib/keymap";
|
||||
import { dispatch, useKeyScope } 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
|
||||
@@ -19,6 +19,7 @@ export default function UndoToolbar({
|
||||
onRedo: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useKeyScope("undo");
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
|
||||
@@ -101,7 +101,6 @@ describe("dispatch", () => {
|
||||
mute: noop,
|
||||
audio: () => calls.push("audio"),
|
||||
subtitle: noop,
|
||||
help: noop,
|
||||
info: noop,
|
||||
stats: noop,
|
||||
back: () => calls.push("back"),
|
||||
@@ -137,6 +136,20 @@ describe("dispatch", () => {
|
||||
input.remove();
|
||||
});
|
||||
|
||||
// A `handledLocally` row is documentation + a shared chord, matched on the focused element by its
|
||||
// own component (Shift+F10 belongs to whichever row has focus). `dispatch` must not claim it, and
|
||||
// the mapped handler type must not demand one — this test is the runtime half of that.
|
||||
it("does not route a locally-handled row", () => {
|
||||
const calls: string[] = [];
|
||||
// The handler map is the global scope's routable ids only — `contextMenu` is absent from it,
|
||||
// which is the compile-time half of the same claim; tsc rejects passing one.
|
||||
const handlers = { help: () => calls.push("help") };
|
||||
expect(dispatch(press({ key: "F10", shiftKey: true }), "global", handlers)).toBe(false);
|
||||
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
|
||||
expect(dispatch(press({ key: "h" }), "global", handlers)).toBe(true);
|
||||
expect(calls).toEqual(["help"]);
|
||||
});
|
||||
|
||||
it("ignores keys belonging to another scope", () => {
|
||||
const calls: string[] = [];
|
||||
// `m` (mute) is a plexPlayer key; the browse scope only owns Backspace.
|
||||
@@ -190,7 +203,7 @@ describe("helpLines", () => {
|
||||
it("orders scopes as asked, not as tabled", () => {
|
||||
const first = (scopes: Parameters<typeof helpLines>[0]) => helpLines(scopes)[0]?.labelKey;
|
||||
expect(first(["plexBrowse", "global"])).toBe("keys.browseBack");
|
||||
expect(first(["global", "plexBrowse"])).toBe("keys.contextMenu");
|
||||
expect(first(["global", "plexBrowse"])).toBe("keys.help");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { createStore } from "./store";
|
||||
|
||||
// ── 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
|
||||
@@ -31,6 +34,10 @@ export interface Shortcut {
|
||||
* help line with their chords side by side — that is how "← → Seek" stays a single row. */
|
||||
readonly labelKey: string;
|
||||
readonly when?: ShortcutWhen;
|
||||
/** Matched at the element level rather than through `dispatch` — a row that is here to be
|
||||
* DOCUMENTED and to share the chord parser, not to be routed. `dispatch` neither requires nor
|
||||
* accepts a handler for it, so the exhaustiveness check stays honest about what it covers. */
|
||||
readonly handledLocally?: boolean;
|
||||
/** Fires even while a text field has focus. Escape only: it is the universal "get me out of here",
|
||||
* and a player whose Escape died inside its own subtitle-offset input is a trap. Every other
|
||||
* shortcut stays out of the way of typing. */
|
||||
@@ -42,7 +49,18 @@ export interface Shortcut {
|
||||
// focused button reports `key === " "` inconsistently across engines).
|
||||
export const SHORTCUTS = [
|
||||
// --- global ---------------------------------------------------------------------------------
|
||||
{ id: "contextMenu", scope: "global", keys: ["Shift+F10"], labelKey: "keys.contextMenu" },
|
||||
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
|
||||
// have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning.
|
||||
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
|
||||
// Matched by `useContextMenu` on the focused row, not by a window-level dispatch: which row the
|
||||
// menu belongs to is a property of focus, not of the app.
|
||||
{
|
||||
id: "contextMenu",
|
||||
scope: "global",
|
||||
keys: ["Shift+F10", "ContextMenu"],
|
||||
labelKey: "keys.contextMenu",
|
||||
handledLocally: true,
|
||||
},
|
||||
|
||||
// --- undo/redo (wherever an UndoToolbar is mounted) -----------------------------------------
|
||||
{ id: "undo", scope: "undo", keys: ["Mod+z"], labelKey: "keys.undo" },
|
||||
@@ -91,7 +109,6 @@ export const SHORTCUTS = [
|
||||
{ id: "mute", scope: "plexPlayer", keys: ["m"], labelKey: "keys.mute" },
|
||||
{ id: "audio", scope: "plexPlayer", keys: ["a"], labelKey: "keys.audio" },
|
||||
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
|
||||
{ id: "help", scope: "plexPlayer", keys: ["h"], labelKey: "keys.help" },
|
||||
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
|
||||
{ id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" },
|
||||
// Two rows, one help line: ⌫ is the plain "stop & out", Escape is the layer-aware cascade (defer to
|
||||
@@ -117,7 +134,10 @@ const ROWS: readonly Shortcut[] = SHORTCUTS;
|
||||
|
||||
type Row = (typeof SHORTCUTS)[number];
|
||||
/** The shortcut ids defined for one scope — the exact handler keys `dispatch()` requires. */
|
||||
export type ShortcutId<S extends KeyScope> = Extract<Row, { scope: S }>["id"];
|
||||
export type ShortcutId<S extends KeyScope> = Extract<
|
||||
Row,
|
||||
{ scope: S; handledLocally?: undefined }
|
||||
>["id"];
|
||||
|
||||
/** True while focus sits somewhere that swallows plain keys. The ONE definition: before S3 the
|
||||
* players disagreed about `<select>` and `contentEditable`, so the same keystroke was a shortcut in
|
||||
@@ -169,7 +189,7 @@ export function dispatch<S extends KeyScope>(
|
||||
): boolean {
|
||||
const typing = isTypingTarget(e);
|
||||
for (const s of ROWS) {
|
||||
if (s.scope !== scope) continue;
|
||||
if (s.scope !== scope || s.handledLocally) continue;
|
||||
if (typing && !s.allowInInput) continue;
|
||||
if (!s.keys.some((k) => matches(e, k))) continue;
|
||||
(handlers as Record<string, (ev: KeyboardEvent) => void>)[s.id]?.(e);
|
||||
@@ -178,6 +198,66 @@ export function dispatch<S extends KeyScope>(
|
||||
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<readonly KeyScope[]>([]);
|
||||
|
||||
/** 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.use();
|
||||
return [...stack].reverse().concat("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);
|
||||
|
||||
export const closeKeyboardHelp = (): void => helpOpen.set(false);
|
||||
export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
|
||||
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
||||
|
||||
/** Answers for the `when` conditions, published by the surface that knows them. */
|
||||
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
|
||||
const helpConditions = createStore<HelpConditions>({});
|
||||
|
||||
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
|
||||
* sheet lives in App and cannot ask, so the surface tells it. */
|
||||
export function useHelpConditions(conditions: HelpConditions): void {
|
||||
const episode = conditions.episode;
|
||||
useEffect(() => {
|
||||
helpConditions.set({ episode });
|
||||
return () => helpConditions.set({});
|
||||
}, [episode]);
|
||||
}
|
||||
|
||||
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
|
||||
|
||||
// ── Display ───────────────────────────────────────────────────────────────────────────────────
|
||||
const GLYPHS: Record<string, string> = {
|
||||
ArrowLeft: "←",
|
||||
@@ -185,6 +265,7 @@ const GLYPHS: Record<string, string> = {
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
Backspace: "⌫",
|
||||
ContextMenu: "Menu", // the dedicated menu key; "ContextMenu" is a DOM name, not a keycap
|
||||
Escape: "Esc",
|
||||
Shift: "⇧",
|
||||
};
|
||||
|
||||
@@ -365,8 +365,11 @@ export function usePlexTracks(opts: {
|
||||
setSubOrd: (ord: number | null) => void;
|
||||
changeAudio: (ord: number | null) => void;
|
||||
changeSubtitle: (ord: number | null) => void;
|
||||
cycleAudio: () => void;
|
||||
cycleSubtitle: () => void;
|
||||
/** Step to the next track and RETURN the ord landed on (null = the default/off entry), or
|
||||
* `undefined` when there was nothing to cycle. The caller needs the answer to put it on screen:
|
||||
* a keyboard track switch used to change the audio and tell you nothing. */
|
||||
cycleAudio: () => number | null | undefined;
|
||||
cycleSubtitle: () => number | null | undefined;
|
||||
changeAudioOffset: (v: number) => void;
|
||||
textSubs: PlexItemDetail["subtitle_streams"];
|
||||
selectedSub: PlexItemDetail["subtitle_streams"][number] | null;
|
||||
@@ -423,16 +426,21 @@ export function usePlexTracks(opts: {
|
||||
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
|
||||
const cycleAudio = useCallback(() => {
|
||||
const streams = detail?.audio_streams ?? [];
|
||||
if (streams.length < 2) return;
|
||||
if (streams.length < 2) return undefined;
|
||||
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
|
||||
const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds
|
||||
changeAudio(next.ord === 0 ? null : next.ord);
|
||||
const ord = next.ord === 0 ? null : next.ord;
|
||||
changeAudio(ord);
|
||||
return ord;
|
||||
}, [detail, changeAudio, audioRef]);
|
||||
const cycleSubtitle = useCallback(() => {
|
||||
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||||
if (subs.length === 0) return undefined; // nothing to cycle through but "off"
|
||||
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
|
||||
const i = order.findIndex((o) => o === subOrd);
|
||||
changeSubtitle(order[(i + 1) % order.length]!); // order always has [null, ...] → non-empty
|
||||
const ord = order[(i + 1) % order.length]!; // order always has [null, ...] → non-empty
|
||||
changeSubtitle(ord);
|
||||
return ord;
|
||||
}, [detail, subOrd, changeSubtitle]);
|
||||
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
|
||||
// dragging doesn't respawn ffmpeg on every step.
|
||||
|
||||
Reference in New Issue
Block a user