fix(nav,plex,playlists): extend account-pin re-hydrate to every affected provider
The fresh-tab restore bug wasn't Channels-specific: any provider that mounts above <App> and reads per-account state at useState-init hit it, since <App> pins the tab's account only after /api/me. Audited all of them — FeedFilters/FeedView/Prefs/App already self-correct via their own me?.id server-prefs adopt effects, so the still-broken ones were the pure-localStorage providers with no server adopt: NavigationProvider (landing page), PlexProvider (scope/show/sort/filters/q), PlaylistsProvider (selected playlist). Each now adopts the shared useAccountRehydrate hook (markDirty in the setters; page uses replaceState so the restore doesn't add a phantom Back step). Also baked re-hydrate into useAccountPersistedState/Object so their consumers (persisted tabs, player prefs) are robust even if one ever mounts before the pin. Self-E2E'd on a genuinely fresh tab: it lands on Plex with Movies/In-progress/Year/search all restored on the FIRST load.
This commit is contained in:
@@ -3,6 +3,7 @@ import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
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
|
||||
@@ -10,8 +11,10 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
// 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
|
||||
// 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
|
||||
// <App>, 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.
|
||||
@@ -58,45 +61,51 @@ 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) ?? "";
|
||||
// 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<string>(() => {
|
||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
||||
});
|
||||
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
|
||||
// Sort is now 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.
|
||||
const [sort, setSortState] = useState<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 };
|
||||
});
|
||||
const [scope, setScopeState] = useState<string>(readPlexScope);
|
||||
const [show, setShowState] = useState<string>(readPlexShow);
|
||||
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||
// 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 [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
|
||||
@@ -108,30 +117,59 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [filtersRaw]);
|
||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
||||
const [q, setQState] = useState<string>(() => getAccountRaw(LS.plexQ) ?? "");
|
||||
const [q, setQState] = useState<string>(readPlexQ);
|
||||
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: SortState) => {
|
||||
setSortState(v);
|
||||
setAccountRaw(LS.plexSort, JSON.stringify(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);
|
||||
}, []);
|
||||
// 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());
|
||||
});
|
||||
|
||||
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 state = useMemo<PlexState>(
|
||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
||||
|
||||
Reference in New Issue
Block a user