import { NTP, type MusicSource } from '@music-together/shared' import { SYNC_PACKET_INTERVAL_MAX_SECONDS, SYNC_PACKET_INTERVAL_MIN_SECONDS } from '@/lib/constants' const PREFIX = 'mt-' const SETTINGS_SCHEMA_VERSION_KEY = 'settingsSchemaVersion' const SETTINGS_SCHEMA_VERSION = 2 function safeGet(key: string): string | null { try { return localStorage.getItem(`${PREFIX}${key}`) } catch { return null } } function safeSet(key: string, value: string): void { try { localStorage.setItem(`${PREFIX}${key}`, value) } catch { // quota exceeded or blocked } } function safeRemove(key: string): void { try { localStorage.removeItem(`${PREFIX}${key}`) } catch { // blocked } } // --------------------------------------------------------------------------- // JSON helpers (safe parse / stringify through the PREFIX system) // --------------------------------------------------------------------------- function safeGetJSON(key: string): T | null { const raw = safeGet(key) if (!raw) return null try { return JSON.parse(raw) as T } catch { return null } } function safeSetJSON(key: string, value: unknown): void { safeSet(key, JSON.stringify(value)) } /** Parse a float from storage, returning the fallback if invalid */ function safeFloat(key: string, fallback: number): number { const raw = safeGet(key) if (raw === null) return fallback const parsed = parseFloat(raw) return Number.isFinite(parsed) ? parsed : fallback } /** Parse an int from storage, returning the fallback if invalid */ function safeInt(key: string, fallback: number): number { const raw = safeGet(key) if (raw === null) return fallback const parsed = parseInt(raw, 10) return Number.isFinite(parsed) ? parsed : fallback } /** Validate a string value is one of the allowed options */ function safeEnum(key: string, allowed: readonly T[], fallback: T): T { const raw = safeGet(key) as T | null if (raw !== null && allowed.includes(raw)) return raw return fallback } /** * Apply browser-setting migrations once per schema version. * * Version 1 changes automatic tempo correction from opt-out to opt-in. This * deliberately resets the legacy value once; choices made after the upgrade * are preserved because the recorded version prevents the migration from * running again on reload or restart. * Version 2 adds the opt-in hard-seek fallback used while tempo correction is * disabled. */ function migrateSettings(): void { const storedVersion = Math.max(0, safeInt(SETTINGS_SCHEMA_VERSION_KEY, 0)) if (storedVersion >= SETTINGS_SCHEMA_VERSION) return if (storedVersion < 1) { safeSet('playbackTempoSyncEnabled', 'false') } if (storedVersion < 2) { safeSet('playbackHardSeekSyncEnabled', 'false') } safeSet(SETTINGS_SCHEMA_VERSION_KEY, String(SETTINGS_SCHEMA_VERSION)) } migrateSettings() const LYRIC_ANCHORS = ['top', 'center', 'bottom'] as const /** 所有持久化设置项的默认值 — 供 store 层的 resettable 工厂使用 */ export const SETTING_DEFAULTS = { playbackTempoSyncEnabled: false, playbackHardSeekSyncEnabled: false, syncPacketIntervalSeconds: NTP.STEADY_STATE_INTERVAL_MS / 1000, ttmlEnabled: true, ttmlDbUrl: 'https://amlldb.bikonoo.com/ncm-lyrics/%s.ttml', lyricAlignAnchor: 'center' as 'top' | 'center' | 'bottom', lyricAlignPosition: 0.4, lyricEnableSpring: true, lyricEnableBlur: false, lyricEnableScale: true, lyricFontWeight: 600, lyricFontSize: 90, lyricTranslationFontSize: 75, lyricRomanFontSize: 75, lyricOffsets: {} as Record, bgFps: 30, bgFlowSpeed: 2, bgRenderScale: 0.5, } satisfies Record export const storage = { /** Persistent user identity — synced from server identity bootstrap */ getUserId: (): string => { return safeGet('userId') ?? '' }, setUserId: (id: string) => safeSet('userId', id), clearUserId: () => safeRemove('userId'), getNickname: () => safeGet('nickname') ?? '', setNickname: (v: string) => safeSet('nickname', v), clearNickname: () => safeRemove('nickname'), getVolume: () => { const vol = safeFloat('volume', 0.8) return Math.max(0, Math.min(1, vol)) }, setVolume: (v: number) => safeSet('volume', String(v)), // Playback synchronization getPlaybackTempoSyncEnabled: () => safeGet('playbackTempoSyncEnabled') === 'true', setPlaybackTempoSyncEnabled: (v: boolean) => safeSet('playbackTempoSyncEnabled', String(v)), getPlaybackHardSeekSyncEnabled: () => safeGet('playbackHardSeekSyncEnabled') === 'true', setPlaybackHardSeekSyncEnabled: (v: boolean) => safeSet('playbackHardSeekSyncEnabled', String(v)), getSyncPacketIntervalSeconds: () => { const seconds = safeInt('syncPacketIntervalSeconds', SETTING_DEFAULTS.syncPacketIntervalSeconds) return Math.max(SYNC_PACKET_INTERVAL_MIN_SECONDS, Math.min(SYNC_PACKET_INTERVAL_MAX_SECONDS, seconds)) }, setSyncPacketIntervalSeconds: (v: number) => safeSet('syncPacketIntervalSeconds', String(v)), // Lyric settings getLyricAlignAnchor: () => safeEnum('lyricAlignAnchor', LYRIC_ANCHORS, SETTING_DEFAULTS.lyricAlignAnchor), setLyricAlignAnchor: (v: (typeof LYRIC_ANCHORS)[number]) => safeSet('lyricAlignAnchor', v), getLyricAlignPosition: () => { const pos = safeFloat('lyricAlignPosition', SETTING_DEFAULTS.lyricAlignPosition) return Math.max(0, Math.min(1, pos)) }, setLyricAlignPosition: (v: number) => safeSet('lyricAlignPosition', String(v)), getLyricEnableSpring: () => safeGet('lyricEnableSpring') !== 'false', setLyricEnableSpring: (v: boolean) => safeSet('lyricEnableSpring', String(v)), getLyricEnableBlur: () => safeGet('lyricEnableBlur') === 'true', setLyricEnableBlur: (v: boolean) => safeSet('lyricEnableBlur', String(v)), getLyricEnableScale: () => safeGet('lyricEnableScale') !== 'false', setLyricEnableScale: (v: boolean) => safeSet('lyricEnableScale', String(v)), getLyricFontWeight: () => { const w = safeInt('lyricFontWeight', SETTING_DEFAULTS.lyricFontWeight) return Math.max(100, Math.min(900, w)) }, setLyricFontWeight: (v: number) => safeSet('lyricFontWeight', String(v)), getLyricFontSize: () => { const size = safeInt('lyricFontSize', SETTING_DEFAULTS.lyricFontSize) return Math.max(10, Math.min(200, size)) }, setLyricFontSize: (v: number) => safeSet('lyricFontSize', String(v)), getLyricTranslationFontSize: () => { const size = safeInt('lyricTranslationFontSize', SETTING_DEFAULTS.lyricTranslationFontSize) return Math.max(10, Math.min(200, size)) }, setLyricTranslationFontSize: (v: number) => safeSet('lyricTranslationFontSize', String(v)), getLyricRomanFontSize: () => { const size = safeInt('lyricRomanFontSize', SETTING_DEFAULTS.lyricRomanFontSize) return Math.max(10, Math.min(200, size)) }, setLyricRomanFontSize: (v: number) => safeSet('lyricRomanFontSize', String(v)), getLyricOffsets: () => { const stored = safeGetJSON>('lyricOffsets') if (!stored) return {} return Object.fromEntries( Object.entries(stored).flatMap(([key, value]) => typeof value === 'number' && Number.isFinite(value) ? [[key, Math.max(-10_000, Math.min(10_000, value))]] : [], ), ) }, setLyricOffsets: (offsets: Record) => safeSetJSON('lyricOffsets', offsets), // TTML 在线逐词歌词 getTtmlEnabled: () => safeGet('ttmlEnabled') !== 'false', // 默认开启 setTtmlEnabled: (v: boolean) => safeSet('ttmlEnabled', String(v)), getTtmlDbUrl: () => safeGet('ttmlDbUrl') || SETTING_DEFAULTS.ttmlDbUrl, setTtmlDbUrl: (v: string) => safeSet('ttmlDbUrl', v), // Background settings getBgFps: () => { const fps = safeInt('bgFps', SETTING_DEFAULTS.bgFps) return [15, 30, 60].includes(fps) ? fps : SETTING_DEFAULTS.bgFps }, setBgFps: (v: number) => safeSet('bgFps', String(v)), getBgFlowSpeed: () => { const speed = safeFloat('bgFlowSpeed', SETTING_DEFAULTS.bgFlowSpeed) return Math.max(0.5, Math.min(5, speed)) }, setBgFlowSpeed: (v: number) => safeSet('bgFlowSpeed', String(v)), getBgRenderScale: () => { const scale = safeFloat('bgRenderScale', SETTING_DEFAULTS.bgRenderScale) return [0.25, 0.5, 0.75, 1].includes(scale) ? scale : SETTING_DEFAULTS.bgRenderScale }, setBgRenderScale: (v: number) => safeSet('bgRenderScale', String(v)), // Auth cookie persistence getAuthCookies: (): StoredCookie[] => safeGetJSON('auth-cookies') ?? [], setAuthCookies: (cookies: StoredCookie[]) => safeSetJSON('auth-cookies', cookies), upsertAuthCookie: (platform: MusicSource, cookie: string) => { const list = (safeGetJSON('auth-cookies') ?? []).filter((c) => c.platform !== platform) list.push({ platform, cookie }) safeSetJSON('auth-cookies', list) }, removeAuthCookie: (platform: MusicSource) => { const list = (safeGetJSON('auth-cookies') ?? []).filter((c) => c.platform !== platform) safeSetJSON('auth-cookies', list) }, hasAuthCookie: (platform: MusicSource): boolean => { const list = safeGetJSON('auth-cookies') ?? [] return list.some((c) => c.platform === platform) }, getRejoinToken: (roomId: string): string | null => { const data = safeGetJSON('rejoin-token') if (!data) return null if (data.roomId !== roomId) return null if (data.expiresAt <= Date.now()) return null return data.token }, setRejoinToken: (roomId: string, token: string, expiresAt: number) => safeSetJSON('rejoin-token', { roomId, token, expiresAt } satisfies StoredRejoinToken), clearRejoinToken: (roomId?: string) => { const data = safeGetJSON('rejoin-token') if (!data) return if (roomId && data.roomId !== roomId) return safeRemove('rejoin-token') }, } /** Shape stored in localStorage for auth cookies */ export interface StoredCookie { platform: MusicSource cookie: string } interface StoredRejoinToken { roomId: string token: string expiresAt: number }