code.pablomurad.com

mymusics

Retro web player for random tracks from the Myspace Dragon Hoard

custom player allowed
author
Pablo Murad
date
commit
f778f47dfc3e68bc5b5688551e7e99f377733c88
parent
e0949777fb94e0d6b3f232182c4e0f51ec350fb4
changes
11 files +1055 −16
@@ -118,7 +118,17 @@ | `autoplay` | `1` | `0` disables auto-advance and skips random load on mount | | `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 @@ - `mymusics:ready` — `{ trackCount }` - `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
 
@@ -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)
@@ -570,15 +570,22 @@ min-height: 0;   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__play, .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 {
@@ -863,6 +925,61 @@ border-radius: 6px;   border: 1px solid rgba(255, 255, 255, 0.15);
   background: rgba(0, 0, 0, 0.25);
   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 {
@@ -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 @@ const [autoplay, setAutoplay] = useState(true);   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 @@ aria-label="Embed this player"     >
       <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 @@ Autoplay / auto-advance         </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 @@ />         </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>
   );
@@ -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 @@ playbackState: EmbedPlaybackState;   onNext: () => void;
   onPlay: () => void;
   onPause: () => void;
+  onTheme?: (patch: EmbedThemeOverrides) => void;
+  themeOverrides?: EmbedThemeOverrides;
 };
 
 export function useEmbedMessaging({
@@ -35,6 +43,8 @@ playbackState,   onNext,
   onPlay,
   onPause,
+  onTheme,
+  themeOverrides,
 }: Options) {
   useEffect(() => {
     if (!enabled) return;
@@ -55,6 +65,12 @@ useEffect(() => {     if (!enabled) return;
     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) => {
@@ -67,9 +83,21 @@   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 @@ else if (cmd === "next") onNext();     };
     window.addEventListener("message", onMessage);
     return () => window.removeEventListener("message", onMessage);
-  }, [enabled, onNext, onPlay, onPause]);
+  }, [enabled, onNext, onPlay, onPause, onTheme]);
 
   return { postError };
 }
@@ -96,4 +96,7 @@ } 
 html.embed-active body {
   overflow-x: hidden;
+  color: var(--mm-text, var(--text));
+  background-color: var(--mm-bg, var(--bg-deep));
+  background-image: none;
 }
@@ -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 @@ theme: EmbedTheme;   startId: string | null;
   showBrand: boolean;
   startMuted: boolean;
+  themeOverrides: EmbedThemeOverrides;
 };
 
 function parseBool(raw: string | null, defaultValue: boolean): boolean {
@@ -13,6 +25,28 @@ if (raw === null || raw === "") return defaultValue;   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 @@ theme: themeRaw === "compact" ? "compact" : "default",     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}` : "";
 }
@@ -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",
+    });
+  });
+});
@@ -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;
+}
@@ -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 @@ 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 @@ playbackState: embedPlaybackState,     onNext: requestNextTrack,
     onPlay: handlePlay,
     onPause: handlePause,
+    onTheme: handleThemePatch,
+    themeOverrides,
   });
 
   const shellClass =
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; 
 export default defineConfig({
   test: {
-    include: ["server/**/*.test.ts"],
+    include: ["server/**/*.test.ts", "src/**/*.test.ts"],
   },
 });