Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
822 lines
39 KiB
TypeScript
822 lines
39 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
PinOff,
|
|
Play,
|
|
RotateCcw,
|
|
} from "lucide-react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import {
|
|
api,
|
|
DISCOVER_BUDGETS,
|
|
discoverCardKey,
|
|
type DiscoverBudget,
|
|
type DiscoverCard,
|
|
type DiscoverPayload,
|
|
HERO_TIERS,
|
|
type HeroTier,
|
|
type PlexCard,
|
|
type Video,
|
|
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";
|
|
import { ComfortPinMenu } from "./ComfortPinMenu";
|
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
|
import { Button } from "./ui/Button";
|
|
import { Carousel } from "./ui/Carousel";
|
|
import { ContextMenu } from "./ui/ContextMenu";
|
|
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import VideoCard from "./VideoCard";
|
|
import { PlexPosterCard } from "./plexCards";
|
|
import { useNavigationActions } from "./navigationContext";
|
|
import { usePlexActions } from "./plexContext";
|
|
import { navItemAttrs } from "../lib/listNav";
|
|
|
|
// 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`. The two PLAYABLE card faces are reused rather than re-drawn; the only
|
|
// card this file defines is `ShelfTile`, because a pinned channel or playlist has no card anywhere
|
|
// in the app and `PosterTile`'s art is hardcoded to a Plex image URL.
|
|
|
|
// Lazy, exactly as the feed does it: the YouTube IFrame player loads only once something is played.
|
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
|
|
|
// From the api slice's own list, not a second copy: `readonly DiscoverBudget[]` accepts any
|
|
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
|
|
// silently kept offering four.
|
|
|
|
// 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. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
|
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
|
|
const YT_SLOT = 200;
|
|
|
|
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
|
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
|
|
// before the resume rail (20 rows) was exhausted, so the button cycled the same nine cards forever
|
|
// (measured, 2026-09-06). It is bounded at all only because the server will not scan an unbounded
|
|
// exclusion list.
|
|
const SEEN_WINDOW = 32;
|
|
|
|
// The four shelf moves, in the order they appear in the menu.
|
|
const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
|
|
["first", "home.shelf.moveFirst", ChevronsLeft],
|
|
["left", "home.shelf.moveLeft", ChevronLeft],
|
|
["right", "home.shelf.moveRight", ChevronRight],
|
|
["last", "home.shelf.moveLast", ChevronsRight],
|
|
];
|
|
|
|
function Rail({
|
|
title,
|
|
ariaLabel,
|
|
cards,
|
|
emptyLabel,
|
|
onPlay,
|
|
onInfo,
|
|
onState,
|
|
onToggleSave,
|
|
onToggleWatched,
|
|
onMove,
|
|
moveSaving = false,
|
|
}: {
|
|
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;
|
|
/** Only the comfort shelf is orderable. Given, this rail's menu grows the four move rows; omitted,
|
|
* its tiles offer the plain pin instead. `rendered` is the order this rail is SHOWING — the one a
|
|
* move must act on (see `moveInShelf`). */
|
|
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
|
|
/** A reorder is being written: the move rows wait for it (one write at a time). */
|
|
moveSaving?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
|
|
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
|
|
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
|
|
const menu = useContextMenu<DiscoverCard>();
|
|
const pin = useComfortPin(menu.open);
|
|
const target = menu.target;
|
|
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
|
|
const items: ActionMenuItem[] = !target
|
|
? []
|
|
: [
|
|
{
|
|
id: "play",
|
|
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
|
|
icon: <Play className="w-4 h-4" />,
|
|
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
|
|
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
|
|
// than dropped, so the menu does not change shape from tile to tile.
|
|
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
|
onSelect: () => onPlay(target),
|
|
},
|
|
...(onMove && target.pin_id != null
|
|
? [
|
|
...SHELF_MOVES.map(([to, key, Icon], i) => ({
|
|
id: `shelf-${to}`,
|
|
label: t(key),
|
|
icon: <Icon className="w-4 h-4" />,
|
|
separatorBefore: i === 0,
|
|
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
|
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
|
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
|
|
onSelect: () => onMove(target, to, cards),
|
|
})),
|
|
{
|
|
id: "shelf-unpin",
|
|
label: t("home.pin.remove"),
|
|
icon: <PinOff className="w-4 h-4" />,
|
|
separatorBefore: true,
|
|
// The only row here that removes a curated entry, and it sat in the same colour as
|
|
// the four harmless reorders. `danger` matches the shared unpin row in
|
|
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
|
|
// rail below) composes for the same command — the two must not disagree.
|
|
danger: true,
|
|
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
|
// other surface it can never be in the "not known yet" state.
|
|
onSelect: () => pin.remove(target.pin_id as number, target.title),
|
|
},
|
|
]
|
|
: pin.item(pinTargetForCard(target), target.title)),
|
|
];
|
|
|
|
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
|
|
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
|
|
const menuFor = useItemTriggers(cards, menu.triggerProps);
|
|
|
|
// 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 compact>{emptyLabel}</StateMessage>
|
|
</section>
|
|
);
|
|
}
|
|
return (
|
|
<section className="mb-6">
|
|
<Carousel
|
|
title={<span className="text-sm font-semibold">{title}</span>}
|
|
ariaLabel={ariaLabel}
|
|
keyboardNav
|
|
>
|
|
{cards.map((c, i) => (
|
|
<div
|
|
key={`${c.source}:${c.kind}:${c.id}`}
|
|
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
|
|
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
|
>
|
|
{c.video ? (
|
|
<VideoCard
|
|
video={c.video}
|
|
view="cardsSmall"
|
|
width={YT_SLOT}
|
|
navIndex={i}
|
|
onState={onState}
|
|
onToggleSave={onToggleSave}
|
|
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
|
menuProps={menuFor[i]}
|
|
/>
|
|
) : c.plex ? (
|
|
<PlexPosterCard
|
|
card={c.plex}
|
|
navIndex={i}
|
|
onOpen={() => onPlay(c)}
|
|
onInfo={() => onInfo(c)}
|
|
onToggleWatched={onToggleWatched}
|
|
menuProps={menuFor[i]}
|
|
/>
|
|
) : (
|
|
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</Carousel>
|
|
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
|
</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,
|
|
menuProps,
|
|
navIndex,
|
|
}: {
|
|
card: DiscoverCard;
|
|
onPlay: (card: DiscoverCard) => void;
|
|
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
|
|
navIndex?: number;
|
|
}) {
|
|
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={() => card.next && onPlay(card)}
|
|
{...menuProps?.(card)}
|
|
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
|
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
|
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
|
|
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
|
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
|
|
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
|
|
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
|
// alive to all three input paths.
|
|
aria-disabled={!card.next}
|
|
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
|
|
>
|
|
<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();
|
|
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
|
|
// answered with — so it answers the same right-click every card in the app does.
|
|
const pinMenu = useContextMenu<DiscoverCard>();
|
|
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
|
|
{...pinMenu.triggerProps(card)}
|
|
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={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
|
>
|
|
{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>
|
|
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
|
|
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
|
|
The owner's call (2026-09-19): the row unpins the show. */}
|
|
<ComfortPinMenu
|
|
menu={pinMenu}
|
|
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
|
|
title={(c) => (c.pinned_from ?? c).title}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const SAVED_KEY = "sfHome";
|
|
|
|
interface SavedQuestion {
|
|
budget: DiscoverBudget;
|
|
seed: number;
|
|
press: number;
|
|
seen: string[];
|
|
}
|
|
|
|
/** The question this history entry was left with, if it was left from Home. Read from
|
|
* `history.state`, which only this app writes — still checked field by field. */
|
|
function readSavedQuestion(): SavedQuestion | null {
|
|
const raw = (window.history.state as Record<string, unknown> | null)?.[SAVED_KEY] as
|
|
| Partial<SavedQuestion>
|
|
| undefined;
|
|
if (!raw || !DISCOVER_BUDGETS.includes(raw.budget as DiscoverBudget)) return null;
|
|
return {
|
|
budget: raw.budget as DiscoverBudget,
|
|
seed: Number.isInteger(raw.seed) ? (raw.seed as number) : 0,
|
|
press: Number.isInteger(raw.press) ? (raw.press as number) : 0,
|
|
seen: Array.isArray(raw.seen) ? raw.seen.filter((k) => typeof k === "string") : [],
|
|
};
|
|
}
|
|
|
|
export default function Home() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const { setPage } = useNavigationActions();
|
|
const { setJumpTo } = usePlexActions();
|
|
|
|
// The question on screen lives on this page's history ENTRY as well: opening a card leaves Home
|
|
// (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card
|
|
// then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19).
|
|
const [saved] = useState(readSavedQuestion);
|
|
const [budget, setBudget] = useState<DiscoverBudget>(saved?.budget ?? "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(saved?.seed ?? 0);
|
|
// Which press this is. It rotates the tier the server leads with, so "Mást kérek" offers a
|
|
// different KIND of answer each time (where you left off → your shelf → a surprise) rather than
|
|
// the next row of the same rail. Reset with the budget: a new question starts the rotation over.
|
|
const [press, setPress] = useState(saved?.press ?? 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 bounded one cannot. Its size is `SEEN_WINDOW` — see there for
|
|
// why it is 32 and not the 8 this comment used to quote. Kept here (not in the URL or a pref)
|
|
// because it is a property of this sitting.
|
|
const [seen, setSeen] = useState<string[]>(saved?.seen ?? []);
|
|
useEffect(() => {
|
|
const question: SavedQuestion = { budget, seed, press, seen };
|
|
window.history.replaceState({ ...window.history.state, [SAVED_KEY]: question }, "");
|
|
}, [budget, seed, press, seen]);
|
|
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, press),
|
|
queryFn: () => api.discoverPick(budget, seed, seen, press),
|
|
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.
|
|
//
|
|
// But only WITHIN a budget. Plain `keepPreviousData` chains through the disabled seed-0 query a
|
|
// chip click leaves behind, so the first reroll under a new budget held up the card the PREVIOUS
|
|
// question was answered with — under a chip promising something else (round 2). Returning
|
|
// undefined instead falls the hero back to this budget's own loaded answer, which is already in
|
|
// hand; the spinner still covers the wait.
|
|
placeholderData: (prev) => (prev?.budget === budget ? prev : undefined),
|
|
});
|
|
// Is a PRESS outstanding? NOT `pickQ.isFetching`. The pick key deliberately sits under the
|
|
// `["discover"]` prefix so a mutation's invalidation reaches the rerolled hero — which also means
|
|
// every mark-watched, bookmark, pin/unpin and shelf move on this page refetches it in the
|
|
// background, and `ui/Button` turns `loading` into `disabled`. So the reroll spun and swallowed
|
|
// clicks after actions the user never associated with it (round 2). A press is outstanding exactly
|
|
// while THIS key has never settled — which is what `isFetched` means (react-query counts both a
|
|
// success and an error as settled), and every press mints a fresh seed, so a fresh key.
|
|
//
|
|
// Round 3 corrected this: `isPlaceholderData || data === undefined` looked equivalent and was not.
|
|
// A pick that FAILED leaves its key data-less forever (`retry: false`), so the second disjunct
|
|
// stayed true and the next unrelated mutation re-disabled the button — the same defect, surviving
|
|
// in the error branch. Guarding on `!isError` does NOT help either: query-core resets a data-less
|
|
// query to status "pending" for the duration of a refetch, so the error is invisible exactly when
|
|
// it matters. `isFetched` is the only flag that remembers.
|
|
const rerolling = pickQ.isFetching && !pickQ.isFetched;
|
|
// The last pick that actually RESOLVED, tagged with the budget it answered. Every press is a new
|
|
// query key, so a failed one has no data of its own and `keepPreviousData` (pending-only) cannot
|
|
// cover it — without this the hero silently rewound to the card the page loaded with, several
|
|
// presses back, while `core.req`'s own dialog was explaining the failure. A reroll that fails
|
|
// leaves the answer on screen alone.
|
|
//
|
|
// The TAG is load-bearing, not bookkeeping: the chip row resets `seed`/`seen`/`press` but cannot
|
|
// reset an answer that already resolved, so an untagged slot outlived the question it answered —
|
|
// switch to "10 perc", press reroll, let that request fail (`retry: false`, so it is terminal),
|
|
// and the two-hour film picked under "Bármennyi" was still the hero, under a chip promising ten
|
|
// minutes (round 2). Taken from the SERVER's `budget` echo rather than this render's state, so the
|
|
// tag cannot be minted from a budget the pick never answered.
|
|
const [lastPick, setLastPick] = useState<{
|
|
budget: DiscoverBudget;
|
|
hero: DiscoverPayload["hero"];
|
|
} | null>(null);
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- deliberately OUTLIVES the query: a failed reroll is terminal (retry:false) and must leave the previous answer on screen with the budget tag the SERVER echoed for it. Derived from pickQ it would vanish with the query.
|
|
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
|
|
}, [pickQ.data]);
|
|
|
|
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
|
|
: // A ternary, not `??`: the tagged slot is AUTHORITATIVE for its own budget, and "the last
|
|
// pick for this budget resolved to no hero" is a real answer, not a missing one. Under
|
|
// `??` the two collapsed, so a failed press after an empty pick resurrected the card the
|
|
// page loaded with — which it had already excluded as seen and just said it had nothing
|
|
// beyond (round 3). A mismatched or absent tag still falls through to the loaded hero,
|
|
// which is what the FIRST press of a budget needs.
|
|
lastPick?.budget === budget
|
|
? lastPick.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.
|
|
//
|
|
// Against BOTH of a row's keys, the way `hero.pick`'s own exclusion does. For a Plex series the
|
|
// rail row is the SHOW while the hero is the episode that show RESOLVES to, so a single-key match
|
|
// never fires and the most common Plex resume case printed the series twice.
|
|
const heroKey = hero ? discoverCardKey(hero.card) : null;
|
|
const resumeRail = (data?.continue_watching ?? []).filter(
|
|
(c) => discoverCardKey(c) !== heroKey && discoverCardKey(c.next ?? c) !== heroKey,
|
|
);
|
|
// Empty because the hero ATE the rail, not because nothing is half-finished. An empty rail earns
|
|
// its empty state by teaching you how to fill it; here there is nothing to teach, and "Nothing
|
|
// half-finished right now" under a hero captioned "Where you left off" is the page contradicting
|
|
// itself on one screen.
|
|
const resumeConsumed = !resumeRail.length && (data?.continue_watching ?? []).length > 0;
|
|
|
|
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() }))
|
|
// Without this the rejection escapes unhandled and the click just does nothing — the
|
|
// feed's equivalents both catch, and the reorder on this same page already does.
|
|
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
|
|
// your suggestions"), and it is what a detail-less rejection actually shows — an offline
|
|
// click raises a bare fetch TypeError, so there is no server reason to prefer over it. It
|
|
// would describe a state the user can see is untrue, over a fully populated page whose
|
|
// invalidations never even ran (they sit inside `.then`).
|
|
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
|
|
},
|
|
[qc, t],
|
|
);
|
|
|
|
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() });
|
|
// And the payload the star is READ from. `VideoCard` derives the filled/empty bookmark
|
|
// straight from `video.saved` and keeps no optimistic state of its own (the feed instead
|
|
// holds a `savedOverrides` map), so without this the icon never changed and a second click
|
|
// re-sent the same call — the control looked dead.
|
|
qc.invalidateQueries({ queryKey: qk.discover() });
|
|
})
|
|
// The write key, for the same reason as `onState` above.
|
|
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
|
|
},
|
|
[qc, t],
|
|
);
|
|
|
|
// 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) {
|
|
// `replace`: the Plex module's sub-view rewrites the page entry `setPage` is about to push,
|
|
// so browser-Back from the player returns HERE — not to the Plex Discover root, which this
|
|
// navigation passed through and the user never saw.
|
|
setJumpTo({
|
|
id: target.plex.id,
|
|
type: target.plex.type === "show" ? "show" : "play",
|
|
replace: true,
|
|
});
|
|
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",
|
|
replace: true,
|
|
});
|
|
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],
|
|
);
|
|
|
|
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
|
|
// tile moves under the pointer, then written.
|
|
//
|
|
// It acts on the order the rail RENDERED, not on a fresh read of this budget's cache: during a
|
|
// budget switch the rail still shows the previous budget's payload (`keepPreviousData`) while the
|
|
// new key holds nothing yet, so `from` came back -1 and the move silently did nothing — the
|
|
// "looks available, then no-ops" row `shelfMoveTarget` exists to rule out.
|
|
//
|
|
// ONE write at a time (`shelfSaving`; the move rows wait with a note). That is what makes a
|
|
// failure's rollback right: with no overlapping move, "the order the rail showed before this move"
|
|
// is the order the server still holds, so a refused move puts it back instead of leaving the
|
|
// rejected order on screen whenever the follow-up read failed too. (Two overlapping moves, each
|
|
// reverting to its own snapshot, settled on an order neither side ever held — why the rollback
|
|
// used to be a re-read only.)
|
|
const [shelfSaving, setShelfSaving] = useState(false);
|
|
const shelfSavingRef = useRef(false);
|
|
const moveInShelf = useCallback(
|
|
(card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => {
|
|
if (shelfSavingRef.current) return;
|
|
const key = qk.discover(budget);
|
|
const cards = rendered;
|
|
const from = cards.findIndex((c) => c.pin_id === card.pin_id);
|
|
const target = shelfMoveTarget(from, to, cards.length);
|
|
if (target < 0) return;
|
|
const next = [...cards];
|
|
const [moved] = next.splice(from, 1);
|
|
if (!moved) return; // unreachable: `from` is in range whenever `target` is
|
|
next.splice(target, 0, moved);
|
|
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: next } : old));
|
|
// `pin_id` is present on every shelf card by construction; the filter is what proves it to the
|
|
// type, and a short list would be rejected by the server rather than silently reordering half
|
|
// the shelf.
|
|
const ids = next.map((c) => c.pin_id).filter((id): id is number => id != null);
|
|
shelfSavingRef.current = true;
|
|
setShelfSaving(true);
|
|
void api
|
|
.reorderComfortPins(ids)
|
|
.catch((e: unknown) => {
|
|
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: cards } : old));
|
|
notifyActionError(e, t("home.shelf.moveFailed"));
|
|
})
|
|
.finally(() => {
|
|
shelfSavingRef.current = false;
|
|
setShelfSaving(false);
|
|
// The shelf is also the pin menus' source of truth everywhere else in the app.
|
|
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
|
|
// And the PREFIX, not this budget's key: the shelf is budget-independent server-side but
|
|
// cached per budget, so without it the next chip click renders the pre-move order and a
|
|
// move from there writes it back. (The rollback above restores only `comfort`, so a rail
|
|
// refresh that landed meanwhile is kept.)
|
|
void qc.invalidateQueries({ queryKey: qk.discover() });
|
|
});
|
|
},
|
|
[budget, qc, t],
|
|
);
|
|
|
|
const reroll = useCallback(
|
|
(lead?: HeroTier) => {
|
|
// 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));
|
|
// `lead` asks for a tier: the next press whose rotation leads with it. The rotation carries on
|
|
// from there, so the button's following press is the tier after it, as it would have been.
|
|
setPress((n) => {
|
|
let next = n + 1;
|
|
// Guarded on membership: a click handler passed straight in would hand over its event
|
|
// as `lead`, and this loop would never end.
|
|
if (lead && HERO_TIERS.includes(lead))
|
|
while (HERO_TIERS[next % HERO_TIERS.length] !== lead) next += 1;
|
|
return next;
|
|
});
|
|
setSeed((prev) => {
|
|
let next = prev;
|
|
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
|
|
return next;
|
|
});
|
|
},
|
|
[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 — and to a token minted for another history entry.
|
|
const surpriseRequest = useSurpriseRequest();
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- consuming a cross-page token (lib/homeIntent) that was minted before this module's chunk existed. takeSurprise() clears it and answers false to every later pass, including StrictMode's second mount.
|
|
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
|
|
}, [surpriseRequest, reroll]);
|
|
|
|
if (q.isPending) return <LoadingState />;
|
|
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
|
|
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
|
|
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
|
|
// blip replaced a fully populated landing page with an error line.
|
|
if (q.isError && !data)
|
|
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
|
|
setPress(0); // …and the tier rotation starts from the page's own priority order
|
|
}}
|
|
options={DISCOVER_BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
|
|
/>
|
|
</div>
|
|
|
|
{hero ? (
|
|
<Hero
|
|
tier={hero.tier}
|
|
card={hero.card}
|
|
onPlay={onPlay}
|
|
onReroll={() => reroll()}
|
|
rerolling={rerolling}
|
|
/>
|
|
) : (
|
|
// 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">
|
|
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
|
|
a re-run of something that failed. Nothing failed — the library simply has no answer
|
|
this short, and the honest offer is to ask a different question. */}
|
|
<StateMessage compact>
|
|
{budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")}
|
|
</StateMessage>
|
|
{budget !== "any" && (
|
|
<div className="flex justify-center pb-4">
|
|
{/* The same action as the Hero's own reroll, so it carries the same in-flight signal.
|
|
Without it the press was unacknowledged — `hero` stays null for the whole round
|
|
trip in this branch, so nothing on the page moved — and a second click spent
|
|
another pick AND skipped a tier in the server's `start=` rotation. The icon comes
|
|
along because `loading` swaps it for the spinner: with no icon the button would
|
|
grow by a spinner's width mid-press. */}
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
type="button"
|
|
icon={<RotateCcw className="h-4 w-4" />}
|
|
loading={rerolling}
|
|
onClick={() => reroll()}
|
|
>
|
|
{t("home.hero.reroll")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</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}
|
|
onMove={moveInShelf}
|
|
moveSaving={shelfSaving}
|
|
/>
|
|
{!resumeConsumed && (
|
|
<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>
|
|
);
|
|
}
|