mymusics

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

commit f778f47dfc3e68bc5b5688551e7e99f377733c88
parent e0949777fb94e0d6b3f232182c4e0f51ec350fb4
Author: Pablo Murad <pablo@pablomurad.com>
Date:   Sun, 14 Jun 2026 12:37:40 -0300

custom player allowed

Diffstat:
MREADME.md | 17+++++++++++++++--
Adocs/EMBED-CUSTOMIZATION.md | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/App.css | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/EmbedSnippet.tsx | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/hooks/useEmbedMessaging.ts | 32++++++++++++++++++++++++++++++--
Msrc/index.css | 3+++
Msrc/lib/embedParams.ts | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/lib/embedTheme.test.ts | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/embedTheme.ts | 301+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/Embed.tsx | 31++++++++++++++++++++++++++++++-
Mvitest.config.ts | 2+-
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"], }, });