feat(plex): shareable Plex-browse state in the URL — scope+filters+sort+show (S4)
Mirror the Plex-browse content state into the address bar so a browse view is bookmarkable/shareable, the Plex analogue of the feed's urlState. Keys are NAMESPACED (pscope/psort/pshow/pf) rather than reusing the feed's bare scope/sort/show: FeedFiltersProvider actively strips its own KEYS from the URL whenever off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced keys are Plex-unique (like ?v=/?pl), so no other provider ever reads or clears them. - New lib/plexUrlState.ts: plexParamUpdates / plexParamsToState / hasPlexParams. The pf filter blob reuses the SAME appendPlexFilters wire serializer (new public plexFiltersToParams + inverse parsePlexFilters, kept next to each other so a new wire key can't be added on one side only). - PlexProvider: URL-seed wins over localStorage at mount; the account-pin rehydrate persists the seed instead of clobbering it (mirrors feed's initialHadUrlFilters); useUrlMirror mirrors/clears the 4 keys, gated on page===plex. q/field stay out of the shareable contract. - NavigationProvider: a page switch now also nulls the 4 plex keys. Tests: 8 new round-trip cases in plexUrlState.dom.test.ts (FE vitest 483→491).
This commit is contained in:
@@ -3,8 +3,16 @@ import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/
|
||||
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 { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
// 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
|
||||
@@ -112,14 +120,41 @@ function readPlexSort(): SortState {
|
||||
return { key: raw, dir };
|
||||
}
|
||||
|
||||
// S4: 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<string, unknown>);
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
function readPlexUrlSeed(): PlexUrlState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasPlexParams(params)) return null;
|
||||
return plexParamsToState(params, {
|
||||
scope: readPlexScope(),
|
||||
sort: readPlexSort(),
|
||||
show: readPlexShow(),
|
||||
filters: readPlexFilters(),
|
||||
});
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
|
||||
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
|
||||
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<string>(readPlexScope);
|
||||
const [show, setShowState] = useState<string>(readPlexShow);
|
||||
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
|
||||
);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
|
||||
@@ -140,7 +175,19 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||
|
||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
|
||||
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
|
||||
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
if (urlSeed) {
|
||||
setAccountRaw(LS.plexLibrary, urlSeed.scope);
|
||||
setAccountRaw(LS.plexShow, urlSeed.show);
|
||||
setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
|
||||
setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
return;
|
||||
}
|
||||
setScopeState(readPlexScope());
|
||||
setShowState(readPlexShow());
|
||||
setSortState(readPlexSort());
|
||||
@@ -208,6 +255,13 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||
);
|
||||
|
||||
// S4: 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 (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user