import { useEffect, useSyncExternalStore } from 'react' import { backgroundUrl, DEFAULT_PHOTO, PHOTOS } from './backgrounds' /** * 展示页右下角「外观」里能调的参数:都是相对默认值的倍数(1 = 原样),存在浏览器里。 * 分两类:天气(雪,见 weather/)和背景(模糊、跟着鼠标晃动)。 */ export interface AppearanceSettings { /** 下雪的总开关。 */ enabled: boolean /** 数量。 */ density: number /** 下落速度。 */ speed: number /** 风力(风的大小和鼠标带起的风)。 */ wind: number /** 粒子大小。 */ size: number /** 虚化程度(1 是预设原样,小于 1 更清晰,大于 1 更糊)。 */ blur: number /** 透明度。 */ opacity: number /** 子弹时间(鼠标停住时周围变慢)。 */ bullet: boolean /** 鼠标拨动雪。 */ mouse: boolean /** 用哪张照片(id,见 backgrounds.ts)。 */ photo: string /** 背景模糊:开是随主题的两张预先模糊过的图,关是不模糊的图(两张,也是随主题)。 */ bgBlurred: boolean /** 背景模糊关着的时候:开是加载没压缩的原图(PNG,近 2MB 一张),关是压缩过的(JPG,不到 300KB)。 */ bgOriginal: boolean /** 背景模糊开着的时候,在预先模糊的基础上再模糊多少(像素,0 是不再模糊)。 */ bgBlur: number /** 背景跟着鼠标晃动:总开关,关了背景就完全不动。 */ sway: boolean /** 背景跟着鼠标晃动的幅度(1 是默认)。 */ parallax: number /** 盖在背景图上的那层遮罩(亮色是白、暗色是灰)的浓度:0 是不盖,1 是默认(亮色 50%、暗色 62%)。 */ bgVeil: number } export const APPEARANCE_DEFAULTS: AppearanceSettings = { enabled: true, density: 1, speed: 1, wind: 1, size: 1, blur: 1, opacity: 1, bullet: true, mouse: true, photo: DEFAULT_PHOTO, bgBlurred: true, bgOriginal: false, bgBlur: 0, sway: true, parallax: 1, bgVeil: 1, } /** 每个滑块的范围(和 AppearanceSettings 里同名的数值项对应)。 */ export const APPEARANCE_RANGES = { density: { min: 0.2, max: 3, step: 0.05 }, speed: { min: 0.3, max: 3, step: 0.05 }, wind: { min: 0, max: 3, step: 0.05 }, size: { min: 0.5, max: 2.5, step: 0.05 }, blur: { min: 0, max: 2, step: 0.05 }, opacity: { min: 0.2, max: 2.5, step: 0.05 }, bgBlur: { min: 0, max: 30, step: 1 }, parallax: { min: 0, max: 1.5, step: 0.05 }, bgVeil: { min: 0, max: 1.6, step: 0.05 }, } as const const KEY = 'moshiro.appearance' const listeners = new Set<() => void>() function load(): AppearanceSettings { try { const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Partial const merged: Record = { ...APPEARANCE_DEFAULTS } for (const name of Object.keys(APPEARANCE_DEFAULTS) as (keyof AppearanceSettings)[]) { const value = saved[name] if (typeof value !== typeof APPEARANCE_DEFAULTS[name]) continue // 照片只认列表里有的,图删了、改名了就回到默认 if (name === 'photo' && !PHOTOS.some((photo) => photo.id === value)) continue merged[name] = value } return merged as unknown as AppearanceSettings } catch { return { ...APPEARANCE_DEFAULTS } } } let current = load() export const getAppearance = () => current export function setAppearance(patch: Partial) { current = { ...current, ...patch } try { localStorage.setItem(KEY, JSON.stringify(current)) } catch { // 存不了(隐私模式等)就只在这次打开期间有效 } listeners.forEach((listener) => listener()) } export const resetAppearance = (keys: (keyof AppearanceSettings)[]) => setAppearance(Object.fromEntries(keys.map((name) => [name, APPEARANCE_DEFAULTS[name]])) as Partial) export function subscribeAppearance(listener: () => void): () => void { listeners.add(listener) return () => listeners.delete(listener) } export const useAppearance = () => useSyncExternalStore(subscribeAppearance, getAppearance) /** * 把当前该用的背景图写进 的 --bg-image(照片、主题、背景模糊、不压缩一起决定用哪个版本;display.css 里 .bg-layer 用它), * 把遮罩的浓度写进 --bg-veil-scale,把背景的额外模糊写进 --bg-filter(不模糊的时候是 none,不给浏览器多一层滤镜), * 把晃动的开关写进 data-sway(关了就按窗口的原始比例铺满,display.css 里 data-sway='off')。切换主题时也重新算。 */ export function useAppearanceVars() { useEffect(() => { const root = document.documentElement const apply = () => { const { bgBlur, bgBlurred, bgOriginal, photo, bgVeil, sway } = getAppearance() root.style.setProperty('--bg-veil-scale', String(bgVeil)) root.style.setProperty('--bg-filter', bgBlurred && bgBlur > 0 ? `blur(${bgBlur}px)` : 'none') root.style.setProperty('--bg-image', `url("${backgroundUrl(photo, root.dataset.theme === 'dark', bgBlurred, bgOriginal)}")`) root.dataset.sway = sway ? 'on' : 'off' } apply() const observer = new MutationObserver(apply) observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] }) const unsubscribe = subscribeAppearance(apply) return () => { observer.disconnect() unsubscribe() } }, []) }