Files
Genarrative/src/hooks/useGameSettings.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

211 lines
6.6 KiB
TypeScript

import {useCallback, useEffect, useRef, useState} from 'react';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
normalizePlatformTheme,
readSavedSettings,
writeSavedSettings,
} from '../persistence/gameSettingsStorage';
import { isAbortError } from '../services/apiClient';
import {
getRpgProfileSettings,
putRpgProfileSettings,
} from '../services/rpg-entry';
const SETTINGS_SYNC_DELAY_MS = 180;
export function useGameSettings(authenticatedUserId: string | null = null) {
const [musicVolume, setMusicVolumeState] = useState(
() => readSavedSettings().musicVolume,
);
const [platformTheme, setPlatformThemeState] = useState(
() => readSavedSettings().platformTheme,
);
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
const [isHydratingSettings, setIsHydratingSettings] = useState(true);
const [isPersistingSettings, setIsPersistingSettings] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
const currentVolumeRef = useRef(readSavedSettings().musicVolume);
const lastSyncedThemeRef = useRef(readSavedSettings().platformTheme);
const currentThemeRef = useRef(readSavedSettings().platformTheme);
const hydrateControllerRef = useRef<AbortController | null>(null);
const persistControllerRef = useRef<AbortController | null>(null);
const persistRequestIdRef = useRef(0);
const [isRemoteSyncReady, setIsRemoteSyncReady] = useState(false);
const abortActivePersist = useCallback(() => {
persistControllerRef.current?.abort();
persistControllerRef.current = null;
setIsPersistingSettings(false);
}, []);
useEffect(() => {
currentVolumeRef.current = musicVolume;
currentThemeRef.current = platformTheme;
writeSavedSettings({ musicVolume, platformTheme });
}, [musicVolume, platformTheme]);
useEffect(() => {
hydrateControllerRef.current?.abort();
hydrateControllerRef.current = null;
abortActivePersist();
if (!authenticatedUserId) {
lastSyncedVolumeRef.current = currentVolumeRef.current;
lastSyncedThemeRef.current = currentThemeRef.current;
setSettingsError(null);
setIsHydratingSettings(false);
setHasHydratedSettings(true);
setIsRemoteSyncReady(true);
return;
}
const controller = new AbortController();
hydrateControllerRef.current = controller;
setIsRemoteSyncReady(false);
setHasHydratedSettings(false);
setIsHydratingSettings(true);
void getRpgProfileSettings({ signal: controller.signal })
.then((settings) => {
const nextVolume = clampVolume(settings.musicVolume);
const nextPlatformTheme = normalizePlatformTheme(settings.platformTheme);
lastSyncedVolumeRef.current = nextVolume;
lastSyncedThemeRef.current = nextPlatformTheme;
setMusicVolumeState(nextVolume);
setPlatformThemeState(nextPlatformTheme);
setSettingsError(null);
})
.catch((error) => {
if (isAbortError(error)) {
return;
}
lastSyncedVolumeRef.current = currentVolumeRef.current;
const message =
error instanceof Error ? error.message : '读取远端设置失败';
setSettingsError(message);
console.warn('[useGameSettings] failed to load remote settings', error);
})
.finally(() => {
if (hydrateControllerRef.current === controller) {
hydrateControllerRef.current = null;
setIsHydratingSettings(false);
setHasHydratedSettings(true);
setIsRemoteSyncReady(true);
}
});
return () => {
controller.abort();
if (hydrateControllerRef.current === controller) {
hydrateControllerRef.current = null;
}
};
}, [abortActivePersist, authenticatedUserId]);
useEffect(() => () => {
hydrateControllerRef.current?.abort();
persistControllerRef.current?.abort();
persistControllerRef.current = null;
}, []);
useEffect(() => {
if (!authenticatedUserId || !hasHydratedSettings || !isRemoteSyncReady) {
return;
}
if (
lastSyncedVolumeRef.current === musicVolume
&& lastSyncedThemeRef.current === platformTheme
) {
return;
}
const timeoutId = window.setTimeout(() => {
abortActivePersist();
const requestId = persistRequestIdRef.current + 1;
persistRequestIdRef.current = requestId;
const controller = new AbortController();
persistControllerRef.current = controller;
setIsPersistingSettings(true);
setSettingsError(null);
void putRpgProfileSettings(
{
musicVolume,
platformTheme,
},
{ signal: controller.signal },
)
.then((settings) => {
if (persistRequestIdRef.current !== requestId) {
return;
}
const nextVolume = clampVolume(settings.musicVolume);
const nextPlatformTheme = normalizePlatformTheme(
settings.platformTheme,
);
lastSyncedVolumeRef.current = nextVolume;
lastSyncedThemeRef.current = nextPlatformTheme;
setMusicVolumeState((currentValue) =>
currentValue === nextVolume ? currentValue : nextVolume,
);
setPlatformThemeState((currentValue) =>
currentValue === nextPlatformTheme
? currentValue
: nextPlatformTheme,
);
})
.catch((error) => {
if (isAbortError(error)) {
return;
}
const message =
error instanceof Error ? error.message : '保存远端设置失败';
if (persistRequestIdRef.current === requestId) {
setSettingsError(message);
}
console.warn('[useGameSettings] failed to persist remote settings', error);
})
.finally(() => {
if (persistControllerRef.current === controller) {
persistControllerRef.current = null;
setIsPersistingSettings(false);
}
});
}, SETTINGS_SYNC_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [
abortActivePersist,
authenticatedUserId,
hasHydratedSettings,
isRemoteSyncReady,
musicVolume,
platformTheme,
]);
const setMusicVolume = useCallback((value: number) => {
setMusicVolumeState(clampVolume(value));
}, []);
const setPlatformTheme = useCallback((value: 'light' | 'dark') => {
setPlatformThemeState(normalizePlatformTheme(value));
}, []);
return {
musicVolume,
setMusicVolume,
platformTheme,
setPlatformTheme,
hasHydratedSettings,
isHydratingSettings,
isPersistingSettings,
settingsError,
};
}