Their titles open the item's page, a pointer-only door; the card menu is the keyboard's.
799 lines
36 KiB
TypeScript
799 lines
36 KiB
TypeScript
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
useInfiniteQuery,
|
|
useQuery,
|
|
useQueryClient,
|
|
type InfiniteData,
|
|
} from "@tanstack/react-query";
|
|
import { User, X } from "lucide-react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../lib/nextPage";
|
|
import { healPlexField } from "../lib/plexSearch";
|
|
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
|
import type { SortState } from "../lib/columnSort";
|
|
import {
|
|
api,
|
|
EMPTY_PLEX_FILTERS,
|
|
plexFilterCount,
|
|
type PlexCard,
|
|
type PlexFilters,
|
|
type PlexUnifiedResult,
|
|
} from "../lib/api";
|
|
import { plexLang } from "../i18n";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
import { useHistorySubview } from "../lib/history";
|
|
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
|
import { toggleWatchState } from "../lib/plexWatch";
|
|
import { usePlayShow } from "./usePlayShow";
|
|
import SortControl from "./ui/SortControl";
|
|
import FilterSelect from "./ui/FilterSelect";
|
|
import PlexDiscover from "./PlexDiscover";
|
|
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
|
import { PageToolbar } from "./ui/PageShell";
|
|
import { PageMenu } from "./ui/PageMenu";
|
|
import { menuGroups } from "./ui/menuGroups";
|
|
import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
|
import ActiveFilterChips from "./ActiveFilterChips";
|
|
import SelectedViewChip from "./SelectedViewChip";
|
|
import ItemCount from "./ui/ItemCount";
|
|
import { FilterFoldButton } from "./ui/FilterFold";
|
|
import { useFilterFold } from "../lib/useFilterFold";
|
|
import { StateMessage } from "./ui/QueryState";
|
|
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
|
import { usePlex, usePlexActions } from "./plexContext";
|
|
import { usePublishPlexView } from "./plexViewContext";
|
|
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
|
import { PlexMenu } from "./PlexMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { usePlexCardActions } from "./usePlexCardActions";
|
|
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
|
import { dispatch, useKeyScope } from "../lib/keymap";
|
|
import NavGrid from "./ui/NavGrid";
|
|
import { Chip } from "./ui/Chip";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
|
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
|
const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
|
|
|
|
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
|
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
|
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
|
// (useHistorySubview) so browser Back returns to the grid. Rendered by App for page==="plex" (its own
|
|
// nav module). The cards/strips live in plexCards; the sub-view branches in plexViews.
|
|
|
|
const PAGE = 40;
|
|
|
|
// The "last watched" sort auto-applied when you enter the in-progress watch-state view (most
|
|
// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference).
|
|
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
|
key: "lastwatched",
|
|
dir: "desc" as const,
|
|
});
|
|
|
|
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
|
|
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
|
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
|
|
|
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
|
|
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
|
|
const BACKABLE_VIEWS: readonly string[] = [
|
|
"grid",
|
|
"playlists",
|
|
"collections",
|
|
"show",
|
|
"season",
|
|
"info",
|
|
"person",
|
|
"playlist",
|
|
"collection",
|
|
];
|
|
|
|
export default function PlexBrowse() {
|
|
const { t, i18n } = useTranslation();
|
|
const qc = useQueryClient();
|
|
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
|
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
|
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
|
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
|
const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
|
const dq = useDebounced(q.trim(), 350);
|
|
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
|
|
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
|
|
const lang = plexLang(i18n.language);
|
|
// The language the grid query is allowed to run at — lags `lang` by one render on a switch so the
|
|
// genre-clear effect (below) can settle first (see the effect for why).
|
|
const [committedLang, setCommittedLang] = useState(lang);
|
|
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
|
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
|
const plexChips = usePlexActiveFilters();
|
|
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
|
|
const fold = useFilterFold();
|
|
const clearAllFilters = usePlexClearFilters();
|
|
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
|
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
|
const sortState = healPlexSort(sort, scope);
|
|
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
|
|
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
|
const fieldState = healPlexField(field, scope);
|
|
// Discover is the landing; 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);
|
|
|
|
// Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via
|
|
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. `sub.open`
|
|
// is referentially stable (lib/history memoises it), so naming it costs nothing and the dep list is
|
|
// the honest one: the toggle is republished when the view KIND changes, and on nothing else.
|
|
const publishView = usePublishPlexView();
|
|
useLayoutEffect(() => {
|
|
publishView({ kind: sub.view.kind, open: sub.open });
|
|
return () => publishView(null);
|
|
}, [sub.view.kind, sub.open, publishView]);
|
|
|
|
// 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.
|
|
useEffect(() => {
|
|
if (openPlaylist != null) {
|
|
sub.open({ kind: "playlist", id: openPlaylist });
|
|
setPlaylistOpen(null);
|
|
}
|
|
}, [openPlaylist, sub, setPlaylistOpen]);
|
|
|
|
// Like the grid's scroll memory (below), for the info page: "Browse collection" (onFilter) leaves
|
|
// the info view for a filtered grid, and browser Back returns to this same info page — restore
|
|
// where the user had scrolled it (down to a collection strip) instead of snapping to the top. Reset
|
|
// on a fresh info open so a different title's info starts at the top.
|
|
const infoScrollRef = useRef(0);
|
|
// ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
|
|
// rule the code holds rather than one spread over the call sites. Declared above the effects that
|
|
// use it: an effect closing over the ref before its declaration made the compiler skip PlexBrowse.
|
|
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
|
|
|
|
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
|
// info page like the card menu's "Media info", a show's drill-down), then clear the hand-off so it
|
|
// doesn't re-open.
|
|
useEffect(() => {
|
|
if (jumpTo != null) {
|
|
if (jumpTo.type !== "show") rememberInfoScroll(0); // a jumped-to info page starts at the top
|
|
sub.open(
|
|
jumpTo.type === "show"
|
|
? { kind: "show", id: jumpTo.id }
|
|
: jumpTo.type === "play"
|
|
? { kind: "player", id: jumpTo.id, fromStart: jumpTo.fromStart }
|
|
: { kind: "info", id: jumpTo.id },
|
|
// A cross-module hand-off continues the navigation that brought us here rather than adding
|
|
// to it. It also makes this effect idempotent, which matters more than it looks: under
|
|
// StrictMode the effect body runs twice before `setJumpTo(null)` re-renders, and two pushes
|
|
// of the SAME sub-view left a duplicate entry whose Back visibly did nothing.
|
|
{ replace: jumpTo.replace },
|
|
);
|
|
setJumpTo(null);
|
|
}
|
|
}, [jumpTo, sub, setJumpTo]);
|
|
|
|
// Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
|
|
// the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
|
|
// to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip
|
|
// trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so
|
|
// adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid.
|
|
const browseIntent = !!dq;
|
|
const prevIntent = useRef(browseIntent);
|
|
useEffect(() => {
|
|
// A search targets titles, so jump to the title grid from any non-title landing (Discover or the
|
|
// playlists/collections browse surfaces) when a search first appears.
|
|
const onListing = ["discover", "playlists", "collections"].includes(sub.view.kind);
|
|
if (onListing && 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
|
|
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
|
const scrollRef = useRef(0);
|
|
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
|
|
const menu = useContextMenu<PlexCard>();
|
|
const epMenu = useContextMenu<PlexCard>();
|
|
const scroller = () => document.querySelector("main");
|
|
useLayoutEffect(() => {
|
|
const el = scroller();
|
|
if (!el) return;
|
|
if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
|
|
else if (sub.view.kind === "info" && infoScrollRef.current)
|
|
el.scrollTop = infoScrollRef.current;
|
|
}, [sub.view.kind]);
|
|
|
|
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
|
|
// matching the browser/mouse Back; the chord and the typing guard come from the shared keymap.
|
|
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
|
|
// sheet can never promise a "Back one level" the handler declines to perform.
|
|
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
|
|
useKeyScope("plexBrowse", canStepBack);
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
dispatch(e, "plexBrowse", {
|
|
back: (ev) => {
|
|
if (!canStepBack) return;
|
|
ev.preventDefault();
|
|
sub.back();
|
|
},
|
|
});
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [sub, canStepBack]);
|
|
|
|
// Entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
|
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
|
// forces the sort, so a manual sort change while in the in-progress view still wins — it's just not
|
|
// carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not
|
|
// the scope-healed one, so the user's real preference round-trips.
|
|
const prevShow = useRef(show);
|
|
const preInProgressSort = useRef<SortState | null>(null);
|
|
useEffect(() => {
|
|
const was = prevShow.current;
|
|
if (was !== "in_progress" && show === "in_progress") {
|
|
preInProgressSort.current = sort;
|
|
setSort(LASTWATCHED_SORT);
|
|
} else if (was === "in_progress" && show !== "in_progress") {
|
|
if (preInProgressSort.current) setSort(preInProgressSort.current);
|
|
preInProgressSort.current = null;
|
|
}
|
|
prevShow.current = show;
|
|
}, [show, sort, setSort]);
|
|
|
|
const browseQ = useInfiniteQuery({
|
|
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
|
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
|
|
// settles), not a throwaway one with stale-language genres followed by a second.
|
|
enabled: !!scope && sub.view.kind === "grid" && committedLang === lang,
|
|
initialPageParam: 0,
|
|
queryFn: ({ pageParam }) =>
|
|
api.plexLibrary({
|
|
scope,
|
|
q: dq || undefined,
|
|
field: fieldState,
|
|
sort: sortState,
|
|
show,
|
|
lang,
|
|
filters,
|
|
offset: pageParam as number,
|
|
limit: PAGE,
|
|
}),
|
|
getNextPageParam: (last) => {
|
|
const seen = last.offset + last.items.length;
|
|
return seen < last.total ? seen : undefined;
|
|
},
|
|
});
|
|
|
|
// Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
|
|
// selection made in one language matches nothing against the other's column and would silently empty
|
|
// the grid. On a language flip, drop the genre selection — and gate the grid query on the language
|
|
// being "committed" (below) so we don't fire one throwaway fetch with the stale-language genres and
|
|
// then a second after the clear; the query waits for both to settle into a single fetch.
|
|
useEffect(() => {
|
|
if (committedLang !== lang) {
|
|
// Not adjustable during render, which is where a state reset like this would otherwise
|
|
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
|
|
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
|
|
// — the guard above is false immediately afterwards.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters is PlexProvider's state, so it cannot reset during render; the cascade is one bounded pass
|
|
setCommittedLang(lang);
|
|
if (filters.genres.length) setFilters({ ...filters, genres: [] });
|
|
}
|
|
}, [lang, committedLang, filters, setFilters]);
|
|
|
|
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
|
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
|
// grid so you SEE who you're exploring, not just chips in the rail.
|
|
const peopleCardsQ = useQuery({
|
|
queryKey: qk.plexPeopleCards(scope, filters.actors),
|
|
queryFn: () => api.plexPeopleCards(filters.actors, scope),
|
|
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
|
|
staleTime: 60_000,
|
|
});
|
|
const personCards = peopleCardsQ.data?.people ?? [];
|
|
const removeActor = (name: string) =>
|
|
setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) });
|
|
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
|
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
|
|
|
// Infinite scroll: auto-load the next page when the sentinel scrolls into view. The sentinel lives
|
|
// in the grid branch, which UNMOUNTS when we drill into an info/show/season page and remounts on
|
|
// Back — as a NEW element. A callback ref (state, not useRef) re-runs this effect on every
|
|
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
|
|
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
|
|
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
|
|
const { isFetching, isFetchingNextPage, isFetchNextPageError, fetchNextPage } = browseQ;
|
|
// A failed next page stops the read-ahead (the row at the bottom retries): the observer is rebuilt
|
|
// on every settle and would re-request it once a round-trip while the sentinel is in view.
|
|
const hasNextPage = browseQ.hasNextPage && !isFetchNextPageError;
|
|
useEffect(() => {
|
|
if (!sentinelEl) return;
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting && mayReadAhead(hasNextPage, isFetching))
|
|
void fetchNextPage(NEXT_PAGE);
|
|
},
|
|
{ rootMargin: "600px" },
|
|
);
|
|
io.observe(sentinelEl);
|
|
return () => io.disconnect();
|
|
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
|
|
|
const resolveShow = usePlayShow(
|
|
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
|
|
(id) => sub.open({ kind: "show", id }),
|
|
sub.view,
|
|
);
|
|
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
|
|
function playShow(card: PlexCard, fromStart: boolean) {
|
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
|
void resolveShow(card, fromStart);
|
|
}
|
|
function onCard(card: PlexCard) {
|
|
if (card.type === "show") return playShow(card, false);
|
|
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
|
sub.open({ kind: "player", id: card.id });
|
|
}
|
|
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
|
function onRestartCard(card: PlexCard) {
|
|
if (card.type === "show") return playShow(card, true);
|
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
|
sub.open({ kind: "player", id: card.id, fromStart: true });
|
|
}
|
|
// A card's page: a show's own page, a movie's or an episode's info page (the card menu's "Details",
|
|
// and a card title's link).
|
|
function onInfo(card: PlexCard) {
|
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
|
if (card.type === "show") return void sub.open({ kind: "show", id: card.id });
|
|
rememberInfoScroll(0); // fresh info page starts at the top
|
|
sub.open({ kind: "info", id: card.id });
|
|
}
|
|
// Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip).
|
|
// Only when leaving an INFO page do we stash its scroll (so browser Back to that cast row lands where
|
|
// it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a
|
|
// later info page would wrongly restore this stale offset.
|
|
function onOpenPerson(name: string) {
|
|
if (sub.view.kind === "info") rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
|
sub.open({ kind: "person", name });
|
|
}
|
|
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
|
|
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
|
|
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
|
|
function optimisticStatus(id: string, next: string) {
|
|
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: qk.plexLibrary() }, (old) =>
|
|
old
|
|
? {
|
|
...old,
|
|
pages: old.pages.map((pg) => ({
|
|
...pg,
|
|
items: pg.items.map((it) => (it.id === id ? { ...it, status: next } : it)),
|
|
episodes: pg.episodes.map((ep) => (ep.id === id ? { ...ep, status: next } : ep)),
|
|
})),
|
|
}
|
|
: old,
|
|
);
|
|
}
|
|
const toggleWatched = (card: PlexCard) =>
|
|
toggleWatchState(card, t, {
|
|
optimistic: (next) => optimisticStatus(card.id, next),
|
|
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
|
});
|
|
const actions = usePlexCardActions(
|
|
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
|
menu.open,
|
|
);
|
|
// Details too: an episode's title opens its page, and the menu is that door for the keyboard.
|
|
const epActions = usePlexCardActions(
|
|
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
|
epMenu.open,
|
|
);
|
|
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
|
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
|
|
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
|
function applyFilter(patch: Partial<PlexFilters>) {
|
|
const merged = { ...filters } as unknown as Record<string, unknown>;
|
|
const isPlainObject = (x: unknown): x is Record<string, unknown> =>
|
|
typeof x === "object" && x !== null && !Array.isArray(x);
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
const cur = merged[k];
|
|
if (Array.isArray(v) && Array.isArray(cur)) {
|
|
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
|
} else if (isPlainObject(v) && isPlainObject(cur)) {
|
|
// Merge label maps (collectionTitles / playlistTitles) so adding one keeps the others' titles.
|
|
merged[k] = { ...cur, ...v };
|
|
} else {
|
|
merged[k] = v;
|
|
}
|
|
}
|
|
setFilters(merged as unknown as PlexFilters);
|
|
if (patch.actors?.length || patch.directors?.length) setScope("both");
|
|
rememberInfoScroll(scroller()?.scrollTop ?? 0); // restore on Back to the info/show page
|
|
sub.open({ kind: "grid" });
|
|
}
|
|
|
|
if (sub.view.kind === "player") {
|
|
// The fallback is portaled for the same reason the player itself is: it is a fixed
|
|
// full-viewport overlay, and this tree renders inside the page scroller.
|
|
return (
|
|
<Suspense
|
|
fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
|
|
>
|
|
<PlexPlayer
|
|
itemId={sub.view.id}
|
|
queue={sub.view.queue}
|
|
fromStart={sub.view.fromStart}
|
|
onClose={sub.back}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
if (sub.view.kind === "playlist") {
|
|
const pid = sub.view.id;
|
|
return (
|
|
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
|
<PlexPlaylistView
|
|
playlistId={pid}
|
|
onBack={sub.back}
|
|
onPlay={(itemRk, queue, fromStart) =>
|
|
sub.open({ kind: "player", id: itemRk, queue, fromStart })
|
|
}
|
|
onInfoCard={onInfo}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
if (sub.view.kind === "collection") {
|
|
const cid = sub.view.id;
|
|
return (
|
|
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
|
<PlexCollectionView
|
|
collectionId={cid}
|
|
onBack={sub.back}
|
|
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
|
|
onOpenCard={onCard}
|
|
onRestartCard={onRestartCard}
|
|
onInfoCard={onInfo}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
if (sub.view.kind === "info") {
|
|
const infoId = sub.view.id;
|
|
return (
|
|
<PlexInfoView
|
|
id={infoId}
|
|
onBack={sub.back}
|
|
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
|
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
|
|
onFilter={applyFilter}
|
|
onOpenPerson={onOpenPerson}
|
|
// Up to the show or season: Back returns to this page where it was (as from a cast row).
|
|
onOpenShow={(showId) => {
|
|
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
|
sub.open({ kind: "show", id: showId });
|
|
}}
|
|
onOpenSeason={(showId, seasonId) => {
|
|
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
|
sub.open({ kind: "season", showId, seasonId });
|
|
}}
|
|
// A collection strip's title: another info page, from its top.
|
|
onOpenInfo={(id) => {
|
|
rememberInfoScroll(0);
|
|
sub.open({ kind: "info", id });
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
if (sub.view.kind === "person") {
|
|
const personName = sub.view.name;
|
|
return (
|
|
// Keyed by the name: another person is a fresh page (their own bio wait, a collapsed bio).
|
|
<PlexPersonView
|
|
key={personName}
|
|
name={personName}
|
|
onBack={sub.back}
|
|
onOpenCard={onCard}
|
|
onRestartCard={onRestartCard}
|
|
onInfoCard={onInfo}
|
|
onFilter={applyFilter}
|
|
/>
|
|
);
|
|
}
|
|
if (sub.view.kind === "show") {
|
|
const showId = sub.view.id;
|
|
return (
|
|
// Keyed by the show: another show is a fresh page (its own summary collapsed, no open dialog).
|
|
<PlexShowView
|
|
key={showId}
|
|
showId={showId}
|
|
onBack={sub.back}
|
|
onPlay={(epRk, queue, fromStart) =>
|
|
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
|
}
|
|
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
|
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
|
onPlayShow={playShow}
|
|
onFilter={applyFilter}
|
|
onOpenPerson={onOpenPerson}
|
|
/>
|
|
);
|
|
}
|
|
if (sub.view.kind === "season") {
|
|
const { showId, seasonId } = sub.view;
|
|
return (
|
|
<PlexSeasonView
|
|
showId={showId}
|
|
seasonId={seasonId}
|
|
onBack={sub.back}
|
|
onInfoCard={onInfo}
|
|
onPlay={(epRk, queue, fromStart) =>
|
|
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Sort is tightly bound to the grid below, so on the grid view it lives pinned to the right of the
|
|
// applied-filter chip row (directly above the cards), not on its own toolbar line — the same idiom as
|
|
// the Feed's `sortView`. The Discover/Browse toggle moved up to the header (next to the scope pill).
|
|
const sortView = (
|
|
<SortControl
|
|
options={sortOptions}
|
|
sort={sortState}
|
|
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
|
label={t("plex.filter.sort")}
|
|
ascLabel={t("plex.filter.dir.asc")}
|
|
descLabel={t("plex.filter.dir.desc")}
|
|
/>
|
|
);
|
|
|
|
if (sub.view.kind === "discover") {
|
|
return (
|
|
<div className="p-4 max-w-400 mx-auto">
|
|
<PlexDiscover
|
|
scope={scope}
|
|
onOpenCard={onCard}
|
|
onRestartCard={onRestartCard}
|
|
onInfoCard={onInfo}
|
|
onGenre={(g) => applyFilter({ genres: [g] })}
|
|
onSeeAllRecent={() => sub.open({ kind: "grid" })}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
// The header-pill "Playlist" / "Collection" browse surfaces: a grid of tiles into each area. A
|
|
// playlist tile opens that playlist; a collection tile filters the title grid by that collection.
|
|
if (sub.view.kind === "playlists") {
|
|
return (
|
|
<div className="p-4 max-w-400 mx-auto">
|
|
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
|
|
</div>
|
|
);
|
|
}
|
|
if (sub.view.kind === "collections") {
|
|
return (
|
|
<div className="p-4 max-w-400 mx-auto">
|
|
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const showOptions = SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }));
|
|
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
|
const pageMenuItems = menuGroups(
|
|
[
|
|
sortSubmenu({
|
|
id: "sort",
|
|
label: t("plex.filter.sort"),
|
|
options: sortOptions,
|
|
sort: sortState,
|
|
onChange: (next) => setSort(next ?? PLEX_SORT_FALLBACK),
|
|
ascLabel: t("plex.filter.dir.asc"),
|
|
descLabel: t("plex.filter.dir.desc"),
|
|
}),
|
|
choiceSubmenu({
|
|
id: "show",
|
|
label: t("plex.filter.show"),
|
|
options: showOptions,
|
|
selected: show,
|
|
onPick: setShow,
|
|
}),
|
|
],
|
|
plexChips.length > 0
|
|
? [{ id: "clear", label: t("sidebar.clearAll"), onSelect: clearAllFilters }]
|
|
: [],
|
|
);
|
|
|
|
return (
|
|
<div className="p-4 max-w-400 mx-auto">
|
|
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
|
{/* 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-2 pb-1 max-w-400 mx-auto">
|
|
<div className="flex items-center gap-1.5">
|
|
<ItemCount count={browseQ.isLoading ? undefined : total} />
|
|
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={plexChips.length} />
|
|
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail),
|
|
the same idiom as the feed's Watch-state FilterSelect. */}
|
|
{!fold.folded && (
|
|
<FilterSelect
|
|
label={t("plex.filter.show")}
|
|
testId="plex-show"
|
|
options={showOptions}
|
|
selected={[show]}
|
|
onChange={(next) => setShow(next[0] ?? "all")}
|
|
/>
|
|
)}
|
|
{/* Folded, the band is one row: Sort + the view toggle join it on the right. */}
|
|
{fold.folded && (
|
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
|
)}
|
|
</div>
|
|
{/* The controls row sits directly above the grid: the applied-filter chips flow from the
|
|
left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
|
|
the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
|
|
{!fold.folded && (
|
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
|
|
<SelectedViewChip />
|
|
<ActiveFilterChips
|
|
filters={plexChips}
|
|
onClearAll={clearAllFilters}
|
|
className="flex-1"
|
|
/>
|
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PageToolbar>
|
|
|
|
{personCards.length > 0 && (
|
|
<div className="mb-4 flex flex-wrap gap-3">
|
|
{personCards.map((p) => (
|
|
<div
|
|
key={p.name}
|
|
className={`${SURFACE.card} flex items-center gap-1 py-1.5 pl-1.5 pr-2.5`}
|
|
>
|
|
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
|
|
<Pressable
|
|
selectable
|
|
onClick={() => onOpenPerson(p.name)}
|
|
title={t("plex.info.viewPerson", { name: p.name })}
|
|
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
|
|
>
|
|
{p.photo ? (
|
|
<img
|
|
src={p.photo}
|
|
alt=""
|
|
loading="lazy"
|
|
className="w-10 h-10 rounded-full object-cover shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
|
<User className="w-5 h-5" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate max-w-37.5 group-hover/person:text-accent">
|
|
{p.name}
|
|
</div>
|
|
<div className="text-2xs text-muted">
|
|
{t("plex.people.count", { count: p.count })}
|
|
</div>
|
|
</div>
|
|
</Pressable>
|
|
<IconButton
|
|
onClick={() => removeActor(p.name)}
|
|
label={t("plex.people.remove")}
|
|
className="ml-1"
|
|
>
|
|
<X />
|
|
</IconButton>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{browseQ.isError && !browseQ.data ? (
|
|
<StateMessage tone="error" onRetry={() => browseQ.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : browseQ.isLoading ? (
|
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
|
) : items.length === 0 && episodes.length === 0 ? (
|
|
dq && plexFilterCount(filters) > 0 ? (
|
|
// A search that comes up empty WHILE filters are active is usually the filters, not the
|
|
// query — say so and offer a one-click escape, instead of a bare "No matches".
|
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
|
|
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
|
|
<Chip onClick={() => setFilters(EMPTY_PLEX_FILTERS)} className="font-medium">
|
|
{t("plex.filter.clearAll")}
|
|
</Chip>
|
|
</div>
|
|
) : (
|
|
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
|
)
|
|
) : (
|
|
<>
|
|
{/* Titles grid — movies + shows mixed (visually tagged). */}
|
|
{items.length > 0 && (
|
|
<>
|
|
{episodes.length > 0 && (
|
|
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
|
)}
|
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
|
{items.map((c, i) => (
|
|
<PlexPosterCard
|
|
key={c.id}
|
|
navIndex={i}
|
|
card={c}
|
|
onOpen={() => onCard(c)}
|
|
onPage={onInfo}
|
|
onRestart={() => onRestartCard(c)}
|
|
onToggleWatched={toggleWatched}
|
|
onAdd={actions.add}
|
|
menuProps={menu.triggerProps}
|
|
menuButtonProps={menu.buttonProps}
|
|
menuOpen={menu.open && menu.target === c}
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
</>
|
|
)}
|
|
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
|
|
{episodes.length > 0 && (
|
|
<section className="mt-8">
|
|
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
|
{episodes.map((ep, i) => (
|
|
<EpisodeCard
|
|
key={ep.id}
|
|
navIndex={i}
|
|
ep={ep}
|
|
withShowTitle
|
|
onPlay={() => onCard(ep)}
|
|
onPage={() => onInfo(ep)}
|
|
onRestart={() => onRestartCard(ep)}
|
|
onToggleWatched={() => toggleWatched(ep)}
|
|
onAdd={() => epActions.add(ep)}
|
|
menuProps={epMenu.triggerProps}
|
|
menuButtonProps={epMenu.buttonProps}
|
|
menuOpen={epMenu.open && epMenu.target === ep}
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<PlexMenu menu={menu} actions={actions} />
|
|
<PlexMenu menu={epMenu} actions={epActions} pin={false} />
|
|
<div ref={setSentinelEl} className="h-8" />
|
|
{isFetchingNextPage && (
|
|
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
|
)}
|
|
{isFetchNextPageError && !isFetchingNextPage && (
|
|
<StateMessage
|
|
tone="error"
|
|
compact
|
|
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
|
>
|
|
{t("common.loadMoreFailed")}
|
|
</StateMessage>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|