mymusics

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

commit 71ad757a42523f92f12dbf1793c009d6f66c717a
parent 9f126945b789102505d314188090670b913b2d7c
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Fri,  1 May 2026 14:18:16 -0300

emb

Diffstat:
M.env.example | 3+++
MREADME.md | 2++
Mserver/index.ts | 12++++++++++++
Msrc/App.css | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/App.tsx | 2++
Asrc/components/EmbedSnippet.tsx | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/config/siteUrl.ts | 4++++
Asrc/hooks/useMyMusicsPlayback.ts | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/About.tsx | 3+++
Asrc/pages/Embed.tsx | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/Home.tsx | 205++++++++-----------------------------------------------------------------------
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>