import { useCallback, useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; import { api } from "./api"; import type { Me } from "./api/account"; import { readAccount, writeAccount } from "./storage"; // The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to // the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted // back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its // call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache, // (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds // all three into one hook so a new server-synced pref is a one-liner. // // Two mirror strategies, unified here (pick via `localKey`): // • Providers mounted ABOVE (feed view, panel layouts, rail-collapse flags) render before // /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on // reload — pass `localKey`. // • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they // seed straight from the react-query cache and need no localStorage mirror — omit `localKey`. // Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other // consumers reading the same key (e.g. a second player instance) see the change at once. export interface UsePrefOptions { /** Also mirror to per-account localStorage under this base key, and seed synchronously from it on * first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */ localKey?: string; /** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The * local state + cache still update instantly; only the network write is deferred. */ debounceMs?: number; /** Normalize a raw stored value (from the server payload or the localStorage mirror) into T — e.g. * mapping legacy values forward, or clamping. Defaults to identity. Must be idempotent. */ parse?: (raw: unknown) => T; } function readServerPref(me: Me | null | undefined, key: string): unknown { return me?.preferences?.[key]; } // Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch — // the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set // (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge // shape lives in ONE place. function mergePrefsCache(qc: QueryClient, patch: Record): void { qc.setQueryData(qk.me(), (m) => m ? { ...m, preferences: { ...m.preferences, ...patch } } : m, ); } /** The raw preference persister: save an arbitrary partial preferences patch to the server and merge * it into the `me` cache optimistically (so readers see it without a refetch). For dynamic or * multi-key prefs that don't map to a single stateful usePref call — e.g. a variable set of toggles * (PlexInfo's per-collection strip-source hiding). Prefer usePref for a fixed, stateful key. */ export function useSavePref(): (patch: Record) => void { const qc = useQueryClient(); return useCallback( (patch: Record) => { api.savePrefs(patch).catch(() => {}); mergePrefsCache(qc, patch); }, [qc], ); } /** A per-account preference persisted to the server (and optionally mirrored to localStorage), with * automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */ export function usePref( serverKey: string, defaultValue: T, options: UsePrefOptions = {}, ): [T, (next: T) => void] { const { localKey, debounceMs, parse } = options; const qc = useQueryClient(); // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); // Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which // would churn the memoized setter and the adopt effect). parse must be idempotent anyway. const parseRef = useRef(parse); parseRef.current = parse; const norm = useCallback((raw: unknown): T => { const p = parseRef.current; return p ? p(raw) : (raw as T); }, []); const seed = useCallback((): T => { const server = readServerPref(qc.getQueryData(qk.me()), serverKey); if (server !== undefined) return norm(server); if (localKey) return norm(readAccount(localKey, defaultValue)); return defaultValue; // defaultValue / serverKey / localKey are stable for a given call site. // eslint-disable-next-line react-hooks/exhaustive-deps }, [qc, serverKey, localKey, norm]); const [value, setValue] = useState(seed); const saveTimer = useRef(undefined); // The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change // isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new // value, and a later adopt could then overwrite the newer local value with the stale server one. const pendingSave = useRef<(() => void) | null>(null); useEffect( () => () => { window.clearTimeout(saveTimer.current); pendingSave.current?.(); pendingSave.current = null; }, [], ); const set = useCallback( (next: T) => { setValue(next); if (localKey) writeAccount(localKey, next); // Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect) // see the new value without a refetch. Merges into preferences, mirroring the server PUT. const payload = { [serverKey]: next }; mergePrefsCache(qc, payload); // savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state + // cache already reflect the change (idempotent PUT retries a transient blip). const flush = () => api.savePrefs(payload).catch(() => {}); if (debounceMs) { window.clearTimeout(saveTimer.current); pendingSave.current = flush; saveTimer.current = window.setTimeout(() => { pendingSave.current = null; flush(); }, debounceMs); } else { flush(); } }, [qc, serverKey, localKey, debounceMs], ); // Adopt the server value when the account becomes known / changes (login, account switch). Guarded on // the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key → // keep the current (seeded / user-set) value rather than clobbering it with the default. const prevId = useRef(me?.id); useEffect(() => { if (me?.id === prevId.current) return; prevId.current = me?.id; const server = readServerPref(me, serverKey); if (server === undefined) return; const val = norm(server); setValue(val); if (localKey) writeAccount(localKey, val); // eslint-disable-next-line react-hooks/exhaustive-deps }, [me?.id]); return [value, set]; }