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:
@@ -25,6 +25,8 @@ import { useHistorySubview } from "../lib/history";
|
||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import SortControl from "./SortControl";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import { StateMessage } from "./QueryState";
|
||||
@@ -70,7 +72,9 @@ export default function PlexBrowse() {
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||
const fieldState = healPlexField(field, scope);
|
||||
const sub = useHistorySubview<Sub>({ kind: "grid" }, PLEX_SUB_URL);
|
||||
// Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search
|
||||
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
// clear the signal so it doesn't re-open.
|
||||
@@ -95,6 +99,19 @@ export default function PlexBrowse() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jumpTo]);
|
||||
|
||||
// Discover → grid on a browse intent: when a header search or a filter FIRST becomes active while on
|
||||
// the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge,
|
||||
// so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a
|
||||
// Back to Discover with the search still in the box doesn't immediately re-flip.
|
||||
const browseIntent = !!dq || plexFilterCount(filters) > 0;
|
||||
const prevIntent = useRef(browseIntent);
|
||||
useEffect(() => {
|
||||
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
|
||||
sub.open({ kind: "grid" });
|
||||
}
|
||||
prevIntent.current = browseIntent;
|
||||
}, [browseIntent, sub]);
|
||||
|
||||
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
||||
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||
@@ -124,7 +141,7 @@ export default function PlexBrowse() {
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
||||
return;
|
||||
if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
|
||||
if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
|
||||
e.preventDefault();
|
||||
sub.back();
|
||||
}
|
||||
@@ -337,12 +354,42 @@ export default function PlexBrowse() {
|
||||
);
|
||||
}
|
||||
|
||||
// Discover ⟷ Browse switch, shown on both surfaces so either can return to the other.
|
||||
const viewToggle = (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("plex.view.label")}
|
||||
options={[
|
||||
{ value: "discover", label: t("plex.discover.tab") },
|
||||
{ value: "grid", label: t("plex.view.browse") },
|
||||
]}
|
||||
value={sub.view.kind === "discover" ? "discover" : "grid"}
|
||||
onChange={(v) => sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sub.view.kind === "discover") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<div className="mb-4 flex items-center">{viewToggle}</div>
|
||||
<PlexDiscover
|
||||
scope={scope}
|
||||
onOpenCard={onCard}
|
||||
onInfoCard={onInfo}
|
||||
onGenre={(g) => applyFilter({ genres: [g] })}
|
||||
onSeeAllRecent={() => sub.open({ kind: "grid" })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||
while only the poster grid scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
|
||||
{viewToggle}
|
||||
<p className="text-xs text-muted">
|
||||
{browseQ.isLoading
|
||||
? " "
|
||||
|
||||
Reference in New Issue
Block a user