Deep-review round 3 of bug hunt 2026-09 — the sibling of the collection page's fix (d349bc90).
Discover's toggle flips the Surprise draw too, but its resync refetches only the rails (refetching the
draw would reshuffle it under the viewer), so a refused toggle stayed flipped there. toggleWatchState
takes an optional `revert`, called on a failure only; the queryKeys comment that claimed the watched
toggle invalidates the Surprise key now says what the code does.
250 lines
8.3 KiB
TypeScript
250 lines
8.3 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { RotateCcw } from "lucide-react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
|
|
import PosterTile from "./PosterTile";
|
|
import { plexLang } from "../i18n";
|
|
import { toggleWatchState } from "../lib/plexWatch";
|
|
import { usePlex } from "./PlexProvider";
|
|
import { Carousel } from "./ui/Carousel";
|
|
import { PlexPosterCard } from "./plexCards";
|
|
import { PlexPinMenu } from "./ComfortPinMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
|
|
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
|
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
|
// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its
|
|
// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click /
|
|
// info / watched-toggle handlers come from the shell (PlexBrowse) so a rail card behaves exactly like
|
|
// a grid card; the watched-toggle optimistically flips THIS surface's cache too.
|
|
|
|
function CardRail({
|
|
title,
|
|
ariaLabel,
|
|
seeAllLabel,
|
|
onSeeAll,
|
|
cards,
|
|
onOpenCard,
|
|
onInfoCard,
|
|
onToggleWatched,
|
|
action,
|
|
}: {
|
|
title: ReactNode;
|
|
ariaLabel: string;
|
|
seeAllLabel?: string;
|
|
onSeeAll?: () => void;
|
|
cards: PlexCard[];
|
|
onOpenCard: (c: PlexCard) => void;
|
|
onInfoCard: (c: PlexCard) => void;
|
|
onToggleWatched: (c: PlexCard) => void;
|
|
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
|
|
}) {
|
|
// One comfort-pin menu per rail (see ui/ContextMenu), not one per poster.
|
|
const pinMenu = useContextMenu<PlexCard>();
|
|
const heading =
|
|
action != null ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
{title}
|
|
{action}
|
|
</span>
|
|
) : (
|
|
title
|
|
);
|
|
return (
|
|
<section className="mb-5">
|
|
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
|
|
{cards.map((c) => (
|
|
<div key={c.id} className="w-[150px] shrink-0">
|
|
<PlexPosterCard
|
|
card={c}
|
|
onOpen={() => onOpenCard(c)}
|
|
onInfo={() => onInfoCard(c)}
|
|
onToggleWatched={onToggleWatched}
|
|
menuProps={pinMenu.triggerProps}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Carousel>
|
|
<PlexPinMenu menu={pinMenu} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default function PlexDiscover({
|
|
scope,
|
|
onOpenCard,
|
|
onInfoCard,
|
|
onGenre,
|
|
onSeeAllRecent,
|
|
}: {
|
|
scope: string;
|
|
onOpenCard: (c: PlexCard) => void;
|
|
onInfoCard: (c: PlexCard) => void;
|
|
onGenre: (genre: string) => void;
|
|
onSeeAllRecent: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const lang = plexLang();
|
|
// The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the
|
|
// query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same).
|
|
const { filters } = usePlex();
|
|
const q = useQuery({
|
|
queryKey: qk.plexDiscover(scope, lang, filters),
|
|
queryFn: () => api.plexDiscover(scope, lang, filters),
|
|
});
|
|
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
|
const surpriseQ = useQuery({
|
|
queryKey: qk.plexSurprise(scope, lang, filters),
|
|
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
|
|
staleTime: 0,
|
|
});
|
|
|
|
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
|
|
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
|
|
const setSurpriseStatus = (card: PlexCard, status: PlexCard["status"]) =>
|
|
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
|
|
qk.plexSurprise(scope, lang, filters),
|
|
(old) =>
|
|
old
|
|
? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status } : c)) }
|
|
: old,
|
|
);
|
|
const toggleWatched = (card: PlexCard) =>
|
|
toggleWatchState(card, t, {
|
|
optimistic: (next) => {
|
|
const flip = (cards: PlexCard[]) =>
|
|
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
|
|
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang, filters), (old) =>
|
|
old
|
|
? {
|
|
...old,
|
|
continue_watching: flip(old.continue_watching),
|
|
recently_added: flip(old.recently_added),
|
|
because_you_watched: old.because_you_watched
|
|
? { ...old.because_you_watched, items: flip(old.because_you_watched.items) }
|
|
: null,
|
|
}
|
|
: old,
|
|
);
|
|
setSurpriseStatus(card, next);
|
|
},
|
|
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed
|
|
// toggle was left flipped there — put back in place instead.
|
|
revert: () => setSurpriseStatus(card, card.status),
|
|
resync: () => {
|
|
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
|
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
|
},
|
|
});
|
|
|
|
if (q.isError && !q.data) {
|
|
return (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
);
|
|
}
|
|
if (q.isLoading || !q.data) {
|
|
return <LoadingState label={t("plex.loading")} />;
|
|
}
|
|
|
|
const d = q.data;
|
|
const surprise = surpriseQ.data?.items ?? [];
|
|
const bw = d.because_you_watched;
|
|
const nothing =
|
|
d.continue_watching.length === 0 &&
|
|
d.recently_watched.length === 0 &&
|
|
d.recently_added.length === 0 &&
|
|
d.genres.length === 0 &&
|
|
surprise.length === 0 &&
|
|
(!bw || bw.items.length === 0);
|
|
|
|
if (nothing) {
|
|
return <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
|
|
}
|
|
|
|
const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched };
|
|
|
|
return (
|
|
<div>
|
|
{d.continue_watching.length > 0 && (
|
|
<CardRail
|
|
title={t("plex.discover.continueWatching")}
|
|
ariaLabel={t("plex.discover.continueWatching")}
|
|
cards={d.continue_watching}
|
|
{...railProps}
|
|
/>
|
|
)}
|
|
|
|
{d.recently_watched.length > 0 && (
|
|
<CardRail
|
|
title={t("plex.discover.recentlyWatched")}
|
|
ariaLabel={t("plex.discover.recentlyWatched")}
|
|
cards={d.recently_watched}
|
|
{...railProps}
|
|
/>
|
|
)}
|
|
|
|
{d.recently_added.length > 0 && (
|
|
<CardRail
|
|
title={t("plex.discover.recentlyAdded")}
|
|
ariaLabel={t("plex.discover.recentlyAdded")}
|
|
onSeeAll={onSeeAllRecent}
|
|
cards={d.recently_added}
|
|
{...railProps}
|
|
/>
|
|
)}
|
|
|
|
{surprise.length > 0 && (
|
|
<CardRail
|
|
title={t("plex.discover.surprise")}
|
|
ariaLabel={t("plex.discover.surprise")}
|
|
cards={surprise}
|
|
action={
|
|
<button
|
|
type="button"
|
|
onClick={() => surpriseQ.refetch()}
|
|
aria-label={t("plex.discover.reshuffle")}
|
|
title={t("plex.discover.reshuffle")}
|
|
className="rounded-full p-1 text-muted transition hover:text-accent"
|
|
>
|
|
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
|
|
</button>
|
|
}
|
|
{...railProps}
|
|
/>
|
|
)}
|
|
|
|
{d.genres.length > 0 && (
|
|
<section className="mb-5">
|
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
|
{d.genres.map((g) => (
|
|
<PosterTile
|
|
key={g.value}
|
|
title={g.value}
|
|
art={g.art}
|
|
onClick={() => onGenre(g.value)}
|
|
className="h-24"
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{bw && bw.items.length > 0 && (
|
|
<CardRail
|
|
title={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
|
ariaLabel={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
|
cards={bw.items}
|
|
{...railProps}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|