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:
+7
-148
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -8,13 +8,6 @@ import {
|
|||||||
setUnauthorizedHandler,
|
setUnauthorizedHandler,
|
||||||
} from "./lib/api";
|
} from "./lib/api";
|
||||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
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 { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
@@ -24,16 +17,13 @@ import {
|
|||||||
type PanelId,
|
type PanelId,
|
||||||
type PanelLayout,
|
type PanelLayout,
|
||||||
} from "./lib/panelLayout";
|
} from "./lib/panelLayout";
|
||||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
|
||||||
import {
|
import {
|
||||||
getAccountRaw,
|
|
||||||
LS,
|
LS,
|
||||||
readAccount,
|
readAccount,
|
||||||
setAccountRaw,
|
setAccountRaw,
|
||||||
writeAccount,
|
writeAccount,
|
||||||
} from "./lib/storage";
|
} from "./lib/storage";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
|
||||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
||||||
@@ -76,23 +66,10 @@ const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
|
|||||||
const About = lazy(() => import("./components/About"));
|
const About = lazy(() => import("./components/About"));
|
||||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
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() {
|
export default function App() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { page, channelView, ytSearch } = useNavigation();
|
const { page, channelView, ytSearch } = useNavigation();
|
||||||
const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions();
|
const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
|
||||||
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
// 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
|
// 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).
|
// 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>.
|
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||||
const view = useFeedView();
|
const view = useFeedView();
|
||||||
const { setView } = useFeedViewActions();
|
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),
|
// 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.
|
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
|
||||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
|
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
|
||||||
@@ -171,70 +131,6 @@ export default function App() {
|
|||||||
writeAccount(LS.playlistsCollapsed, next);
|
writeAccount(LS.playlistsCollapsed, next);
|
||||||
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
|
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"
|
// 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
|
// link, its params have already hydrated the initial state — persist them and strip the
|
||||||
// query so the URL stays clean from here on.
|
// query so the URL stays clean from here on.
|
||||||
@@ -330,31 +226,12 @@ export default function App() {
|
|||||||
stripUrlParams();
|
stripUrlParams();
|
||||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const prefs = meQuery.data?.preferences;
|
const prefs = meQuery.data?.preferences;
|
||||||
if (!prefs) return;
|
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>;
|
const prefsRec = prefs as Record<string, unknown>;
|
||||||
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
|
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
|
||||||
const raw = prefsRec[PREF_KEY[p]];
|
const raw = prefsRec[PREF_KEY[p]];
|
||||||
@@ -411,30 +288,12 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
|
}, [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.
|
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||||
function changeLanguage(code: LangCode) {
|
function changeLanguage(code: LangCode) {
|
||||||
setLanguage(code);
|
setLanguage(code);
|
||||||
api.savePrefs({ language: code }).catch(() => {});
|
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.
|
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||||
if (setupQuery.isLoading)
|
if (setupQuery.isLoading)
|
||||||
return (
|
return (
|
||||||
@@ -581,7 +440,7 @@ export default function App() {
|
|||||||
) : page === "downloads" && !meQuery.data!.is_demo ? (
|
) : page === "downloads" && !meQuery.data!.is_demo ? (
|
||||||
<DownloadCenter me={meQuery.data!} />
|
<DownloadCenter me={meQuery.data!} />
|
||||||
) : page === "settings" ? (
|
) : page === "settings" ? (
|
||||||
<SettingsPanel me={meQuery.data!} prefs={prefsCtl} />
|
<SettingsPanel me={meQuery.data!} />
|
||||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||||
<PlexBrowse />
|
<PlexBrowse />
|
||||||
) : (
|
) : (
|
||||||
@@ -624,7 +483,7 @@ export default function App() {
|
|||||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||||
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
||||||
<GlassTuner enabled={theme.showTuner} />
|
<GlassTuner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Overlay from "./Overlay";
|
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
|
* 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 initial = useMemo(loadSaved, []);
|
||||||
const [vars, setVars] = useState<Record<string, number>>(() => {
|
const [vars, setVars] = useState<Record<string, number>>(() => {
|
||||||
const base: Record<string, number> = {};
|
const base: Record<string, number> = {};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Section, SettingRow, Switch } from "./ui/form";
|
|||||||
import { type SaveState } from "./ui/DraftSaveBar";
|
import { type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
|
import { usePrefs } from "./PrefsProvider";
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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();
|
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).
|
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
|
||||||
const { openWizard } = useWizard();
|
const { openWizard } = useWizard();
|
||||||
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
|
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
|||||||
import { PlexProvider } from "./components/PlexProvider";
|
import { PlexProvider } from "./components/PlexProvider";
|
||||||
import { ChannelsProvider } from "./components/ChannelsProvider";
|
import { ChannelsProvider } from "./components/ChannelsProvider";
|
||||||
import { FeedViewProvider } from "./components/FeedViewProvider";
|
import { FeedViewProvider } from "./components/FeedViewProvider";
|
||||||
|
import { PrefsProvider } from "./components/PrefsProvider";
|
||||||
import { WizardProvider } from "./components/WizardProvider";
|
import { WizardProvider } from "./components/WizardProvider";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
@@ -48,9 +49,11 @@ const root =
|
|||||||
<PlexProvider>
|
<PlexProvider>
|
||||||
<ChannelsProvider>
|
<ChannelsProvider>
|
||||||
<FeedViewProvider>
|
<FeedViewProvider>
|
||||||
<WizardProvider>
|
<PrefsProvider>
|
||||||
<App />
|
<WizardProvider>
|
||||||
</WizardProvider>
|
<App />
|
||||||
|
</WizardProvider>
|
||||||
|
</PrefsProvider>
|
||||||
</FeedViewProvider>
|
</FeedViewProvider>
|
||||||
</ChannelsProvider>
|
</ChannelsProvider>
|
||||||
</PlexProvider>
|
</PlexProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user