mymusics

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

commit 22b49eca4c9534729bff24ffeedf53328e1b7db6
parent 30e531e1e7495fbef59fb9d07576034e6a5da92f
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Fri,  1 May 2026 12:30:44 -0300

metadata

Diffstat:
Mpackage-lock.json | 41++++++++++++++++++++++++++++++++++++++++-
Mpackage.json | 3++-
Msrc/App.css | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/App.tsx | 215+++----------------------------------------------------------------------------
Asrc/components/SiteHeader.tsx | 31+++++++++++++++++++++++++++++++
Msrc/main.tsx | 13+++++++++----
Asrc/pages/About.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/pages/Home.tsx | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 402 insertions(+), 214 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -13,7 +13,8 @@ "dotenv": "^17.4.2", "fastify": "^5.8.5", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -3796,6 +3797,44 @@ "react": "^19.2.5" } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", diff --git a/package.json b/package.json @@ -20,7 +20,8 @@ "dotenv": "^17.4.2", "fastify": "^5.8.5", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.css b/src/App.css @@ -9,6 +9,26 @@ margin-bottom: 2rem; } +.logo-link { + display: inline-block; +} + +.site-nav { + margin-top: 1rem; +} + +.nav-link { + font-weight: 600; + color: var(--sky); + text-decoration: none; + border-bottom: 1px solid color-mix(in srgb, var(--sky) 45%, transparent); +} + +.nav-link:hover { + color: var(--yellow); + border-bottom-color: color-mix(in srgb, var(--yellow) 50%, transparent); +} + .logo { max-width: min(360px, 100%); height: auto; @@ -208,3 +228,40 @@ font-size: 0.85em; color: var(--sky); } + +.about-main { + grid-column: 1 / -1; + max-width: 42rem; + margin: 0 auto; + width: 100%; +} + +.about-card { + padding: 1.5rem 1.35rem 1.75rem; +} + +.about-title { + margin: 0 0 1.25rem; + font-size: 1.35rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--yellow); + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); +} + +.about-prose p { + margin: 0 0 1rem; + font-size: 0.98rem; + line-height: 1.6; + color: color-mix(in srgb, #fff 88%, var(--muted)); +} + +.about-prose p:last-child { + margin-bottom: 0; +} + +.about-signoff { + margin-top: 1.5rem !important; + font-style: italic; + color: var(--muted) !important; +} diff --git a/src/App.tsx b/src/App.tsx @@ -1,213 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import "./App.css"; - -type TrackInfo = { - id: string; - title: string; - artist: string; -}; - -type RandomResponse = { - track: 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; +import { Route, Routes } from "react-router-dom"; +import About from "./pages/About"; +import Home from "./pages/Home"; export default function App() { - const audioRef = useRef<HTMLAudioElement>(null); - const archiveStreamErrorsRef = useRef(0); - const [track, setTrack] = useState<TrackInfo | 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 playUrl = useCallback((url: string) => { - const a = audioRef.current; - if (!a) return; - a.src = url; - a.load(); - void a.play().catch(() => {}); - }, []); - - const loadNext = useCallback(async () => { - setStatus("Loading…"); - try { - const res = await fetch("/api/track/random"); - const body = (await res.json()) as RandomResponse | ErrBody; - if (!res.ok) { - setTrack(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(""); - } catch { - setStatus("Network error while requesting a track."); - } - }, [playUrl]); - - 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 loadNext(); - }, [autoPlay, loadNext]); - - const requestNextTrack = useCallback(() => { - archiveStreamErrorsRef.current = 0; - void loadNext(); - }, [loadNext]); - - useEffect(() => { - void (async () => { - try { - const res = await fetch("/api/health"); - const h = (await res.json()) as HealthBody; - 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 - void loadNext(); - }, [loadNext]); - - const onEnded = () => { - if (autoPlay) void loadNext(); - }; - return ( - <div className="page"> - {healthWarn ? ( - <div className="health-banner" role="alert"> - <strong>Server metadata</strong> - <p>{healthWarn}</p> - <p className="health-banner-hint"> - On the host, run <code>curl -sS http://127.0.0.1:38471/api/health</code> (adjust - port) and fix <code>METADATA_TSV</code> or remove it to use the default{" "} - <code>data/metadata.tsv</code>. - </p> - </div> - ) : null} - <header className="header"> - <img - className="logo" - src="/mymusics.png" - alt="MyMusics" - width={200} - height={80} - decoding="async" - /> - </header> - - <main className="main"> - <section className="card now-playing"> - <h2>Now playing</h2> - {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> - )} - - <audio - ref={audioRef} - className="player" - controls - controlsList="nodownload noplaybackrate" - preload="metadata" - onEnded={onEnded} - onPlaying={handleAudioPlaying} - onError={handleAudioError} - /> - - <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} - </section> - - <section className="card history"> - <h2>History</h2> - <ol className="history-list"> - {history.map((t, idx) => ( - <li key={`${t.id}-${idx}-${t.title}`}> - <span className="h-artist">{t.artist}</span> - <span className="sep">—</span> - <span className="h-title">{t.title}</span> - </li> - ))} - </ol> - </section> - </main> - - <footer className="footer"> - <small className="muted">Developed by Pablo Murad — 2026</small> - </footer> - </div> + <Routes> + <Route path="/" element={<Home />} /> + <Route path="/about" element={<About />} /> + </Routes> ); } diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx @@ -0,0 +1,31 @@ +import { Link } from "react-router-dom"; + +type NavKind = "home" | "about"; + +export function SiteHeader({ nav }: { nav: NavKind }) { + return ( + <header className="header"> + <Link to="/" className="logo-link" aria-label="MyMusics home"> + <img + className="logo" + src="/mymusics.png" + alt="MyMusics" + width={200} + height={80} + decoding="async" + /> + </Link> + <nav className="site-nav" aria-label="Site"> + {nav === "home" ? ( + <Link to="/about" className="nav-link"> + About + </Link> + ) : ( + <Link to="/" className="nav-link"> + Back to player + </Link> + )} + </nav> + </header> + ); +} diff --git a/src/main.tsx b/src/main.tsx @@ -1,5 +1,10 @@ -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import "./index.css"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render(<App />) +createRoot(document.getElementById("root")!).render( + <BrowserRouter> + <App /> + </BrowserRouter>, +); diff --git a/src/pages/About.tsx b/src/pages/About.tsx @@ -0,0 +1,51 @@ +import { SiteHeader } from "../components/SiteHeader"; +import "../App.css"; + +export default function About() { + return ( + <div className="page"> + <SiteHeader nav="about" /> + + <main className="main about-main"> + <article className="card about-card"> + <h1 className="about-title">About MyMusics</h1> + + <div className="about-prose"> + <p> + MyMusics is a small love letter to a corner of the early web: millions of songs + lived on MySpace profiles—messy, heartfelt, often buried under glitter and + autoplay. Many of those tracks survived thanks to archivists and the{" "} + <strong>Internet Archive</strong>, bundled in collections such as{" "} + <em>The Myspace Dragon Hoard</em>. This project aggregates metadata for those + recordings and lets you hit “random” and listen again, streamed from the Archive’s + mirrors of history rather than from MySpace itself (that ship sailed long ago). + </p> + + <p> + Years ago I stumbled on another player or demo built around the same idea. I wish I + could name the author and link to their work; memory failed me, so proper credit + goes missing here with my apologies. What you see today is not a fork: the stack, + server, UX, and plenty of behaviour were reworked from scratch. I changed a lot, + learned a lot, and I’m genuinely happy with how it turned out. + </p> + + <p> + The old internet was slower, louder, and less polished—but it felt owned by people. + Bands shouted into the void with orange backgrounds; friends traded playlists in + comments; nothing asked you for a subscription before it would play a song. If + this player reminds you of that era for even a minute, it did its job. + </p> + + <p className="about-signoff"> + — Pablo Murad, 2026 + </p> + </div> + </article> + </main> + + <footer className="footer"> + <small className="muted">Developed by Pablo Murad — 2026</small> + </footer> + </div> + ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { SiteHeader } from "../components/SiteHeader"; +import "../App.css"; + +type TrackInfo = { + id: string; + title: string; + artist: string; +}; + +type RandomResponse = { + track: 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 [track, setTrack] = useState<TrackInfo | 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 playUrl = useCallback((url: string) => { + const a = audioRef.current; + if (!a) return; + a.src = url; + a.load(); + void a.play().catch(() => {}); + }, []); + + const loadNext = useCallback(async () => { + setStatus("Loading…"); + try { + const res = await fetch("/api/track/random"); + const body = (await res.json()) as RandomResponse | ErrBody; + if (!res.ok) { + setTrack(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(""); + } catch { + setStatus("Network error while requesting a track."); + } + }, [playUrl]); + + 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 loadNext(); + }, [autoPlay, loadNext]); + + const requestNextTrack = useCallback(() => { + archiveStreamErrorsRef.current = 0; + void loadNext(); + }, [loadNext]); + + useEffect(() => { + void (async () => { + try { + const res = await fetch("/api/health"); + const h = (await res.json()) as HealthBody; + 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 + void loadNext(); + }, [loadNext]); + + const onEnded = () => { + if (autoPlay) void loadNext(); + }; + + return ( + <div className="page"> + {healthWarn ? ( + <div className="health-banner" role="alert"> + <strong>Server metadata</strong> + <p>{healthWarn}</p> + <p className="health-banner-hint"> + On the host, run <code>curl -sS http://127.0.0.1:38471/api/health</code> (adjust + port) and fix <code>METADATA_TSV</code> or remove it to use the default{" "} + <code>data/metadata.tsv</code>. + </p> + </div> + ) : null} + <SiteHeader nav="home" /> + + <main className="main"> + <section className="card now-playing"> + <h2>Now playing</h2> + {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> + )} + + <audio + ref={audioRef} + className="player" + controls + controlsList="nodownload noplaybackrate" + preload="metadata" + onEnded={onEnded} + onPlaying={handleAudioPlaying} + onError={handleAudioError} + /> + + <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} + </section> + + <section className="card history"> + <h2>History</h2> + <ol className="history-list"> + {history.map((t, idx) => ( + <li key={`${t.id}-${idx}-${t.title}`}> + <span className="h-artist">{t.artist}</span> + <span className="sep">—</span> + <span className="h-title">{t.title}</span> + </li> + ))} + </ol> + </section> + </main> + + <footer className="footer"> + <small className="muted">Developed by Pablo Murad — 2026</small> + </footer> + </div> + ); +}