E5 S4: Plex Discover rail-surface (default landing)
A dedicated Plex "Discover" landing — a stack of horizontal Carousel rails served from ONE consolidated read (serve-from-DB, no live Plex/TMDB call): - GET /api/plex/discover → continue_watching (in-progress movies + shows, most recently progressed) · recently_added · genres (top-N tiles) · because_you_watched (recs of the most-recent watched, TMDB-enriched movie, resolved to library rows by guid via serve_block's resolver). - GET /api/plex/surprise → a fresh random, scope-bounded sample for the ↻ rail. Frontend: PlexDiscover.tsx (rails reuse the S1 Carousel + S3 PlexPosterCard; colored genre tiles → tap to filter the grid; ↻ reshuffles the surprise rail alone). Discover is now the root sub-view; the flat grid becomes the bookmarkable "Browse all" secondary view (?v=browse) reached by a header search/filter (a browse-intent flip) or the new Discover⟷Browse toggle. resolve_recs is promoted from _resolve_recs (now cross-module). Tests: test_plex_discover.py (rail shape/order, continue-watching, because-you-watched + null, surprise bounds, scope) and PlexDiscover.dom.test.tsx; plexSubUrl round-trip updated for discover-root/grid-browse. Backend lane 393, vitest 332.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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 { plexLang } from "../i18n";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { LoadingState, StateMessage } from "./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.
|
||||
|
||||
// A stable, pleasant hue per genre string (no art to collage yet — colored tiles stand in). Hashing
|
||||
// keeps a genre the same color across renders/sessions without storing anything.
|
||||
function genreHue(name: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
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)
|
||||
}) {
|
||||
const heading =
|
||||
action != null ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{title}
|
||||
{action}
|
||||
</span>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</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();
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexDiscover(scope, lang),
|
||||
queryFn: () => api.plexDiscover(scope, lang),
|
||||
});
|
||||
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
||||
const surpriseQ = useQuery({
|
||||
queryKey: qk.plexSurprise(scope),
|
||||
queryFn: () => api.plexSurprise(scope),
|
||||
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 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), (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,
|
||||
);
|
||||
qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) =>
|
||||
old ? { ...old, items: flip(old.items) } : old,
|
||||
);
|
||||
},
|
||||
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_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_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-8">
|
||||
<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) => {
|
||||
const hue = genreHue(g.value);
|
||||
return (
|
||||
<button
|
||||
key={g.value}
|
||||
type="button"
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
|
||||
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user