diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a61db11b..3252da5d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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. */}
+ {/* 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. */}
+
{meQuery.data && !meQuery.data.is_demo && }
{aboutOpen && (
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index 6ad2391b..9f738691 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -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
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 7b9ee507..e30f4c2e 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -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 = 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
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index 32514acd..42404d1c 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -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(null);
+ const osdTimer = useRef(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(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) {
)}
+ {/* 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 && (
+
+
+ {osd}
+
+
+ )}
+
{/* 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) && }
+ {(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
+
+ )}
{/* Top bar */}
- {/* 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. */}
-
- )}
-
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && (
{
+ const actual = await importOriginal();
+ 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();
+ 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();
+ 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(
+ <>
+
+
+ >,
+ );
+ 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();
+ 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(
+ <>
+
+
+ >,
+ );
+ pressH();
+ expect(descriptions()).not.toContain("keys.episode");
+ unmount();
+ closeKeyboardHelp(); // the sheet outlives the tree it was opened from
+
+ render(
+ <>
+
+
+ >,
+ );
+ pressH();
+ expect(descriptions()).toContain("keys.episode");
+ });
+});
diff --git a/frontend/src/components/ui/KeyboardHelp.tsx b/frontend/src/components/ui/KeyboardHelp.tsx
new file mode 100644
index 00000000..ad90085b
--- /dev/null
+++ b/frontend/src/components/ui/KeyboardHelp.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/UndoToolbar.tsx b/frontend/src/components/ui/UndoToolbar.tsx
index 947742f2..1c896473 100644
--- a/frontend/src/components/ui/UndoToolbar.tsx
+++ b/frontend/src/components/ui/UndoToolbar.tsx
@@ -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) {
diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts
index 8bfdc060..0cf903c5 100644
--- a/frontend/src/lib/keymap.test.ts
+++ b/frontend/src/lib/keymap.test.ts
@@ -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[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");
});
});
diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts
index b0f735b2..32380324 100644
--- a/frontend/src/lib/keymap.ts
+++ b/frontend/src/lib/keymap.ts
@@ -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 = Extract["id"];
+export type ShortcutId = 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 `