feat(e24): the Home surface — one question, one answer, both sources

E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the
time-budget chips on top, one large hero that answers them, then the comfort
shelf and a cross-source resume rail. Home is the landing — deliberately OVER
the remembered last module, since landing on whatever you happened to close the
app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an
account back out (Settings toggle lands in S4).

No third card component: each unified card carries the native feed / Plex
payload, so the rails render through the existing VideoCard (`cardsSmall`) and
PlexPosterCard, and `aspect` decides the tile shape without branching on source.
Plex playback hands off to the Plex module via a new `jumpTo` type — the player
lives there with its lazy hls.js and its provider tree, and mounting a second one
on the landing page to save one hand-off would be the expensive way round.

Four defects found by driving it in a real browser against the live account, each
now pinned by a mutation-checked test:

- **The reroll gave the same card back.** A new seed only re-draws the SURPRISE
  tier; the resume tier deterministically returns the most recently progressed
  thing that fits, so anyone with something half-finished pressed a dead button.
  `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps
  a rolling window of 8. Excluding a SHOW also excludes the episode it resolves
  to — otherwise the same suggestion comes back wearing a hat.
- **"Nothing to suggest yet" flashed over a full library** whenever a reroll was
  in flight: the new query key has no data for a beat. An empty state standing in
  for a loading one is the dishonesty the house rules name outright. The hero now
  falls back to the loaded payload while a pick is pending, and only trusts a null
  once the pick has RESOLVED.
- **A budget change replaced the page — chips included — with a spinner**, so the
  control you just clicked vanished under your cursor. `keepPreviousData`, the
  same reason the feed uses it.
- **The hero printed twice**, once as itself and once as the first resume tile.

Two smaller UI calls made from the same session: the budget caption sits NEXT to
its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem
page throws the answers to the far edge; and the feed takes the YouTube mark its
own toolbar already uses, since Home now owns the house icon.

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780,
pytest 989. Verified end to end on localdev against the real account: budget
chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is
a ten-minute answer), the reroll walks three distinct heroes, and a Plex show
tile opens its drill-down in the Plex module.
This commit is contained in:
2026-09-06 04:14:14 +02:00
parent 53b50231ff
commit f3c0b7be23
21 changed files with 964 additions and 14 deletions
+184
View File
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
//
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
// which is invisible to tsc and to a render-only check:
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
// • the hero is not printed twice (once as the hero, once as the first resume tile);
// • a budget change asks for the new budget and keeps the old answer on screen meanwhile;
// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the
// 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 { 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";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.time ? `${key}:${opts.time}` : key,
i18n: { language: "en" },
}),
};
});
const discover = vi.fn();
const discoverPick = vi.fn();
vi.mock("../lib/api", () => ({
api: {
discover: (...a: unknown[]) => discover(...a),
discoverPick: (...a: unknown[]) => discoverPick(...a),
setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(),
watchLaterRemove: () => Promise.resolve(),
},
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
// The page hands Plex playback off to the Plex module; neither provider is under test here.
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
return {
source: "youtube",
kind: "video",
id,
title,
subtitle: "A channel",
thumb: null,
aspect: "wide",
duration_seconds: 600,
position_seconds: 120,
remaining_seconds: 480,
status: "new",
plex: null,
video: {
id,
title,
channel_id: "UC1",
channel_title: "A channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/channel/UC1",
published_at: "2026-01-01T00:00:00Z",
thumbnail_url: null,
duration_seconds: 600,
view_count: 10,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 120,
saved: false,
watch_url: `https://www.youtube.com/watch?v=${id}`,
},
...extra,
};
}
const payload = (heroId: string, tier = "resume") => ({
budget: "any",
hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) },
continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")],
comfort: [],
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>,
);
}
beforeEach(() => {
discover.mockReset();
discoverPick.mockReset();
discover.mockResolvedValue(payload("v1"));
});
afterEach(cleanup);
describe("Home", () => {
it("labels the hero from the server's tier, not from the card", async () => {
// The same card shape is served under a `surprise` tier: a client that inferred "where you left
// off" from the resume position would get this wrong, which is the dishonest state we forbid.
discover.mockResolvedValue(payload("v1", "surprise"));
mount();
expect(await screen.findByText("home.hero.surprise")).toBeTruthy();
expect(screen.queryByText(/home\.hero\.resume/)).toBeNull();
});
it("interpolates the remaining time into the resume kicker", async () => {
mount();
// 480s left → 8:00, passed through the key-passthrough t() as `<key>:<time>`.
expect(await screen.findByText("home.hero.resumeLeft:8:00")).toBeTruthy();
});
it("does not print the hero again as the first resume tile", async () => {
mount();
await screen.findByText("home.hero.resumeLeft:8:00");
// "Hero v1" is the hero's own heading; the rail below must not repeat it.
expect(screen.getAllByText("Hero v1")).toHaveLength(1);
expect(screen.getByText("Second thing")).toBeTruthy();
});
it("asks for the chosen budget and keeps the previous answer on screen meanwhile", async () => {
mount();
await screen.findByText("Hero v1");
// A never-resolving refetch: the point is what the page shows WHILE it waits.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("home.budget.short"));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
// Still the old answer — not a spinner that would take the chips away with it.
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("rerolls with the shown card excluded, and holds the hero while it loads", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"]),
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
});
it("never shows the empty state while a reroll is in flight", async () => {
// The regression the browser round caught: a new query key has no data for a beat, so the hero
// fell to null and the page rendered "nothing to suggest" over a perfectly good library.
discoverPick.mockReturnValue(new Promise(() => {}));
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalled());
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("says the shelf is empty rather than hiding it", async () => {
mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
});
});
+446
View File
@@ -0,0 +1,446 @@
import { lazy, Suspense, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, RotateCcw } from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
api,
discoverCardKey,
type DiscoverBudget,
type DiscoverCard,
type HeroTier,
type PlexCard,
type Video,
type VideoStatus,
} from "../lib/api";
import { formatDuration } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./NavigationProvider";
import { usePlexActions } from "./PlexProvider";
// The Home surface (E24) — "Mit nézzek most?".
//
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
// promise is that it ANSWERS rather than offers, which is why there is one hero and not a grid of
// three: a shortlist is still a comparison, and comparing is the thing the user cannot do on the
// evenings this page exists for.
//
// Everything domain-shaped is already built: the cards are the feed's `VideoCard` and Plex's
// `PlexPosterCard`, rendered from the native payload each unified card carries, and the rails are
// the platform `Carousel`. Nothing here is a third card component.
// Lazy, exactly as the feed does it: the YouTube IFrame player loads only once something is played.
const PlayerModal = lazy(() => import("./PlayerModal"));
const BUDGETS: readonly DiscoverBudget[] = ["short", "medium", "long", "any"];
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
// drift from it.
const YT_SLOT = 200;
// How many already-offered cards the reroll remembers. Matched by the server's own draw depth
// (`hero.SURPRISE_DRAW`), which has to be able to step past a full window.
const SEEN_WINDOW = 8;
function Rail({
title,
ariaLabel,
cards,
emptyLabel,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
}: {
title: string;
ariaLabel: string;
cards: DiscoverCard[];
emptyLabel: string;
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
}) {
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
// you have not pinned anything teaches nothing about how to fill it.
if (!cards.length) {
return (
<section className="mb-6">
<h2 className="text-sm font-semibold mb-2">{title}</h2>
<StateMessage className="!py-6">{emptyLabel}</StateMessage>
</section>
);
}
return (
<section className="mb-6">
<Carousel
title={<span className="text-sm font-semibold">{title}</span>}
ariaLabel={ariaLabel}
>
{cards.map((c) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
>
{c.video ? (
<VideoCard
video={c.video}
view="cardsSmall"
width={YT_SLOT}
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
/>
) : c.plex ? (
<PlexPosterCard
card={c.plex}
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} />
)}
</div>
))}
</Carousel>
</section>
);
}
// A pinned CHANNEL or PLAYLIST has no native card of its own — neither is a playable thing, so
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
// runtime it does not have.
function ShelfTile({ card, onPlay }: { card: DiscoverCard; onPlay: (card: DiscoverCard) => void }) {
const { t } = useTranslation();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel =
card.kind === "channel"
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 });
return (
<button
type="button"
onClick={() => onPlay(card)}
disabled={!card.next}
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface disabled:opacity-60 enabled:hover:border-accent transition-colors"
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
) : null}
</span>
<span className="block p-2">
<span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span>
</span>
</button>
);
}
function Hero({
tier,
card,
onPlay,
onReroll,
rerolling,
}: {
tier: HeroTier;
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
onReroll: () => void;
rerolling: boolean;
}) {
const { t } = useTranslation();
const started = card.position_seconds > 0;
const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
// you left off" over something never opened is exactly the quiet lie the house rules forbid.
const kicker =
tier === "resume"
? left != null
? t("home.hero.resumeLeft", { time: formatDuration(left) })
: t("home.hero.resume")
: tier === "comfort"
? t("home.hero.comfort")
: t("home.hero.surprise");
return (
<section className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]">
<button
type="button"
onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
}`}
aria-label={t("home.hero.play")}
>
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
{card.duration_seconds != null && (
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
</button>
<div className="flex min-w-0 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
{started ? t("home.hero.continue") : t("home.hero.play")}
</Button>
<Button
variant="outline"
icon={<RotateCcw className="h-4 w-4" />}
loading={rerolling}
onClick={onReroll}
>
{t("home.hero.reroll")}
</Button>
</div>
</div>
</section>
);
}
export default function Home() {
const { t } = useTranslation();
const qc = useQueryClient();
const { setPage } = useNavigationActions();
const { setJumpTo } = usePlexActions();
const [budget, setBudget] = useState<DiscoverBudget>("any");
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
// pressing the button does not refetch the rails under the user's cursor.
const [seed, setSeed] = useState(0);
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
// the whole history: an unbounded list would eventually exclude everything and leave the button
// with nothing to give, whereas a window of 8 can never exhaust a library and only repeats after
// eight presses. Kept here (not in the URL or a pref) because it is a property of this sitting.
const [seen, setSeen] = useState<string[]>([]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
const q = useQuery({
queryKey: qk.discover(budget),
queryFn: () => api.discover(budget, 0),
// Keep the previous budget's answer on screen while the new one loads. Without this the page
// falls back to `isPending` and replaces itself — chips included — with a spinner, so the
// control you just clicked vanishes under your cursor. (Same reason the feed does it.)
placeholderData: keepPreviousData,
});
const pickQ = useQuery({
queryKey: qk.discoverPick(budget, seed, seen),
queryFn: () => api.discoverPick(budget, seed, seen),
enabled: seed !== 0,
staleTime: 0,
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
// has no data for a beat, `hero` falls to null, and the page renders "nothing to suggest yet" —
// an EMPTY state standing in for a loading one, which is the exact dishonesty the house rules
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
placeholderData: keepPreviousData,
});
const data = q.data;
// Which hero is on screen. The middle branch is the load-bearing one: `keepPreviousData` holds the
// PREVIOUS reroll, but the FIRST reroll has no previous pick to hold, so without falling back to
// the loaded payload the page renders "nothing to suggest" over a perfectly good library for a
// beat. `pickQ.data !== undefined` (not `?? null`) is what keeps a genuinely empty answer honest:
// once the pick has RESOLVED, a null hero really means nothing is left and the empty state is the
// truth.
const hero =
seed === 0
? (data?.hero ?? null)
: pickQ.data !== undefined
? pickQ.data.hero
: (data?.hero ?? null);
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
const heroKey = hero ? discoverCardKey(hero.card) : null;
const resumeRail = (data?.continue_watching ?? []).filter((c) => discoverCardKey(c) !== heroKey);
const onState = useCallback(
(id: string, status: VideoStatus) => {
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
// something watched genuinely changes what the page should answer (it can retire the hero).
// The feed avoids this because it would refetch ten infinite-query pages per click.
void api.setState(id, status).then(() => qc.invalidateQueries({ queryKey: qk.discover() }));
},
[qc],
);
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)).then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
});
},
[qc],
);
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would
// start, a channel → its newest unwatched video); everything else plays itself. Plex hands off to
// its own module, which owns the hls.js player and its whole provider tree — the alternative would
// be a second player mounted on the landing page.
const onPlay = useCallback(
(card: DiscoverCard, startAt?: number | null) => {
const target = card.next ?? card;
if (target.video) {
setPlayer({ video: target.video, startAt: startAt ?? null });
return;
}
if (target.plex) {
setJumpTo({ id: target.plex.id, type: target.plex.type === "show" ? "show" : "play" });
setPage("plex");
}
},
[setJumpTo, setPage],
);
// The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same
// click goes from the Plex grid. A show tile's card click lands there too — a show is not playable.
const onInfo = useCallback(
(card: DiscoverCard) => {
if (!card.plex) return;
setJumpTo({ id: card.plex.id, type: card.plex.type === "show" ? "show" : "movie" });
setPage("plex");
},
[setJumpTo, setPage],
);
// Watched-toggle on a Plex tile. No optimistic cache patch here (unlike the Plex Discover
// surface): marking something watched can retire the hero as well as the tile, so this page
// re-asks the server rather than trying to predict a cross-rail consequence.
const onToggleWatched = useCallback(
(card: PlexCard) =>
void toggleWatchState(card, t, {
optimistic: () => {},
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
}),
[qc, t],
);
const reroll = useCallback(() => {
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
// would look like a dead button).
if (heroKey)
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
setSeed((prev) => {
let next = prev;
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
return next;
});
}, [heroKey]);
if (q.isPending) return <LoadingState />;
if (q.isError)
return (
<StateMessage tone="error" onRetry={() => void q.refetch()}>
{t("home.error")}
</StateMessage>
);
return (
<div className="p-4">
{/* The caption sits NEXT TO the chips, not opposite them: SegmentedToggle's own `label` prop
renders a justify-between row, which is right for a narrow filter rail and wrong here — on
an 80rem page it throws the answers to the far edge, away from the question they answer. */}
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-sm text-muted">{t("home.budget.ask")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("home.budget.ask")}
value={budget}
onChange={(v) => {
setBudget(v);
setSeed(0); // the payload for the new budget carries its own hero
setSeen([]); // a new question deserves the whole library again
}}
options={BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
/>
</div>
{hero ? (
<Hero
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
onReroll={reroll}
rerolling={pickQ.isFetching}
/>
) : (
// Honest: nothing in the library fits what was asked. Offering something that does not fit
// would make the one control on this page meaningless.
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
<StateMessage className="!py-6" onRetry={budget === "any" ? undefined : reroll}>
{budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")}
</StateMessage>
</section>
)}
<Rail
title={t("home.comfort.title")}
ariaLabel={t("home.comfort.title")}
cards={data?.comfort ?? []}
emptyLabel={t("home.comfort.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
/>
<Rail
title={t("home.continue.title")}
ariaLabel={t("home.continue.title")}
cards={resumeRail}
emptyLabel={t("home.continue.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
/>
{player && (
<Suspense fallback={null}>
<PlayerModal
video={player.video}
startAt={player.startAt}
onClose={() => {
setPlayer(null);
// The player saves progress on close, so the rails and the hero are stale by
// definition once it shuts.
void qc.invalidateQueries({ queryKey: qk.discover() });
}}
onState={onState}
/>
</Suspense>
)}
</div>
);
}
+5 -1
View File
@@ -134,7 +134,11 @@ export default function PlexBrowse() {
if (jumpTo != null) {
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
sub.open(
jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id },
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
: jumpTo.type === "play"
? { kind: "player", id: jumpTo.id }
: { kind: "info", id: jumpTo.id },
);
setJumpTo(null);
}
+6 -3
View File
@@ -28,9 +28,12 @@ import { useNavigation } from "./NavigationProvider";
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
// a show's drill-down (like clicking a show card), or — for E24's Home hero, whose whole promise is a
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
// landing page to avoid one hand-off would be the expensive way round.
export type PlexJumpTarget = { id: string; type: "movie" | "show" | "play" };
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
@@ -22,6 +22,7 @@ import { useSavedViews } from "./SavedViewsProvider";
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
const Home = lazy(() => import("./Home"));
const Feed = lazy(() => import("./Feed"));
const Channels = lazy(() => import("./Channels"));
const Playlists = lazy(() => import("./Playlists"));
@@ -92,6 +93,7 @@ export type PageDef = {
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
home: { Component: Home, maxWidth: "80rem" },
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
+15 -2
View File
@@ -58,15 +58,28 @@ describe("loadInitialPage precedence", () => {
setUrl("?v=show:1&pl=3&tags=1");
expect(loadInitialPage()).toBe("plex");
});
it("a bare URL uses the account's stored landing page", () => {
// E24 changed this rung: a bare URL lands on Home, OVER the remembered module. Landing on
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
// exists to replace, so the remembered page only wins once the account opts out.
it("a bare URL lands on Home even when another module is remembered", () => {
stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("home");
});
it("opting out restores the account's stored landing page", () => {
stored[LS.startOnHome] = "0";
stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("downloads");
});
it("a bare URL with no / invalid stored page defaults to feed", () => {
it("opted out with no / invalid stored page still defaults to feed", () => {
stored[LS.startOnHome] = "0";
expect(loadInitialPage()).toBe("feed");
stored[LS.page] = "nope";
expect(loadInitialPage()).toBe("feed");
});
it("a deep link still beats Home", () => {
setUrl("?pl=3");
expect(loadInitialPage()).toBe("playlists");
});
});
describe("channelViewFrom", () => {
+8 -1
View File
@@ -6,6 +6,9 @@ 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.
const START_HOME_KEY = LS.startOnHome;
export interface ChannelView {
id: string;
@@ -20,7 +23,11 @@ export function loadInitialPage(): Page {
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → the account's stored landing 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";
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}