mymusics

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

commit 9f126945b789102505d314188090670b913b2d7c
parent 22b49eca4c9534729bff24ffeedf53328e1b7db6
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Fri,  1 May 2026 14:10:34 -0300

modifications

Diffstat:
Mindex.html | 8+++++++-
Mserver/index.ts | 31+++++++++++++++++++++++++++++++
Msrc/App.css | 350++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Asrc/components/CozyAudioBar.tsx | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/SiteHeader.tsx | 1+
Msrc/index.css | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/pages/About.tsx | 64+++++++++++++++++++++++++++++++---------------------------------
Msrc/pages/Home.tsx | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
8 files changed, 708 insertions(+), 142 deletions(-)

diff --git a/index.html b/index.html @@ -1,10 +1,16 @@ <!doctype html> -<html lang="en"> +<html lang="pt-BR"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>MyMusics</title> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link + href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;1,9..144,400&family=Source+Sans+3:ital,wght@0,400;0,600;0,700;1,400&display=swap" + rel="stylesheet" + /> </head> <body> <div id="root"></div> diff --git a/server/index.ts b/server/index.ts @@ -122,6 +122,17 @@ function randomTrack(): TrackMeta | null { return pool[Math.floor(Math.random() * pool.length)]!; } +/** Prefer a track whose id differs from `excludeId` when the pool has more than one row. */ +function randomTrackExcluding(excludeId: string | undefined): TrackMeta | null { + if (!pool.length) return null; + if (!excludeId?.trim() || pool.length === 1) { + return randomTrack(); + } + const filtered = pool.filter((t) => t.id !== excludeId); + if (filtered.length === 0) return randomTrack(); + return filtered[Math.floor(Math.random() * filtered.length)]!; +} + async function main() { applyMetadataPathsFromEnv(); console.info(`MyMusics: cwd=${process.cwd()}`); @@ -194,6 +205,26 @@ async function main() { }); }); + app.get("/api/track/up-next", async (req, reply) => { + const raw = (req.query as { exclude?: string }).exclude; + const excludeId = typeof raw === "string" ? raw.trim() : undefined; + const track = randomTrackExcluding(excludeId); + if (!track) { + return reply.code(503).send({ + error: "No tracks available. Check that metadata loaded correctly.", + }); + } + return reply.send({ + track: { + id: track.id, + title: track.title, + artist: track.artist, + fileKey: track.fileKey, + }, + streamUrl: track.archiveUrl, + }); + }); + if (serveStatic) { if (distExists) { await app.register(fastifyStatic, { diff --git a/src/App.css b/src/App.css @@ -19,27 +19,29 @@ .nav-link { font-weight: 600; - color: var(--sky); + color: color-mix(in srgb, var(--accent-cyan) 94%, #fff 6%); text-decoration: none; - border-bottom: 1px solid color-mix(in srgb, var(--sky) 45%, transparent); + border-bottom: 1px dashed color-mix(in srgb, var(--accent-cyan) 48%, transparent); } .nav-link:hover { - color: var(--yellow); - border-bottom-color: color-mix(in srgb, var(--yellow) 50%, transparent); + color: var(--accent); + border-bottom-color: color-mix(in srgb, var(--accent) 55%, transparent); } .logo { max-width: min(360px, 100%); height: auto; - filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.45)); + filter: drop-shadow(0 10px 28px rgba(0, 0, 0, 0.5)); } .tagline { margin: 1rem auto 0; max-width: 36rem; color: var(--muted); - font-size: 0.95rem; + font-size: 0.98rem; + font-style: italic; + line-height: 1.45; } .main { @@ -56,26 +58,33 @@ .card { background: var(--panel); - border: 2px solid color-mix(in srgb, var(--sky) 35%, transparent); - border-radius: 16px; + border: 2px dashed var(--border-dash); + border-radius: 18px 10px 20px 14px; padding: 1.25rem 1.25rem 1.5rem; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + box-shadow: var(--shadow-card); +} + +.card-head { + margin-bottom: 1rem; } .card h2 { - margin: 0 0 1rem; - font-size: 1.1rem; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--yellow); - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); + margin: 0 0 0.35rem; + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 600; + font-variation-settings: "SOFT" 40; + letter-spacing: 0.01em; + text-transform: none; + color: var(--accent); + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35); } .track-block .artist { font-size: 1.35rem; font-weight: 700; margin: 0 0 0.35rem; - color: var(--sky); + color: color-mix(in srgb, var(--accent-sky) 52%, var(--accent-cyan) 48%); } .track-block .title { @@ -84,6 +93,52 @@ margin: 0 0 0.75rem; } +.up-next { + margin-top: 1rem; + padding: 0.85rem 1rem; + border-radius: 12px 14px 10px 12px; + border: 1px dashed color-mix(in srgb, var(--accent-cyan) 34%, transparent); + background: color-mix(in srgb, var(--panel) 92%, var(--accent-cyan) 8%); +} + +.up-next-label { + margin: 0 0 0.45rem; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--accent); + letter-spacing: 0.02em; +} + +.up-next-track { + margin: 0; + font-size: 0.96rem; + line-height: 1.45; +} + +.up-next-artist { + color: color-mix(in srgb, var(--accent-sky) 58%, var(--accent-cyan) 42%); + font-weight: 600; +} + +.up-next-title { + font-weight: 500; +} + +.up-next-sep { + color: var(--muted); +} + +.up-next-empty { + margin: 0; + font-size: 0.92rem; +} + +.up-next-note { + margin: 0.55rem 0 0; + font-size: 0.8rem; +} + .meta { margin: 0; } @@ -93,8 +148,8 @@ padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.75rem; - background: color-mix(in srgb, var(--orange) 35%, transparent); - border: 1px solid color-mix(in srgb, var(--orange) 55%, transparent); + background: color-mix(in srgb, var(--accent-cyan) 22%, transparent); + border: 1px dashed color-mix(in srgb, var(--accent-cyan) 45%, transparent); word-break: break-all; } @@ -102,10 +157,172 @@ color: var(--muted); } -.player { - width: 100%; +.player-nook { + position: relative; margin-top: 1rem; + padding: 1.2rem 1.05rem 1.15rem; + background: var(--panel-nook); + border-radius: 14px 18px 12px 16px; + border: 1px solid color-mix(in srgb, var(--accent-cyan) 28%, transparent); + box-shadow: var(--shadow-inset-nook); +} + +.player-nook::before { + content: "♪"; + position: absolute; + top: 0.65rem; + right: 0.85rem; + font-size: 1.15rem; + opacity: 0.22; + pointer-events: none; + color: var(--accent); +} + +/* Native <audio> is screen-reader–backed by CozyAudioBar controls */ +.audio-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.cozy-player { + display: flex; + align-items: center; + gap: 0.65rem; + width: 100%; + margin-top: 0; + padding: 0.6rem 0.75rem; border-radius: 999px; + background: color-mix(in srgb, #0a1220 75%, var(--logo-navy) 25%); + border: 1px solid color-mix(in srgb, var(--accent-cyan) 25%, transparent); + box-shadow: + inset 0 2px 8px rgba(0, 0, 0, 0.35), + 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.cozy-player--disabled { + opacity: 0.55; + pointer-events: none; +} + +.cozy-player__play, +.cozy-player__mute { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: none; + border-radius: 50%; + cursor: pointer; + color: #f0f6ff; + background: color-mix(in srgb, var(--accent-cyan) 18%, var(--logo-navy-deep) 82%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); + transition: filter 0.12s ease, transform 0.08s ease; +} + +.cozy-player__play:hover:not(:disabled), +.cozy-player__mute:hover:not(:disabled) { + filter: brightness(1.12); +} + +.cozy-player__play:active:not(:disabled), +.cozy-player__mute:active:not(:disabled) { + transform: scale(0.96); +} + +.cozy-player__play:focus-visible, +.cozy-player__mute:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.cozy-player__play:disabled, +.cozy-player__mute:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.cozy-player__icon { + width: 1.35rem; + height: 1.35rem; +} + +.cozy-player__progress-wrap { + flex: 1; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.cozy-player__time { + flex-shrink: 0; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: var(--muted); +} + +.cozy-player__scrub { + flex: 1; + min-width: 0; + height: 0.45rem; + -webkit-appearance: none; + appearance: none; + background: transparent; + border-radius: 999px; + cursor: pointer; +} + +.cozy-player__scrub::-webkit-slider-runnable-track { + height: 0.45rem; + background: color-mix(in srgb, var(--muted) 28%, transparent); + border-radius: 999px; +} + +.cozy-player__scrub:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.cozy-player__scrub::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: linear-gradient(160deg, var(--accent), var(--accent-strong)); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + border: 2px solid color-mix(in srgb, #fff 40%, transparent); +} + +.cozy-player__scrub::-moz-range-thumb { + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: linear-gradient(160deg, var(--accent), var(--accent-strong)); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + border: 2px solid color-mix(in srgb, #fff 40%, transparent); +} + +.cozy-player__scrub::-moz-range-track { + height: 0.45rem; + background: color-mix(in srgb, var(--muted) 28%, transparent); + border-radius: 999px; +} + +.cozy-player__scrub:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 2px; } .actions { @@ -113,7 +330,7 @@ flex-wrap: wrap; align-items: center; gap: 1rem; - margin-top: 1.25rem; + margin-top: 1.15rem; } .btn { @@ -123,17 +340,28 @@ padding: 0.65rem 1.4rem; font-weight: 700; font-size: 1rem; - transition: transform 0.08s ease, filter 0.15s ease; + font-family: inherit; + transition: transform 0.08s ease, filter 0.15s ease, box-shadow 0.15s ease; } .btn.primary { - background: linear-gradient(180deg, var(--orange), color-mix(in srgb, var(--orange) 70%, #992200)); + background: linear-gradient( + 175deg, + color-mix(in srgb, var(--accent) 96%, #fff 4%), + color-mix(in srgb, var(--accent-strong) 88%, #8c2508 12%) + ); color: #fff; - box-shadow: 0 4px 14px rgba(255, 102, 0, 0.35); + text-shadow: 0 1px 2px rgba(0, 15, 40, 0.45); + box-shadow: + 0 3px 14px color-mix(in srgb, var(--accent-strong) 42%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.35); } .btn.primary:hover { filter: brightness(1.06); + box-shadow: + 0 4px 18px color-mix(in srgb, var(--accent-strong) 48%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.4); } .btn.primary:active { @@ -167,7 +395,7 @@ } .h-artist { - color: var(--sky); + color: color-mix(in srgb, var(--accent-sky) 62%, var(--accent-cyan) 38%); font-weight: 600; } @@ -183,19 +411,21 @@ .health-banner { margin-bottom: 1.25rem; padding: 1rem 1.1rem; - border-radius: 12px; - border: 2px solid color-mix(in srgb, var(--orange) 55%, transparent); - background: color-mix(in srgb, var(--orange) 12%, var(--panel)); - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.3); + border-radius: 14px 10px 12px 16px; + border: 2px dashed color-mix(in srgb, var(--accent-strong) 52%, transparent); + background: color-mix(in srgb, var(--accent-strong) 14%, var(--panel)); + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.32); } .health-banner strong { display: block; margin-bottom: 0.5rem; - color: var(--yellow); - font-size: 0.95rem; - letter-spacing: 0.03em; - text-transform: uppercase; + font-family: var(--font-display); + color: var(--accent); + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: none; } .health-banner p { @@ -216,7 +446,7 @@ .health-banner code { font-size: 0.8em; - color: var(--sky); + color: var(--accent-cyan); } .footer { @@ -226,42 +456,54 @@ .footer code { font-size: 0.85em; - color: var(--sky); + color: color-mix(in srgb, var(--accent-cyan) 78%, var(--accent) 22%); } -.about-main { - grid-column: 1 / -1; - max-width: 42rem; - margin: 0 auto; +/* About: single-column layout — avoids squeezed half-width card on wide viewports */ +.page-about { + max-width: min(56rem, 100%); +} + +.main-about { + grid-template-columns: 1fr; +} + +.about-shell { width: 100%; + max-width: min(52rem, 100%); + margin-inline: auto; } .about-card { - padding: 1.5rem 1.35rem 1.75rem; + padding: 1.75rem 1.5rem 2rem; +} + +@media (min-width: 640px) { + .about-card { + padding: 2rem 2rem 2.25rem; + } } .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); + font-family: var(--font-display); + font-size: 1.45rem; + font-weight: 600; + font-variation-settings: "SOFT" 38; + letter-spacing: 0.02em; + text-transform: none; + line-height: 1.25; + color: var(--accent); + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35); } .about-prose p { margin: 0 0 1rem; - font-size: 0.98rem; - line-height: 1.6; - color: color-mix(in srgb, #fff 88%, var(--muted)); + font-size: clamp(1rem, 0.96rem + 0.2vw, 1.08rem); + line-height: 1.7; + color: color-mix(in srgb, var(--text) 92%, 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/components/CozyAudioBar.tsx b/src/components/CozyAudioBar.tsx @@ -0,0 +1,161 @@ +import { type ChangeEvent, type RefObject, useCallback, useEffect, useState } from "react"; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const m = Math.floor(seconds / 60); + const sec = Math.floor(seconds % 60); + return `${m}:${sec.toString().padStart(2, "0")}`; +} + +type Props = { + audioRef: RefObject<HTMLAudioElement | null>; + /** No track / no usable stream */ + disabled?: boolean; +}; + +export function CozyAudioBar({ audioRef, disabled }: Props) { + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [playing, setPlaying] = useState(false); + const [muted, setMuted] = useState(false); + + useEffect(() => { + const el = audioRef.current; + if (!el) return; + + const syncFromElement = () => { + setCurrentTime(el.currentTime); + const d = el.duration; + setDuration(Number.isFinite(d) && d > 0 ? d : 0); + setPlaying(!el.paused); + setMuted(el.muted); + }; + + const onTimeUpdate = () => setCurrentTime(el.currentTime); + const onLoadedMeta = () => syncFromElement(); + const onPlay = () => setPlaying(true); + const onPause = () => setPlaying(false); + const onVol = () => setMuted(el.muted); + + el.addEventListener("timeupdate", onTimeUpdate); + el.addEventListener("loadedmetadata", onLoadedMeta); + el.addEventListener("durationchange", onLoadedMeta); + el.addEventListener("play", onPlay); + el.addEventListener("pause", onPause); + el.addEventListener("volumechange", onVol); + + syncFromElement(); + + return () => { + el.removeEventListener("timeupdate", onTimeUpdate); + el.removeEventListener("loadedmetadata", onLoadedMeta); + el.removeEventListener("durationchange", onLoadedMeta); + el.removeEventListener("play", onPlay); + el.removeEventListener("pause", onPause); + el.removeEventListener("volumechange", onVol); + }; + }, [audioRef]); + + const pct = + duration > 0 && Number.isFinite(duration) ? Math.min(100, (currentTime / duration) * 100) : 0; + + const togglePlay = useCallback(() => { + const el = audioRef.current; + if (!el || disabled) return; + if (el.paused) void el.play().catch(() => {}); + else el.pause(); + }, [audioRef, disabled]); + + const onSeek = useCallback( + (e: ChangeEvent<HTMLInputElement>) => { + const el = audioRef.current; + if (!el || disabled || duration <= 0) return; + const next = (parseFloat(e.target.value) / 100) * duration; + el.currentTime = next; + setCurrentTime(next); + }, + [audioRef, disabled, duration], + ); + + const toggleMute = useCallback(() => { + const el = audioRef.current; + if (!el || disabled) return; + el.muted = !el.muted; + setMuted(el.muted); + }, [audioRef, disabled]); + + const durLabel = formatTime(duration); + const curLabel = formatTime(currentTime); + + return ( + <div + className={`cozy-player${disabled ? " cozy-player--disabled" : ""}`} + role="group" + aria-label="Audio playback" + > + <button + type="button" + className="cozy-player__play" + onClick={() => void togglePlay()} + disabled={disabled} + aria-label={playing ? "Pause" : "Play"} + > + {playing ? ( + <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> + <rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor" /> + <rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor" /> + </svg> + ) : ( + <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> + <path d="M9 6.5v11l9-5.5z" fill="currentColor" /> + </svg> + )} + </button> + + <div className="cozy-player__progress-wrap"> + <span className="cozy-player__time cozy-player__time--current">{curLabel}</span> + <input + type="range" + className="cozy-player__scrub" + min={0} + max={100} + step={0.25} + value={pct} + onChange={onSeek} + disabled={disabled || duration <= 0} + aria-label="Seek" + aria-valuemin={0} + aria-valuemax={100} + aria-valuenow={Math.round(pct)} + aria-valuetext={`${curLabel} of ${durLabel}`} + /> + <span className="cozy-player__time cozy-player__time--duration">{durLabel}</span> + </div> + + <button + type="button" + className="cozy-player__mute" + onClick={() => void toggleMute()} + disabled={disabled} + aria-label={muted ? "Unmute" : "Mute"} + aria-pressed={muted} + > + {muted ? ( + <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> + <path + fill="currentColor" + d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" + /> + </svg> + ) : ( + <svg className="cozy-player__icon" viewBox="0 0 24 24" aria-hidden> + <path + fill="currentColor" + d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" + /> + </svg> + )} + </button> + </div> + ); +} diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx @@ -26,6 +26,7 @@ export function SiteHeader({ nav }: { nav: NavKind }) { </Link> )} </nav> + <p className="tagline">Pull up a chair — random songs from the archive.</p> </header> ); } diff --git a/src/index.css b/src/index.css @@ -1,15 +1,38 @@ :root { - font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-family: "Source Sans 3", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; color-scheme: dark; - --navy: #002147; - --sky: #33ccff; - --orange: #ff6600; - --yellow: #ffcc00; - --panel: rgba(0, 33, 71, 0.92); + + --font-display: "Fraunces", Georgia, "Times New Roman", serif; + + /* Palette aligned with public/mymusics.png */ + --logo-navy: #001b44; + --logo-navy-deep: #000f28; + --logo-sky: #3498db; + --logo-cyan: #40c4ff; + --logo-gold: #fbc02d; + --logo-orange: #e64a19; + + --bg-deep: var(--logo-navy-deep); + --bg-mid: #061a35; + --bg-elevated: color-mix(in srgb, var(--logo-navy) 78%, #0a1628 22%); + --panel: color-mix(in srgb, var(--logo-navy) 88%, #f0f6ff 12%); + --panel-nook: color-mix(in srgb, var(--logo-navy) 72%, var(--logo-cyan) 28%); + --text: #f2f7ff; - --muted: rgba(242, 247, 255, 0.72); + --muted: rgba(242, 247, 255, 0.74); + + --accent: var(--logo-gold); + --accent-sky: var(--logo-sky); + --accent-cyan: var(--logo-cyan); + --accent-strong: var(--logo-orange); + --accent-glow: color-mix(in srgb, var(--logo-cyan) 22%, transparent); + + --border-dash: color-mix(in srgb, var(--logo-cyan) 38%, transparent); + --shadow-card: 0 14px 42px rgba(0, 10, 30, 0.45); + --shadow-inset-nook: + inset 0 1px 0 rgba(255, 255, 255, 0.06), inset 0 -2px 14px rgba(0, 0, 0, 0.35); } * { @@ -23,13 +46,26 @@ body { margin: 0; min-height: 100vh; - background: radial-gradient( - ellipse 120% 80% at 50% 0%, - color-mix(in srgb, var(--sky) 18%, transparent), + color: var(--text); + background-color: var(--bg-deep); + background-image: + radial-gradient( + ellipse 115% 70% at 50% -8%, + color-mix(in srgb, var(--logo-cyan) 16%, transparent), transparent 55% ), - linear-gradient(160deg, var(--navy) 0%, #001428 45%, #020814 100%); - color: var(--text); + radial-gradient( + ellipse 80% 45% at 80% 100%, + color-mix(in srgb, var(--logo-orange) 10%, transparent), + transparent 50% + ), + radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--text) 5%, transparent) 1px, transparent 0), + linear-gradient(165deg, var(--bg-mid) 0%, var(--bg-deep) 45%, #030812 100%); + background-size: + 100% 100%, + 100% 100%, + 22px 22px, + 100% 100%; } #root { @@ -37,11 +73,15 @@ body { } a { - color: var(--sky); + color: color-mix(in srgb, var(--logo-cyan) 92%, #fff 8%); +} + +a:hover { + color: var(--logo-sky); } button:focus-visible, audio:focus-visible { - outline: 2px solid var(--yellow); - outline-offset: 2px; + outline: 2px solid var(--logo-gold); + outline-offset: 3px; } diff --git a/src/pages/About.tsx b/src/pages/About.tsx @@ -3,44 +3,42 @@ import "../App.css"; export default function About() { return ( - <div className="page"> + <div className="page page-about"> <SiteHeader nav="about" /> - <main className="main about-main"> - <article className="card about-card"> - <h1 className="about-title">About MyMusics</h1> + <main className="main main-about"> + <div className="about-shell"> + <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> + <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> + 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> + <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> + </div> + </article> + </div> </main> <footer className="footer"> diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { CozyAudioBar } from "../components/CozyAudioBar"; import { SiteHeader } from "../components/SiteHeader"; import "../App.css"; @@ -13,6 +14,8 @@ type RandomResponse = { streamUrl: string; }; +type QueuedTrack = TrackInfo & { streamUrl: string }; + type ErrBody = { error?: string }; type HealthBody = { @@ -28,11 +31,19 @@ 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; @@ -42,13 +53,55 @@ export default function Home() { void a.play().catch(() => {}); }, []); - const loadNext = useCallback(async () => { + 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 @@ -66,10 +119,12 @@ export default function Home() { 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]); + }, [playUrl, refillUpNext]); const handleAudioPlaying = useCallback(() => { archiveStreamErrorsRef.current = 0; @@ -86,19 +141,20 @@ export default function Home() { return; } setStatus("This track is not available from the Archive right now; trying another…"); - if (autoPlay) void loadNext(); - }, [autoPlay, loadNext]); + if (autoPlay) void advance(); + }, [autoPlay, advance]); const requestNextTrack = useCallback(() => { archiveStreamErrorsRef.current = 0; - void loadNext(); - }, [loadNext]); + 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, @@ -121,14 +177,18 @@ export default function Home() { }, []); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount bootstrap - void loadNext(); - }, [loadNext]); + // 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 loadNext(); + if (autoPlay) void advance(); }; + const showUpNextHint = + poolTrackCount === 1 && track && upNext && upNext.id === track.id; + return ( <div className="page"> {healthWarn ? ( @@ -145,8 +205,10 @@ export default function Home() { <SiteHeader nav="home" /> <main className="main"> - <section className="card now-playing"> - <h2>Now playing</h2> + <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> @@ -156,34 +218,59 @@ export default function Home() { <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> + <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> - {status && track ? <p className="hint">{status}</p> : null} - </section> + </article> - <section className="card history"> + <aside className="card history" aria-label="Recently played"> <h2>History</h2> <ol className="history-list"> {history.map((t, idx) => ( @@ -194,7 +281,7 @@ export default function Home() { </li> ))} </ol> - </section> + </aside> </main> <footer className="footer">