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 "./plexContext"; import { Carousel } from "./ui/Carousel"; import { PlexPosterCard } from "./plexCards"; import { PlexPinMenu } from "./ComfortPinMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; // 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(); const heading = action != null ? ( {title} {action} ) : ( title ); return (
{cards.map((c, i) => (
onOpenCard(c)} onInfo={() => onInfoCard(c)} onToggleWatched={onToggleWatched} menuProps={pinMenu.triggerProps} />
))}
); } 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(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 ( 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, onInfoCard, onToggleWatched: toggleWatched }; return (
{d.continue_watching.length > 0 && ( )} {d.recently_watched.length > 0 && ( )} {d.recently_added.length > 0 && ( )} {surprise.length > 0 && ( surpriseQ.refetch()} aria-label={t("plex.discover.reshuffle")} title={t("plex.discover.reshuffle")} className="rounded-full p-1 text-muted transition hover:text-accent" > } {...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 && ( )}
); }