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:
2026-09-05 21:15:42 +02:00
parent 12822a9e04
commit 86163958d6
10 changed files with 389 additions and 107 deletions
+13 -5
View File
@@ -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.