refactor(prefs): move Settings auto-save machinery into PrefsProvider

Lift the four editable Settings prefs (theme/perf/hints/notifications), their
live-apply effects, the debounced auto-save + baseline echo-guard, and the
server-adopt-on-login into PrefsProvider, exposing the PrefsController via
usePrefs(). SettingsPanel and GlassTuner read it from the hook instead of props;
App's big adopt effect keeps only the shell prefs (panel layouts, rail collapse,
language). App.tsx 655->489 lines. E2E 17/17 incl. settings auto-save. (Platform S6b.3)
This commit is contained in:
2026-07-19 01:09:20 +02:00
parent 36e4419c98
commit fd1742e783
5 changed files with 192 additions and 153 deletions
+7 -148
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -8,13 +8,6 @@ import {
setUnauthorizedHandler,
} from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import {
applyTheme,
DEFAULT_THEME,
loadLocalTheme,
saveLocalTheme,
type ThemePrefs,
} from "./lib/theme";
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
import {
loadLayout,
@@ -24,16 +17,13 @@ import {
type PanelId,
type PanelLayout,
} from "./lib/panelLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { notify, removeByMetaKind } from "./lib/notifications";
import {
getAccountRaw,
LS,
readAccount,
setAccountRaw,
writeAccount,
} from "./lib/storage";
import type { PrefsController } from "./components/SettingsPanel";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
@@ -76,23 +66,10 @@ const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
const PERF_KEY = LS.perfMode;
// The preferences edited on the Settings page. They apply locally for instant preview but
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
hints: boolean;
notifications: NotifSettings;
};
export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions();
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
@@ -102,23 +79,6 @@ export default function App() {
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
const view = useFeedView();
const { setView } = useFeedViewActions();
// Settings-page prefs: applied live by the effects below, and auto-saved (debounced) on change
// — no draft, no Save button (see the auto-save effect).
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
// The last server-known prefs. The same states are ALSO written when we adopt server prefs on
// login/account-switch, so the auto-save effect compares against this baseline to avoid echoing
// a server-adopted value straight back. A ref (drives no UI); updated on adopt + after each save.
const baselineRef = useRef<EditablePrefs>({
theme: loadLocalTheme(),
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
});
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(undefined);
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
@@ -171,70 +131,6 @@ export default function App() {
writeAccount(LS.playlistsCollapsed, next);
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
}
useEffect(() => applyTheme(theme), [theme]);
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
// the stores too); persistence to the server is deferred to an explicit Save.
useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
// theme-appropriate image on <body> (dark or /light/ set) and switches the glass to translucent.
useEffect(() => {
const on = theme.bgImage && !perf;
document.documentElement.dataset.backdrop = on ? "on" : "off";
}, [theme.bgImage, perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
// Auto-save the Settings prefs on change (debounced ~500ms so a slider drag coalesces into one
// PUT). Live-apply is handled by the effects above; this is the only persistence path — there is
// no draft or Save button. `baselineRef` suppresses the echo when the change came from adopting
// server prefs on login (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
[theme, perf, hints, notif],
);
useEffect(() => {
window.clearTimeout(saveTimer.current);
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
// save; clear any "Saving…" left from a change that was reverted before it flushed.
setPrefsSaveState((s) => (s === "saving" ? "idle" : s));
return;
}
setPrefsSaveState("saving");
window.clearTimeout(saveMsgTimer.current);
const payload = editablePrefs;
saveTimer.current = window.setTimeout(() => {
api
.savePrefs(payload)
.then(() => {
baselineRef.current = payload;
setPrefsSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure; flag the indicator so the user sees it didn't take.
setPrefsSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
});
}, 500);
return () => window.clearTimeout(saveTimer.current);
}, [editablePrefs]);
// Clear pending pref-save timers on unmount so a debounced PUT or a fade timer never fires
// setState on a dead component.
useEffect(
() => () => {
window.clearTimeout(saveTimer.current);
window.clearTimeout(saveMsgTimer.current);
},
[],
);
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
// link, its params have already hydrated the initial state — persist them and strip the
// query so the URL stays clean from here on.
@@ -330,31 +226,12 @@ export default function App() {
stripUrlParams();
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// On login, adopt server-stored preferences.
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
// and the feed view by FeedViewProvider — each via its own passive ["me"] observer.
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
// Adopt the server prefs into the live states (the runtime effects above apply them for
// display) AND into the auto-save baseline, so adopting them doesn't echo a save back.
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
baselineRef.current = adopted;
// Out-of-scope prefs stay instant (edited outside the Settings page). The feed view is adopted
// by FeedViewProvider (its own passive ["me"] observer), not here.
const prefsRec = prefs as Record<string, unknown>;
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefsRec[PREF_KEY[p]];
@@ -411,30 +288,12 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
function setTheme(next: ThemePrefs) {
setThemeState(next);
saveLocalTheme(next);
}
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
setLanguage(code);
api.savePrefs({ language: code }).catch(() => {});
}
const prefsCtl: PrefsController = {
theme,
setTheme,
perf,
setPerf,
hints,
setHints,
notif,
setNotif,
saveState: prefsSaveState,
};
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (setupQuery.isLoading)
return (
@@ -581,7 +440,7 @@ export default function App() {
) : page === "downloads" && !meQuery.data!.is_demo ? (
<DownloadCenter me={meQuery.data!} />
) : page === "settings" ? (
<SettingsPanel me={meQuery.data!} prefs={prefsCtl} />
<SettingsPanel me={meQuery.data!} />
) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse />
) : (
@@ -624,7 +483,7 @@ export default function App() {
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
<GlassTuner enabled={theme.showTuner} />
<GlassTuner />
</div>
);
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import Overlay from "./Overlay";
import { usePrefs } from "./PrefsProvider";
/**
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
@@ -91,7 +92,9 @@ function loadSaved(): Saved {
}
}
export default function GlassTuner({ enabled }: { enabled: boolean }) {
export default function GlassTuner() {
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
const enabled = usePrefs().theme.showTuner;
const initial = useMemo(loadSaved, []);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
+171
View File
@@ -0,0 +1,171 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import {
applyTheme,
DEFAULT_THEME,
loadLocalTheme,
saveLocalTheme,
type ThemePrefs,
} from "../lib/theme";
import { configureNotifications, getNotifSettings, type NotifSettings } from "../lib/notifications";
import { hintsEnabled, setHintsEnabled } from "../lib/hints";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import type { PrefsController } from "./SettingsPanel";
const PERF_KEY = LS.perfMode;
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" used to suppress the echo when a change came from adopting
// server prefs on login. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
hints: boolean;
notifications: NotifSettings;
};
const PrefsContext = createContext<PrefsController>({
theme: DEFAULT_THEME,
setTheme: () => {},
perf: false,
setPerf: () => {},
hints: true,
setHints: () => {},
notif: getNotifSettings(),
setNotif: () => {},
saveState: "idle",
});
export function usePrefs(): PrefsController {
return useContext(PrefsContext);
}
export function PrefsProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
// The last server-known prefs. Written on adopt + after each save so the auto-save effect can
// compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI).
const baselineRef = useRef<EditablePrefs>({
theme: loadLocalTheme(),
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
});
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(undefined);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
// Live-apply the prefs locally for instant preview (their localStorage mirrors update via the
// stores too); persistence to the server is the auto-save effect below.
useEffect(() => applyTheme(theme), [theme]);
useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
// theme-appropriate image on <body> and switches the glass to translucent.
useEffect(() => {
const on = theme.bgImage && !perf;
document.documentElement.dataset.backdrop = on ? "on" : "off";
}, [theme.bgImage, perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
// Auto-save on change (debounced ~500ms so a slider drag coalesces into one PUT). Live-apply is the
// effects above; this is the only persistence path. `baselineRef` suppresses the echo when the
// change came from adopting server prefs on login (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
[theme, perf, hints, notif],
);
useEffect(() => {
window.clearTimeout(saveTimer.current);
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
// save; clear any "Saving…" left from a change that was reverted before it flushed.
setSaveState((s) => (s === "saving" ? "idle" : s));
return;
}
setSaveState("saving");
window.clearTimeout(saveMsgTimer.current);
const payload = editablePrefs;
saveTimer.current = window.setTimeout(() => {
api
.savePrefs(payload)
.then(() => {
baselineRef.current = payload;
setSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure; flag the indicator so the user sees it didn't take.
setSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 4000);
});
}, 500);
return () => window.clearTimeout(saveTimer.current);
}, [editablePrefs]);
// Clear pending timers on unmount so a debounced PUT or a fade timer never fires setState on a dead
// component.
useEffect(
() => () => {
window.clearTimeout(saveTimer.current);
window.clearTimeout(saveMsgTimer.current);
},
[],
);
// On login / account switch, adopt the server-stored prefs into the live states AND the auto-save
// baseline (so adopting them doesn't echo a save back).
useEffect(() => {
const prefs = me?.preferences;
if (!prefs) return;
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
baselineRef.current = adopted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
const setTheme = useCallback((next: ThemePrefs) => {
setThemeState(next);
saveLocalTheme(next);
}, []);
const value = useMemo<PrefsController>(
() => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }),
[theme, setTheme, perf, hints, notif, saveState],
);
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
}
+4 -1
View File
@@ -12,6 +12,7 @@ import { Section, SettingRow, Switch } from "./ui/form";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { useWizard } from "./WizardProvider";
import { usePrefs } from "./PrefsProvider";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
@@ -40,8 +41,10 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
export default function SettingsPanel({ me, prefs }: { me: Me; prefs: PrefsController }) {
export default function SettingsPanel({ me }: { me: Me }) {
const { t } = useTranslation();
// The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider.
const prefs = usePrefs();
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
const { openWizard } = useWizard();
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
+6 -3
View File
@@ -9,6 +9,7 @@ import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import { ChannelsProvider } from "./components/ChannelsProvider";
import { FeedViewProvider } from "./components/FeedViewProvider";
import { PrefsProvider } from "./components/PrefsProvider";
import { WizardProvider } from "./components/WizardProvider";
import "./i18n";
import "./index.css";
@@ -48,9 +49,11 @@ const root =
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<WizardProvider>
<App />
</WizardProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>