208 lines
6.6 KiB
TypeScript
208 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 { getSettings, putSettings } from '../services/storageService';
|
|
|
|
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 getSettings({ 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 putSettings(
|
|
{
|
|
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,
|
|
};
|
|
}
|