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
+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>
);
}