The ↻ intent was spent as its fetch set out, so a watched toggle's resync cancelling that fetch re-ran it as a re-read of the old draw. It now lasts until the latest deal answers. The button spun on every re-read of the same titles; it now follows the deal. Comments that said the Surprise draw is never refetched are brought in line.
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { useRef, useState, 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 "./plexContext";
|
|
import { Carousel } from "./ui/Carousel";
|
|
import { PlexPosterCard } from "./plexCards";
|
|
import { PlexMenu } from "./PlexMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { usePlexCardActions } from "./usePlexCardActions";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import NavGrid from "./ui/NavGrid";
|
|
import { IconButton } from "./ui/IconButton";
|
|
|
|
// The Plex "Discover" landing: 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,
|
|
onRestartCard,
|
|
onInfoCard,
|
|
onToggleWatched,
|
|
action,
|
|
}: {
|
|
title: ReactNode;
|
|
ariaLabel: string;
|
|
seeAllLabel?: string;
|
|
onSeeAll?: () => void;
|
|
cards: PlexCard[];
|
|
onOpenCard: (c: PlexCard) => void;
|
|
onRestartCard: (c: PlexCard) => void;
|
|
onInfoCard: (c: PlexCard) => void;
|
|
onToggleWatched: (c: PlexCard) => void;
|
|
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
|
|
}) {
|
|
// One menu per rail (see ui/ContextMenu), not one per poster.
|
|
const menu = useContextMenu<PlexCard>();
|
|
const actions = usePlexCardActions(
|
|
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched },
|
|
menu.open,
|
|
);
|
|
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}
|
|
keyboardNav
|
|
>
|
|
{cards.map((c, i) => (
|
|
<div key={c.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
|
<PlexPosterCard
|
|
shelf
|
|
card={c}
|
|
navIndex={i}
|
|
onOpen={() => onOpenCard(c)}
|
|
onPage={onInfoCard}
|
|
onRestart={() => onRestartCard(c)}
|
|
onToggleWatched={onToggleWatched}
|
|
onAdd={actions.add}
|
|
menuProps={menu.triggerProps}
|
|
menuButtonProps={menu.buttonProps}
|
|
menuOpen={menu.open && menu.target === c}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Carousel>
|
|
<PlexMenu menu={menu} actions={actions} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default function PlexDiscover({
|
|
scope,
|
|
onOpenCard,
|
|
onRestartCard,
|
|
onInfoCard,
|
|
onGenre,
|
|
onSeeAllRecent,
|
|
}: {
|
|
scope: string;
|
|
onOpenCard: (c: PlexCard) => void;
|
|
onRestartCard: (c: PlexCard) => void;
|
|
onInfoCard: (c: PlexCard) => void;
|
|
onGenre: (genre: string) => void;
|
|
onSeeAllRecent: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const lang = plexLang(i18n.language);
|
|
// 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.
|
|
// A draw stands until the ↻ (or another scope, language or filter) asks for a new one: redrawn on
|
|
// every refetch, the card just looked at was gone from its rail on Back from the player or a show
|
|
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
|
|
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
|
|
const surpriseKey = qk.plexSurprise(scope, lang, filters);
|
|
// The ↻ stands until ITS deal answers, not until the fetch sets out: a refresh of the rails landing
|
|
// mid-deal (a watched toggle's resync) cancels that fetch and runs this one again, which read the
|
|
// spent flag and handed back the old draw. Only the latest deal ends it, so the cancelled one's late
|
|
// answer does not stop the spinner of the deal that replaced it.
|
|
const redraw = useRef(false);
|
|
const lastDeal = useRef<Promise<unknown> | null>(null);
|
|
const [dealing, setDealing] = useState(false);
|
|
const surpriseQ = useQuery({
|
|
queryKey: surpriseKey,
|
|
queryFn: () => {
|
|
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
|
|
(c) => c.id,
|
|
);
|
|
if (!redraw.current && shown.length > 0) {
|
|
return api.plexSurprise(scope, undefined, filters, lang, { keep: shown });
|
|
}
|
|
const deal = api.plexSurprise(scope, undefined, filters, lang, { exclude: shown });
|
|
lastDeal.current = deal;
|
|
const done = () => {
|
|
if (lastDeal.current !== deal) return;
|
|
redraw.current = false;
|
|
setDealing(false);
|
|
};
|
|
void deal.then(done, done);
|
|
return deal;
|
|
},
|
|
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
|
|
// a collected draw is a new one.
|
|
gcTime: Infinity,
|
|
});
|
|
const reshuffle = () => {
|
|
redraw.current = true;
|
|
setDealing(true);
|
|
void surpriseQ.refetch();
|
|
};
|
|
|
|
// 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 re-reads the Surprise draw too, but only once the write has answered: a
|
|
// refused toggle is put back at once rather than left flipped until that read lands.
|
|
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 shape="poster" 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, onRestartCard, 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={
|
|
<IconButton
|
|
onClick={reshuffle}
|
|
// A deal, not any fetch: a refresh re-reads the same titles, and a spinning ↻ promised a
|
|
// new draw that never came.
|
|
loading={dealing}
|
|
label={t("plex.discover.reshuffle")}
|
|
>
|
|
<RotateCcw />
|
|
</IconButton>
|
|
}
|
|
{...railProps}
|
|
/>
|
|
)}
|
|
|
|
{d.genres.length > 0 && (
|
|
<section className="mb-5">
|
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
|
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
|
{d.genres.map((g, i) => (
|
|
<PosterTile
|
|
key={g.value}
|
|
navIndex={i}
|
|
title={g.value}
|
|
art={g.art}
|
|
onClick={() => onGenre(g.value)}
|
|
className="h-24"
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
</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>
|
|
);
|
|
}
|