"use client"; import { useSyncExternalStore } from "react"; import { allowAeroMusic } from "./aero-music"; /** * The theme, as a shared store. It used to be component state inside * `useTheme()`, which meant every caller (header, rail, chat, Settings) had * its own copy: switching to Aero in Settings left the rail's light/dark row * on screen until a reload. One store, one snapshot, everyone re-renders. */ const STORAGE_KEY = "freezmo.theme"; const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424", aero: "#2fa7e6" } as const; /** The three themes. Aero is not a dark/light variant: it replaces the palette * and the scene, and the light/dark toggles hide while it is on. */ export type Theme = "light" | "dark" | "aero"; let cached: Theme | null = null; const listeners = new Set<() => void>(); function read(): Theme { try { const stored = window.localStorage.getItem(STORAGE_KEY); if (stored === "light" || stored === "dark" || stored === "aero") return stored; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } catch { /* private mode */ return "light"; } } function snapshot(): Theme { cached ??= read(); return cached; } function serverSnapshot(): Theme { return "light"; } /** * Keeps the first `theme-color` meta (the one the app owns) in step with the * in-app theme. The SSR metas follow the OS preference; this one always wins * because it is media-less and first in the head. */ export function syncThemeColor(theme: Theme) { try { let meta = document.querySelector('meta[name="theme-color"][data-app]'); if (!meta) { meta = document.createElement("meta"); meta.setAttribute("name", "theme-color"); meta.setAttribute("data-app", "1"); document.head.insertBefore(meta, document.head.firstChild); } meta.setAttribute("content", THEME_COLORS[theme]); } catch { /* private mode / odd embedded browsers */ } } export function applyTheme(next: Theme) { const previous = cached; document.documentElement.classList.toggle("dark", next === "dark"); document.documentElement.classList.toggle("aero", next === "aero"); syncThemeColor(next); /* Picking Aero (again) is the gesture that starts its soundtrack, so it also clears an earlier "turn the music off" (lib/aero-music). */ if (next === "aero" && previous !== "aero") allowAeroMusic(); cached = next; try { window.localStorage.setItem(STORAGE_KEY, next); } catch { /* private mode */ } listeners.forEach((listener) => listener()); } export function useTheme() { const theme = useSyncExternalStore( (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, snapshot, serverSnapshot, ); return { theme, setTheme: applyTheme, /** The light/dark flip; aero is chosen in Settings. */ toggle: () => applyTheme(theme === "dark" ? "light" : "dark"), }; }