Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf) reopen from the address; the searches are written once the typing pauses.
411 lines
20 KiB
TypeScript
411 lines
20 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
|
||
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
|
||
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
|
||
// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each
|
||
// declares its kind: what it is decides where it belongs. storageKinds.contract.test.ts holds what a
|
||
// reading of the source can: no key is spelled out (or handed to the Storage API as a literal)
|
||
// anywhere else, a `pref` is read and written only through usePref or the one store listed as
|
||
// keeping it, and usePref is handed a `pref` and nothing else. That a kind fits what the code does
|
||
// with its key is the reviewer's to check.
|
||
// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and
|
||
// follows the account to every device — usePref, whose local copy only carries a value
|
||
// this browser held before the server did.
|
||
// device right for this screen, speaker or GPU and wrong on another (a panel width, the volume,
|
||
// performance mode): this browser only.
|
||
// view where the reader is (page, filters, a selected list, a sub-tab): this browser only.
|
||
// cache a copy of server data for a fast first paint, rebuilt freely.
|
||
// public the public /watch page's, which has no account: kept whoever signs in.
|
||
export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
|
||
|
||
const KEYS = {
|
||
theme: { key: "siftlode.theme", kind: "pref" },
|
||
// A side panel's group cards: their order, collapsed and hidden. Synced by lib/panelLayout.ts.
|
||
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "pref" },
|
||
plexLayout: { key: "siftlode.plexLayout", kind: "pref" },
|
||
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "pref" },
|
||
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
|
||
filesLayout: { key: "siftlode.filesLayout", kind: "pref" },
|
||
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
|
||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||
filesView: { key: "siftlode.filesView", kind: "view" },
|
||
// The Files table's rows-per-page and page (DataTable's persist blob), per-account.
|
||
filesTable: { key: "siftlode.filesTable", kind: "device" },
|
||
// 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: { key: "siftlode.filesLayouts", kind: "pref" },
|
||
filesSort: { key: "siftlode.filesSort", kind: "pref" },
|
||
filesCardSize: { key: "siftlode.filesCardSize", kind: "device" },
|
||
// The download tray open or folded to its pill — a per-account mirror of the server pref.
|
||
downloadTray: { key: "siftlode.downloadTray", kind: "pref" },
|
||
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
|
||
// changes. Per-account mirror of the server pref.
|
||
downloadTrayClosed: { key: "siftlode.downloadTrayClosed", kind: "pref" },
|
||
// The download format used last — the download dialog opens on it. Per-account mirror of the
|
||
// server pref.
|
||
downloadProfile: { key: "siftlode.downloadProfile", kind: "pref" },
|
||
hints: { key: "siftlode.hints", kind: "pref" },
|
||
lang: { key: "siftlode.lang", kind: "pref" },
|
||
filters: { key: "siftlode.filters", kind: "view" },
|
||
page: { key: "siftlode.page", kind: "view" },
|
||
// 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 account
|
||
// is claimed (claimLocalData) — so the landing is decided right before the first paint, never
|
||
// corrected after.
|
||
startOnHome: { key: "siftlode.startOnHome", kind: "pref" },
|
||
perfMode: { key: "siftlode.perfMode", kind: "device" },
|
||
// 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: { key: "siftlode.feedView", kind: "pref" },
|
||
// 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: { key: "siftlode.channelsFilterMap", kind: "view" },
|
||
// DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into
|
||
// `channelsFilterMap`, then inert. Do not write.
|
||
legacyChannelFilter: { key: "siftlode.channelFilter", kind: "view" },
|
||
channelsView: { key: "siftlode.channelsView", kind: "view" },
|
||
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
||
channelLayout: { key: "siftlode.channelLayout", kind: "pref" },
|
||
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
||
channelSort: { key: "siftlode.channelSort", kind: "view" },
|
||
channelShow: { key: "siftlode.channelShow", kind: "pref" },
|
||
channelDiscoverySort: { key: "siftlode.channelDiscoverySort", kind: "pref" },
|
||
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
||
channelCardSize: { key: "siftlode.channelCardSize", kind: "device" },
|
||
channelDiscoveryCardSize: { key: "siftlode.channelDiscoveryCardSize", kind: "device" },
|
||
channelsTable: { key: "siftlode.channelsTable", kind: "device" },
|
||
// 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: { key: "siftlode.channelNames", kind: "cache" },
|
||
channelDiscoveryTable: { key: "siftlode.channelDiscoveryTable", kind: "device" },
|
||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab
|
||
// below.
|
||
settingsTab: { key: "siftlode.settingsTab", kind: "view" },
|
||
configTab: { key: "siftlode.configTab", kind: "view" },
|
||
adminUsersTab: { key: "siftlode.adminUsersTab", kind: "view" },
|
||
filterCollapsed: { key: "siftlode.filterCollapsed", kind: "pref" },
|
||
playlist: { key: "siftlode.playlist", kind: "view" },
|
||
plSort: { key: "siftlode.plSort", kind: "pref" },
|
||
plexLibrary: { key: "siftlode.plexLibrary", kind: "view" },
|
||
plexShow: { key: "siftlode.plexShow", kind: "view" },
|
||
plexSort: { key: "siftlode.plexSort", kind: "view" },
|
||
plexFilters: { key: "siftlode.plexFilters", kind: "view" },
|
||
plexQ: { key: "siftlode.plexQ", kind: "view" },
|
||
plexField: { key: "siftlode.plexField", kind: "view" },
|
||
plexPlaylistLayout: { key: "siftlode.plexPlaylistLayout", kind: "pref" },
|
||
plexPlayerPrefs: { key: "siftlode.plexPlayerPrefs", kind: "device" },
|
||
// 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: { key: "siftlode.ytPlayerPrefs", kind: "device" },
|
||
notifHistory: { key: "siftlode.notifications", kind: "cache" },
|
||
notifSettings: { key: "siftlode.notifSettings", kind: "pref" },
|
||
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
|
||
seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
|
||
// 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: { key: "siftlode.watchRepeat", kind: "public" },
|
||
// 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: { key: "siftlode.watchResume", kind: "public" },
|
||
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in
|
||
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id, and
|
||
// when the tab last reloaded onto a newer build (`lib/staleBuild.ts`).
|
||
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
|
||
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
|
||
logTab: { key: "siftlode.logTab", kind: "device" },
|
||
staleBuildReload: { key: "siftlode.staleBuildReload", kind: "device" },
|
||
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
|
||
|
||
/** Every key the app stores, by name. */
|
||
export const LS = Object.fromEntries(
|
||
Object.entries(KEYS).map(([name, { key }]) => [name, key]),
|
||
) as {
|
||
readonly [N in keyof typeof KEYS]: (typeof KEYS)[N]["key"];
|
||
};
|
||
|
||
/** Each stored key's kind (see StorageKind), for the rules that hold per kind. */
|
||
export const KIND_OF: ReadonlyMap<string, StorageKind> = new Map(
|
||
Object.values(KEYS).map(({ key, kind }) => [key, kind]),
|
||
);
|
||
|
||
// --- the signed-in account's local state --------------------------------------------------
|
||
// A browser session holds ONE account, so its local state needs no account id in the key: the browser
|
||
// records whose state it holds instead (OWNER_KEY), and an account that is not the recorded one starts
|
||
// from a clean slate. The account is known only once BootGate has it from the server, and `claimLocalData`
|
||
// is how BootGate hands it over; until then the helpers below read nothing and write nothing, so the
|
||
// signed-out landing, or a boot still asking, cannot touch the state of whoever signs in next.
|
||
|
||
const OWNER_KEY = "siftlode.owner";
|
||
// Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none).
|
||
const BROWSER_KEYS: ReadonlySet<string> = new Set([
|
||
OWNER_KEY,
|
||
...[...KIND_OF].flatMap(([key, kind]) => (kind === "public" ? [key] : [])),
|
||
]);
|
||
// The multi-account build pinned each tab to an account (sessionStorage) and suffixed every key with
|
||
// its id (`siftlode.filters.5`). A claim moves the account's own copies onto the plain keys and drops
|
||
// the other accounts' copies; only keys the registry knows are touched.
|
||
const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"];
|
||
const REGISTERED: ReadonlySet<string> = new Set(KIND_OF.keys());
|
||
const ACCOUNT_COPY = /^(.+)\.(\d+)$/;
|
||
|
||
let claimed: string | null = null;
|
||
const claimListeners = new Set<() => void>();
|
||
|
||
/** Re-read module-level state read at import (the notification and hints stores), which runs before
|
||
* BootGate knows the account. */
|
||
export function onLocalDataClaimed(reread: () => void): void {
|
||
claimListeners.add(reread);
|
||
}
|
||
|
||
function each(store: Storage, visit: (key: string) => void): void {
|
||
for (const key of Object.keys(store)) visit(key);
|
||
}
|
||
|
||
function settleStore(store: Storage, id: string, wipe: boolean): void {
|
||
each(store, (key) => {
|
||
if (!key.startsWith("siftlode.")) return;
|
||
if (wipe && !BROWSER_KEYS.has(key)) {
|
||
store.removeItem(key);
|
||
return;
|
||
}
|
||
const [, base, owner] = ACCOUNT_COPY.exec(key) ?? [];
|
||
if (!base || !REGISTERED.has(base)) return;
|
||
const value = store.getItem(key);
|
||
if (owner === id && value != null) store.setItem(base, value);
|
||
store.removeItem(key);
|
||
});
|
||
}
|
||
|
||
/** Hand the browser's local state to the signed-in account `id` (BootGate, before the app mounts).
|
||
* Another account's state is wiped first; the old per-account copies are moved onto the plain keys. */
|
||
export function claimLocalData(id: number): void {
|
||
const me = String(id);
|
||
try {
|
||
// Each store records whose it is: a tab's sessionStorage is its own, so a tab reloading for the
|
||
// account another tab already claimed the browser for still holds the previous account's. A
|
||
// tab that records no owner (one a build before this opened) goes by the browser's.
|
||
const owner = localStorage.getItem(OWNER_KEY);
|
||
const tabOwner = sessionStorage.getItem(OWNER_KEY) ?? owner;
|
||
settleStore(localStorage, me, owner != null && owner !== me);
|
||
settleStore(sessionStorage, me, tabOwner != null && tabOwner !== me);
|
||
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
|
||
localStorage.setItem(OWNER_KEY, me);
|
||
sessionStorage.setItem(OWNER_KEY, me);
|
||
} catch {
|
||
/* storage unavailable: nothing to settle */
|
||
}
|
||
claimed = me;
|
||
claimListeners.forEach((reread) => reread());
|
||
}
|
||
|
||
/** The account this tab was built for (claimed at boot), as its id; null before the claim. Every
|
||
* request says so (`identityHeaders`): the server refuses one from a tab whose browser has since
|
||
* signed in as someone else, and the tab reloads as whoever is signed in now. */
|
||
export function claimedAccount(): string | null {
|
||
return claimed;
|
||
}
|
||
|
||
/** `base` while this tab's account owns the browser's local state, else null: before the claim, and
|
||
* after another tab has claimed it for a different account (a sign-in there replaces this one). */
|
||
export function ownKey(base: string): string | null {
|
||
if (claimed == null) return null;
|
||
try {
|
||
return localStorage.getItem(OWNER_KEY) === claimed ? base : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** The account's read/write helpers: no-op / the fallback while the state is not this tab's (see ownKey). */
|
||
export function readAccount<T>(base: string, fallback: T): T {
|
||
const k = ownKey(base);
|
||
return k ? readJSON(k, fallback) : fallback;
|
||
}
|
||
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
|
||
const k = ownKey(base);
|
||
return k ? readMerged(k, defaults) : { ...defaults };
|
||
}
|
||
export function writeAccount(base: string, value: unknown): void {
|
||
const k = ownKey(base);
|
||
if (k) writeJSON(k, value);
|
||
}
|
||
export function getAccountRaw(base: string): string | null {
|
||
const k = ownKey(base);
|
||
try {
|
||
return k ? localStorage.getItem(k) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
export function setAccountRaw(base: string, value: string): void {
|
||
const k = ownKey(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<T extends object>(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<T>(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 the account's localStorage (see ownKey). 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 claimed. */
|
||
export function useAccountPersistedState(
|
||
base: string,
|
||
fallback = "",
|
||
): [string, (value: string) => void] {
|
||
const [value, setValue] = useState<string>(() => 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, Users) is kept for THIS browser tab only —
|
||
// sessionStorage, the account's (see ownKey): 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
|
||
// address names the tab too (the page mirrors it): a link or a bookmark that carries one wins.
|
||
|
||
/** The sub-tab stored for this browser tab, or null. */
|
||
function getTabRaw(base: string): string | null {
|
||
const k = ownKey(base);
|
||
try {
|
||
return k ? sessionStorage.getItem(k) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writeTabRaw(base: string, value: string): void {
|
||
const k = ownKey(base);
|
||
if (!k) return;
|
||
try {
|
||
sessionStorage.setItem(k, value);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
// A pre-selection made while the page is already on screen (a notice's link opened over Settings) has
|
||
// no mount to be read at, and the navigation to the same page is a no-op: the page's hook hears it.
|
||
const TAB_SELECT_EVENT = "siftlode:tab-select";
|
||
type TabSelect = { base: string; value: string };
|
||
|
||
/** 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 {
|
||
writeTabRaw(base, value);
|
||
window.dispatchEvent(new CustomEvent<TabSelect>(TAB_SELECT_EVENT, { detail: { base, value } }));
|
||
}
|
||
|
||
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). `urlKey`: the
|
||
* address-bar key the page mirrors its tab to — a tab it names is the one the page opens on. */
|
||
export function useTabPersistedState(
|
||
base: string,
|
||
fallback = "",
|
||
urlKey?: string,
|
||
): [string, (value: string) => void] {
|
||
const [value, setValue] = useState<string>(() => {
|
||
// The position used to live in localStorage; a value left there would never be read again.
|
||
const old = ownKey(base);
|
||
if (old) {
|
||
try {
|
||
localStorage.removeItem(old);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
const linked = urlKey ? new URLSearchParams(window.location.search).get(urlKey) : null;
|
||
if (linked) {
|
||
// Kept for this browser tab too, so a later trip back to the page (its address cleared by the
|
||
// page switch) still opens it here.
|
||
writeTabRaw(base, linked);
|
||
return linked;
|
||
}
|
||
return getTabRaw(base) ?? fallback;
|
||
});
|
||
useEffect(() => {
|
||
function onSelect(e: Event) {
|
||
const { detail } = e as CustomEvent<TabSelect>;
|
||
if (detail.base === base) setValue(detail.value);
|
||
}
|
||
window.addEventListener(TAB_SELECT_EVENT, onSelect);
|
||
return () => window.removeEventListener(TAB_SELECT_EVENT, onSelect);
|
||
}, [base]);
|
||
const set = useCallback(
|
||
(next: string) => {
|
||
setValue(next);
|
||
writeTabRaw(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<T extends object>(
|
||
base: string,
|
||
defaults: T,
|
||
): [T, (patch: Partial<T>) => void] {
|
||
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
||
const patch = useCallback(
|
||
(p: Partial<T>) => {
|
||
setValue((prev) => {
|
||
const next = { ...prev, ...p };
|
||
writeAccount(base, next);
|
||
return next;
|
||
});
|
||
},
|
||
[base],
|
||
);
|
||
return [value, patch];
|
||
}
|