| 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 { |
| |
| } |
| } |
|
|
| function safeRemove(key: string): void { |
| try { |
| localStorage.removeItem(`${PREFIX}${key}`) |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
|
|
| function safeGetJSON<T>(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)) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| function safeEnum<T extends string>(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 |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 |
|
|
| |
| 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<string, number>, |
| bgFps: 30, |
| bgFlowSpeed: 2, |
| bgRenderScale: 0.5, |
| } satisfies Record<string, unknown> |
|
|
| export const storage = { |
| |
| 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)), |
|
|
| |
| 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)), |
|
|
| |
| 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<Record<string, unknown>>('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<string, number>) => safeSetJSON('lyricOffsets', offsets), |
|
|
| |
| getTtmlEnabled: () => safeGet('ttmlEnabled') !== 'false', |
| setTtmlEnabled: (v: boolean) => safeSet('ttmlEnabled', String(v)), |
|
|
| getTtmlDbUrl: () => safeGet('ttmlDbUrl') || SETTING_DEFAULTS.ttmlDbUrl, |
| setTtmlDbUrl: (v: string) => safeSet('ttmlDbUrl', v), |
|
|
| |
| 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)), |
|
|
| |
| getAuthCookies: (): StoredCookie[] => safeGetJSON<StoredCookie[]>('auth-cookies') ?? [], |
| setAuthCookies: (cookies: StoredCookie[]) => safeSetJSON('auth-cookies', cookies), |
|
|
| upsertAuthCookie: (platform: MusicSource, cookie: string) => { |
| const list = (safeGetJSON<StoredCookie[]>('auth-cookies') ?? []).filter((c) => c.platform !== platform) |
| list.push({ platform, cookie }) |
| safeSetJSON('auth-cookies', list) |
| }, |
|
|
| removeAuthCookie: (platform: MusicSource) => { |
| const list = (safeGetJSON<StoredCookie[]>('auth-cookies') ?? []).filter((c) => c.platform !== platform) |
| safeSetJSON('auth-cookies', list) |
| }, |
|
|
| hasAuthCookie: (platform: MusicSource): boolean => { |
| const list = safeGetJSON<StoredCookie[]>('auth-cookies') ?? [] |
| return list.some((c) => c.platform === platform) |
| }, |
|
|
| getRejoinToken: (roomId: string): string | null => { |
| const data = safeGetJSON<StoredRejoinToken>('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<StoredRejoinToken>('rejoin-token') |
| if (!data) return |
| if (roomId && data.roomId !== roomId) return |
| safeRemove('rejoin-token') |
| }, |
| } |
|
|
| |
| export interface StoredCookie { |
| platform: MusicSource |
| cookie: string |
| } |
|
|
| interface StoredRejoinToken { |
| roomId: string |
| token: string |
| expiresAt: number |
| } |
|
|