import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { parseSortState, type SortState } from "../lib/columnSort"; import { parsePlexField, type PlexSearchField } from "../lib/plexSearch"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { hasPlexParams, plexParamsToState, plexParamUpdates, type PlexUrlState, } from "../lib/plexUrlState"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { logFilterChange } from "../lib/filterLog"; import { useUrlMirror } from "../lib/useUrlMirror"; import { useNavigation } from "./navigationContext"; import { PlexActionsContext, PlexStateContext, type PlexActions, type PlexJumpTarget, type PlexState, } from "./plexContext"; // 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 ). // // scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount // (BootGate claims the local state for the account first; a different account reloads the tab). `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. // // The contexts, their value types and the hooks live in plexContext.ts. // The per-account init readers, shared by the useState seeds and the shared-link seed's base. function readPlexScope(): string { const v = getAccountRaw(LS.plexLibrary) ?? "both"; return ["movie", "show", "both"].includes(v) ? v : "both"; } const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all"; const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}"; const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? ""; const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField)); // Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the // direction that used to live in plexFilters.sortDir — so an existing sort preference survives the // upgrade rather than snapping back to the default. function readPlexSort(): SortState { const raw = getAccountRaw(LS.plexSort); if (!raw) return PLEX_SORT_FALLBACK; let wasJson = false; try { const parsed = parseSortState(JSON.parse(raw)); wasJson = true; if (parsed) return parsed; } catch { /* not JSON → a legacy bare-key string, handled below */ } // Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a // key. Only a non-JSON raw is a genuine legacy bare key. if (wasJson) return PLEX_SORT_FALLBACK; let dir: "asc" | "desc" = "asc"; try { if ( (JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === "desc" ) dir = "desc"; } catch { /* ignore */ } return { key: raw, dir }; } // A shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/ // pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage // seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex // view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages). function readPlexFilters(): PlexFilters { try { return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record); } catch { return EMPTY_PLEX_FILTERS; } } // The base is this account's stored view (BootGate claims the local state before this provider mounts), so a // PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest. // `present` records which dimensions the link actually carried: only those are persisted (below). type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean }; type PlexUrlSeed = PlexUrlState & { present: PlexSeedPresent }; function readPlexUrlSeed(): PlexUrlSeed | null { const params = new URLSearchParams(window.location.search); if (!hasPlexParams(params)) return null; const state = plexParamsToState(params, { scope: readPlexScope(), sort: readPlexSort(), show: readPlexShow(), filters: readPlexFilters(), }); const present = { scope: params.has("pscope"), sort: params.has("psort"), show: params.has("pshow"), filters: params.has("pf"), }; return { ...state, present }; } export function PlexProvider({ children }: { children: ReactNode }) { // Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to // the account (the effect further down), so the shared view also survives a later plain reload. const [urlSeed] = useState(readPlexUrlSeed); // 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(() => urlSeed?.scope ?? readPlexScope()); const [show, setShowState] = useState(() => urlSeed?.show ?? readPlexShow()); const [sort, setSortState] = useState(() => urlSeed?.sort ?? readPlexSort()); // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). const [filtersRaw, setFiltersRaw] = useState(() => urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(), ); const filters = useMemo(() => { try { // coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort // SortState — left here it would leak into the facets key and be re-persisted), and migrates the // legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with // the saved-views provider so the localStorage seed and a DB view blob coerce identically. return coercePlexFilters(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(readPlexQ); const [field, setFieldState] = useState(readPlexField); const [playlistOpen, setPlaylistOpen] = useState(null); // Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to; // PlexBrowse consumes it into an info/show history subview then clears it. const [jumpTo, setJumpTo] = useState(null); // Persist a shared link's view to the account once, so it wins over the stored view and survives a // later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions // the link CARRIED are written: an omitted one already reads from storage, and writing its default // would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the // account has picked filters, so the account's default saved view would never be applied again. useEffect(() => { if (!urlSeed) return; const { present } = urlSeed; if (present.scope) setAccountRaw(LS.plexLibrary, urlSeed.scope); if (present.show) setAccountRaw(LS.plexShow, urlSeed.show); if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort)); if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters)); }, [urlSeed]); const setScope = useCallback((v: string) => { logFilterChange("plex", { scope: v }); setScopeState(v); setAccountRaw(LS.plexLibrary, v); }, []); const setShow = useCallback((v: string) => { logFilterChange("plex", { show: v }); setShowState(v); setAccountRaw(LS.plexShow, v); }, []); const setSort = useCallback((v: SortState) => { logFilterChange("plex", { sort: v }); setSortState(v); setAccountRaw(LS.plexSort, JSON.stringify(v)); }, []); const setFilters = useCallback((f: PlexFilters) => { logFilterChange("plex", { filters: f }); const s = JSON.stringify(f); setFiltersRaw(s); setAccountRaw(LS.plexFilters, s); }, []); const setQ = useCallback((v: string) => { logFilterChange("plex", {}, v); setQState(v); setAccountRaw(LS.plexQ, v); }, []); const setField = useCallback((v: PlexSearchField) => { logFilterChange("plex", { field: v }); setFieldState(v); setAccountRaw(LS.plexField, v); }, []); const state = useMemo( () => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }), [scope, show, sort, filters, q, field, playlistOpen, jumpTo], ); const actions = useMemo( () => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }), [setScope, setShow, setSort, setFilters, setQ, setField], ); // Mirror the content state (scope + sort + watch-state + filters) into the address bar under the // namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys // are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the // search term is deliberately transient-per-view and not part of the shared four-key contract. const { page } = useNavigation(); useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null)); return ( {children} ); }