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(); const actions = usePlexCardActions( { onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched }, menu.open, ); const heading = action != null ? ( {title} {action} ) : ( title ); return (
{cards.map((c, i) => (
onOpenCard(c)} onPage={onInfoCard} onRestart={() => onRestartCard(c)} onToggleWatched={onToggleWatched} onAdd={actions.add} menuProps={menu.triggerProps} menuButtonProps={menu.buttonProps} menuOpen={menu.open && menu.target === c} />
))}
); } 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 | 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(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 ( q.refetch()}> {t("common.loadError")} ); } if (q.isLoading || !q.data) { return ; } 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

{t("plex.discover.empty")}

; } const railProps = { onOpenCard, onRestartCard, onInfoCard, onToggleWatched: toggleWatched }; return (
{d.continue_watching.length > 0 && ( )} {d.recently_watched.length > 0 && ( )} {d.recently_added.length > 0 && ( )} {surprise.length > 0 && ( } {...railProps} /> )} {d.genres.length > 0 && (

{t("plex.discover.byGenre")}

{d.genres.map((g, i) => ( onGenre(g.value)} className="h-24" /> ))}
)} {bw && bw.items.length > 0 && ( )}
); }