diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7fdb2b6e..b15fd02b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -137,10 +137,11 @@ export default function App() {
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
- // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette. Bound only once
- // signed in, because that is where both overlays are mounted (bottom of this tree) — see
- // useGlobalKeymap on why binding it above the signed-out branch is worse than not binding it.
- useGlobalKeymap(!!meQuery.data);
+ // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
+ // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
+ // (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
+ // above the signed-out branch is worse than not binding it.
+ useGlobalKeymap(!!meQuery.data, () => setPage("home"));
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER
diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx
index 500ef172..a4c3f33a 100644
--- a/frontend/src/components/AppCommandPalette.tsx
+++ b/frontend/src/components/AppCommandPalette.tsx
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
-import { Keyboard, ListVideo, Palette, Tv } from "lucide-react";
+import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react";
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
@@ -9,6 +9,7 @@ import { useMe } from "../lib/useMe";
import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
+import { requestSurprise } from "../lib/homeIntent";
import { useNavigationActions } from "./NavigationProvider";
import { usePlaylistsActions } from "./PlaylistsProvider";
import { usePrefs } from "./PrefsProvider";
@@ -124,6 +125,21 @@ export default function AppCommandPalette() {
id: "actions",
label: t("palette.groups.actions"),
items: [
+ // "Meglepetés" — Home's reroll, reachable from anywhere (E24). There is deliberately no
+ // second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
+ // because the palette searches a module's descriptive `titleKey` too and Home's is literally
+ // "Mit nézzek most?". A row that navigates where an existing row already navigates would be
+ // duplication in the one list whose whole job is to be scanned quickly.
+ {
+ id: "action:surprise",
+ label: t("palette.actions.surprise"),
+ icon: ,
+ keywords: t("home.title"),
+ run: () => {
+ setPage("home");
+ requestSurprise();
+ },
+ },
{
id: "action:shortcuts",
label: t("keys.title"),
diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx
index ac5b3ba7..2fa249e3 100644
--- a/frontend/src/components/Home.dom.test.tsx
+++ b/frontend/src/components/Home.dom.test.tsx
@@ -9,6 +9,7 @@
// identical card straight back and the button looks dead.
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
+import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -63,6 +64,7 @@ vi.mock("./PlexProvider", () => ({
}));
import Home from "./Home";
+import { requestSurprise, takeSurprise } from "../lib/homeIntent";
function ytCard(id: string, title: string, extra: Record = {}) {
return {
@@ -155,6 +157,8 @@ beforeEach(() => {
setPage.mockReset();
setJumpTo.mockReset();
discover.mockResolvedValue(payload("v1"));
+ // Module-level state: a token left behind by one test would reroll the next one's first render.
+ takeSurprise();
});
afterEach(cleanup);
@@ -219,6 +223,51 @@ describe("Home", () => {
expect(screen.getByText("Hero v1")).toBeTruthy();
});
+ // E24 S4. The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
+ // mounted yet — the request has to WAIT as a token rather than fire into an empty room. And it
+ // must be spent: a token that survived would make a later visit reroll on its own, which reads as
+ // the page overruling the answer you came back for.
+ it("acts on a pending surprise request once, and never replays it", async () => {
+ discoverPick.mockResolvedValue({
+ budget: "any",
+ hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
+ });
+ requestSurprise();
+ const first = mount();
+ expect(await screen.findByText("Rerolled")).toBeTruthy();
+ expect(discoverPick).toHaveBeenCalledTimes(1);
+
+ first.unmount();
+ discoverPick.mockClear();
+ mount();
+ await screen.findByText("Hero v1");
+ expect(discoverPick).not.toHaveBeenCalled();
+ });
+
+ // Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
+ // as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
+ // mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
+ // taken with a compare-and-clear against the LIVE store, which is what makes the second pass moot.
+ it("does not reroll twice under StrictMode's double-invoked mount effect", async () => {
+ discoverPick.mockResolvedValue({
+ budget: "any",
+ hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
+ });
+ requestSurprise();
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+
+
+ ,
+ );
+ expect(await screen.findByText("Rerolled")).toBeTruthy();
+ expect(discoverPick).toHaveBeenCalledTimes(1);
+ // …and on the press count the server reads as "which tier answers first".
+ expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
+ });
+
it("says the shelf is empty rather than hiding it", async () => {
mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx
index b2c7f6a7..bd5e1d8c 100644
--- a/frontend/src/components/Home.tsx
+++ b/frontend/src/components/Home.tsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense, useCallback, useState } from "react";
+import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -23,6 +23,7 @@ import {
type VideoStatus,
} from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
+import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
import { formatDuration } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
@@ -511,6 +512,16 @@ export default function Home() {
});
}, [heroKey]);
+ // "Meglepetés" from the command palette: the request is made on another page, so it waits as a
+ // token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
+ // is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
+ // false to every later pass, including StrictMode's second mount invocation and the re-runs
+ // `reroll`'s changing identity causes.
+ const surpriseRequest = useSurpriseRequest();
+ useEffect(() => {
+ if (surpriseRequest && takeSurprise()) reroll();
+ }, [surpriseRequest, reroll]);
+
if (q.isPending) return ;
if (q.isError)
return (
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index e473d7b8..e32c270c 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -16,6 +16,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { StateMessage } from "./ui/QueryState";
import { useWizard } from "./WizardProvider";
+import { START_ON_HOME } from "./navigationInit";
import { usePrefs } from "./PrefsProvider";
import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe";
@@ -129,6 +130,11 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
+ // The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage`
+ // has to answer before /api/me lands, so the value must be readable synchronously from
+ // localStorage — a landing module decided after the first paint would be a visible jump. The two
+ // stored values come from navigationInit, which is what reads them back at boot.
+ const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
return (
<>
@@ -214,6 +220,19 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
+
+
+ setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
+ />
+
+
+
{
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
});
-import { loadInitialPage, channelViewFrom } from "./navigationInit";
+import { loadInitialPage, channelViewFrom, START_ON_HOME } from "./navigationInit";
import { LS } from "../lib/storage";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
@@ -66,12 +66,12 @@ describe("loadInitialPage precedence", () => {
expect(loadInitialPage()).toBe("home");
});
it("opting out restores the account's stored landing page", () => {
- stored[LS.startOnHome] = "0";
+ stored[LS.startOnHome] = START_ON_HOME.off;
stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("downloads");
});
it("opted out with no / invalid stored page still defaults to feed", () => {
- stored[LS.startOnHome] = "0";
+ stored[LS.startOnHome] = START_ON_HOME.off;
expect(loadInitialPage()).toBe("feed");
stored[LS.page] = "nope";
expect(loadInitialPage()).toBe("feed");
diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts
index 00452d50..f1109778 100644
--- a/frontend/src/components/navigationInit.ts
+++ b/frontend/src/components/navigationInit.ts
@@ -6,10 +6,16 @@ import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
import { getAccountRaw, LS } from "../lib/storage";
export const PAGE_KEY = LS.page;
-// Module-local: the Settings opt-out (S4) writes `LS.startOnHome` directly, so exporting this
-// alias would only be a second name for the same key.
+// Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would
+// only be a second name for the same key.
const START_HOME_KEY = LS.startOnHome;
+/** The two stored values of the landing preference (raw strings, via useAccountPersistedState).
+ * Settings → Appearance → Startup writes them and `loadInitialPage` reads them, so they are defined
+ * ONCE: the same magic string spelled out in a writer and a reader is precisely the drift that has
+ * bitten this codebase before (`cancelled` vs `canceled`). */
+export const START_ON_HOME = { on: "1", off: "0" } as const;
+
export interface ChannelView {
id: string;
name?: string;
@@ -26,8 +32,9 @@ export function loadInitialPage(): Page {
// A bare URL → Home (E24), the surface whose whole job is to answer "what should I watch" before
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
- // replace. The account can opt out (Settings) and get the remembered module back.
- if (getAccountRaw(START_HOME_KEY) !== "0") return "home";
+ // replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
+ // module back.
+ if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
diff --git a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx
index cf99270c..7949ff89 100644
--- a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx
+++ b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx
@@ -27,9 +27,9 @@ afterEach(() => {
closeKeyboardHelp();
});
-/** App's job: it binds the `global` scope (h / Mod+K) once for the whole tree. */
+/** App's job: it binds the `global` scope (h / Mod+K / Shift+W) once for the whole tree. */
function Host({ children }: { children?: React.ReactNode }) {
- useGlobalKeymap();
+ useGlobalKeymap(true, () => {});
return (
<>
{children}
@@ -83,13 +83,13 @@ describe("KeyboardHelp", () => {
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.palette", "keys.help"]);
+ expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.home"]);
});
it("lists only the app-wide keys when no surface is registered", () => {
render();
pressH();
- expect(descriptions()).toEqual(["keys.palette", "keys.help"]);
+ expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.home"]);
});
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json
index 5c255351..37f9076c 100644
--- a/frontend/src/i18n/locales/en/keys.json
+++ b/frontend/src/i18n/locales/en/keys.json
@@ -15,6 +15,7 @@
"audio": "Cycle audio track",
"subtitle": "Cycle subtitle",
"help": "Show this help",
+ "home": "Go to Home (what should I watch?)",
"info": "Media info",
"stats": "Stats for nerds",
"back": "Stop & back to feed",
diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json
index 75dda89d..16963d00 100644
--- a/frontend/src/i18n/locales/en/palette.json
+++ b/frontend/src/i18n/locales/en/palette.json
@@ -11,5 +11,8 @@
"playlists": "Playlists",
"views": "Saved views",
"actions": "Actions"
+ },
+ "actions": {
+ "surprise": "Surprise me"
}
}
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index 1c84c950..9d6d0606 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -34,6 +34,9 @@
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
"showTuner": "Show the glass tuner",
"showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
+ "startup": "Startup",
+ "startOnHome": "Start on Home",
+ "startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
"textSize": "Text size"
},
"notifications": {
diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json
index c8cfca4f..b6e24c44 100644
--- a/frontend/src/i18n/locales/hu/keys.json
+++ b/frontend/src/i18n/locales/hu/keys.json
@@ -15,6 +15,7 @@
"audio": "Hangsáv váltása",
"subtitle": "Felirat váltása",
"help": "Súgó megjelenítése",
+ "home": "Ugrás a kezdőlapra (mit nézzek?)",
"info": "Média infó",
"stats": "Statisztika (nerdöknek)",
"back": "Leállítás és vissza a feedre",
diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json
index 88e80ca2..3e00a1a6 100644
--- a/frontend/src/i18n/locales/hu/palette.json
+++ b/frontend/src/i18n/locales/hu/palette.json
@@ -11,5 +11,8 @@
"playlists": "Listák",
"views": "Mentett nézetek",
"actions": "Műveletek"
+ },
+ "actions": {
+ "surprise": "Meglepetés"
}
}
diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json
index 9fb47826..9515829c 100644
--- a/frontend/src/i18n/locales/hu/settings.json
+++ b/frontend/src/i18n/locales/hu/settings.json
@@ -34,6 +34,9 @@
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
"showTuner": "Glass tuner megjelenítése",
"showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.",
+ "startup": "Indulás",
+ "startOnHome": "Indítás a kezdőlapon",
+ "startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
"textSize": "Betűméret"
},
"notifications": {
diff --git a/frontend/src/lib/globalKeymap.dom.test.tsx b/frontend/src/lib/globalKeymap.dom.test.tsx
index 2df7ec95..023f242c 100644
--- a/frontend/src/lib/globalKeymap.dom.test.tsx
+++ b/frontend/src/lib/globalKeymap.dom.test.tsx
@@ -30,8 +30,16 @@ afterEach(() => {
});
/** Renders the live stores so an assertion reads what a real consumer would render. */
-function Host({ enabled = true, onState }: { enabled?: boolean; onState: (s: string) => void }) {
- useGlobalKeymap(enabled);
+function Host({
+ enabled = true,
+ onState,
+ onHome = () => {},
+}: {
+ enabled?: boolean;
+ onState: (s: string) => void;
+ onHome?: () => void;
+}) {
+ useGlobalKeymap(enabled, onHome);
const help = useKeyboardHelpOpen();
const palette = usePaletteOpen();
useEffect(() => {
@@ -111,6 +119,45 @@ describe("useGlobalKeymap", () => {
expect(state).toBe("none");
});
+ // E24. The chord is the point: the Plex player binds bare letters and `w` sits on top of `s`, so a
+ // bare `w` would yank a viewer out of the film on a blind reach for the subtitle key.
+ it("jumps Home on Shift+W and ignores a bare w", () => {
+ const onHome = vi.fn();
+ render( {}} onHome={onHome} />);
+ key({ key: "w" });
+ expect(onHome).not.toHaveBeenCalled();
+ key({ key: "w", shiftKey: true });
+ expect(onHome).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves Shift+W to the text field it was typed into", () => {
+ const onHome = vi.fn();
+ render( {}} onHome={onHome} />);
+ const field = document.createElement("input");
+ document.body.appendChild(field);
+ field.focus();
+ act(() => {
+ field.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "w", shiftKey: true, bubbles: true, cancelable: true }),
+ );
+ });
+ expect(onHome).not.toHaveBeenCalled();
+ field.remove();
+ });
+
+ // The handler is App's `() => setPage("home")` — a fresh closure every render. It is held in a ref
+ // rather than in the effect's deps, so the listener is bound once; this pins that the ref is the
+ // CURRENT one and not the closure captured at mount.
+ it("calls the latest handler after a re-render, without rebinding", () => {
+ const first = vi.fn();
+ const second = vi.fn();
+ const { rerender } = render( {}} onHome={first} />);
+ rerender( {}} onHome={second} />);
+ key({ key: "w", shiftKey: true });
+ expect(first).not.toHaveBeenCalled();
+ expect(second).toHaveBeenCalledTimes(1);
+ });
+
// Bound above the branch that mounts the overlays, the keys were swallowed on the loading/Welcome
// screens and the flipped store then sprang the overlay open by itself once the shell mounted.
it("binds nothing, and clears a stale store, while disabled", () => {
diff --git a/frontend/src/lib/homeIntent.ts b/frontend/src/lib/homeIntent.ts
new file mode 100644
index 00000000..f58d173f
--- /dev/null
+++ b/frontend/src/lib/homeIntent.ts
@@ -0,0 +1,30 @@
+import { createStore } from "./store";
+
+// A one-shot "…and surprise me straight away" request for the Home surface (E24), handed over by
+// callers that cannot reach its state — today the command palette's "Meglepetés".
+//
+// A TOKEN that waits, not an event that fires: Home is a lazy module and is normally NOT mounted
+// when the request is made (the palette navigates to it in the same click), so an event would be
+// shouted into an empty room. Home takes the token on arrival, which clears it, so walking back to
+// Home an hour later does not replay the press — a stale reroll would look like the page deciding
+// on its own that the answer you came back for was wrong.
+const surprise = createStore(0);
+
+/** Ask Home to reroll its hero as soon as it is on screen. Pair it with a `setPage("home")`. */
+export const requestSurprise = (): void => surprise.set((n) => n + 1);
+
+/** The pending token (0 = nothing pending) — the render subscription that wakes Home up. Home only. */
+export const useSurpriseRequest = (): number => surprise.use();
+
+/** Take the pending request, clearing it; false when there was nothing to take.
+ *
+ * Compare-and-clear rather than a plain read + `consume()`, because a reroll is NOT idempotent — it
+ * spends a `/discover/pick` and steps the tier rotation — and React 18's StrictMode invokes a mount
+ * effect twice with the same render snapshot. Measured in the browser (2026-09-07): the palette's
+ * "Meglepetés" from another module arrived at Home as `start=2`, two picks deep, before the page had
+ * painted once. Checking the LIVE store is what makes the second pass a no-op. */
+export const takeSurprise = (): boolean => {
+ if (!surprise.get()) return false;
+ surprise.set(0);
+ return true;
+};
diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts
index 44b37a84..681a3c25 100644
--- a/frontend/src/lib/keymap.test.ts
+++ b/frontend/src/lib/keymap.test.ts
@@ -146,6 +146,7 @@ describe("dispatch", () => {
const handlers = {
help: () => void calls.push("help"),
palette: () => void calls.push("palette"),
+ home: () => void calls.push("home"),
};
expect(dispatch(press({ key: "F10", shiftKey: true }), "global", handlers)).toBe(false);
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts
index d907e692..ee47c000 100644
--- a/frontend/src/lib/keymap.ts
+++ b/frontend/src/lib/keymap.ts
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import { createStore } from "./store";
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
@@ -56,6 +56,10 @@ export const SHORTCUTS = [
// (E10 review round 1). Its handler preventDefaults, so nothing leaks to the browser either way.
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
+ // Shift+W and not a bare `w` (E24): the Plex player binds bare letters (a s d f h i k m) and `w`
+ // sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
+ // and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
+ { id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
@@ -281,11 +285,16 @@ export const usePaletteOpen = (): boolean => paletteOpen.use();
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
* calls this; the overlays are pure renderers of their store.
*
- * `enabled` must track where those overlays are actually MOUNTED. Bound unconditionally it swallowed
- * `h` and Mod+K on the loading/Welcome/setup screens — the browser shortcut was preventDefault'ed,
- * nothing appeared, and the flipped module-level store then popped the overlay open by itself the
- * moment the signed-in shell mounted (E10 review round 1). */
-export function useGlobalKeymap(enabled = true): void {
+ * `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
+ * Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
+ * browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
+ * popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
+export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
+ // The Home jump is the one global key whose action lives OUTSIDE this file — navigation is a
+ // provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
+ // closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
+ const homeRef = useRef(onHome);
+ homeRef.current = onHome;
useEffect(() => {
if (!enabled) {
// Leaving a stale `true` behind would spring the overlay open on the next mount.
@@ -294,10 +303,11 @@ export function useGlobalKeymap(enabled = true): void {
return;
}
const onKey = (e: KeyboardEvent) => {
- // Both global keys TOGGLE an overlay, and each overlay mount/unmount costs a real history
- // entry (useBackToClose). On auto-repeat that is ~30 push/back pairs a second, which Chrome
- // starts dropping while our own depth bookkeeping keeps counting them — a desynced stack whose
- // next browser Back walks out of the app. Holding a key is not a second press.
+ // Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
+ // history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
+ // way, on auto-repeat that is ~30 push/back pairs a second, which Chrome starts dropping while
+ // our own depth bookkeeping keeps counting them — a desynced stack whose next browser Back
+ // walks out of the app. Holding a key is not a second press.
if (e.repeat) return;
dispatch(e, "global", {
help: (ev) => {
@@ -309,6 +319,10 @@ export function useGlobalKeymap(enabled = true): void {
ev.preventDefault();
paletteOpen.set((v) => !v);
},
+ home: (ev) => {
+ ev.preventDefault();
+ homeRef.current();
+ },
});
};
window.addEventListener("keydown", onKey);