import { useCallback, useState } from "react"; import { activeAccountId } from "./activeAccount"; // Central registry of every localStorage key the app uses. One place to see them all, rename // safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across // files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`. export const LS = { theme: "siftlode.theme", sidebarLayout: "siftlode.sidebarLayout", plexLayout: "siftlode.plexLayout", playlistsLayout: "siftlode.playlistsLayout", playlistsCollapsed: "siftlode.playlistsCollapsed", filesLayout: "siftlode.filesLayout", filesCollapsed: "siftlode.filesCollapsed", // The Files view last open (every file, shared with me, or a folder id), per-account. filesView: "siftlode.filesView", // The Files table's rows-per-page and page (DataTable's persist blob), per-account. filesTable: "siftlode.filesTable", // List or grid per Files view (a `{ viewParam: layout }` map), the sort both paints share, and the // grid's cards per page — per-account mirrors of the server prefs (the last is local only). filesLayouts: "siftlode.filesLayouts", filesSort: "siftlode.filesSort", filesCardSize: "siftlode.filesCardSize", hints: "siftlode.hints", lang: "siftlode.lang", filters: "siftlode.filters", page: "siftlode.page", // Start every session on the Home surface rather than on the module you happened to leave // open. Per-account, "0" = opt out and go back to remembering the last module. Read by // `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's // account is pinned — so the landing is decided right before the first paint, never corrected after. startOnHome: "siftlode.startOnHome", perfMode: "siftlode.perfMode", // The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds // from only when the server has never stored the key. feedView: "siftlode.feedView", // The subscribed table's column filters (a FilterMap), lifted out of the table so the top status // chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes // the old single-string `channelFilter` sync chip. channelsFilterMap: "siftlode.channelsFilterMap", // DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into // `channelsFilterMap`, then inert. Do not write. legacyChannelFilter: "siftlode.channelFilter", channelsView: "siftlode.channelsView", // The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account. channelLayout: "siftlode.channelLayout", // The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab). channelSort: "siftlode.channelSort", channelShow: "siftlode.channelShow", channelDiscoverySort: "siftlode.channelDiscoverySort", // Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob). channelCardSize: "siftlode.channelCardSize", channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize", channelsTable: "siftlode.channelsTable", // Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload, // before the subscription list has landed. A cache, rewritten from the list on every load. channelNames: "siftlode.channelNames", channelDiscoveryTable: "siftlode.channelDiscoveryTable", // A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and // statsTab below. settingsTab: "siftlode.settingsTab", configTab: "siftlode.configTab", auditTable: "siftlode.auditTable", statsTab: "siftlode.statsTab", adminUsersTab: "siftlode.adminUsersTab", filterCollapsed: "siftlode.filterCollapsed", playlist: "siftlode.playlist", plSort: "siftlode.plSort", plexLibrary: "siftlode.plexLibrary", plexShow: "siftlode.plexShow", plexSort: "siftlode.plexSort", plexFilters: "siftlode.plexFilters", plexQ: "siftlode.plexQ", plexField: "siftlode.plexField", plexPlaylistLayout: "siftlode.plexPlaylistLayout", plexPlayerPrefs: "siftlode.plexPlayerPrefs", // The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate // flag). A YT.Player instance is created per video and starts at full volume, so without this // every next video is loud again (same idea as plexPlayerPrefs). ytPlayerPrefs: "siftlode.ytPlayerPrefs", notifHistory: "siftlode.notifications", notifSettings: "siftlode.notifSettings", onboardingDismissed: "siftlode.onboarding.dismissed", seenVersion: "siftlode.seenVersion", // The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account: // that page runs outside the app, with no account at all. watchRepeat: "siftlode.watchRepeat", // Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same // reason as above: the viewer has no account to keep it in. watchResume: "siftlode.watchResume", } as const; // --- per-account scoping ----------------------------------------------------------------- // The active-account primitive (activeAccountId) lives in ./activeAccount // so it can be shared without a cycle; the helpers below key localStorage on it. /** Suffix a base localStorage key with an account id so per-account UI state (filters, selected * playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed * into one browser — a bare shared key leaks one account's state into another (and, with per-tab * accounts, two tabs would fight over it). Defaults to the current tab's active account; returns * null when no account is known yet, so callers fall back to defaults / skip persistence instead * of touching another account's data. */ export function accountKey( base: string, id: number | null | undefined = activeAccountId(), ): string | null { return id != null ? `${base}.${id}` : null; } /** Per-account variants of the read/write helpers: key by the current account, no-op / return the * fallback when the account isn't known (avoids reading or writing a shared/other-account key). */ export function readAccount(base: string, fallback: T): T { const k = accountKey(base); return k ? readJSON(k, fallback) : fallback; } export function readAccountMerged(base: string, defaults: T): T { const k = accountKey(base); return k ? readMerged(k, defaults) : { ...defaults }; } export function writeAccount(base: string, value: unknown): void { const k = accountKey(base); if (k) writeJSON(k, value); } export function getAccountRaw(base: string): string | null { const k = accountKey(base); return k ? localStorage.getItem(k) : null; } export function setAccountRaw(base: string, value: string): void { const k = accountKey(base); if (k) { try { localStorage.setItem(k, value); } catch { /* ignore */ } } } // --- typed read/write helpers (do the JSON + try/catch once) ---------------------------- /** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated), * falling back to `defaults` on missing or corrupt data. Module-private: its only caller is * `readAccountMerged` above (exporting it makes knip flag it as an unused export). */ function readMerged(key: string, defaults: T): T { try { return { ...defaults, ...JSON.parse(localStorage.getItem(key) || "{}") }; } catch { return { ...defaults }; } } /** Read any JSON value (arrays/scalars), falling back on missing or corrupt data. */ export function readJSON(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); return raw == null ? fallback : (JSON.parse(raw) as T); } catch { return fallback; } } /** Persist a value as JSON, swallowing quota/serialization errors. */ export function writeJSON(key: string, value: unknown): void { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore quota / serialization errors */ } } // --- reactive persisted string state ---------------------------------------------------- /** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see * accountKey), so one account's persisted tab/view position doesn't carry over to another in the * same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it * can be used before an async-loaded option list is known, keeping hook order stable. Read once, at * mount: BootGate mounts every consumer after the account is pinned. */ export function useAccountPersistedState( base: string, fallback = "", ): [string, (value: string) => void] { const [value, setValue] = useState(() => getAccountRaw(base) ?? fallback); // Stable setter (base is stable) so a provider can build a stable action on top of it — e.g. // ChannelsProvider's setView, which must not change identity on every tab switch. const set = useCallback( (next: string) => { setValue(next); setAccountRaw(base, next); }, [base], ); return [value, set]; } // --- a page's sub-tab, per browser tab --------------------------------------------------------- // Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser // tab only — sessionStorage, account-scoped: it survives a reload and a trip to another page, and a new // tab opens the page on its first sub-tab (the owner's call: a position remembered forever in // localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home). /** The sub-tab stored for this browser tab, or null. */ function getTabRaw(base: string): string | null { const k = accountKey(base); try { return k ? sessionStorage.getItem(k) : null; } catch { return null; } } /** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */ export function setTabRaw(base: string, value: string): void { const k = accountKey(base); if (!k) return; try { sessionStorage.setItem(k, value); } catch { /* ignore */ } } /** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */ export function useTabPersistedState( base: string, fallback = "", ): [string, (value: string) => void] { const [value, setValue] = useState(() => { // The position used to live in localStorage; a value left there would never be read again. const old = accountKey(base); if (old) { try { localStorage.removeItem(old); } catch { /* ignore */ } } return getTabRaw(base) ?? fallback; }); const set = useCallback( (next: string) => { setValue(next); setTabRaw(base, next); }, [base], ); return [value, set]; } /** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall * back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of * related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */ export function useAccountPersistedObject( base: string, defaults: T, ): [T, (patch: Partial) => void] { const [value, setValue] = useState(() => readAccountMerged(base, defaults)); const patch = useCallback( (p: Partial) => { setValue((prev) => { const next = { ...prev, ...p }; writeAccount(base, next); return next; }); }, [base], ); return [value, patch]; }