commit 71ad757a42523f92f12dbf1793c009d6f66c717a
parent 9f126945b789102505d314188090670b913b2d7c
Author: Pablo Murad <pblmrd@gmail.com>
Date: Fri, 1 May 2026 14:18:16 -0300
emb
Diffstat:
11 files changed, 472 insertions(+), 185 deletions(-)
diff --git a/.env.example b/.env.example
@@ -19,3 +19,6 @@ METADATA_TSV=data/metadata.tsv
# Serve Vite build from dist/ on the same port as the API (production / PM2).
# SERVE_STATIC=true
+
+# Optional: public site URL for embed snippet text (default https://mymusics.murad.gg)
+# VITE_PUBLIC_SITE_URL=https://mymusics.murad.gg
diff --git a/README.md b/README.md
@@ -77,6 +77,8 @@ server {
No extra `vite.config` base URL is needed when the site is served at the domain root.
+**Embed (`/embed`) on third-party sites:** The Node server sends `Content-Security-Policy: frame-ancestors *` on HTML responses so the player can be iframed. If the iframe still appears blank elsewhere, check that nginx (or another proxy) is **not** adding `X-Frame-Options: DENY` / `SAMEORIGIN` or a stricter `frame-ancestors` — those headers override or combine with the app’s policy.
+
## Troubleshooting
### HTTP 503 on `/api/track/random` (“No tracks available”)
diff --git a/server/index.ts b/server/index.ts
@@ -151,6 +151,18 @@ async function main() {
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
+ /** Allow embedding the SPA (e.g. /embed iframe on third-party sites). Strip anti-framing headers on HTML. */
+ app.addHook("onSend", async (_request, reply, payload) => {
+ const ct = reply.getHeader("content-type");
+ const ctStr = Array.isArray(ct) ? ct[0] : ct;
+ if (typeof ctStr === "string" && ctStr.includes("text/html")) {
+ reply.header("Content-Security-Policy", "frame-ancestors *");
+ reply.removeHeader("x-frame-options");
+ reply.removeHeader("X-Frame-Options");
+ }
+ return payload;
+ });
+
app.get("/api/health", async () => {
let metadataSizeBytes: number | null = null;
try {
diff --git a/src/App.css b/src/App.css
@@ -507,3 +507,67 @@
.about-prose p:last-child {
margin-bottom: 0;
}
+
+.embed-page {
+ min-height: 100vh;
+ margin: 0;
+ padding: 0.75rem;
+ box-sizing: border-box;
+}
+
+.embed-shell {
+ max-width: 420px;
+ margin: 0 auto;
+}
+
+.health-banner--embed {
+ margin-bottom: 0.85rem;
+ padding: 0.75rem 0.85rem;
+}
+
+.health-banner--embed p:last-child {
+ margin-bottom: 0;
+}
+
+.embed-snippet {
+ max-width: min(52rem, 100%);
+ margin: 2rem auto 0;
+ padding: 1.25rem 1.25rem 1.5rem;
+}
+
+.page-about .embed-snippet {
+ margin-top: 1.75rem;
+}
+
+.embed-snippet-title {
+ margin: 0 0 0.5rem;
+ font-family: var(--font-display);
+ font-size: 1.15rem;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.embed-snippet-lead {
+ margin: 0 0 0.85rem;
+ font-size: 0.92rem;
+}
+
+.embed-snippet-code {
+ display: block;
+ width: 100%;
+ margin: 0 0 1rem;
+ padding: 0.85rem 1rem;
+ font-family: ui-monospace, "Cascadia Code", "Segoe UI Mono", Menlo, monospace;
+ font-size: 0.78rem;
+ line-height: 1.45;
+ color: var(--text);
+ background: color-mix(in srgb, var(--logo-navy-deep) 65%, var(--panel) 35%);
+ border: 1px dashed color-mix(in srgb, var(--accent-cyan) 35%, transparent);
+ border-radius: 10px;
+ resize: vertical;
+ min-height: 8rem;
+}
+
+.embed-snippet-copy {
+ margin-top: 0;
+}
diff --git a/src/App.tsx b/src/App.tsx
@@ -1,5 +1,6 @@
import { Route, Routes } from "react-router-dom";
import About from "./pages/About";
+import Embed from "./pages/Embed";
import Home from "./pages/Home";
export default function App() {
@@ -7,6 +8,7 @@ export default function App() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
+ <Route path="/embed" element={<Embed />} />
</Routes>
);
}
diff --git a/src/components/EmbedSnippet.tsx b/src/components/EmbedSnippet.tsx
@@ -0,0 +1,54 @@
+import { useCallback, useState } from "react";
+import { PUBLIC_SITE_URL } from "../config/siteUrl";
+
+function buildIframeSnippet(): string {
+ const src = `${PUBLIC_SITE_URL}/embed`;
+ return `<iframe
+ src="${src}"
+ title="MyMusics"
+ width="100%"
+ height="420"
+ style="max-width:420px;border:0;border-radius:12px"
+ loading="lazy"
+></iframe>`;
+}
+
+export function EmbedSnippet() {
+ const [copied, setCopied] = useState(false);
+ const code = buildIframeSnippet();
+
+ const copy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 2000);
+ } catch {
+ try {
+ const ta = document.createElement("textarea");
+ ta.value = code;
+ ta.setAttribute("readonly", "");
+ ta.style.position = "absolute";
+ ta.style.left = "-9999px";
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ document.body.removeChild(ta);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setCopied(false);
+ }
+ }
+ }, [code]);
+
+ return (
+ <section className="embed-snippet card" 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 to appear.</p>
+ <textarea className="embed-snippet-code" readOnly rows={7} value={code} spellCheck={false} />
+ <button type="button" className="btn primary embed-snippet-copy" onClick={() => void copy()}>
+ {copied ? "Copied!" : "Copy code"}
+ </button>
+ </section>
+ );
+}
diff --git a/src/config/siteUrl.ts b/src/config/siteUrl.ts
@@ -0,0 +1,4 @@
+/** Canonical public URL (prod). Override with VITE_PUBLIC_SITE_URL for staging. */
+const raw = import.meta.env.VITE_PUBLIC_SITE_URL;
+export const PUBLIC_SITE_URL =
+ typeof raw === "string" && raw.trim() ? raw.replace(/\/$/, "") : "https://mymusics.murad.gg";
diff --git a/src/hooks/useMyMusicsPlayback.ts b/src/hooks/useMyMusicsPlayback.ts
@@ -0,0 +1,209 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export type TrackInfo = {
+ id: string;
+ title: string;
+ artist: string;
+};
+
+type RandomResponse = {
+ track: TrackInfo;
+ streamUrl: string;
+};
+
+export type QueuedTrack = TrackInfo & { streamUrl: string };
+
+type ErrBody = { error?: string };
+
+type HealthBody = {
+ tracksReady?: boolean;
+ hint?: string;
+ metadataTsv?: string;
+ metadataExists?: boolean;
+ trackCount?: number;
+};
+
+const MAX_ARCHIVE_STREAM_ERRORS = 3;
+
+export function useMyMusicsPlayback() {
+ const audioRef = useRef<HTMLAudioElement>(null);
+ const archiveStreamErrorsRef = useRef(0);
+ const upNextRef = useRef<QueuedTrack | null>(null);
+ const [track, setTrack] = useState<TrackInfo | null>(null);
+ const [upNext, setUpNext] = useState<QueuedTrack | null>(null);
+ const [status, setStatus] = useState<string>("");
+ const [history, setHistory] = useState<TrackInfo[]>([]);
+ const [autoPlay, setAutoPlay] = useState(true);
+ const [healthWarn, setHealthWarn] = useState<string | null>(null);
+ const [poolTrackCount, setPoolTrackCount] = useState<number | null>(null);
+ const [queueBusy, setQueueBusy] = useState(false);
+
+ useEffect(() => {
+ upNextRef.current = upNext;
+ }, [upNext]);
+
+ const playUrl = useCallback((url: string) => {
+ const a = audioRef.current;
+ if (!a) return;
+ a.src = url;
+ a.load();
+ void a.play().catch(() => {});
+ }, []);
+
+ const refillUpNext = useCallback(async (excludeId: string) => {
+ setQueueBusy(true);
+ try {
+ const res = await fetch(
+ `/api/track/up-next?exclude=${encodeURIComponent(excludeId)}`,
+ );
+ const body = (await res.json()) as RandomResponse | ErrBody;
+ if (!res.ok) {
+ setUpNext(null);
+ return;
+ }
+ const data = body as RandomResponse;
+ setUpNext({
+ id: data.track.id,
+ title: data.track.title,
+ artist: data.track.artist,
+ streamUrl: data.streamUrl,
+ });
+ } catch {
+ setUpNext(null);
+ } finally {
+ setQueueBusy(false);
+ }
+ }, []);
+
+ const advance = useCallback(async () => {
+ setStatus("Loading…");
+ const queued = upNextRef.current;
+ try {
+ if (queued) {
+ const info: TrackInfo = {
+ id: queued.id,
+ title: queued.title,
+ artist: queued.artist,
+ };
+ setTrack(info);
+ setHistory((h) => [info, ...h].slice(0, 15));
+ playUrl(queued.streamUrl);
+ setUpNext(null);
+ setStatus("");
+ await refillUpNext(queued.id);
+ return;
+ }
+
+ const res = await fetch("/api/track/random");
+ const body = (await res.json()) as RandomResponse | ErrBody;
+ if (!res.ok) {
+ setTrack(null);
+ setUpNext(null);
+ setStatus(
+ "error" in body && body.error
+ ? body.error
+ : "Service unavailable. Try again later.",
+ );
+ return;
+ }
+ const data = body as RandomResponse;
+ const info: TrackInfo = {
+ id: data.track.id,
+ title: data.track.title,
+ artist: data.track.artist,
+ };
+ setTrack(info);
+ setHistory((h) => [info, ...h].slice(0, 15));
+ playUrl(data.streamUrl);
+ setStatus("");
+ await refillUpNext(info.id);
+ } catch {
+ setStatus("Network error while requesting a track.");
+ setUpNext(null);
+ }
+ }, [playUrl, refillUpNext]);
+
+ const handleAudioPlaying = useCallback(() => {
+ archiveStreamErrorsRef.current = 0;
+ setStatus("");
+ }, []);
+
+ const handleAudioError = useCallback(() => {
+ archiveStreamErrorsRef.current += 1;
+ const n = archiveStreamErrorsRef.current;
+ if (n >= MAX_ARCHIVE_STREAM_ERRORS) {
+ setStatus(
+ "Internet Archive could not stream several tracks in a row (e.g. 503). Try Next or wait.",
+ );
+ return;
+ }
+ setStatus("This track is not available from the Archive right now; trying another…");
+ if (autoPlay) void advance();
+ }, [autoPlay, advance]);
+
+ const requestNextTrack = useCallback(() => {
+ archiveStreamErrorsRef.current = 0;
+ void advance();
+ }, [advance]);
+
+ useEffect(() => {
+ void (async () => {
+ try {
+ const res = await fetch("/api/health");
+ const h = (await res.json()) as HealthBody;
+ if (typeof h.trackCount === "number") setPoolTrackCount(h.trackCount);
+ if (!h.tracksReady) {
+ const parts = [
+ h.hint,
+ h.metadataTsv && `Path: ${h.metadataTsv}`,
+ h.metadataExists === false && "File not found at configured path.",
+ typeof h.trackCount === "number" && `Tracks loaded: ${h.trackCount}.`,
+ ].filter(Boolean);
+ setHealthWarn(
+ parts.length > 0
+ ? parts.join(" ")
+ : "No tracks loaded. Check server metadata and /api/health.",
+ );
+ } else {
+ setHealthWarn(null);
+ }
+ } catch {
+ setHealthWarn(null);
+ }
+ })();
+ }, []);
+
+ useEffect(
+ () => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- mount bootstrap via advance()
+ void advance();
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only bootstrap
+ [],
+ );
+
+ const onEnded = useCallback(() => {
+ if (autoPlay) void advance();
+ }, [autoPlay, advance]);
+
+ const showUpNextHint =
+ poolTrackCount === 1 && track && upNext && upNext.id === track.id;
+
+ return {
+ audioRef,
+ track,
+ upNext,
+ status,
+ history,
+ autoPlay,
+ setAutoPlay,
+ healthWarn,
+ poolTrackCount,
+ queueBusy,
+ requestNextTrack,
+ handleAudioPlaying,
+ handleAudioError,
+ onEnded,
+ showUpNextHint,
+ };
+}
diff --git a/src/pages/About.tsx b/src/pages/About.tsx
@@ -1,3 +1,4 @@
+import { EmbedSnippet } from "../components/EmbedSnippet";
import { SiteHeader } from "../components/SiteHeader";
import "../App.css";
@@ -41,6 +42,8 @@ export default function About() {
</div>
</main>
+ <EmbedSnippet />
+
<footer className="footer">
<small className="muted">Developed by Pablo Murad — 2026</small>
</footer>
diff --git a/src/pages/Embed.tsx b/src/pages/Embed.tsx
@@ -0,0 +1,99 @@
+import { CozyAudioBar } from "../components/CozyAudioBar";
+import { useMyMusicsPlayback } from "../hooks/useMyMusicsPlayback";
+import "../App.css";
+
+export default function Embed() {
+ const {
+ audioRef,
+ track,
+ upNext,
+ status,
+ autoPlay,
+ setAutoPlay,
+ healthWarn,
+ queueBusy,
+ requestNextTrack,
+ handleAudioPlaying,
+ handleAudioError,
+ onEnded,
+ showUpNextHint,
+ } = useMyMusicsPlayback();
+
+ return (
+ <div className="embed-page">
+ <div className="embed-shell">
+ {healthWarn ? (
+ <div className="health-banner health-banner--embed" role="alert">
+ <strong>Server metadata</strong>
+ <p>{healthWarn}</p>
+ </div>
+ ) : null}
+
+ <article className="card now-playing">
+ <header className="card-head">
+ <h2>Now playing</h2>
+ </header>
+ {track ? (
+ <div className="track-block">
+ <p className="artist">{track.artist}</p>
+ <p className="title">{track.title}</p>
+ </div>
+ ) : (
+ <p className="muted">{status || "No track loaded."}</p>
+ )}
+
+ <section className="up-next" aria-label="Up next">
+ <h3 className="up-next-label">Up next</h3>
+ {upNext ? (
+ <>
+ <p className="up-next-track">
+ <span className="up-next-artist">{upNext.artist}</span>
+ <span className="up-next-sep"> — </span>
+ <span className="up-next-title">{upNext.title}</span>
+ </p>
+ {showUpNextHint ? (
+ <p className="up-next-note muted">Only one track in the pool — it will repeat.</p>
+ ) : null}
+ </>
+ ) : queueBusy ? (
+ <p className="up-next-empty muted">Queuing…</p>
+ ) : track ? (
+ <p className="up-next-empty muted">—</p>
+ ) : (
+ <p className="up-next-empty muted">Queuing…</p>
+ )}
+ </section>
+
+ <div className="player-nook">
+ <audio
+ ref={audioRef}
+ className="audio-hidden"
+ preload="metadata"
+ tabIndex={-1}
+ aria-hidden="true"
+ onEnded={onEnded}
+ onPlaying={handleAudioPlaying}
+ onError={handleAudioError}
+ />
+ <CozyAudioBar audioRef={audioRef} disabled={!track} />
+
+ <div className="actions">
+ <button type="button" className="btn primary" onClick={() => void requestNextTrack()}>
+ Next
+ </button>
+ <label className="check">
+ <input
+ type="checkbox"
+ checked={autoPlay}
+ onChange={(e) => setAutoPlay(e.target.checked)}
+ />
+ Auto-advance when track ends
+ </label>
+ </div>
+ {status && track ? <p className="hint">{status}</p> : null}
+ </div>
+ </article>
+ </div>
+ </div>
+ );
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
@@ -1,193 +1,26 @@
-import { useCallback, useEffect, useRef, useState } from "react";
import { CozyAudioBar } from "../components/CozyAudioBar";
+import { EmbedSnippet } from "../components/EmbedSnippet";
import { SiteHeader } from "../components/SiteHeader";
+import { useMyMusicsPlayback } from "../hooks/useMyMusicsPlayback";
import "../App.css";
-type TrackInfo = {
- id: string;
- title: string;
- artist: string;
-};
-
-type RandomResponse = {
- track: TrackInfo;
- streamUrl: string;
-};
-
-type QueuedTrack = TrackInfo & { streamUrl: string };
-
-type ErrBody = { error?: string };
-
-type HealthBody = {
- tracksReady?: boolean;
- hint?: string;
- metadataTsv?: string;
- metadataExists?: boolean;
- trackCount?: number;
-};
-
-const MAX_ARCHIVE_STREAM_ERRORS = 3;
-
export default function Home() {
- const audioRef = useRef<HTMLAudioElement>(null);
- const archiveStreamErrorsRef = useRef(0);
- const upNextRef = useRef<QueuedTrack | null>(null);
- const [track, setTrack] = useState<TrackInfo | null>(null);
- const [upNext, setUpNext] = useState<QueuedTrack | null>(null);
- const [status, setStatus] = useState<string>("");
- const [history, setHistory] = useState<TrackInfo[]>([]);
- const [autoPlay, setAutoPlay] = useState(true);
- const [healthWarn, setHealthWarn] = useState<string | null>(null);
- const [poolTrackCount, setPoolTrackCount] = useState<number | null>(null);
- const [queueBusy, setQueueBusy] = useState(false);
-
- useEffect(() => {
- upNextRef.current = upNext;
- }, [upNext]);
-
- const playUrl = useCallback((url: string) => {
- const a = audioRef.current;
- if (!a) return;
- a.src = url;
- a.load();
- void a.play().catch(() => {});
- }, []);
-
- const refillUpNext = useCallback(async (excludeId: string) => {
- setQueueBusy(true);
- try {
- const res = await fetch(
- `/api/track/up-next?exclude=${encodeURIComponent(excludeId)}`,
- );
- const body = (await res.json()) as RandomResponse | ErrBody;
- if (!res.ok) {
- setUpNext(null);
- return;
- }
- const data = body as RandomResponse;
- setUpNext({
- id: data.track.id,
- title: data.track.title,
- artist: data.track.artist,
- streamUrl: data.streamUrl,
- });
- } catch {
- setUpNext(null);
- } finally {
- setQueueBusy(false);
- }
- }, []);
-
- const advance = useCallback(async () => {
- setStatus("Loading…");
- const queued = upNextRef.current;
- try {
- if (queued) {
- const info: TrackInfo = {
- id: queued.id,
- title: queued.title,
- artist: queued.artist,
- };
- setTrack(info);
- setHistory((h) => [info, ...h].slice(0, 15));
- playUrl(queued.streamUrl);
- setUpNext(null);
- setStatus("");
- await refillUpNext(queued.id);
- return;
- }
-
- const res = await fetch("/api/track/random");
- const body = (await res.json()) as RandomResponse | ErrBody;
- if (!res.ok) {
- setTrack(null);
- setUpNext(null);
- setStatus(
- "error" in body && body.error
- ? body.error
- : "Service unavailable. Try again later.",
- );
- return;
- }
- const data = body as RandomResponse;
- const info: TrackInfo = {
- id: data.track.id,
- title: data.track.title,
- artist: data.track.artist,
- };
- setTrack(info);
- setHistory((h) => [info, ...h].slice(0, 15));
- playUrl(data.streamUrl);
- setStatus("");
- await refillUpNext(info.id);
- } catch {
- setStatus("Network error while requesting a track.");
- setUpNext(null);
- }
- }, [playUrl, refillUpNext]);
-
- const handleAudioPlaying = useCallback(() => {
- archiveStreamErrorsRef.current = 0;
- setStatus("");
- }, []);
-
- const handleAudioError = useCallback(() => {
- archiveStreamErrorsRef.current += 1;
- const n = archiveStreamErrorsRef.current;
- if (n >= MAX_ARCHIVE_STREAM_ERRORS) {
- setStatus(
- "Internet Archive could not stream several tracks in a row (e.g. 503). Try Next or wait.",
- );
- return;
- }
- setStatus("This track is not available from the Archive right now; trying another…");
- if (autoPlay) void advance();
- }, [autoPlay, advance]);
-
- const requestNextTrack = useCallback(() => {
- archiveStreamErrorsRef.current = 0;
- void advance();
- }, [advance]);
-
- useEffect(() => {
- void (async () => {
- try {
- const res = await fetch("/api/health");
- const h = (await res.json()) as HealthBody;
- if (typeof h.trackCount === "number") setPoolTrackCount(h.trackCount);
- if (!h.tracksReady) {
- const parts = [
- h.hint,
- h.metadataTsv && `Path: ${h.metadataTsv}`,
- h.metadataExists === false && "File not found at configured path.",
- typeof h.trackCount === "number" && `Tracks loaded: ${h.trackCount}.`,
- ].filter(Boolean);
- setHealthWarn(
- parts.length > 0
- ? parts.join(" ")
- : "No tracks loaded. Check server metadata and /api/health.",
- );
- } else {
- setHealthWarn(null);
- }
- } catch {
- setHealthWarn(null);
- }
- })();
- }, []);
-
- useEffect(() => {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount bootstrap via advance()
- void advance();
- // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only bootstrap
- }, []);
-
- const onEnded = () => {
- if (autoPlay) void advance();
- };
-
- const showUpNextHint =
- poolTrackCount === 1 && track && upNext && upNext.id === track.id;
+ const {
+ audioRef,
+ track,
+ upNext,
+ status,
+ history,
+ autoPlay,
+ setAutoPlay,
+ healthWarn,
+ queueBusy,
+ requestNextTrack,
+ handleAudioPlaying,
+ handleAudioError,
+ onEnded,
+ showUpNextHint,
+ } = useMyMusicsPlayback();
return (
<div className="page">
@@ -284,6 +117,8 @@ export default function Home() {
</aside>
</main>
+ <EmbedSnippet />
+
<footer className="footer">
<small className="muted">Developed by Pablo Murad — 2026</small>
</footer>