feat(streamer): react views ported from prototype with sse hooks

This commit is contained in:
devilreef 2026-04-30 15:26:57 +06:00
parent 870a41a2f7
commit e0d93b03ae
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
9 changed files with 672 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { StreamDenpa } from "./denpa.tsx";
import { StreamModern } from "./modern.tsx";
declare global {
interface Window {
__STREAMER_CONFIG__: { style: "denpa" | "modern"; station: string; tz: string; tuneInUrl: string };
}
}
function useListeners(): { current: number; peak: number } {
const [v, setV] = useState({ current: 0, peak: 0 });
useEffect(() => {
let peak = 0;
const tick = async () => {
try {
const r = await fetch(`/listeners`);
if (r.ok) {
const j = await r.json();
peak = Math.max(peak, j.current ?? 0);
setV({ current: j.current ?? 0, peak });
}
} catch { /* ignore */ }
};
tick();
const t = setInterval(tick, 30000);
return () => clearInterval(t);
}, []);
return v;
}
function App() {
const cfg = window.__STREAMER_CONFIG__;
const listeners = useListeners();
return cfg.style === "denpa"
? <StreamDenpa cfg={cfg} listeners={listeners} />
: <StreamModern cfg={cfg} listeners={listeners} />;
}
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);

View file

@ -0,0 +1,19 @@
import { build } from "esbuild";
import { copyFileSync, mkdirSync } from "node:fs";
const outDir = "dist/views";
mkdirSync(outDir, { recursive: true });
copyFileSync("src/views/index.html", `${outDir}/index.html`);
await build({
entryPoints: ["src/views/App.tsx"],
bundle: true,
format: "esm",
target: "es2022",
jsx: "automatic",
outfile: `${outDir}/main.js`,
loader: { ".tsx": "tsx", ".ts": "ts" },
define: { "process.env.NODE_ENV": '"production"' },
minify: true,
});
console.log("views bundled →", outDir);

View file

@ -0,0 +1,259 @@
import { useEffect, useState } from "react";
import { useNowPlaying } from "./shared/useNowPlaying.ts";
import { useSpectrum } from "./shared/useSpectrum.ts";
import { useElapsed } from "./shared/useElapsed.ts";
import { fmtMs, fmtClock, fmtClockSec, fmtJpDate } from "./shared/format.ts";
const KAOMOJI_STREAM = ["(´。• ᵕ •。`)", "٩(◕‿◕)۶", "(。◕‿◕。)", "(>ω<)", "(✿◠‿◠)"];
interface Cfg { station: string; tz: string; tuneInUrl: string; }
interface Listeners { current: number; peak: number; }
interface Props { cfg: Cfg; listeners: Listeners; }
export function StreamDenpa({ cfg, listeners }: Props) {
const np = useNowPlaying();
const spectrum = useSpectrum();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const startedAt = np ? Number(np.started_at) : 0;
const duration = np ? Number(np.duration) : 0;
const elapsed = useElapsed(startedAt, duration);
if (!np) return <div style={{ width: 1920, height: 1080, background: "#1a0820" }} />;
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
const upNext = np.up_next.slice(0, 4);
const nameJp = "ラジオ";
const channel = "CH.01";
const bitrate = 192;
const codec = "MP3/OPUS";
const genre = "auto";
const uptime = "—";
return (
<div className="sd-root">
<style>{`
.sd-root {
width: 1920px; height: 1080px;
box-sizing: border-box;
background:
radial-gradient(ellipse at 25% 0%, #5ef7ff22 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, #ff3ea522 0%, transparent 55%),
#1a0820;
color: #fff4e8;
font-family: var(--f-mono);
position: relative; overflow: hidden;
padding: 56px 64px 0;
font-variant-emoji: text;
}
.sd-root::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: 0.07; mix-blend-mode: overlay;
}
.sd-root::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0 1px, transparent 1px 3px);
opacity: 0.4;
}
.sd-header { display: grid; grid-template-columns: auto 1fr auto; align-items: end; gap: 36px; margin-bottom: 36px; position: relative; z-index: 2; }
.sd-wm .eyebrow { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 6px; color: #ff3ea5; }
.sd-wm .big {
display: block; font-family: var(--f-pixel); font-size: 110px; line-height: 0.9;
margin-top: 10px; padding-right: 0.12em;
color: transparent;
background: linear-gradient(180deg, #fff4e8 0%, #fff4e8 50%, #ff3ea5 50%, #ff3ea5 100%);
-webkit-background-clip: text; background-clip: text;
filter: drop-shadow(0 0 14px #ff3ea580);
}
.sd-channel { font-family: var(--f-hand); font-size: 34px; transform: rotate(-2deg); line-height: 1.05; max-width: 280px; align-self: flex-end; padding-bottom: 14px; }
.sd-status { text-align: right; font-family: var(--f-mono); font-size: 18px; line-height: 1.5; color: #5ef7ff; padding-bottom: 12px; }
.sd-status .live { color: #ff3ea5; font-family: var(--f-pixel); font-size: 22px; letter-spacing: 3px; }
.sd-status .blink { animation: sd-blink 1s steps(2) infinite; }
@keyframes sd-blink { 50% { opacity: 0.15; } }
.sd-body { display: grid; grid-template-columns: 1.3fr 1fr; gap: 48px; position: relative; z-index: 2; }
.sd-deck {
background: linear-gradient(180deg, #fff4e8 0%, #f5e0c4 100%);
color: #0a0410; padding: 32px;
border: 4px solid #0a0410;
box-shadow: 12px 12px 0 #ff3ea5, 24px 24px 0 #5ef7ff;
position: relative;
}
.sd-deck::before { content: ""; position: absolute; inset: 6px; border: 1.5px dashed #0a041044; pointer-events: none; }
.sd-screen { background: #0a0410; color: #5eff9b; font-family: var(--f-mono); font-size: 18px; padding: 22px 26px; border: 3px inset #0a0410; position: relative; overflow: hidden; }
.sd-screen::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, rgba(94,255,155,0.08) 0 1px, transparent 1px 3px); }
.sd-screen-row { display: flex; justify-content: space-between; opacity: 0.78; }
.sd-screen .now { font-family: var(--f-pixel); font-size: 56px; line-height: 1.05; color: #fff4e8; text-shadow: 0 0 12px #5eff9b88; margin: 10px 0 6px; }
.sd-screen .artist { font-size: 24px; color: #5ef7ff; margin-bottom: 14px; }
.sd-progress-row { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
.sd-progress { flex: 1; height: 6px; background: #5eff9b22; border: 1px solid #5eff9b66; }
.sd-progress-fill { height: 100%; background: #5eff9b; transition: width 200ms linear; }
.sd-time { font-size: 18px; color: #5ef7ff; min-width: 56px; }
.sd-reels { display: flex; align-items: center; gap: 22px; padding: 22px; margin-top: 26px; background: #0a0410; border: 3px solid #0a0410; }
.sd-reel {
width: 150px; height: 150px; border-radius: 50%;
background:
radial-gradient(circle, #fff4e8 0 22px, transparent 22px),
conic-gradient(from 0deg, #2a1030 0 25%, #1a0820 25% 50%, #2a1030 50% 75%, #1a0820 75%);
border: 3px solid #fff4e8;
animation: sd-spin 1.6s linear infinite;
position: relative;
}
.sd-reel::before { content: ""; position: absolute; inset: 10px; border-radius: 50%; border: 1px dashed #fff4e833; }
@keyframes sd-spin { to { transform: rotate(360deg); } }
.sd-strip { flex: 1; height: 10px; background: linear-gradient(90deg, #fff4e8 0%, #d4a578 50%, #fff4e8 100%); border-top: 2px solid #0a0410; border-bottom: 2px solid #0a0410; }
.sd-spectrum { display: flex; align-items: flex-end; gap: 3px; height: 110px; margin-top: 22px; padding: 12px; background: #0a0410; border: 3px solid #0a0410; }
.sd-spec-bar { flex: 1; min-height: 4px; background: linear-gradient(180deg, #ff3ea5 0%, #ffe24a 50%, #5eff9b 100%); transition: height 80ms linear; }
.sd-right { display: flex; flex-direction: column; gap: 28px; min-width: 0; }
.sd-card { background: #1a0820; border: 2px solid #0a0410; padding: 24px 28px; position: relative; }
.sd-card::before { content: ""; position: absolute; inset: 5px; border: 1px dashed #ff3ea533; pointer-events: none; }
.sd-h { font-family: var(--f-pixel); font-size: 18px; letter-spacing: 4px; color: #ffe24a; margin-bottom: 4px; }
.sd-h-en { font-family: var(--f-pixel); font-size: 24px; color: #fff4e8; letter-spacing: 1px; }
.sd-h-sub { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; margin-top: 6px; }
.sd-queue { margin-top: 14px; display: flex; flex-direction: column; gap: 8px; }
.sd-queue-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; padding: 8px 4px; border-bottom: 1px dashed #ff3ea522; align-items: baseline; }
.sd-queue-row:last-child { border-bottom: none; }
.sd-queue-row .n { font-family: var(--f-mono); font-size: 14px; color: #5ef7ff; }
.sd-queue-row .t { font-family: var(--f-pixel); font-size: 22px; color: #fff4e8; }
.sd-queue-row .a { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; }
.sd-queue-row .d { font-family: var(--f-mono); font-size: 14px; color: #ff3ea5; align-self: center; }
.sd-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 14px; }
.sd-stat { padding: 14px 16px; background: #0a0410; border: 1.5px dashed #5ef7ff44; }
.sd-stat .lbl { font-family: var(--f-pixel); font-size: 12px; letter-spacing: 2px; color: #ffe24a; }
.sd-stat .val { font-family: var(--f-pixel); font-size: 32px; color: #fff4e8; margin-top: 4px; line-height: 1; }
.sd-stat .sub { font-family: var(--f-mono); font-size: 12px; color: #5ef7ff; margin-top: 4px; }
.sd-bomb { position: absolute; pointer-events: none; z-index: 5; }
.sd-bomb.b1 { top: 36px; right: 340px; }
.sd-bomb.b2 { top: 250px; right: 36px; }
.sd-bottom {
position: absolute; left: 0; right: 0; bottom: 0;
height: 64px; background: #0a0410;
border-top: 2px dashed #ff3ea566;
display: grid; grid-template-columns: auto 1fr auto;
align-items: center; gap: 24px; padding: 0 36px;
z-index: 6;
}
.sd-listen { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 2px; color: #5ef7ff; }
.sd-listen b { color: #ff3ea5; margin-right: 10px; }
.sd-ticker { overflow: hidden; white-space: nowrap; }
.sd-ticker-inner { display: inline-block; padding-left: 100%; animation: sd-marq 36s linear infinite; color: #ffe24a; font-family: var(--f-mono); font-size: 16px; letter-spacing: 1px; }
@keyframes sd-marq { to { transform: translateX(-100%); } }
.sd-clock { font-family: var(--f-pixel); font-size: 26px; color: #fff4e8; letter-spacing: 2px; padding: 6px 14px; background: #1a0820; border: 1.5px dashed #5ef7ff66; }
.sd-sticker { display: inline-block; padding: 10px 18px; font-family: var(--f-pixel); font-size: 20px; letter-spacing: 1px; border: 2px solid #0a0410; box-shadow: 4px 4px 0 #0a0410; }
`}</style>
<div className="sd-header">
<div className="sd-wm">
<div className="eyebrow"> TRANSMISSION</div>
<span className="big">denpa.fm</span>
</div>
<div className="sd-channel">
{channel} <br/>
{nameJp}<br/>
{"<3"} block radio
</div>
<div className="sd-status">
<div className="live">[<span className="blink">REC</span>] ON AIR</div>
<div>{fmtJpDate(now)}</div>
<div>{fmtClockSec(now)} JST</div>
<div>{listeners.current} listening · peak {listeners.peak}</div>
</div>
</div>
<div className="sd-body">
<div className="sd-deck">
<div className="sd-screen">
<div className="sd-screen-row">
<span>&gt;&gt; NOW PLAYING</span>
<span>{bitrate}kbps · {codec}</span>
</div>
<div className="now">{np.title}</div>
<div className="artist">{np.artist} {np.album}</div>
<div className="sd-progress-row">
<span className="sd-time">{fmtMs(elapsed)}</span>
<div className="sd-progress"><div className="sd-progress-fill" style={{ width: pct + "%" }}></div></div>
<span className="sd-time">{fmtMs(duration)}</span>
</div>
</div>
<div className="sd-reels">
<div className="sd-reel"></div>
<div className="sd-strip"></div>
<div className="sd-reel"></div>
</div>
<div className="sd-spectrum">
{spectrum.map((v, i) => (
<div key={i} className="sd-spec-bar" style={{ height: `${Math.max(4, v * 100)}%` }}></div>
))}
</div>
</div>
<div className="sd-right">
<div className="sd-card">
<div className="sd-h"></div>
<div className="sd-h-en">// up next</div>
<div className="sd-h-sub">auto-DJ · no ads · no skips · forever</div>
<div className="sd-queue">
{upNext.map((q, i) => (
<div key={i} className="sd-queue-row">
<span className="n">0{i + 1}</span>
<span>
<span className="t">{q.title}</span><br/>
<span className="a">{q.artist}</span>
</span>
<span className="d">{fmtMs(Number(q.duration))}</span>
</div>
))}
</div>
</div>
<div className="sd-card">
<div className="sd-h"></div>
<div className="sd-h-en">// station info</div>
<div className="sd-stats">
<div className="sd-stat">
<div className="lbl">CHANNEL</div>
<div className="val">{channel}</div>
<div className="sub">{genre}</div>
</div>
<div className="sd-stat">
<div className="lbl">UPTIME</div>
<div className="val">{uptime}</div>
<div className="sub">since 2024</div>
</div>
<div className="sd-stat">
<div className="lbl">LISTENING</div>
<div className="val">{listeners.current}</div>
<div className="sub">peak {listeners.peak} today</div>
</div>
<div className="sd-stat">
<div className="lbl">BITRATE</div>
<div className="val">{bitrate}k</div>
<div className="sub">{codec}</div>
</div>
</div>
</div>
</div>
</div>
{/* Sticker bombs */}
<div className="sd-bomb b1"><span className="sd-sticker" style={{ background: "#5ef7ff", color: "#0a0410", transform: "rotate(-6deg)" }}>// 24/7 ON AIR //</span></div>
<div className="sd-bomb b2"><span className="sd-sticker" style={{ background: "#ffe24a", color: "#0a0410", transform: "rotate(5deg)" }}>SIDE A · CH.01</span></div>
{/* Bottom strip */}
<div className="sd-bottom">
<div className="sd-listen"><b>&gt;&gt;</b> tune in @ {cfg.tuneInUrl}</div>
<div className="sd-ticker">
<div className="sd-ticker-inner">
now broadcasting from a small room in tokyo 24/7 always-on {cfg.station} station stream URL: {cfg.tuneInUrl} requests via @denpa_bot no ads · no algorithms · just blocks {KAOMOJI_STREAM.join(" ※ ")}
</div>
</div>
<div className="sd-clock">{fmtClock(now)}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>denpa streamer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DotGothic16&family=VT323&family=Reenie+Beanie&family=Inter:wght@400;500;600;700;800&display=swap" />
<style>
:root {
--f-pixel: "DotGothic16", "VT323", monospace;
--f-mono: "VT323", ui-monospace, monospace;
--f-hand: "Reenie Beanie", cursive;
}
html, body { margin: 0; padding: 0; background: #0a0410; }
body { font-family: "Inter", system-ui, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/assets/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useNowPlaying } from "./shared/useNowPlaying.ts";
import { useSpectrum } from "./shared/useSpectrum.ts";
import { useElapsed } from "./shared/useElapsed.ts";
import { fmtMs, fmtClock } from "./shared/format.ts";
interface Cfg { station: string; tz: string; tuneInUrl: string; }
interface Listeners { current: number; peak: number; }
interface Props { cfg: Cfg; listeners: Listeners; }
export function StreamModern({ cfg, listeners }: Props) {
const np = useNowPlaying();
const spectrum = useSpectrum();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const startedAt = np ? Number(np.started_at) : 0;
const duration = np ? Number(np.duration) : 0;
const elapsed = useElapsed(startedAt, duration);
if (!np) return <div style={{ width: 1920, height: 1080, background: "#0c0d10" }} />;
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
const upNext = np.up_next.slice(0, 3);
const bitrate = 192;
return (
<div className="sm-root">
<style>{`
.sm-root {
width: 1920px; height: 1080px;
box-sizing: border-box;
background: #0c0d10;
color: #f5f5f0;
font-family: "Inter", system-ui, sans-serif;
position: relative; overflow: hidden;
padding: 80px;
display: grid;
grid-template-columns: 720px 1fr;
gap: 80px;
align-items: center;
}
/* Ambient color wash from artwork */
.sm-bg {
position: absolute; inset: -20%;
background:
radial-gradient(ellipse at 25% 40%, #4a6b7a 0%, transparent 50%),
radial-gradient(ellipse at 75% 60%, #6a8a5a 0%, transparent 55%);
filter: blur(80px); opacity: 0.5; z-index: 0;
}
.sm-grain {
position: absolute; inset: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: 0.04; mix-blend-mode: overlay; z-index: 1;
}
/* Brand mark — top-left */
.sm-brand {
position: absolute; top: 48px; left: 80px;
display: flex; align-items: center; gap: 14px;
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 4px;
color: #f5f5f0aa; z-index: 3;
text-transform: uppercase;
}
.sm-brand-dot { width: 10px; height: 10px; border-radius: 50%; background: #e8546b; box-shadow: 0 0 12px #e8546b; }
/* Live + clock — top-right */
.sm-meta-top {
position: absolute; top: 48px; right: 80px;
display: flex; align-items: center; gap: 28px;
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 3px;
color: #f5f5f0aa; z-index: 3;
text-transform: uppercase;
}
.sm-live { display: flex; align-items: center; gap: 10px; color: #e8546b; }
.sm-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #e8546b; animation: sm-pulse 1.6s ease-in-out infinite; }
@keyframes sm-pulse { 50% { opacity: 0.2; transform: scale(0.8); } }
.sm-clock { font-variant-numeric: tabular-nums; color: #f5f5f0; font-weight: 500; letter-spacing: 2px; }
/* Artwork */
.sm-art-wrap { position: relative; z-index: 2; aspect-ratio: 1 / 1; }
.sm-art {
width: 100%; aspect-ratio: 1 / 1;
border-radius: 12px;
background:
linear-gradient(135deg, #4a6b7a 0%, #2a3a48 100%);
box-shadow:
0 60px 120px rgba(0,0,0,0.6),
0 0 0 1px rgba(255,255,255,0.04);
position: relative;
overflow: hidden;
object-fit: cover;
display: block;
}
/* Stylized cover for "Sweden" — soft hills + sun */
.sm-art svg { width: 100%; height: 100%; display: block; }
/* Right column */
.sm-right { position: relative; z-index: 2; max-width: 880px; }
.sm-eyebrow {
font-family: "Inter", sans-serif; font-size: 13px; letter-spacing: 4px;
text-transform: uppercase; color: #f5f5f0aa; margin-bottom: 20px;
display: flex; gap: 18px; align-items: center;
}
.sm-eyebrow .pill {
padding: 4px 10px; border: 1px solid #f5f5f033; border-radius: 999px;
font-size: 11px; letter-spacing: 2px;
}
.sm-title {
font-family: "Inter", "Helvetica Neue", sans-serif;
font-weight: 800;
font-size: 124px; line-height: 0.95;
letter-spacing: -0.04em;
color: #f5f5f0;
margin: 0 0 24px;
}
.sm-artist {
font-family: "Inter", sans-serif; font-weight: 500;
font-size: 36px; color: #d4d0c4;
margin-bottom: 6px;
}
.sm-album {
font-family: "Inter", sans-serif; font-weight: 400;
font-size: 22px; color: #d4d0c499;
margin-bottom: 56px;
}
/* Progress */
.sm-progress {
height: 4px; background: #f5f5f01a;
border-radius: 2px; overflow: hidden;
margin-bottom: 12px;
}
.sm-progress-fill {
height: 100%; background: #f5f5f0;
width: 0%;
transition: width 200ms linear;
border-radius: 2px;
}
.sm-times {
display: flex; justify-content: space-between;
font-family: "Inter", sans-serif; font-size: 16px;
color: #f5f5f0aa; font-variant-numeric: tabular-nums;
letter-spacing: 1px;
margin-bottom: 56px;
}
/* Spectrum (subtle, bottom of right column) */
.sm-spectrum {
display: flex; align-items: flex-end; gap: 3px;
height: 64px;
margin-bottom: 32px;
}
.sm-spec-bar {
flex: 1; min-height: 2px;
background: #f5f5f088;
border-radius: 1px;
transition: height 80ms linear;
}
/* Up next — minimal */
.sm-upnext {
border-top: 1px solid #f5f5f01a;
padding-top: 24px;
}
.sm-upnext-h {
font-family: "Inter", sans-serif; font-size: 12px;
letter-spacing: 4px; text-transform: uppercase;
color: #f5f5f088;
margin-bottom: 16px;
}
.sm-upnext-list { display: flex; flex-direction: column; gap: 10px; }
.sm-upnext-row {
display: grid; grid-template-columns: 1fr auto;
gap: 24px; align-items: baseline;
padding-bottom: 10px;
border-bottom: 1px solid #f5f5f00f;
}
.sm-upnext-row:last-child { border-bottom: none; }
.sm-upnext-row .t {
font-family: "Inter", sans-serif; font-size: 22px; font-weight: 500;
color: #f5f5f0;
}
.sm-upnext-row .a { font-size: 16px; color: #d4d0c499; margin-left: 14px; font-weight: 400; }
.sm-upnext-row .d { font-size: 16px; color: #f5f5f088; font-variant-numeric: tabular-nums; }
/* Bottom strip */
.sm-bottom {
position: absolute; left: 80px; right: 80px; bottom: 56px;
display: flex; justify-content: space-between; align-items: center;
font-family: "Inter", sans-serif; font-size: 13px;
letter-spacing: 3px; text-transform: uppercase;
color: #f5f5f088;
z-index: 3;
}
.sm-bottom .listeners { color: #f5f5f0; }
`}</style>
<div className="sm-bg"></div>
<div className="sm-grain"></div>
<div className="sm-brand">
<span className="sm-brand-dot"></span>
denpa.fm {cfg.station}
</div>
<div className="sm-meta-top">
<div className="sm-live"><span className="sm-live-dot"></span>Live</div>
<div className="sm-clock">{fmtClock(now)} JST</div>
</div>
{/* Artwork (left) */}
<div className="sm-art-wrap">
<img className="sm-art" src={np.cover_url} alt="" />
</div>
{/* Right column */}
<div className="sm-right">
<div className="sm-eyebrow">
<span>Now playing</span>
<span className="pill">{cfg.station}</span>
</div>
<h1 className="sm-title">{np.title}</h1>
<div className="sm-artist">{np.artist}</div>
<div className="sm-album">{np.album} · {bitrate}kbps</div>
<div className="sm-progress"><div className="sm-progress-fill" style={{ width: pct + "%" }}></div></div>
<div className="sm-times">
<span>{fmtMs(elapsed)}</span>
<span>{fmtMs(duration)}</span>
</div>
<div className="sm-spectrum">
{spectrum.map((v, i) => (
<div key={i} className="sm-spec-bar" style={{ height: `${Math.max(3, v * 100)}%`, opacity: 0.3 + v * 0.6 }}></div>
))}
</div>
<div className="sm-upnext">
<div className="sm-upnext-h">Up next</div>
<div className="sm-upnext-list">
{upNext.map((q, i) => (
<div key={i} className="sm-upnext-row">
<div>
<span className="t">{q.title}</span>
<span className="a">{q.artist}</span>
</div>
<div className="d">{fmtMs(Number(q.duration))}</div>
</div>
))}
</div>
</div>
</div>
<div className="sm-bottom">
<span>Tune in at <span className="listeners">{cfg.tuneInUrl}</span></span>
<span><span className="listeners">{listeners.current}</span> listeners · 24/7 · auto-DJ</span>
<span>{cfg.tuneInUrl}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
export const fmtMs = (sec: number): string => {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
export const fmtClock = (d: Date): string =>
`${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
export const fmtClockSec = (d: Date): string =>
`${fmtClock(d)}:${d.getSeconds().toString().padStart(2, "0")}`;
export const fmtJpDate = (d: Date): string =>
`${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;

View file

@ -0,0 +1,10 @@
import { useEffect, useState } from "react";
export function useElapsed(startedAt: number, duration: number): number {
const [now, setNow] = useState(() => Date.now() / 1000);
useEffect(() => {
const t = setInterval(() => setNow(Date.now() / 1000), 200);
return () => clearInterval(t);
}, []);
return Math.min(duration, Math.max(0, now - startedAt));
}

View file

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
export interface NowPlayingTrack {
title: string;
artist: string;
album: string;
duration: string;
cover_url: string;
}
export interface NowPlayingState {
station: string;
title: string;
artist: string;
album: string;
duration: string;
started_at: string;
cover_url: string;
up_next: NowPlayingTrack[];
}
export function useNowPlaying(): NowPlayingState | null {
const [state, setState] = useState<NowPlayingState | null>(null);
useEffect(() => {
const es = new EventSource("/now-playing");
es.onmessage = (e) => setState(JSON.parse(e.data));
return () => es.close();
}, []);
return state;
}

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from "react";
export function useSpectrum(): number[] {
const [bars, setBars] = useState<number[]>([]);
useEffect(() => {
const es = new EventSource("/spectrum");
es.onmessage = (e) => setBars(JSON.parse(e.data));
return () => es.close();
}, []);
return bars;
}