import { lazy, Suspense, useCallback, useEffect, 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, 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 } 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 "./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`. 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. 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, }: { 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. */ onMove?: (card: DiscoverCard, to: ShelfMove) => void; }) { 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(); 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: , // 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: , separatorBefore: i === 0, // The same rule the move itself runs, so a row is never enabled onto a no-op. disabled: shelfMoveTarget(shelfIdx, to, cards.length) < 0, onSelect: () => onMove(target, to), })), { id: "shelf-unpin", label: t("home.pin.remove"), icon: , 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)), ]; // 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 (

{title}

{emptyLabel}
); } return (
{title}} ariaLabel={ariaLabel} > {cards.map((c) => (
{c.video ? ( onPlay({ ...c, video: v }, startAt)} // Bound to the UNIFIED card, not the native payload the card component holds: the // menu acts on the shelf ENTRY (its pin id, its place in the order), which the // `Video` inside it does not carry. menuProps={() => menu.triggerProps(c)} /> ) : c.plex ? ( onPlay(c)} onInfo={() => onInfo(c)} onToggleWatched={onToggleWatched} menuProps={() => menu.triggerProps(c)} /> ) : ( )}
))}
); } // 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, }: { card: DiscoverCard; onPlay: (card: DiscoverCard) => void; menuProps?: (c: DiscoverCard) => Record; }) { 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 ( ); } 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(); 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 (

{kicker}

{card.title}

{card.subtitle &&

{card.subtitle}

}
c.title} />
); } export default function Home() { const { t } = useTranslation(); const qc = useQueryClient(); const { setPage } = useNavigationActions(); const { setJumpTo } = usePlexActions(); const [budget, setBudget] = useState("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); // 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(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([]); 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(() => { 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; a failure re-asks the server rather than leaving the // page showing an order the server never accepted (the same lesson the playlist reorder learned). const moveInShelf = useCallback( (card: DiscoverCard, to: ShelfMove) => { const key = qk.discover(budget); const before = qc.getQueryData(key); const cards = before?.comfort ?? []; 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(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); void api .reorderComfortPins(ids) .catch((e: unknown) => notifyActionError(e, t("home.shelf.moveFailed"))) .finally(() => { // 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 — which is what actually re-asks the server, and // is what the comment above this callback promises. Restoring the captured snapshot // instead was wrong twice over: two overlapping moves each reverted to their own // snapshot, so the shelf settled on an order neither side ever held; and `before` is the // whole payload, so a rollback also threw away any rail refresh that had landed while the // request was in flight. On the success path the prefix matters for a second reason — 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. void qc.invalidateQueries({ queryKey: qk.discover() }); }); }, [budget, 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)); setPress((n) => n + 1); 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. const surpriseRequest = useSurpriseRequest(); useEffect(() => { if (surpriseRequest && takeSurprise()) reroll(); }, [surpriseRequest, reroll]); if (q.isPending) return ; // `&& !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 ( void q.refetch()}> {t("home.error")} ); return (
{/* 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. */}
{t("home.budget.ask")} { 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}`) }))} />
{hero ? ( ) : ( // Honest: nothing in the library fits what was asked. Offering something that does not fit // would make the one control on this page meaningless.
{/* 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. */} {budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")} {budget !== "any" && (
{/* 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. */}
)}
)} {!resumeConsumed && ( )} {player && ( { 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} /> )}
); }