mymusics

retro MySpace-style music player
Log | Files | Refs | README

embedTheme.ts (8754B)


      1 export type EmbedPresetId = "default" | "light" | "dark" | "midnight" | "minimal";
      2 
      3 export type EmbedFontId = "system" | "sans" | "serif";
      4 
      5 export type EmbedThemeOverrides = {
      6   preset?: EmbedPresetId;
      7   accent?: string | null;
      8   bg?: string | null;
      9   panel?: string | null;
     10   text?: string | null;
     11   fgMuted?: string | null;
     12   radius?: number | null;
     13   font?: EmbedFontId | null;
     14 };
     15 
     16 export type EmbedThemeTokens = {
     17   bg: string;
     18   bgPanel: string;
     19   text: string;
     20   textMuted: string;
     21   accent: string;
     22   accentSecondary: string;
     23   border: string;
     24   radius: string;
     25   shadow: string;
     26   fontBody: string;
     27   fontDisplay: string;
     28 };
     29 
     30 export const EMBED_PRESET_IDS: EmbedPresetId[] = [
     31   "default",
     32   "light",
     33   "dark",
     34   "midnight",
     35   "minimal",
     36 ];
     37 
     38 export const EMBED_FONT_IDS: EmbedFontId[] = ["system", "sans", "serif"];
     39 
     40 const FONT_STACKS: Record<EmbedFontId, { body: string; display: string }> = {
     41   system: {
     42     body: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
     43     display: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
     44   },
     45   sans: {
     46     body: '"Source Sans 3", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
     47     display: '"Source Sans 3", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
     48   },
     49   serif: {
     50     body: '"Source Sans 3", system-ui, sans-serif',
     51     display: '"Fraunces", Georgia, "Times New Roman", serif',
     52   },
     53 };
     54 
     55 const PRESETS: Record<EmbedPresetId, EmbedThemeTokens> = {
     56   default: {
     57     bg: "#000f28",
     58     bgPanel: "#0a1f3d",
     59     text: "#f2f7ff",
     60     textMuted: "rgba(242, 247, 255, 0.74)",
     61     accent: "#fbc02d",
     62     accentSecondary: "#40c4ff",
     63     border: "rgba(64, 196, 255, 0.38)",
     64     radius: "14px",
     65     shadow: "0 14px 42px rgba(0, 10, 30, 0.45)",
     66     fontBody: FONT_STACKS.sans.body,
     67     fontDisplay: FONT_STACKS.serif.display,
     68   },
     69   light: {
     70     bg: "#f1f5f9",
     71     bgPanel: "#ffffff",
     72     text: "#0f172a",
     73     textMuted: "#64748b",
     74     accent: "#e64a19",
     75     accentSecondary: "#3498db",
     76     border: "rgba(52, 152, 219, 0.35)",
     77     radius: "12px",
     78     shadow: "0 8px 24px rgba(15, 23, 42, 0.12)",
     79     fontBody: FONT_STACKS.sans.body,
     80     fontDisplay: FONT_STACKS.serif.display,
     81   },
     82   dark: {
     83     bg: "#121212",
     84     bgPanel: "#1e1e1e",
     85     text: "#f5f5f5",
     86     textMuted: "rgba(245, 245, 245, 0.68)",
     87     accent: "#fbc02d",
     88     accentSecondary: "#90caf9",
     89     border: "rgba(255, 255, 255, 0.14)",
     90     radius: "12px",
     91     shadow: "0 10px 32px rgba(0, 0, 0, 0.55)",
     92     fontBody: FONT_STACKS.sans.body,
     93     fontDisplay: FONT_STACKS.sans.display,
     94   },
     95   midnight: {
     96     bg: "#000f28",
     97     bgPanel: "#001b44",
     98     text: "#f2f7ff",
     99     textMuted: "rgba(242, 247, 255, 0.72)",
    100     accent: "#40c4ff",
    101     accentSecondary: "#3498db",
    102     border: "rgba(64, 196, 255, 0.42)",
    103     radius: "14px",
    104     shadow: "0 16px 48px rgba(0, 8, 24, 0.6)",
    105     fontBody: FONT_STACKS.sans.body,
    106     fontDisplay: FONT_STACKS.serif.display,
    107   },
    108   minimal: {
    109     bg: "#0b1220",
    110     bgPanel: "#111827",
    111     text: "#e5e7eb",
    112     textMuted: "#9ca3af",
    113     accent: "#fbbf24",
    114     accentSecondary: "#93c5fd",
    115     border: "rgba(156, 163, 175, 0.28)",
    116     radius: "8px",
    117     shadow: "none",
    118     fontBody: FONT_STACKS.system.body,
    119     fontDisplay: FONT_STACKS.system.display,
    120   },
    121 };
    122 
    123 export const MM_CSS_VAR_KEYS = [
    124   "--mm-bg",
    125   "--mm-bg-panel",
    126   "--mm-text",
    127   "--mm-text-muted",
    128   "--mm-accent",
    129   "--mm-accent-secondary",
    130   "--mm-border",
    131   "--mm-radius",
    132   "--mm-shadow",
    133   "--mm-font-body",
    134   "--mm-font-display",
    135 ] as const;
    136 
    137 const HEX3 = /^[0-9a-fA-F]{3}$/;
    138 const HEX6 = /^[0-9a-fA-F]{6}$/;
    139 
    140 /** Parse hex color from URL or postMessage (with/without #, optional URI encoding). */
    141 export function parseHexColor(raw: string | null | undefined): string | null {
    142   if (raw === null || raw === undefined) return null;
    143   let s = raw.trim();
    144   if (!s) return null;
    145   try {
    146     s = decodeURIComponent(s);
    147   } catch {
    148     return null;
    149   }
    150   if (s.startsWith("#")) s = s.slice(1);
    151   if (HEX3.test(s)) {
    152     s = s
    153       .split("")
    154       .map((c) => c + c)
    155       .join("");
    156   }
    157   if (!HEX6.test(s)) return null;
    158   return `#${s.toUpperCase()}`;
    159 }
    160 
    161 export function parseEmbedRadius(raw: string | null | undefined): number | null {
    162   if (raw === null || raw === undefined) return null;
    163   const s = raw.trim();
    164   if (!s) return null;
    165   const n = Number.parseInt(s, 10);
    166   if (!Number.isFinite(n)) return null;
    167   return Math.min(24, Math.max(0, n));
    168 }
    169 
    170 export function parseEmbedPreset(raw: string | null | undefined): EmbedPresetId | null {
    171   if (!raw) return null;
    172   const id = raw.trim().toLowerCase() as EmbedPresetId;
    173   return EMBED_PRESET_IDS.includes(id) ? id : null;
    174 }
    175 
    176 export function parseEmbedFont(raw: string | null | undefined): EmbedFontId | null {
    177   if (!raw) return null;
    178   const id = raw.trim().toLowerCase() as EmbedFontId;
    179   return EMBED_FONT_IDS.includes(id) ? id : null;
    180 }
    181 
    182 export function resolveEmbedTheme(overrides: EmbedThemeOverrides = {}): EmbedThemeTokens {
    183   const presetId = overrides.preset ?? "default";
    184   const base = PRESETS[presetId] ?? PRESETS.default;
    185   const fontId = overrides.font ?? null;
    186   const fontStack = fontId ? FONT_STACKS[fontId] : null;
    187 
    188   return {
    189     bg: parseHexColor(overrides.bg) ?? base.bg,
    190     bgPanel: parseHexColor(overrides.panel) ?? base.bgPanel,
    191     text: parseHexColor(overrides.text) ?? base.text,
    192     textMuted: parseHexColor(overrides.fgMuted) ?? base.textMuted,
    193     accent: parseHexColor(overrides.accent) ?? base.accent,
    194     accentSecondary: base.accentSecondary,
    195     border: base.border,
    196     radius:
    197       overrides.radius !== null && overrides.radius !== undefined
    198         ? `${overrides.radius}px`
    199         : base.radius,
    200     shadow: base.shadow,
    201     fontBody: fontStack?.body ?? base.fontBody,
    202     fontDisplay: fontStack?.display ?? base.fontDisplay,
    203   };
    204 }
    205 
    206 export function tokensToCssVars(tokens: EmbedThemeTokens): Record<string, string> {
    207   return {
    208     "--mm-bg": tokens.bg,
    209     "--mm-bg-panel": tokens.bgPanel,
    210     "--mm-text": tokens.text,
    211     "--mm-text-muted": tokens.textMuted,
    212     "--mm-accent": tokens.accent,
    213     "--mm-accent-secondary": tokens.accentSecondary,
    214     "--mm-border": tokens.border,
    215     "--mm-radius": tokens.radius,
    216     "--mm-shadow": tokens.shadow,
    217     "--mm-font-body": tokens.fontBody,
    218     "--mm-font-display": tokens.fontDisplay,
    219   };
    220 }
    221 
    222 export function applyEmbedTheme(overrides: EmbedThemeOverrides = {}): EmbedThemeTokens {
    223   const tokens = resolveEmbedTheme(overrides);
    224   const root = document.documentElement;
    225   for (const [key, value] of Object.entries(tokensToCssVars(tokens))) {
    226     root.style.setProperty(key, value);
    227   }
    228   return tokens;
    229 }
    230 
    231 export function clearEmbedTheme(): void {
    232   const root = document.documentElement;
    233   for (const key of MM_CSS_VAR_KEYS) {
    234     root.style.removeProperty(key);
    235   }
    236 }
    237 
    238 export function parseThemePayload(raw: unknown): EmbedThemeOverrides | null {
    239   if (!raw || typeof raw !== "object") return null;
    240   const o = raw as Record<string, unknown>;
    241   const overrides: EmbedThemeOverrides = {};
    242 
    243   if (typeof o.preset === "string") {
    244     const preset = parseEmbedPreset(o.preset);
    245     if (preset) overrides.preset = preset;
    246   }
    247   if (typeof o.font === "string") {
    248     const font = parseEmbedFont(o.font);
    249     if (font) overrides.font = font;
    250   }
    251 
    252   const radiusRaw = o.radius;
    253   if (typeof radiusRaw === "number" && Number.isFinite(radiusRaw)) {
    254     overrides.radius = Math.min(24, Math.max(0, Math.round(radiusRaw)));
    255   } else if (typeof radiusRaw === "string") {
    256     const radius = parseEmbedRadius(radiusRaw);
    257     if (radius !== null) overrides.radius = radius;
    258   }
    259 
    260   if (typeof o.accent === "string") {
    261     const accent = parseHexColor(o.accent);
    262     if (accent) overrides.accent = accent;
    263   }
    264   if (typeof o.bg === "string") {
    265     const bg = parseHexColor(o.bg);
    266     if (bg) overrides.bg = bg;
    267   }
    268   if (typeof o.panel === "string") {
    269     const panel = parseHexColor(o.panel);
    270     if (panel) overrides.panel = panel;
    271   }
    272   if (typeof o.text === "string") {
    273     const text = parseHexColor(o.text);
    274     if (text) overrides.text = text;
    275   }
    276   const fgRaw =
    277     typeof o.fgMuted === "string"
    278       ? o.fgMuted
    279       : typeof o.textMuted === "string"
    280         ? o.textMuted
    281         : null;
    282   if (fgRaw) {
    283     const fgMuted = parseHexColor(fgRaw);
    284     if (fgMuted) overrides.fgMuted = fgMuted;
    285   }
    286 
    287   return Object.keys(overrides).length > 0 ? overrides : null;
    288 }
    289 
    290 export function mergeThemeOverrides(
    291   base: EmbedThemeOverrides,
    292   patch: EmbedThemeOverrides,
    293 ): EmbedThemeOverrides {
    294   return { ...base, ...patch };
    295 }
    296 
    297 /** Strip # for URL query params (SoundCloud-style). */
    298 export function hexForQueryParam(hex: string | null | undefined): string | null {
    299   const parsed = parseHexColor(hex);
    300   return parsed ? parsed.slice(1).toLowerCase() : null;
    301 }