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}
);
}