import { createContext, useCallback, useContext, 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 { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; // 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). init-from-storage seeds // them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above // , which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload // (an account SWITCH does full-reload, but the FIRST load does not). `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. // A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's // info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type. export type PlexJumpTarget = { id: string; type: "movie" | "show" }; type PlexState = { scope: string; // movie | show | both (unified cross-library scope) show: string; sort: SortState; // {key,dir}; healed against the scope's options at the point of use filters: PlexFilters; q: string; field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use playlistOpen: number | null; jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title") }; interface PlexActions { setScope: (v: string) => void; setShow: (v: string) => void; setSort: (v: SortState) => void; setFilters: (f: PlexFilters) => void; setQ: (q: string) => void; setField: (f: PlexSearchField) => void; setPlaylistOpen: (id: number | null) => void; setJumpTo: (t: PlexJumpTarget | null) => void; } const StateContext = createContext({ scope: "both", show: "all", sort: PLEX_SORT_FALLBACK, filters: EMPTY_PLEX_FILTERS, q: "", field: "all", playlistOpen: null, jumpTo: null, }); const ActionsContext = createContext({ setScope: () => {}, setShow: () => {}, setSort: () => {}, setFilters: () => {}, setQ: () => {}, setField: () => {}, setPlaylistOpen: () => {}, setJumpTo: () => {}, }); export function usePlex(): PlexState { return useContext(StateContext); } export function usePlexActions(): PlexActions { return useContext(ActionsContext); } // The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate. 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 }; } 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(readPlexScope); const [show, setShowState] = useState(readPlexShow); const [sort, setSortState] = useState(readPlexSort); // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). const [filtersRaw, setFiltersRaw] = useState(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); // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab). const markDirty = useAccountRehydrate(() => { setScopeState(readPlexScope()); setShowState(readPlexShow()); setSortState(readPlexSort()); setFiltersRaw(readPlexFiltersRaw()); setQState(readPlexQ()); setFieldState(readPlexField()); }); const setScope = useCallback( (v: string) => { markDirty(); setScopeState(v); setAccountRaw(LS.plexLibrary, v); }, [markDirty], ); const setShow = useCallback( (v: string) => { markDirty(); setShowState(v); setAccountRaw(LS.plexShow, v); }, [markDirty], ); const setSort = useCallback( (v: SortState) => { markDirty(); setSortState(v); setAccountRaw(LS.plexSort, JSON.stringify(v)); }, [markDirty], ); const setFilters = useCallback( (f: PlexFilters) => { markDirty(); const s = JSON.stringify(f); setFiltersRaw(s); setAccountRaw(LS.plexFilters, s); }, [markDirty], ); const setQ = useCallback( (v: string) => { markDirty(); setQState(v); setAccountRaw(LS.plexQ, v); }, [markDirty], ); const setField = useCallback( (v: PlexSearchField) => { markDirty(); setFieldState(v); setAccountRaw(LS.plexField, v); }, [markDirty], ); 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], ); return ( {children} ); }