freezmo/lib/theme.ts
Martin Dvorak a4c85ed77a Frutiger Aero: the theme now plays its soundtrack
Selecting Aero starts a random track from the Frutiger Aero playlist
(Bigblamboii's set on SoundCloud); every following track is drawn at random
too, so the set shuffles itself. The player is the credit bar from the mock:
a 44px glass circle with a note by default, expanding into pause + the
track's title + "SoundCloud - artist" (both reported by the widget).

- Only Aero ever touches SoundCloud: the embed and the widget API are created
  while the theme is on and the listener has not turned it off; light/dark
  request nothing (pinned by a suite check).
- The off choice persists (freezmo.aero-music.v1); re-selecting Aero in
  Settings is fresh consent (applyTheme clears it).
- Autoplay rides the theme-selection gesture; after a reload the circle comes
  up paused (browsers require a gesture) and one tap starts it.
- Volume 45; the iframe is 1px and inert, only the audio engine; the credit
  area collapses back to the circle without stopping the music.
- Geometry: clears the phone tab bar, lifts above the chat composer and the
  wizard bar, steps aside under immersive overlays and while the cookie
  notice shares the corner. 44px targets on phones.
- Cookie policy gained the third-party-embed paragraph it was owed.

tests/aero-music-test.mjs (16th suite, stubbed widget API, 21 checks). All
suites green, contrast scan clean. Version 0.7.0, SW v92.
2026-10-15 22:47:44 +00:00

98 lines
2.9 KiB
TypeScript

"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<HTMLMetaElement>('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"),
};
}