commit f778f47dfc3e68bc5b5688551e7e99f377733c88
parent e0949777fb94e0d6b3f232182c4e0f51ec350fb4
Author: Pablo Murad <pablo@pablomurad.com>
Date: Sun, 14 Jun 2026 12:37:40 -0300
custom player allowed
Diffstat:
11 files changed, 1055 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
@@ -118,7 +118,17 @@ Iframe URL: `https://mymusics.murad.gg/embed` with optional query params:
| `theme` | `default` | `compact` — smaller layout |
| `start` | — | Track id — loads `GET /api/track/:id` |
| `brand` | `1` | `0` hides footer logo |
-| `muted` | `0` | `1` starts muted |
+| `muted` | `0` | `1` starts **audio** muted |
+| `preset` | `default` | Color preset: `light`, `dark`, `midnight`, `minimal` |
+| `accent` | — | Accent hex (with/without `#`) |
+| `bg` | — | Page background hex |
+| `panel` | — | Panel/card background hex |
+| `text` | — | Primary text hex |
+| `fgMuted` | — | Secondary text hex (not audio mute) |
+| `radius` | preset | Border radius 0–24 (px) |
+| `font` | `sans` | `system`, `sans`, or `serif` |
+
+Full customization guide (presets, CSS tokens, postMessage theme): **[docs/EMBED-CUSTOMIZATION.md](docs/EMBED-CUSTOMIZATION.md)**.
**postMessage** (iframe → parent), payload `{ source: "mymusics", type, ... }`:
@@ -126,12 +136,15 @@ Iframe URL: `https://mymusics.murad.gg/embed` with optional query params:
- `mymusics:track` — `{ id, title, artist, streamUrl }`
- `mymusics:state` — `{ state: "playing" \| "paused" \| "buffering" \| "error" }`
- `mymusics:error` — `{ code, message }`
+- `mymusics:theme-applied` — `{ tokens }` after a theme change
Parent → iframe: `{ source: "mymusics-host", type: "mymusics:command", command: "play" \| "pause" \| "next" }`.
+Parent → iframe (theme): `{ source: "mymusics-host", type: "mymusics:theme", theme: { preset, accent, ... } }`.
+
**oEmbed:** `GET /api/oembed?url=https://mymusics.murad.gg/embed`
-The Home and About pages include a snippet generator with these options.
+The Home and About pages include a snippet generator with preset, accent, and live preview.
## API
diff --git a/docs/EMBED-CUSTOMIZATION.md b/docs/EMBED-CUSTOMIZATION.md
@@ -0,0 +1,222 @@
+# Customização visual do embed MyMusics
+
+Este guia descreve como sites que embutem o player MyMusics podem personalizar cores, tipografia e layout **sem injetar CSS no iframe**.
+
+## Por que não dá para usar CSS externo?
+
+O embed roda em um **iframe cross-origin**. Por causa da Same-Origin Policy, a página pai **não consegue** alterar estilos dentro do iframe. A abordagem correta — usada por SoundCloud, Spotify e widgets SaaS — é o MyMusics expor **parâmetros de URL** e **mensagens postMessage** que definem tokens CSS (`--mm-*`) dentro do documento do embed.
+
+### O que você pode customizar (v1)
+
+- Presets nomeados (`light`, `dark`, `midnight`, `minimal`, …)
+- Cores individuais (accent, fundo, painel, texto, texto secundário)
+- Raio de borda (`radius`)
+- Família tipográfica (`system`, `sans`, `serif`)
+- Layout compacto (`theme=compact`)
+- Tema em tempo real via `postMessage`
+
+### O que não está no escopo v1
+
+- Arquivo CSS externo (`?stylesheet=`)
+- Fontes arbitrárias por URL
+- `@import` ou CSS livre do embedder
+
+---
+
+## Início rápido
+
+### Preset claro
+
+```html
+<iframe
+ src="https://mymusics.murad.gg/embed?preset=light&brand=0"
+ title="MyMusics"
+ width="380"
+ height="420"
+ style="border:0"
+ allow="autoplay"
+></iframe>
+```
+
+### Marca do site (accent laranja, layout compacto)
+
+```html
+<iframe
+ src="https://mymusics.murad.gg/embed?preset=minimal&accent=e64a19&theme=compact"
+ title="MyMusics"
+ width="380"
+ height="320"
+ style="border:0"
+ allow="autoplay"
+></iframe>
+```
+
+### Midnight com fonte serifada
+
+```html
+<iframe
+ src="https://mymusics.murad.gg/embed?preset=midnight&font=serif"
+ title="MyMusics"
+ width="380"
+ height="540"
+ style="border:0"
+ allow="autoplay"
+></iframe>
+```
+
+---
+
+## Parâmetros de URL
+
+| Param | Exemplo | Efeito |
+|-------|---------|--------|
+| `preset` | `light` | Preset base de cores |
+| `accent` | `fbc02d` ou `%23fbc02d` | Cor de destaque (botão play, highlights) |
+| `bg` | `0f172a` | Fundo da página embed |
+| `panel` | `1e293b` | Fundo de cards / player |
+| `text` | `f8fafc` | Texto principal |
+| `fgMuted` | `94a3b8` | Texto secundário (artista, hints) |
+| `radius` | `12` | Border-radius em px (0–24) |
+| `font` | `serif` | `system`, `sans` ou `serif` |
+| `theme` | `compact` | Layout compacto |
+| `autoplay` | `0` | Desliga autoplay / auto-advance |
+| `start` | `12345` | ID da faixa inicial |
+| `brand` | `0` | Oculta logo MyMusics |
+| `muted` | `1` | Inicia mudo (**áudio**, não cor) |
+
+**Convenções de cor**
+
+- Hex com ou sem `#`; valores inválidos são ignorados (fallback ao preset).
+- Use `fgMuted` para cor secundária. O param `muted=1` é **somente boolean** de áudio.
+
+---
+
+## Presets
+
+| Preset | Descrição |
+|--------|-----------|
+| `default` | Visual MyMusics padrão (azul profundo, accent amarelo) |
+| `light` | Fundo claro, texto escuro, accent laranja |
+| `dark` | Neutro escuro (#121212), menos saturado que o default |
+| `midnight` | Azul profundo de marca, accent ciano |
+| `minimal` | Painéis flat, sombra reduzida, bordas suaves |
+
+Params individuais **sobrescreem** tokens do preset escolhido.
+
+---
+
+## Tokens CSS (`--mm-*`)
+
+Aplicados em `document.documentElement` quando a rota `/embed` está ativa. Úteis ao inspecionar o iframe no DevTools.
+
+| Token | Uso |
+|-------|-----|
+| `--mm-bg` | Fundo |
+| `--mm-bg-panel` | Cards / shell do player |
+| `--mm-text` | Títulos e labels |
+| `--mm-text-muted` | Artista, hints |
+| `--mm-accent` | Botão play, destaques |
+| `--mm-accent-secondary` | Links, progresso |
+| `--mm-border` | Bordas e divisores |
+| `--mm-radius` | Border-radius |
+| `--mm-shadow` | Sombra do card |
+| `--mm-font-body` | Corpo |
+| `--mm-font-display` | Título da faixa |
+
+---
+
+## postMessage
+
+### Iframe → parent (existentes)
+
+Payload: `{ source: "mymusics", type, ... }`
+
+- `mymusics:ready` — `{ trackCount }`
+- `mymusics:track` — `{ id, title, artist, streamUrl }`
+- `mymusics:state` — `{ state: "playing" | "paused" | "buffering" | "error" }`
+- `mymusics:error` — `{ code, message }`
+- `mymusics:theme-applied` — `{ tokens: { "--mm-bg": "#...", ... } }` (debug)
+
+### Parent → iframe — comandos (existentes)
+
+```javascript
+iframe.contentWindow.postMessage(
+ { source: "mymusics-host", type: "mymusics:command", command: "play" },
+ "*"
+);
+```
+
+Comandos: `play`, `pause`, `next`.
+
+### Parent → iframe — tema (novo)
+
+```javascript
+iframe.contentWindow.postMessage(
+ {
+ source: "mymusics-host",
+ type: "mymusics:theme",
+ theme: { preset: "light", accent: "#e64a19" },
+ },
+ "*"
+);
+```
+
+Campos aceitos em `theme`: `preset`, `accent`, `bg`, `panel`, `text`, `fgMuted` (ou `textMuted`), `radius`, `font`. Mesma validação que a URL.
+
+O iframe responde com `mymusics:theme-applied` contendo os tokens CSS efetivos.
+
+### Tema dinâmico conforme dark mode do site
+
+```javascript
+const iframe = document.querySelector("iframe.mymusics-embed");
+
+function syncEmbedTheme() {
+ const isDark = document.documentElement.classList.contains("dark");
+ iframe?.contentWindow?.postMessage(
+ {
+ source: "mymusics-host",
+ type: "mymusics:theme",
+ theme: { preset: isDark ? "dark" : "light" },
+ },
+ "*"
+ );
+}
+
+const observer = new MutationObserver(syncEmbedTheme);
+observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+syncEmbedTheme();
+```
+
+---
+
+## oEmbed
+
+A API oEmbed repassa a query string da URL embed:
+
+```
+GET /api/oembed?url=https://mymusics.murad.gg/embed?preset=light&accent=fbc02d
+```
+
+---
+
+## Gerador de snippet
+
+As páginas **Home** e **About** incluem um gerador com preset, accent, fonte, radius e preview ao vivo.
+
+---
+
+## Limitações e roadmap
+
+| Limitação v1 | Roadmap |
+|--------------|---------|
+| Sem CSS externo | Possível helper JS (`embed.js`) estilo SoundCloud Widget |
+| Contraste não validado automaticamente | Recomenda-se WCAG manualmente |
+| `accentSecondary` não exposto por URL | Pode ser adicionado em versão futura |
+
+---
+
+## Referências
+
+- [SoundCloud Widget API](https://developers.soundcloud.com/docs/api/html5-widget)
+- [Spotify Embed](https://developer.spotify.com/documentation/embeds)
+- [MDN: Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)
diff --git a/src/App.css b/src/App.css
@@ -570,15 +570,22 @@
margin: 0;
padding: 0.45rem;
box-sizing: border-box;
+ color: var(--mm-text, var(--text));
+ background: var(--mm-bg, transparent);
}
.embed-shell {
max-width: 380px;
margin: 0 auto;
+ font-family: var(--mm-font-body, inherit);
}
.embed-shell .card {
padding: 0.65rem 0.75rem 0.75rem;
+ background: var(--mm-bg-panel, var(--panel));
+ border-color: var(--mm-border, var(--border-dash));
+ border-radius: var(--mm-radius, 18px 10px 20px 14px);
+ box-shadow: var(--mm-shadow, var(--shadow-card));
}
.embed-shell .card-head {
@@ -587,35 +594,58 @@
.embed-shell .card h2 {
font-size: 1rem;
+ font-family: var(--mm-font-display, var(--font-display));
+ color: var(--mm-accent, var(--accent));
}
.embed-shell .track-block .artist {
font-size: 1.08rem;
margin: 0 0 0.2rem;
+ color: var(--mm-accent-secondary, color-mix(in srgb, var(--accent-sky) 52%, var(--accent-cyan) 48%));
}
.embed-shell .track-block .title {
font-size: 0.98rem;
margin: 0 0 0.35rem;
+ color: var(--mm-text, var(--text));
}
.embed-shell .up-next {
margin-top: 0.45rem;
padding: 0.5rem 0.65rem;
+ border-color: var(--mm-border, color-mix(in srgb, var(--accent-cyan) 34%, transparent));
+ background: color-mix(in srgb, var(--mm-bg-panel, var(--panel)) 92%, var(--mm-accent-secondary, var(--accent-cyan)) 8%);
+ border-radius: var(--mm-radius, 12px);
}
.embed-shell .up-next-label {
font-size: 0.78rem;
margin: 0 0 0.3rem;
+ font-family: var(--mm-font-display, var(--font-display));
+ color: var(--mm-accent, var(--accent));
}
.embed-shell .up-next-track {
font-size: 0.84rem;
+ color: var(--mm-text, var(--text));
+}
+
+.embed-shell .up-next-artist {
+ color: var(--mm-accent-secondary, color-mix(in srgb, var(--accent-sky) 58%, var(--accent-cyan) 42%));
+}
+
+.embed-shell .up-next-sep,
+.embed-shell .up-next-empty,
+.embed-shell .up-next-note {
+ color: var(--mm-text-muted, var(--muted));
}
.embed-shell .player-nook {
margin-top: 0.45rem;
padding: 0.65rem 0.6rem 0.65rem;
+ background: color-mix(in srgb, var(--mm-bg-panel, var(--panel-nook)) 88%, var(--mm-accent-secondary, var(--accent-cyan)) 12%);
+ border-color: var(--mm-border, color-mix(in srgb, var(--accent-cyan) 28%, transparent));
+ border-radius: var(--mm-radius, 14px);
}
.embed-shell .cozy-player {
@@ -627,6 +657,8 @@
.embed-shell .cozy-player__mute {
width: 2.15rem;
height: 2.15rem;
+ background: var(--mm-accent, var(--accent));
+ color: color-mix(in srgb, var(--mm-bg, #000) 85%, var(--mm-text, #fff) 15%);
}
.embed-shell .cozy-player__icon {
@@ -636,6 +668,19 @@
.embed-shell .cozy-player__time {
font-size: 0.72rem;
+ color: var(--mm-text-muted, var(--muted));
+}
+
+.embed-shell .cozy-player__scrub::-webkit-slider-runnable-track {
+ background: color-mix(in srgb, var(--mm-accent-secondary, var(--accent-cyan)) 35%, transparent);
+}
+
+.embed-shell .cozy-player__scrub::-webkit-slider-thumb {
+ background: var(--mm-accent, var(--accent));
+}
+
+.embed-shell .cozy-player__scrub::-moz-range-thumb {
+ background: var(--mm-accent, var(--accent));
}
.embed-shell .actions {
@@ -646,15 +691,32 @@
.embed-shell .btn.primary {
padding: 0.5rem 1.1rem;
font-size: 0.92rem;
+ background: var(--mm-accent, var(--accent));
+ color: color-mix(in srgb, var(--mm-bg, #000) 88%, var(--mm-text, #fff) 12%);
}
.embed-shell .check {
font-size: 0.76rem;
+ color: var(--mm-text-muted, var(--muted));
}
.embed-shell .hint {
margin: 0.45rem 0 0;
font-size: 0.78rem;
+ color: var(--mm-text-muted, var(--muted));
+}
+
+.embed-shell .muted {
+ color: var(--mm-text-muted, var(--muted));
+}
+
+.embed-shell .pill {
+ background: color-mix(in srgb, var(--mm-accent-secondary, var(--accent-cyan)) 22%, transparent);
+ border-color: var(--mm-border, color-mix(in srgb, var(--accent-cyan) 45%, transparent));
+}
+
+.embed-shell a {
+ color: var(--mm-accent-secondary, color-mix(in srgb, var(--logo-cyan) 92%, #fff 8%));
}
.embed-brand {
@@ -865,6 +927,61 @@
color: inherit;
}
+.embed-snippet-theme-row {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.65rem 1rem;
+ align-items: flex-end;
+}
+
+.embed-snippet-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.82rem;
+}
+
+.embed-snippet-field select,
+.embed-snippet-field input[type="color"],
+.embed-snippet-field input[type="number"] {
+ padding: 0.35rem 0.5rem;
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(0, 0, 0, 0.25);
+ color: inherit;
+ min-height: 2rem;
+}
+
+.embed-snippet-field input[type="color"] {
+ padding: 0.15rem;
+ width: 3rem;
+ cursor: pointer;
+}
+
+.embed-snippet-accent-row {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.embed-snippet-preview-wrap {
+ grid-column: 1 / -1;
+ margin: 0 0 1rem;
+ border: 1px dashed color-mix(in srgb, var(--accent-cyan) 35%, transparent);
+ border-radius: 10px;
+ overflow: hidden;
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.embed-snippet-preview {
+ display: block;
+ width: 100%;
+ max-width: 380px;
+ min-height: 280px;
+ border: 0;
+}
+
.embed-shell--compact .card {
padding: 0.65rem 0.85rem;
}
diff --git a/src/components/EmbedSnippet.tsx b/src/components/EmbedSnippet.tsx
@@ -1,18 +1,29 @@
import { useCallback, useMemo, useState } from "react";
import { PUBLIC_SITE_URL } from "../config/siteUrl";
import { buildEmbedSearchParams } from "../lib/embedParams";
+import { EMBED_FONT_IDS, EMBED_PRESET_IDS, type EmbedFontId, type EmbedPresetId } from "../lib/embedTheme";
function buildIframeSnippet(opts: {
autoplay: boolean;
compact: boolean;
startId: string;
showBrand: boolean;
+ preset: EmbedPresetId;
+ accent: string;
+ useAccent: boolean;
+ radius: string;
+ font: EmbedFontId;
}): string {
+ const radiusNum = opts.radius.trim() ? Number.parseInt(opts.radius, 10) : undefined;
const qs = buildEmbedSearchParams({
autoplay: opts.autoplay,
theme: opts.compact ? "compact" : "default",
startId: opts.startId.trim() || null,
showBrand: opts.showBrand,
+ preset: opts.preset,
+ accent: opts.useAccent ? opts.accent : undefined,
+ radius: Number.isFinite(radiusNum) ? radiusNum : undefined,
+ font: opts.font,
});
const src = `${PUBLIC_SITE_URL}/embed${qs}`;
return `<iframe
@@ -36,12 +47,44 @@ export function EmbedSnippet({ className }: Props = {}) {
const [compact, setCompact] = useState(false);
const [showBrand, setShowBrand] = useState(true);
const [startId, setStartId] = useState("");
+ const [preset, setPreset] = useState<EmbedPresetId>("default");
+ const [useAccent, setUseAccent] = useState(false);
+ const [accent, setAccent] = useState("#e64a19");
+ const [radius, setRadius] = useState("");
+ const [font, setFont] = useState<EmbedFontId>("sans");
- const code = useMemo(
- () => buildIframeSnippet({ autoplay, compact, startId, showBrand }),
- [autoplay, compact, startId, showBrand],
+ const embedOptions = useMemo(
+ () => ({
+ autoplay,
+ compact,
+ startId,
+ showBrand,
+ preset,
+ accent,
+ useAccent,
+ radius,
+ font,
+ }),
+ [autoplay, compact, startId, showBrand, preset, accent, useAccent, radius, font],
);
+ const code = useMemo(() => buildIframeSnippet(embedOptions), [embedOptions]);
+
+ const previewSrc = useMemo(() => {
+ const radiusNum = radius.trim() ? Number.parseInt(radius, 10) : undefined;
+ const qs = buildEmbedSearchParams({
+ autoplay: false,
+ theme: compact ? "compact" : "default",
+ startId: startId.trim() || null,
+ showBrand,
+ preset,
+ accent: useAccent ? accent : undefined,
+ radius: Number.isFinite(radiusNum) ? radiusNum : undefined,
+ font,
+ });
+ return `${PUBLIC_SITE_URL}/embed${qs}`;
+ }, [compact, startId, showBrand, preset, accent, useAccent, radius, font]);
+
const copy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
@@ -73,10 +116,9 @@ export function EmbedSnippet({ className }: Props = {}) {
>
<h2 className="embed-snippet-title">Embed on your site</h2>
<p className="embed-snippet-lead muted">
- Paste this HTML wherever you want the player. Optional query params:{" "}
- <code>autoplay=0</code>, <code>theme=compact</code>, <code>start=TRACK_ID</code>,{" "}
- <code>brand=0</code>, <code>muted=1</code>. Parent page can listen for{" "}
- <code>postMessage</code> events (<code>mymusics:track</code>, <code>mymusics:state</code>, etc.).
+ Paste the HTML below on your page. Customize colors with presets and query params, or update
+ the theme at runtime via <code>postMessage</code>. See{" "}
+ <code>docs/EMBED-CUSTOMIZATION.md</code> for the full guide.
</p>
<div className="embed-snippet-options">
@@ -86,12 +128,77 @@ export function EmbedSnippet({ className }: Props = {}) {
</label>
<label className="check">
<input type="checkbox" checked={compact} onChange={(e) => setCompact(e.target.checked)} />
- Compact theme
+ Compact layout
</label>
<label className="check">
<input type="checkbox" checked={showBrand} onChange={(e) => setShowBrand(e.target.checked)} />
Show MyMusics logo
</label>
+
+ <div className="embed-snippet-theme-row">
+ <label className="embed-snippet-field">
+ <span>Preset</span>
+ <select
+ value={preset}
+ onChange={(e) => setPreset(e.target.value as EmbedPresetId)}
+ aria-label="Color preset"
+ >
+ {EMBED_PRESET_IDS.map((id) => (
+ <option key={id} value={id}>
+ {id}
+ </option>
+ ))}
+ </select>
+ </label>
+
+ <label className="embed-snippet-field">
+ <span>Font</span>
+ <select
+ value={font}
+ onChange={(e) => setFont(e.target.value as EmbedFontId)}
+ aria-label="Font stack"
+ >
+ {EMBED_FONT_IDS.map((id) => (
+ <option key={id} value={id}>
+ {id}
+ </option>
+ ))}
+ </select>
+ </label>
+
+ <label className="embed-snippet-field">
+ <span>Radius (px)</span>
+ <input
+ type="number"
+ min={0}
+ max={24}
+ placeholder="default"
+ value={radius}
+ onChange={(e) => setRadius(e.target.value)}
+ aria-label="Border radius in pixels"
+ />
+ </label>
+
+ <label className="check embed-snippet-field">
+ <span>Custom accent</span>
+ <span className="embed-snippet-accent-row">
+ <input
+ type="checkbox"
+ checked={useAccent}
+ onChange={(e) => setUseAccent(e.target.checked)}
+ aria-label="Use custom accent color"
+ />
+ <input
+ type="color"
+ value={accent}
+ disabled={!useAccent}
+ onChange={(e) => setAccent(e.target.value)}
+ aria-label="Accent color"
+ />
+ </span>
+ </label>
+ </div>
+
<label className="embed-snippet-start">
<span>Start track id (optional)</span>
<input
@@ -104,12 +211,26 @@ export function EmbedSnippet({ className }: Props = {}) {
</label>
</div>
+ <div className="embed-snippet-preview-wrap">
+ <iframe
+ className="embed-snippet-preview"
+ src={previewSrc}
+ title="MyMusics embed preview"
+ loading="lazy"
+ allow="autoplay"
+ />
+ </div>
+
<textarea className="embed-snippet-code" readOnly rows={8} value={code} spellCheck={false} />
<button type="button" className="btn primary embed-snippet-copy" onClick={() => void copy()}>
{copied ? "Copied!" : "Copy code"}
</button>
<p className="embed-snippet-lead muted">
- oEmbed: <code>{PUBLIC_SITE_URL}/api/oembed?url={encodeURIComponent(`${PUBLIC_SITE_URL}/embed`)}</code>
+ oEmbed:{" "}
+ <code>
+ {PUBLIC_SITE_URL}/api/oembed?url=
+ {encodeURIComponent(`${PUBLIC_SITE_URL}/embed${buildEmbedSearchParams({ preset, accent: useAccent ? accent : undefined, font })}`)}
+ </code>
</p>
</section>
);
diff --git a/src/hooks/useEmbedMessaging.ts b/src/hooks/useEmbedMessaging.ts
@@ -1,6 +1,12 @@
import { useCallback, useEffect } from "react";
import type { TrackInfo } from "./useMyMusicsPlayback";
+import {
+ parseThemePayload,
+ resolveEmbedTheme,
+ tokensToCssVars,
+ type EmbedThemeOverrides,
+} from "../lib/embedTheme";
export type EmbedPlaybackState = "playing" | "paused" | "buffering" | "error";
@@ -24,6 +30,8 @@ type Options = {
onNext: () => void;
onPlay: () => void;
onPause: () => void;
+ onTheme?: (patch: EmbedThemeOverrides) => void;
+ themeOverrides?: EmbedThemeOverrides;
};
export function useEmbedMessaging({
@@ -35,6 +43,8 @@ export function useEmbedMessaging({
onNext,
onPlay,
onPause,
+ onTheme,
+ themeOverrides,
}: Options) {
useEffect(() => {
if (!enabled) return;
@@ -56,6 +66,12 @@ export function useEmbedMessaging({
post("mymusics:state", { state: playbackState });
}, [enabled, playbackState]);
+ useEffect(() => {
+ if (!enabled) return;
+ const tokens = resolveEmbedTheme(themeOverrides ?? {});
+ post("mymusics:theme-applied", { tokens: tokensToCssVars(tokens) });
+ }, [enabled, themeOverrides]);
+
const postError = useCallback(
(code: string, message: string) => {
if (!enabled) return;
@@ -67,9 +83,21 @@ export function useEmbedMessaging({
useEffect(() => {
if (!enabled) return;
const onMessage = (ev: MessageEvent) => {
- const data = ev.data as { source?: string; type?: string; command?: string };
+ const data = ev.data as {
+ source?: string;
+ type?: string;
+ command?: string;
+ theme?: unknown;
+ };
if (data?.source !== "mymusics-host") return;
if (PARENT_ORIGIN !== "*" && ev.origin !== PARENT_ORIGIN) return;
+
+ if (data.type === "mymusics:theme") {
+ const patch = parseThemePayload(data.theme);
+ if (patch && onTheme) onTheme(patch);
+ return;
+ }
+
if (data.type !== "mymusics:command") return;
const cmd = data.command;
if (cmd === "play") onPlay();
@@ -78,7 +106,7 @@ export function useEmbedMessaging({
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
- }, [enabled, onNext, onPlay, onPause]);
+ }, [enabled, onNext, onPlay, onPause, onTheme]);
return { postError };
}
diff --git a/src/index.css b/src/index.css
@@ -96,4 +96,7 @@ html.embed-active #root {
html.embed-active body {
overflow-x: hidden;
+ color: var(--mm-text, var(--text));
+ background-color: var(--mm-bg, var(--bg-deep));
+ background-image: none;
}
diff --git a/src/lib/embedParams.ts b/src/lib/embedParams.ts
@@ -1,3 +1,14 @@
+import {
+ type EmbedFontId,
+ type EmbedPresetId,
+ type EmbedThemeOverrides,
+ hexForQueryParam,
+ parseEmbedFont,
+ parseEmbedPreset,
+ parseEmbedRadius,
+ parseHexColor,
+} from "./embedTheme";
+
export type EmbedTheme = "default" | "compact";
export type EmbedParams = {
@@ -6,6 +17,7 @@ export type EmbedParams = {
startId: string | null;
showBrand: boolean;
startMuted: boolean;
+ themeOverrides: EmbedThemeOverrides;
};
function parseBool(raw: string | null, defaultValue: boolean): boolean {
@@ -13,6 +25,28 @@ function parseBool(raw: string | null, defaultValue: boolean): boolean {
return raw === "1" || raw === "true" || raw === "yes";
}
+function parseThemeOverrides(p: URLSearchParams): EmbedThemeOverrides {
+ const preset = parseEmbedPreset(p.get("preset"));
+ const font = parseEmbedFont(p.get("font"));
+ const radius = parseEmbedRadius(p.get("radius"));
+ const accent = parseHexColor(p.get("accent"));
+ const bg = parseHexColor(p.get("bg"));
+ const panel = parseHexColor(p.get("panel"));
+ const text = parseHexColor(p.get("text"));
+ const fgMuted = parseHexColor(p.get("fgMuted") ?? p.get("textMuted"));
+
+ const overrides: EmbedThemeOverrides = {};
+ if (preset) overrides.preset = preset;
+ if (font) overrides.font = font;
+ if (radius !== null) overrides.radius = radius;
+ if (accent) overrides.accent = accent;
+ if (bg) overrides.bg = bg;
+ if (panel) overrides.panel = panel;
+ if (text) overrides.text = text;
+ if (fgMuted) overrides.fgMuted = fgMuted;
+ return overrides;
+}
+
export function parseEmbedParams(search: string): EmbedParams {
const p = new URLSearchParams(search);
const themeRaw = p.get("theme")?.trim().toLowerCase();
@@ -22,16 +56,56 @@ export function parseEmbedParams(search: string): EmbedParams {
startId: p.get("start")?.trim() || null,
showBrand: parseBool(p.get("brand"), true),
startMuted: parseBool(p.get("muted"), false),
+ themeOverrides: parseThemeOverrides(p),
};
}
-export function buildEmbedSearchParams(opts: Partial<EmbedParams>): string {
+export function buildEmbedSearchParams(
+ opts: Partial<
+ EmbedParams & {
+ themeOverrides?: EmbedThemeOverrides;
+ preset?: EmbedPresetId;
+ accent?: string;
+ bg?: string;
+ panel?: string;
+ text?: string;
+ fgMuted?: string;
+ radius?: number;
+ font?: EmbedFontId;
+ }
+ >,
+): string {
const p = new URLSearchParams();
if (opts.autoplay === false) p.set("autoplay", "0");
if (opts.theme === "compact") p.set("theme", "compact");
if (opts.startId) p.set("start", opts.startId);
if (opts.showBrand === false) p.set("brand", "0");
if (opts.startMuted) p.set("muted", "1");
+
+ const theme = { ...opts.themeOverrides };
+ if (opts.preset) theme.preset = opts.preset;
+ if (opts.accent) theme.accent = opts.accent;
+ if (opts.bg) theme.bg = opts.bg;
+ if (opts.panel) theme.panel = opts.panel;
+ if (opts.text) theme.text = opts.text;
+ if (opts.fgMuted) theme.fgMuted = opts.fgMuted;
+ if (opts.radius !== undefined && opts.radius !== null) theme.radius = opts.radius;
+ if (opts.font) theme.font = opts.font;
+
+ if (theme.preset && theme.preset !== "default") p.set("preset", theme.preset);
+ const accentQ = hexForQueryParam(theme.accent ?? null);
+ if (accentQ) p.set("accent", accentQ);
+ const bgQ = hexForQueryParam(theme.bg ?? null);
+ if (bgQ) p.set("bg", bgQ);
+ const panelQ = hexForQueryParam(theme.panel ?? null);
+ if (panelQ) p.set("panel", panelQ);
+ const textQ = hexForQueryParam(theme.text ?? null);
+ if (textQ) p.set("text", textQ);
+ const fgMutedQ = hexForQueryParam(theme.fgMuted ?? null);
+ if (fgMutedQ) p.set("fgMuted", fgMutedQ);
+ if (theme.radius !== undefined && theme.radius !== null) p.set("radius", String(theme.radius));
+ if (theme.font && theme.font !== "sans") p.set("font", theme.font);
+
const s = p.toString();
return s ? `?${s}` : "";
}
diff --git a/src/lib/embedTheme.test.ts b/src/lib/embedTheme.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from "vitest";
+import { buildEmbedSearchParams, parseEmbedParams } from "./embedParams";
+import {
+ mergeThemeOverrides,
+ parseEmbedFont,
+ parseEmbedPreset,
+ parseEmbedRadius,
+ parseHexColor,
+ parseThemePayload,
+ resolveEmbedTheme,
+} from "./embedTheme";
+
+describe("parseHexColor", () => {
+ it("accepts 6-digit hex with or without hash", () => {
+ expect(parseHexColor("fbc02d")).toBe("#FBC02D");
+ expect(parseHexColor("#fbc02d")).toBe("#FBC02D");
+ expect(parseHexColor("%23fbc02d")).toBe("#FBC02D");
+ });
+
+ it("expands 3-digit shorthand", () => {
+ expect(parseHexColor("f00")).toBe("#FF0000");
+ expect(parseHexColor("#abc")).toBe("#AABBCC");
+ });
+
+ it("rejects invalid values", () => {
+ expect(parseHexColor("")).toBeNull();
+ expect(parseHexColor("gggggg")).toBeNull();
+ expect(parseHexColor("12345")).toBeNull();
+ expect(parseHexColor("not-a-color")).toBeNull();
+ });
+});
+
+describe("parseEmbedRadius", () => {
+ it("clamps radius between 0 and 24", () => {
+ expect(parseEmbedRadius("12")).toBe(12);
+ expect(parseEmbedRadius("0")).toBe(0);
+ expect(parseEmbedRadius("24")).toBe(24);
+ expect(parseEmbedRadius("99")).toBe(24);
+ expect(parseEmbedRadius("-5")).toBe(0);
+ });
+
+ it("returns null for empty or invalid", () => {
+ expect(parseEmbedRadius("")).toBeNull();
+ expect(parseEmbedRadius("abc")).toBeNull();
+ });
+});
+
+describe("resolveEmbedTheme", () => {
+ it("merges preset with overrides", () => {
+ const tokens = resolveEmbedTheme({ preset: "light", accent: "#112233" });
+ expect(tokens.bg).toBe("#f1f5f9");
+ expect(tokens.accent).toBe("#112233");
+ });
+
+ it("applies custom radius in px", () => {
+ const tokens = resolveEmbedTheme({ preset: "minimal", radius: 16 });
+ expect(tokens.radius).toBe("16px");
+ });
+});
+
+describe("parseThemePayload", () => {
+ it("parses postMessage theme object", () => {
+ expect(parseThemePayload({ preset: "dark", accent: "e64a19" })).toEqual({
+ preset: "dark",
+ accent: "#E64A19",
+ });
+ });
+
+ it("returns null when no valid keys", () => {
+ expect(parseThemePayload({})).toBeNull();
+ expect(parseThemePayload(null)).toBeNull();
+ expect(parseThemePayload({ preset: "invalid" })).toBeNull();
+ });
+
+ it("accepts fgMuted alias textMuted", () => {
+ expect(parseThemePayload({ fgMuted: "94a3b8" })?.fgMuted).toBe("#94A3B8");
+ expect(parseThemePayload({ textMuted: "64748b" })?.fgMuted).toBe("#64748B");
+ });
+});
+
+describe("mergeThemeOverrides", () => {
+ it("patch wins over base", () => {
+ expect(
+ mergeThemeOverrides({ preset: "light", accent: "#111111" }, { accent: "#222222" }),
+ ).toEqual({ preset: "light", accent: "#222222" });
+ });
+});
+
+describe("parseEmbedPreset and parseEmbedFont", () => {
+ it("accepts known preset ids", () => {
+ expect(parseEmbedPreset("midnight")).toBe("midnight");
+ expect(parseEmbedPreset("unknown")).toBeNull();
+ });
+
+ it("accepts known font ids", () => {
+ expect(parseEmbedFont("serif")).toBe("serif");
+ expect(parseEmbedFont("comic")).toBeNull();
+ });
+});
+
+describe("buildEmbedSearchParams round-trip", () => {
+ it("preserves theme params in parseEmbedParams", () => {
+ const qs = buildEmbedSearchParams({
+ preset: "minimal",
+ accent: "#e64a19",
+ bg: "0f172a",
+ panel: "1e293b",
+ text: "f8fafc",
+ fgMuted: "94a3b8",
+ radius: 10,
+ font: "serif",
+ theme: "compact",
+ showBrand: false,
+ startMuted: true,
+ });
+ const parsed = parseEmbedParams(qs);
+ expect(parsed.theme).toBe("compact");
+ expect(parsed.showBrand).toBe(false);
+ expect(parsed.startMuted).toBe(true);
+ expect(parsed.themeOverrides).toMatchObject({
+ preset: "minimal",
+ accent: "#E64A19",
+ bg: "#0F172A",
+ panel: "#1E293B",
+ text: "#F8FAFC",
+ fgMuted: "#94A3B8",
+ radius: 10,
+ font: "serif",
+ });
+ });
+});
diff --git a/src/lib/embedTheme.ts b/src/lib/embedTheme.ts
@@ -0,0 +1,301 @@
+export type EmbedPresetId = "default" | "light" | "dark" | "midnight" | "minimal";
+
+export type EmbedFontId = "system" | "sans" | "serif";
+
+export type EmbedThemeOverrides = {
+ preset?: EmbedPresetId;
+ accent?: string | null;
+ bg?: string | null;
+ panel?: string | null;
+ text?: string | null;
+ fgMuted?: string | null;
+ radius?: number | null;
+ font?: EmbedFontId | null;
+};
+
+export type EmbedThemeTokens = {
+ bg: string;
+ bgPanel: string;
+ text: string;
+ textMuted: string;
+ accent: string;
+ accentSecondary: string;
+ border: string;
+ radius: string;
+ shadow: string;
+ fontBody: string;
+ fontDisplay: string;
+};
+
+export const EMBED_PRESET_IDS: EmbedPresetId[] = [
+ "default",
+ "light",
+ "dark",
+ "midnight",
+ "minimal",
+];
+
+export const EMBED_FONT_IDS: EmbedFontId[] = ["system", "sans", "serif"];
+
+const FONT_STACKS: Record<EmbedFontId, { body: string; display: string }> = {
+ system: {
+ body: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
+ display: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
+ },
+ sans: {
+ body: '"Source Sans 3", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
+ display: '"Source Sans 3", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
+ },
+ serif: {
+ body: '"Source Sans 3", system-ui, sans-serif',
+ display: '"Fraunces", Georgia, "Times New Roman", serif',
+ },
+};
+
+const PRESETS: Record<EmbedPresetId, EmbedThemeTokens> = {
+ default: {
+ bg: "#000f28",
+ bgPanel: "#0a1f3d",
+ text: "#f2f7ff",
+ textMuted: "rgba(242, 247, 255, 0.74)",
+ accent: "#fbc02d",
+ accentSecondary: "#40c4ff",
+ border: "rgba(64, 196, 255, 0.38)",
+ radius: "14px",
+ shadow: "0 14px 42px rgba(0, 10, 30, 0.45)",
+ fontBody: FONT_STACKS.sans.body,
+ fontDisplay: FONT_STACKS.serif.display,
+ },
+ light: {
+ bg: "#f1f5f9",
+ bgPanel: "#ffffff",
+ text: "#0f172a",
+ textMuted: "#64748b",
+ accent: "#e64a19",
+ accentSecondary: "#3498db",
+ border: "rgba(52, 152, 219, 0.35)",
+ radius: "12px",
+ shadow: "0 8px 24px rgba(15, 23, 42, 0.12)",
+ fontBody: FONT_STACKS.sans.body,
+ fontDisplay: FONT_STACKS.serif.display,
+ },
+ dark: {
+ bg: "#121212",
+ bgPanel: "#1e1e1e",
+ text: "#f5f5f5",
+ textMuted: "rgba(245, 245, 245, 0.68)",
+ accent: "#fbc02d",
+ accentSecondary: "#90caf9",
+ border: "rgba(255, 255, 255, 0.14)",
+ radius: "12px",
+ shadow: "0 10px 32px rgba(0, 0, 0, 0.55)",
+ fontBody: FONT_STACKS.sans.body,
+ fontDisplay: FONT_STACKS.sans.display,
+ },
+ midnight: {
+ bg: "#000f28",
+ bgPanel: "#001b44",
+ text: "#f2f7ff",
+ textMuted: "rgba(242, 247, 255, 0.72)",
+ accent: "#40c4ff",
+ accentSecondary: "#3498db",
+ border: "rgba(64, 196, 255, 0.42)",
+ radius: "14px",
+ shadow: "0 16px 48px rgba(0, 8, 24, 0.6)",
+ fontBody: FONT_STACKS.sans.body,
+ fontDisplay: FONT_STACKS.serif.display,
+ },
+ minimal: {
+ bg: "#0b1220",
+ bgPanel: "#111827",
+ text: "#e5e7eb",
+ textMuted: "#9ca3af",
+ accent: "#fbbf24",
+ accentSecondary: "#93c5fd",
+ border: "rgba(156, 163, 175, 0.28)",
+ radius: "8px",
+ shadow: "none",
+ fontBody: FONT_STACKS.system.body,
+ fontDisplay: FONT_STACKS.system.display,
+ },
+};
+
+export const MM_CSS_VAR_KEYS = [
+ "--mm-bg",
+ "--mm-bg-panel",
+ "--mm-text",
+ "--mm-text-muted",
+ "--mm-accent",
+ "--mm-accent-secondary",
+ "--mm-border",
+ "--mm-radius",
+ "--mm-shadow",
+ "--mm-font-body",
+ "--mm-font-display",
+] as const;
+
+const HEX3 = /^[0-9a-fA-F]{3}$/;
+const HEX6 = /^[0-9a-fA-F]{6}$/;
+
+/** Parse hex color from URL or postMessage (with/without #, optional URI encoding). */
+export function parseHexColor(raw: string | null | undefined): string | null {
+ if (raw === null || raw === undefined) return null;
+ let s = raw.trim();
+ if (!s) return null;
+ try {
+ s = decodeURIComponent(s);
+ } catch {
+ return null;
+ }
+ if (s.startsWith("#")) s = s.slice(1);
+ if (HEX3.test(s)) {
+ s = s
+ .split("")
+ .map((c) => c + c)
+ .join("");
+ }
+ if (!HEX6.test(s)) return null;
+ return `#${s.toUpperCase()}`;
+}
+
+export function parseEmbedRadius(raw: string | null | undefined): number | null {
+ if (raw === null || raw === undefined) return null;
+ const s = raw.trim();
+ if (!s) return null;
+ const n = Number.parseInt(s, 10);
+ if (!Number.isFinite(n)) return null;
+ return Math.min(24, Math.max(0, n));
+}
+
+export function parseEmbedPreset(raw: string | null | undefined): EmbedPresetId | null {
+ if (!raw) return null;
+ const id = raw.trim().toLowerCase() as EmbedPresetId;
+ return EMBED_PRESET_IDS.includes(id) ? id : null;
+}
+
+export function parseEmbedFont(raw: string | null | undefined): EmbedFontId | null {
+ if (!raw) return null;
+ const id = raw.trim().toLowerCase() as EmbedFontId;
+ return EMBED_FONT_IDS.includes(id) ? id : null;
+}
+
+export function resolveEmbedTheme(overrides: EmbedThemeOverrides = {}): EmbedThemeTokens {
+ const presetId = overrides.preset ?? "default";
+ const base = PRESETS[presetId] ?? PRESETS.default;
+ const fontId = overrides.font ?? null;
+ const fontStack = fontId ? FONT_STACKS[fontId] : null;
+
+ return {
+ bg: parseHexColor(overrides.bg) ?? base.bg,
+ bgPanel: parseHexColor(overrides.panel) ?? base.bgPanel,
+ text: parseHexColor(overrides.text) ?? base.text,
+ textMuted: parseHexColor(overrides.fgMuted) ?? base.textMuted,
+ accent: parseHexColor(overrides.accent) ?? base.accent,
+ accentSecondary: base.accentSecondary,
+ border: base.border,
+ radius:
+ overrides.radius !== null && overrides.radius !== undefined
+ ? `${overrides.radius}px`
+ : base.radius,
+ shadow: base.shadow,
+ fontBody: fontStack?.body ?? base.fontBody,
+ fontDisplay: fontStack?.display ?? base.fontDisplay,
+ };
+}
+
+export function tokensToCssVars(tokens: EmbedThemeTokens): Record<string, string> {
+ return {
+ "--mm-bg": tokens.bg,
+ "--mm-bg-panel": tokens.bgPanel,
+ "--mm-text": tokens.text,
+ "--mm-text-muted": tokens.textMuted,
+ "--mm-accent": tokens.accent,
+ "--mm-accent-secondary": tokens.accentSecondary,
+ "--mm-border": tokens.border,
+ "--mm-radius": tokens.radius,
+ "--mm-shadow": tokens.shadow,
+ "--mm-font-body": tokens.fontBody,
+ "--mm-font-display": tokens.fontDisplay,
+ };
+}
+
+export function applyEmbedTheme(overrides: EmbedThemeOverrides = {}): EmbedThemeTokens {
+ const tokens = resolveEmbedTheme(overrides);
+ const root = document.documentElement;
+ for (const [key, value] of Object.entries(tokensToCssVars(tokens))) {
+ root.style.setProperty(key, value);
+ }
+ return tokens;
+}
+
+export function clearEmbedTheme(): void {
+ const root = document.documentElement;
+ for (const key of MM_CSS_VAR_KEYS) {
+ root.style.removeProperty(key);
+ }
+}
+
+export function parseThemePayload(raw: unknown): EmbedThemeOverrides | null {
+ if (!raw || typeof raw !== "object") return null;
+ const o = raw as Record<string, unknown>;
+ const overrides: EmbedThemeOverrides = {};
+
+ if (typeof o.preset === "string") {
+ const preset = parseEmbedPreset(o.preset);
+ if (preset) overrides.preset = preset;
+ }
+ if (typeof o.font === "string") {
+ const font = parseEmbedFont(o.font);
+ if (font) overrides.font = font;
+ }
+
+ const radiusRaw = o.radius;
+ if (typeof radiusRaw === "number" && Number.isFinite(radiusRaw)) {
+ overrides.radius = Math.min(24, Math.max(0, Math.round(radiusRaw)));
+ } else if (typeof radiusRaw === "string") {
+ const radius = parseEmbedRadius(radiusRaw);
+ if (radius !== null) overrides.radius = radius;
+ }
+
+ if (typeof o.accent === "string") {
+ const accent = parseHexColor(o.accent);
+ if (accent) overrides.accent = accent;
+ }
+ if (typeof o.bg === "string") {
+ const bg = parseHexColor(o.bg);
+ if (bg) overrides.bg = bg;
+ }
+ if (typeof o.panel === "string") {
+ const panel = parseHexColor(o.panel);
+ if (panel) overrides.panel = panel;
+ }
+ if (typeof o.text === "string") {
+ const text = parseHexColor(o.text);
+ if (text) overrides.text = text;
+ }
+ const fgRaw =
+ typeof o.fgMuted === "string"
+ ? o.fgMuted
+ : typeof o.textMuted === "string"
+ ? o.textMuted
+ : null;
+ if (fgRaw) {
+ const fgMuted = parseHexColor(fgRaw);
+ if (fgMuted) overrides.fgMuted = fgMuted;
+ }
+
+ return Object.keys(overrides).length > 0 ? overrides : null;
+}
+
+export function mergeThemeOverrides(
+ base: EmbedThemeOverrides,
+ patch: EmbedThemeOverrides,
+): EmbedThemeOverrides {
+ return { ...base, ...patch };
+}
+
+/** Strip # for URL query params (SoundCloud-style). */
+export function hexForQueryParam(hex: string | null | undefined): string | null {
+ const parsed = parseHexColor(hex);
+ return parsed ? parsed.slice(1).toLowerCase() : null;
+}
diff --git a/src/pages/Embed.tsx b/src/pages/Embed.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { CozyAudioBar } from "../components/CozyAudioBar";
@@ -6,6 +6,12 @@ import { PlayerAttribution } from "../components/PlayerAttribution";
import { PlayerStatus } from "../components/PlayerStatus";
import { PUBLIC_SITE_URL } from "../config/siteUrl";
import { parseEmbedParams } from "../lib/embedParams";
+import {
+ applyEmbedTheme,
+ clearEmbedTheme,
+ mergeThemeOverrides,
+ type EmbedThemeOverrides,
+} from "../lib/embedTheme";
import { useEmbedMessaging } from "../hooks/useEmbedMessaging";
import { useMyMusicsPlayback } from "../hooks/useMyMusicsPlayback";
import "../App.css";
@@ -15,14 +21,35 @@ const EMBED_ROOT_CLASS = "embed-active";
export default function Embed() {
const location = useLocation();
const params = useMemo(() => parseEmbedParams(location.search), [location.search]);
+ const [runtimePatches, setRuntimePatches] = useState<Record<string, EmbedThemeOverrides>>({});
+
+ const themeOverrides = useMemo(
+ () => mergeThemeOverrides(params.themeOverrides, runtimePatches[location.search] ?? {}),
+ [params.themeOverrides, runtimePatches, location.search],
+ );
useEffect(() => {
document.documentElement.classList.add(EMBED_ROOT_CLASS);
return () => {
document.documentElement.classList.remove(EMBED_ROOT_CLASS);
+ clearEmbedTheme();
};
}, []);
+ useEffect(() => {
+ applyEmbedTheme(themeOverrides);
+ }, [themeOverrides]);
+
+ const handleThemePatch = useCallback(
+ (patch: EmbedThemeOverrides) => {
+ setRuntimePatches((prev) => ({
+ ...prev,
+ [location.search]: mergeThemeOverrides(prev[location.search] ?? {}, patch),
+ }));
+ },
+ [location.search],
+ );
+
const {
audioRef,
preloadAudioRef,
@@ -61,6 +88,8 @@ export default function Embed() {
onNext: requestNextTrack,
onPlay: handlePlay,
onPause: handlePause,
+ onTheme: handleThemePatch,
+ themeOverrides,
});
const shellClass =
diff --git a/vitest.config.ts b/vitest.config.ts
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
- include: ["server/**/*.test.ts"],
+ include: ["server/**/*.test.ts", "src/**/*.test.ts"],
},
});