commit 9f126945b789102505d314188090670b913b2d7c
parent 22b49eca4c9534729bff24ffeedf53328e1b7db6
Author: Pablo Murad <pblmrd@gmail.com>
Date: Fri, 1 May 2026 14:10:34 -0300
modifications
Diffstat:
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">