Opening a card left Home, and Back came back to "Doesn't matter" with a different hero, whose card then dropped out of the Continue rail and moved the keyboard onto its neighbour. The budget, reroll seed and seen list live on the history entry now, and a returning list focuses the item by its name before its place.
776 lines
36 KiB
TypeScript
776 lines
36 KiB
TypeScript
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 "./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.
|
|
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<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: shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
|
onSelect: () => onMove(target, to),
|
|
})),
|
|
{
|
|
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)),
|
|
];
|
|
|
|
// 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-[150px] 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)}
|
|
// 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 ? (
|
|
<PlexPosterCard
|
|
card={c.plex}
|
|
navIndex={i}
|
|
onOpen={() => onPlay(c)}
|
|
onInfo={() => onInfo(c)}
|
|
onToggleWatched={onToggleWatched}
|
|
menuProps={() => menu.triggerProps(c)}
|
|
/>
|
|
) : (
|
|
<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>
|
|
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => 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(() => {
|
|
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<DiscoverPayload>(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<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);
|
|
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 <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}
|
|
/>
|
|
{!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>
|
|
);
|
|
}
|