feat(e24): reach the Home surface — the landing toggle, a palette command, Shift+W
S4's reachability half. Home already led the nav order; what was missing was every way IN other than the rail. - Settings → Appearance → Startup: "Start on Home", the documented escape hatch for the landing override. Deliberately still localStorage and not a server pref — `loadInitialPage` answers before /api/me lands, and a landing module decided after the first paint is a visible jump. Its two stored values are now ONE exported constant, so the writer and the boot-time reader cannot drift on a magic string. - The command palette gets "Meglepetés" / "Surprise me": it navigates to Home and rerolls the hero on arrival. No second "Mit nézzek?" row beside it — the Home module row already matches that phrase through its descriptive title. - A global Shift+W jumps to Home. A chord, not a bare `w`: the Plex player binds bare letters and `w` sits on `s`, so a blind reach for the subtitle key would have thrown a viewer out of the film. The palette's request crosses a lazy module boundary, so it is a token that waits (lib/homeIntent) rather than an event fired into an empty room. Browser-measured: taken with a compare-and-clear, because StrictMode's double-invoked mount effect otherwise rerolled twice and landed on the page two picks deep. HU/EN parity kept; UAT on localdev in both languages, including the empty shelf.
This commit is contained in:
@@ -137,10 +137,11 @@ export default function App() {
|
|||||||
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
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
|
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||||
// signed in, because that is where both overlays are mounted (bottom of this tree) — see
|
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||||
// useGlobalKeymap on why binding it above the signed-out branch is worse than not binding it.
|
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
|
||||||
useGlobalKeymap(!!meQuery.data);
|
// 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
|
// 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
|
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
@@ -9,6 +9,7 @@ import { useMe } from "../lib/useMe";
|
|||||||
import { moduleDef, moduleOrder } from "../lib/modules";
|
import { moduleDef, moduleOrder } from "../lib/modules";
|
||||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||||
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
||||||
|
import { requestSurprise } from "../lib/homeIntent";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { usePrefs } from "./PrefsProvider";
|
import { usePrefs } from "./PrefsProvider";
|
||||||
@@ -124,6 +125,21 @@ export default function AppCommandPalette() {
|
|||||||
id: "actions",
|
id: "actions",
|
||||||
label: t("palette.groups.actions"),
|
label: t("palette.groups.actions"),
|
||||||
items: [
|
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: <Sparkles className="w-4 h-4" />,
|
||||||
|
keywords: t("home.title"),
|
||||||
|
run: () => {
|
||||||
|
setPage("home");
|
||||||
|
requestSurprise();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "action:shortcuts",
|
id: "action:shortcuts",
|
||||||
label: t("keys.title"),
|
label: t("keys.title"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// identical card straight back and the button looks dead.
|
// 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
|
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
|
||||||
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||||
|
import { StrictMode } from "react";
|
||||||
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -63,6 +64,7 @@ vi.mock("./PlexProvider", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import Home from "./Home";
|
import Home from "./Home";
|
||||||
|
import { requestSurprise, takeSurprise } from "../lib/homeIntent";
|
||||||
|
|
||||||
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
|
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -155,6 +157,8 @@ beforeEach(() => {
|
|||||||
setPage.mockReset();
|
setPage.mockReset();
|
||||||
setJumpTo.mockReset();
|
setJumpTo.mockReset();
|
||||||
discover.mockResolvedValue(payload("v1"));
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -219,6 +223,51 @@ describe("Home", () => {
|
|||||||
expect(screen.getByText("Hero v1")).toBeTruthy();
|
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(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Home />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
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 () => {
|
it("says the shelf is empty rather than hiding it", async () => {
|
||||||
mount();
|
mount();
|
||||||
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
|
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type VideoStatus,
|
type VideoStatus,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
||||||
@@ -511,6 +512,16 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [heroKey]);
|
}, [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 <LoadingState />;
|
if (q.isPending) return <LoadingState />;
|
||||||
if (q.isError)
|
if (q.isError)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { StateMessage } from "./ui/QueryState";
|
import { StateMessage } from "./ui/QueryState";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
|
import { START_ON_HOME } from "./navigationInit";
|
||||||
import { usePrefs } from "./PrefsProvider";
|
import { usePrefs } from "./PrefsProvider";
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
import { useMe } from "../lib/useMe";
|
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
|
// 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).
|
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
|
||||||
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -214,6 +220,19 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("settings.appearance.startup")}>
|
||||||
|
<SettingRow
|
||||||
|
label={t("settings.appearance.startOnHome")}
|
||||||
|
hint={t("settings.appearance.startOnHomeHint")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
label={t("settings.appearance.startOnHome")}
|
||||||
|
checked={startOnHome !== START_ON_HOME.off}
|
||||||
|
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title={t("settings.appearance.textSize")}>
|
<Section title={t("settings.appearance.textSize")}>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ vi.mock("../lib/storage", async (importOriginal) => {
|
|||||||
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
|
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";
|
import { LS } from "../lib/storage";
|
||||||
|
|
||||||
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
||||||
@@ -66,12 +66,12 @@ describe("loadInitialPage precedence", () => {
|
|||||||
expect(loadInitialPage()).toBe("home");
|
expect(loadInitialPage()).toBe("home");
|
||||||
});
|
});
|
||||||
it("opting out restores the account's stored landing page", () => {
|
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";
|
stored[LS.page] = "downloads";
|
||||||
expect(loadInitialPage()).toBe("downloads");
|
expect(loadInitialPage()).toBe("downloads");
|
||||||
});
|
});
|
||||||
it("opted out with no / invalid stored page still defaults to feed", () => {
|
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");
|
expect(loadInitialPage()).toBe("feed");
|
||||||
stored[LS.page] = "nope";
|
stored[LS.page] = "nope";
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage()).toBe("feed");
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
|
|||||||
import { getAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, LS } from "../lib/storage";
|
||||||
|
|
||||||
export const PAGE_KEY = LS.page;
|
export const PAGE_KEY = LS.page;
|
||||||
// Module-local: the Settings opt-out (S4) writes `LS.startOnHome` directly, so exporting this
|
// Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would
|
||||||
// alias would only be a second name for the same key.
|
// only be a second name for the same key.
|
||||||
const START_HOME_KEY = LS.startOnHome;
|
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 {
|
export interface ChannelView {
|
||||||
id: string;
|
id: string;
|
||||||
name?: 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
|
// 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
|
// 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
|
// 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.
|
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
|
||||||
if (getAccountRaw(START_HOME_KEY) !== "0") return "home";
|
// module back.
|
||||||
|
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
|
||||||
const stored = getAccountRaw(PAGE_KEY);
|
const stored = getAccountRaw(PAGE_KEY);
|
||||||
return isPage(stored) ? stored : "feed";
|
return isPage(stored) ? stored : "feed";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ afterEach(() => {
|
|||||||
closeKeyboardHelp();
|
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 }) {
|
function Host({ children }: { children?: React.ReactNode }) {
|
||||||
useGlobalKeymap();
|
useGlobalKeymap(true, () => {});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@@ -83,13 +83,13 @@ describe("KeyboardHelp", () => {
|
|||||||
const rows = descriptions();
|
const rows = descriptions();
|
||||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||||
expect(rows[0]).toBe("keys.playPause");
|
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", () => {
|
it("lists only the app-wide keys when no surface is registered", () => {
|
||||||
render(<Host />);
|
render(<Host />);
|
||||||
pressH();
|
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
|
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"audio": "Cycle audio track",
|
"audio": "Cycle audio track",
|
||||||
"subtitle": "Cycle subtitle",
|
"subtitle": "Cycle subtitle",
|
||||||
"help": "Show this help",
|
"help": "Show this help",
|
||||||
|
"home": "Go to Home (what should I watch?)",
|
||||||
"info": "Media info",
|
"info": "Media info",
|
||||||
"stats": "Stats for nerds",
|
"stats": "Stats for nerds",
|
||||||
"back": "Stop & back to feed",
|
"back": "Stop & back to feed",
|
||||||
|
|||||||
@@ -11,5 +11,8 @@
|
|||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"views": "Saved views",
|
"views": "Saved views",
|
||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"surprise": "Surprise me"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
||||||
"showTuner": "Show the glass tuner",
|
"showTuner": "Show the glass tuner",
|
||||||
"showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
|
"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"
|
"textSize": "Text size"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"audio": "Hangsáv váltása",
|
"audio": "Hangsáv váltása",
|
||||||
"subtitle": "Felirat váltása",
|
"subtitle": "Felirat váltása",
|
||||||
"help": "Súgó megjelenítése",
|
"help": "Súgó megjelenítése",
|
||||||
|
"home": "Ugrás a kezdőlapra (mit nézzek?)",
|
||||||
"info": "Média infó",
|
"info": "Média infó",
|
||||||
"stats": "Statisztika (nerdöknek)",
|
"stats": "Statisztika (nerdöknek)",
|
||||||
"back": "Leállítás és vissza a feedre",
|
"back": "Leállítás és vissza a feedre",
|
||||||
|
|||||||
@@ -11,5 +11,8 @@
|
|||||||
"playlists": "Listák",
|
"playlists": "Listák",
|
||||||
"views": "Mentett nézetek",
|
"views": "Mentett nézetek",
|
||||||
"actions": "Műveletek"
|
"actions": "Műveletek"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"surprise": "Meglepetés"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
||||||
"showTuner": "Glass tuner megjelenítése",
|
"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.",
|
"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"
|
"textSize": "Betűméret"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
|||||||
@@ -30,8 +30,16 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Renders the live stores so an assertion reads what a real consumer would render. */
|
/** 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 }) {
|
function Host({
|
||||||
useGlobalKeymap(enabled);
|
enabled = true,
|
||||||
|
onState,
|
||||||
|
onHome = () => {},
|
||||||
|
}: {
|
||||||
|
enabled?: boolean;
|
||||||
|
onState: (s: string) => void;
|
||||||
|
onHome?: () => void;
|
||||||
|
}) {
|
||||||
|
useGlobalKeymap(enabled, onHome);
|
||||||
const help = useKeyboardHelpOpen();
|
const help = useKeyboardHelpOpen();
|
||||||
const palette = usePaletteOpen();
|
const palette = usePaletteOpen();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,6 +119,45 @@ describe("useGlobalKeymap", () => {
|
|||||||
expect(state).toBe("none");
|
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(<Host onState={() => {}} 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(<Host onState={() => {}} 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(<Host onState={() => {}} onHome={first} />);
|
||||||
|
rerender(<Host onState={() => {}} 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
|
// 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.
|
// 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", () => {
|
it("binds nothing, and clears a stale store, while disabled", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -146,6 +146,7 @@ describe("dispatch", () => {
|
|||||||
const handlers = {
|
const handlers = {
|
||||||
help: () => void calls.push("help"),
|
help: () => void calls.push("help"),
|
||||||
palette: () => void calls.push("palette"),
|
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: "F10", shiftKey: true }), "global", handlers)).toBe(false);
|
||||||
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
|
expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false);
|
||||||
|
|||||||
+24
-10
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createStore } from "./store";
|
import { createStore } from "./store";
|
||||||
|
|
||||||
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
|
// ── 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.
|
// (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: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
|
||||||
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
|
{ 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) ---------------------------------
|
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
||||||
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
// 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
|
// 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
|
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
|
||||||
* calls this; the overlays are pure renderers of their store.
|
* calls this; the overlays are pure renderers of their store.
|
||||||
*
|
*
|
||||||
* `enabled` must track where those overlays are actually MOUNTED. Bound unconditionally it swallowed
|
* `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
|
||||||
* `h` and Mod+K on the loading/Welcome/setup screens — the browser shortcut was preventDefault'ed,
|
* Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
|
||||||
* nothing appeared, and the flipped module-level store then popped the overlay open by itself the
|
* browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
|
||||||
* moment the signed-in shell mounted (E10 review round 1). */
|
* popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
|
||||||
export function useGlobalKeymap(enabled = true): void {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
// Leaving a stale `true` behind would spring the overlay open on the next mount.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
// Both global keys TOGGLE an overlay, and each overlay mount/unmount costs a real history
|
// Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
|
||||||
// entry (useBackToClose). On auto-repeat that is ~30 push/back pairs a second, which Chrome
|
// history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
|
||||||
// starts dropping while our own depth bookkeeping keeps counting them — a desynced stack whose
|
// way, on auto-repeat that is ~30 push/back pairs a second, which Chrome starts dropping while
|
||||||
// next browser Back walks out of the app. Holding a key is not a second press.
|
// 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;
|
if (e.repeat) return;
|
||||||
dispatch(e, "global", {
|
dispatch(e, "global", {
|
||||||
help: (ev) => {
|
help: (ev) => {
|
||||||
@@ -309,6 +319,10 @@ export function useGlobalKeymap(enabled = true): void {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
paletteOpen.set((v) => !v);
|
paletteOpen.set((v) => !v);
|
||||||
},
|
},
|
||||||
|
home: (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
homeRef.current();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|||||||
Reference in New Issue
Block a user