refactor(plex): scope module state in PlexProvider

Lift the Plex module's shared local state (scope / watch-state / sort / expanded
filters / search term / open-playlist hand-off) out of App into a split-context
PlexProvider with stable useCallback setters. PlexSidebar, PlexBrowse, and the
header search box read it via usePlex()/usePlexActions() instead of props drilled
through App; PlexBrowse is now prop-free. App loses six useStates + the filters
JSON memo/serialise helpers. (Platform S6b.2)
This commit is contained in:
2026-07-18 23:53:10 +02:00
parent d0fc061795
commit 6c32e5c8d6
6 changed files with 148 additions and 98 deletions
+1 -48
View File
@@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
api,
EMPTY_PLEX_FILTERS,
getActiveAccount,
setActiveAccount,
setUnauthorizedHandler,
type PlexFilters,
} from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import {
@@ -156,30 +154,6 @@ export default function App() {
: channelsViewRaw === "blocked"
? "blocked"
: "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
const plexFilters = useMemo<PlexFilters>(() => {
try {
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [plexFiltersRaw]);
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
// The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
// `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
// greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload.
const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
@@ -581,15 +555,6 @@ export default function App() {
{page === "plex" && meQuery.data!.plex_enabled && (
<Suspense fallback={null}>
<PlexSidebar
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
setShow={setPlexShowFilter}
sort={plexSort}
setSort={setPlexSort}
filters={plexFilters}
setFilters={setPlexFilters}
onOpenPlaylist={setPlexPlaylistOpen}
layout={panelLayouts.plex}
setLayout={(l) => setPanelLayout("plex", l)}
collapsed={filterCollapsed}
@@ -644,8 +609,6 @@ export default function App() {
header={
<Header
me={meQuery.data!}
plexQ={plexQ}
setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
onYtSearch={enterYtSearch}
@@ -714,17 +677,7 @@ export default function App() {
onOpenWizard={() => setWizardOpen(true)}
/>
) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse
q={plexQ}
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
sort={plexSort}
filters={plexFilters}
setFilters={setPlexFilters}
openPlaylist={plexPlaylistOpen}
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
/>
<PlexBrowse />
) : (
<Feed
filters={filters}
+5 -6
View File
@@ -9,23 +9,19 @@ import SyncStatus from "./SyncStatus";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
// sync chip at the right end.
export default function Header({
me,
plexQ,
setPlexQ,
channelsSearch,
setChannelsSearch,
onYtSearch,
onGoToFullHistory,
}: {
me: Me;
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
plexQ: string;
setPlexQ: (q: string) => void;
channelsSearch: string;
setChannelsSearch: (q: string) => void;
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
@@ -39,9 +35,12 @@ export default function Header({
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// The playlists name filter lives in PlaylistsProvider (shared with its rail + detail pane).
// The playlists name filter lives in PlaylistsProvider (shared with its rail + detail pane); the
// Plex search term lives in PlexProvider (shared with its rail + browse grid).
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { q: plexQ } = usePlex();
const { setQ: setPlexQ } = usePlexActions();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
// dynamic width so the pill is sized to the widest reachable title in the current language.
+8 -25
View File
@@ -34,6 +34,7 @@ import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { PageToolbar } from "./PageShell";
import { usePlex, usePlexActions } from "./PlexProvider";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -54,34 +55,16 @@ type Sub =
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
// for now. Rendered by App for page==="plex" (its own nav module).
type Props = {
q: string;
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
sort: string;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
// The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it.
openPlaylist?: number | null;
onPlaylistOpened?: () => void;
};
const PAGE = 40;
export default function PlexBrowse({
q,
scope,
setScope,
show,
sort,
filters,
setFilters,
openPlaylist,
onPlaylistOpened,
}: Props) {
export default function PlexBrowse() {
const { t } = 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, playlistOpen: openPlaylist } = usePlex();
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
const sub = useHistorySubview<Sub>({ kind: "grid" });
@@ -90,7 +73,7 @@ export default function PlexBrowse({
useEffect(() => {
if (openPlaylist != null) {
sub.open({ kind: "playlist", id: openPlaylist });
onPlaylistOpened?.();
setPlaylistOpen(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]);
+124
View File
@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
// App so the PlexSidebar (rail), PlexBrowse (content), and the header search box read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage); the account switch does
// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: string;
filters: PlexFilters;
q: string;
playlistOpen: number | null;
};
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
setSort: (v: string) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setPlaylistOpen: (id: number | null) => void;
}
const StateContext = createContext<PlexState>({
scope: "both",
show: "all",
sort: "title",
filters: EMPTY_PLEX_FILTERS,
q: "",
playlistOpen: null,
});
const ActionsContext = createContext<PlexActions>({
setScope: () => {},
setShow: () => {},
setSort: () => {},
setFilters: () => {},
setQ: () => {},
setPlaylistOpen: () => {},
});
export function usePlex(): PlexState {
return useContext(StateContext);
}
export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
export function PlexProvider({ children }: { children: ReactNode }) {
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [scope, setScopeState] = useState<string>(() => {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
});
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
const [sort, setSortState] = useState<string>(() => getAccountRaw(LS.plexSort) ?? "title");
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
const filters = useMemo<PlexFilters>(() => {
try {
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(filtersRaw) };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above.
const [q, setQState] = useState<string>(() => getAccountRaw(LS.plexQ) ?? "");
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
const setScope = useCallback((v: string) => {
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
}, []);
const setShow = useCallback((v: string) => {
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: string) => {
setSortState(v);
setAccountRaw(LS.plexSort, v);
}, []);
const setFilters = useCallback((f: PlexFilters) => {
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
}, []);
const setQ = useCallback((v: string) => {
setQState(v);
setAccountRaw(LS.plexQ, v);
}, []);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, playlistOpen }),
[scope, show, sort, filters, q, playlistOpen],
);
const actions = useMemo<PlexActions>(
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }),
[setScope, setShow, setSort, setFilters, setQ],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+6 -18
View File
@@ -8,6 +8,7 @@ import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlex, usePlexActions } from "./PlexProvider";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -15,15 +16,6 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
// the backend so the panel only offers what the library actually contains.
type Props = {
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
setShow: (v: string) => void;
sort: string;
setSort: (v: string) => void;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
onOpenPlaylist: (id: number) => void;
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
@@ -42,21 +34,17 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
];
export default function PlexSidebar({
scope,
setScope,
show,
setShow,
sort,
setSort,
filters,
setFilters,
onOpenPlaylist,
layout,
setLayout,
collapsed,
onToggleCollapse,
}: Props) {
const { t } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
// the header search box); this rail is the primary editor of them. Opening a playlist is a
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, sort, filters } = usePlex();
const { setScope, setShow, setSort, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
+4 -1
View File
@@ -6,6 +6,7 @@ import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import "./i18n";
import "./index.css";
@@ -41,7 +42,9 @@ const root =
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<App />
<PlexProvider>
<App />
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>