Deep-review round 1 over main..improvement/watch-background-audio. BootGate - The tab's account pin is corrected to the account the server answered as. The server honours the pin only for an account still signed in to this browser and otherwise answers as the default, so a tab pinned to an account signed out in another tab ran as the default on the server while reading and writing the gone account's local state. - A mounted tab whose identity changes (its session ended, or the case above) reloads instead of rebuilding the tree in place, like every account switch; that also drops the previous account's query cache. The account key on the tree goes: the readyFor gate already unmounted it on every change. - An unanswered setup probe shows the error with Retry, like an unanswered account probe, instead of the signed-out landing to a signed-in user. - Comments: /api/me never 401s; the ended-session path is identity → nobody. Loading states - LoadingState's reveal delay is one clock across loading states that hand over to each other (boot screen → page-chunk fallback → the page's own fetching state), instead of a CSS delay restarted by each, which blanked a visible spinner for another 300 ms at every hand-off. BootScreen is now the shared loading visual, full screen, counting from navigation start. Plex shared link - Persists only the dimensions the link carries again. Writing the default filters for an omitted `pf` created the stored-filters key, which is what tells SavedViewsProvider the account picked filters, so the account's default Plex saved view was never applied again. Watch page - The Repeat toggle uses the shared pressed-pill paint (a solid fill when on) instead of Button variants that differ only in colour, and its flag goes through storage.ts readJSON/writeJSON. - The actions wrap under the title when the row is too narrow, instead of squeezing the title into a column a word wide on a phone; the title breaks long words. Comments that still justified code by the removed adopt effects (usePref, PrefsProvider, FeedViewProvider) are rewritten against the seed-once model. Tests: BootGate error/retry for both probes, setup mode, a failed background refetch keeping the app, the pin correction and the reload on an identity change; the shared reveal clock; the Plex link persist; the audio signature actually moved off version 4; a video download (av and v) still remuxed to an mp4. Each mutation-checked RED for its own reason.
139 lines
6.3 KiB
TypeScript
139 lines
6.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { 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";
|
|
import { notifyActionError } from "./actionErrors";
|
|
import i18n from "../i18n";
|
|
|
|
// The local state + cache already show the new value, so a refused save is otherwise invisible until
|
|
// the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the
|
|
// connection notice for a lost connection — say it (E11 S2b).
|
|
const reportPrefSaveFailure = (err: unknown) =>
|
|
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
|
|
|
|
// 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 follows the
|
|
// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
|
|
// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
|
|
// server-synced pref is a one-liner.
|
|
//
|
|
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
|
|
// app tree only once /api/me has answered, and a tab whose account changes reloads, so there is nothing
|
|
// to adopt after mount. 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<T> {
|
|
/** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
|
|
* server payload lacks the key. */
|
|
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<string, unknown>): void {
|
|
qc.setQueryData<Me | null>(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<string, unknown>) => void {
|
|
const qc = useQueryClient();
|
|
return useCallback(
|
|
(patch: Record<string, unknown>) => {
|
|
api.savePrefs(patch).catch(reportPrefSaveFailure);
|
|
mergePrefsCache(qc, patch);
|
|
},
|
|
[qc],
|
|
);
|
|
}
|
|
|
|
/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
|
|
* Returns `[value, setValue]`; the setter is stable. */
|
|
export function usePref<T>(
|
|
serverKey: string,
|
|
defaultValue: T,
|
|
options: UsePrefOptions<T> = {},
|
|
): [T, (next: T) => void] {
|
|
const { localKey, debounceMs, parse } = options;
|
|
const qc = useQueryClient();
|
|
|
|
// Latest parse in a ref so the seed and the setter don't depend on a fresh closure each render (which
|
|
// would churn the memoized setter). 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<Me | null>(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<T>(seed);
|
|
|
|
const saveTimer = useRef<number | undefined>(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 the next load — which seeds from the server first — would bring the old value back.
|
|
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 a later mount of this hook,
|
|
// which seeds from the cache) 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 (idempotent PUT, so `req` retries a transient blip once); a
|
|
// failure that survives that is reported, not swallowed.
|
|
const flush = () => api.savePrefs(payload).catch(reportPrefSaveFailure);
|
|
if (debounceMs) {
|
|
window.clearTimeout(saveTimer.current);
|
|
pendingSave.current = flush;
|
|
saveTimer.current = window.setTimeout(() => {
|
|
pendingSave.current = null;
|
|
flush();
|
|
}, debounceMs);
|
|
} else {
|
|
flush();
|
|
}
|
|
},
|
|
[qc, serverKey, localKey, debounceMs],
|
|
);
|
|
|
|
return [value, set];
|
|
}
|