Files
siftlode/frontend/src/components/Home.tsx
T
peter dae0b80f6a feat(plex): add a movie or show to a collection from its card menu
"Add to collection…" opens a panel beside the card: the title's library's
manual collections tick it in or out, and a name + Enter creates a new one with
the title in it. It used to take the Collections page, a new collection, its
name and a title search, then another search per title. A lean endpoint lists
the collections with whether each holds the title. Admin only, as on the
title's page.

Also: a collection seeded with a show is made as a show collection (Plex type
2). Measured: as a movie one it came out empty and refused the show after.
2026-10-08 03:09:55 +02:00

1136 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
ArrowUpRight,
PinOff,
Play,
RotateCcw,
SkipBack,
Star,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { plexLang } from "../i18n";
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 {
formatCompact,
formatDecimal,
formatDuration,
formatRuntime,
relativeTime,
} from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
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, { VideoHoverActions } from "./VideoCard";
import { useVideoActions } from "./useVideoActions";
import { usePlexCardActions } from "./usePlexCardActions";
import { PlexHoverBar, PlexPosterCard } from "./plexCards";
import { usePlaylistsActions } from "./playlistsContext";
import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import clsx from "clsx";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
import {
MEDIA_BAR,
MEDIA_BADGE,
MEDIA_HOVER_LAYER,
MEDIA_RESUME_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "./ui/mediaCardStyles";
// The Home surface — "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, so the slot holds the same
// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
// at 200 the picture was 184×103 and its centred Play overlapped the hover row docked under it by
// 2px (the owner saw them run together, 2026-09-27); at 232 they stand ~7px apart. 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 = 232;
// 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],
];
/** A Home card's ONE action list — a shelf tile's and the hero's alike: Play (it resolves `next`),
* the card's own rows (a video's or a Plex title's, as the feed and the library show them; a
* channel's or a playlist's open row), then the shelf's rows. */
function useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving = false,
}: {
cards: DiscoverCard[];
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;
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
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);
// A video tile's own action list, the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
const plexActions = usePlexCardActions(
{
onOpen: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onPlay(c);
},
onInfo: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onInfo(c);
},
onToggleWatched,
},
menu.open,
);
const openPage = useOpenShelfPage();
const target = menu.target;
// What the pin row acts on: a comfort hero's pin is the shelf entry 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). Any other card is its own.
const pinned = target ? (target.pinned_from ?? target) : null;
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),
},
// What the Play row would continue can also start over (C5), with the card menus' words.
...((target.next ?? target).position_seconds > 0
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className="w-4 h-4" />,
onSelect: () => onPlay(target, 0),
},
]
: []),
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false, anchor: menu.anchor })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.kind === "channel" || target.kind === "playlist"
? [{ ...openPage.item(target), separatorBefore: true }]
: []),
...(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(pinned ?? target), (pinned ?? target).title)),
];
return {
menu,
items,
panels: (
<>
{videoActions.panel}
{plexActions.panel}
</>
),
plexAdd: plexActions.add,
};
}
/** A pinned channel's or playlist's own page: the shelf tile's ↗ and its menu's open row. */
function useOpenShelfPage() {
const { t } = useTranslation();
const { openChannel, setPage } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const open = (card: DiscoverCard) => {
if (card.kind === "channel") openChannel(card.id, card.title);
else {
setSelectedId(Number(card.id));
setPage("playlists");
}
};
const label = (card: DiscoverCard) =>
card.kind === "channel" ? t("card.menu.openChannel") : t("home.shelf.openPlaylist");
return {
open,
label,
item: (card: DiscoverCard): ActionMenuItem => ({
id: "open",
label: label(card),
icon: <ArrowUpRight className="w-4 h-4" />,
onSelect: () => open(card),
}),
};
}
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 { menu, items, panels, plexAdd } = useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving,
});
// 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);
const buttonFor = useItemTriggers(cards, menu.buttonProps);
// 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}`}
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
// YouTube and Plex, and a poster beside a 16:9 card stretched the whole row to the
// poster's height, leaving a hole in every video card (the owner's pick A, 2026-09-26 —
// Plex's and Netflix's own "Continue watching" rows do the same). A grid cell, so the card
// fills the slot: every card's meta line (pinned to its frame's bottom) sits on one line.
className="grid shrink-0 grid-cols-1"
style={{ width: YT_SLOT }}
>
{c.video ? (
<VideoCard
video={c.video}
view="cardsSmall"
shelf
width={YT_SLOT}
navIndex={i}
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
/>
) : c.plex ? (
<PlexPosterCard
card={c.plex}
wide
shelf
navIndex={i}
onOpen={() => onPlay(c)}
onRestart={() => onPlay(c, 0)}
onToggleWatched={onToggleWatched}
onAdd={plexAdd}
next={c.next?.plex}
onInfo={() => onInfo(c)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
/>
) : (
<ShelfTile
card={c}
onPlay={onPlay}
menuProps={menuFor[i]?.()}
menuButtonProps={buttonFor[i]?.()}
menuOpen={menu.open && menu.target === c}
navIndex={i}
/>
)}
</div>
))}
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
{panels}
</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,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: Record<string, unknown>;
/** The rail menu's "…" for this tile. */
menuButtonProps?: Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const openPage = useOpenShelfPage();
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 });
// The one media card (ui/MediaCard), like the videos and posters beside it on the shelf: the
// picture is the play button, the count the meta line pinned to the frame's bottom.
return (
<MediaCard
dense
rootProps={{
...menuProps,
...(navIndex !== undefined && navItemAttrs(navIndex)),
}}
className={!card.next ? "opacity-60" : undefined}
picture={
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
// on the stretched frame itself that would be the whole card's height.
<div className={mediaPictureBox("wide")}>
<Pressable
onClick={() => card.next && onPlay(card)}
aria-label={card.title}
// `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}
data-nav-open=""
className={MEDIA_PICTURE}
>
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
) : null}
{/* The Play only when there is something to play: the click plays the channel's newest
unwatched video, the playlist's first. */}
<div className={MEDIA_HOVER_LAYER}>{card.next && <MediaPlayGlyph small />}</div>
</Pressable>
{/* The rest of what a tile like this offers, as the video and Plex cards beside it do:
its own page, and the "…" to the whole list (a sibling of the picture's button). */}
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
<IconButton
variant="media"
onClick={() => openPage.open(card)}
label={openPage.label(card)}
>
<ArrowUpRight />
</IconButton>
{menuButtonProps && (
<MoreButton
variant="media"
label={t("card.menu.more")}
expanded={menuOpen}
menuProps={menuButtonProps}
/>
)}
</div>
</div>
}
title={
<span className={`min-h-6 ${mediaTitleClass(true, true)} select-text`} title={card.title}>
{card.title}
</span>
}
meta={<span className="block text-xs leading-6 text-muted">{countLabel}</span>}
more={menuButtonProps && { props: menuButtonProps, open: menuOpen }}
moreLabel={t("card.menu.more")}
/>
);
}
function Hero({
tier,
card,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onReroll,
rerolling,
}: {
tier: HeroTier;
card: DiscoverCard;
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;
onReroll: () => void;
rerolling: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards: [card],
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
});
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");
// One line of facts under the title: a film's year, length and score; a video's views and age.
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
// its hero as well as a film does (the owner's pick, 2026-09-26).
const plex = card.plex;
const video = card.video;
const facts = plex
? [plex.year, formatRuntime(card.duration_seconds, lang)]
: video
? // The channel is the subtitle already.
[
video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
relativeTime(video.published_at, lang, t),
]
: [];
const factLine = facts.filter(Boolean).join(" · ");
// A feed video carries no description, so only a film or an episode has a summary line.
const summary = plex?.summary;
// A film's subtitle is its year, which the fact line already says.
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
return (
// Three quarters of a wide page, centred, so the one answer stands in focus (the owner, 2026-09-27);
// a narrower window gives it the whole width back. The padding is even on every side.
<section
{...menu.triggerProps(card)}
className={`${SURFACE.panel} mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-center lg:mx-auto lg:w-3/4`}
>
<div
className={`group relative shrink-0 ${
card.aspect === "poster" ? "aspect-[2/3] w-40 sm:w-48" : "aspect-video w-full sm:w-72"
}`}
>
<Pressable
onClick={() => onPlay(card)}
className="relative block h-full w-full cursor-pointer overflow-hidden rounded-lg border border-border bg-card"
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 && (
// It steps aside while the hover row covers its corner, as a card's length does.
<span className={`${MEDIA_BADGE} bottom-1.5 right-1.5 ${HOVER_YIELD}`}>
{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}
<div className={MEDIA_HOVER_LAYER}>{!started && <MediaPlayGlyph />}</div>
</Pressable>
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
is a sibling over the picture, never inside its button. */}
{started && (
<div className={clsx(MEDIA_RESUME_LAYER, card.aspect === "poster" && "flex-col")}>
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
</div>
)}
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
{card.video ? (
<div className={MEDIA_BAR}>
<VideoHoverActions
video={card.video}
onState={onState}
onToggleSave={onToggleSave}
media
more={{ props: menu.buttonProps(card), open: menu.open }}
/>
</div>
) : card.plex ? (
<PlexHoverBar
card={card.plex}
onToggleWatched={onToggleWatched}
onAdd={plexAdd}
moreProps={menu.buttonProps(card)}
moreOpen={menu.open}
/>
) : null}
</div>
<div className="flex min-w-0 flex-1 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>
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
{(factLine || plex?.rating != null) && (
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
{factLine && <span>{factLine}</span>}
{plex?.rating != null && (
<span className="inline-flex items-center gap-0.5">
{factLine && <span aria-hidden>·</span>}
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
{formatDecimal(plex.rating, lang)}
</span>
)}
</p>
)}
{/* Only when there is one (a missing summary leaves no gap, the stack is centred), at the
reading measure: the eye finds the next line's start again (it ran ~130 characters). */}
{summary && <p className="mt-2 measure line-clamp-2 text-sm text-muted">{summary}</p>}
<div className="flex flex-wrap items-center gap-2 pt-4">
<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>
{/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits
beside a card's title. */}
<MoreButton
className="touch-only"
label={t("card.menu.more")}
expanded={menu.open}
menuProps={menu.buttonProps(card)}
/>
</div>
</div>
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
{panels}
</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, i18n } = 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);
// A Plex card's title is in the reader's language, so the answer is cached per language.
const lang = plexLang(i18n.language);
const q = useQuery({
queryKey: qk.discover(budget, lang),
queryFn: () => api.discover(budget, 0, lang),
// 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, lang, seed, seen, press),
queryFn: () => api.discoverPick(budget, seed, seen, press, lang),
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. 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. 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.
//
// Since corrected: `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. 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. 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",
fromStart: startAt === 0,
replace: true,
});
setPage("plex");
}
},
[setJumpTo, setPage],
);
// The "Media info" row of a Plex tile's menu: the item's own page in the Plex module, exactly where
// the same row 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, lang); // the query's own key: setQueryData matches exactly
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, lang, 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 shape="grid" />;
// `&& !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}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
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={`${SURFACE.panel} mb-6 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>
);
}