feat(streamer): react views ported from prototype with sse hooks
This commit is contained in:
parent
870a41a2f7
commit
e0d93b03ae
9 changed files with 672 additions and 0 deletions
41
streamer/src/views/App.tsx
Normal file
41
streamer/src/views/App.tsx
Normal 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>);
|
||||
19
streamer/src/views/build.ts
Normal file
19
streamer/src/views/build.ts
Normal 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);
|
||||
259
streamer/src/views/denpa.tsx
Normal file
259
streamer/src/views/denpa.tsx
Normal 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>>> 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>>></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>
|
||||
);
|
||||
}
|
||||
23
streamer/src/views/index.html
Normal file
23
streamer/src/views/index.html
Normal 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>
|
||||
265
streamer/src/views/modern.tsx
Normal file
265
streamer/src/views/modern.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
streamer/src/views/shared/format.ts
Normal file
14
streamer/src/views/shared/format.ts
Normal 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()}日`;
|
||||
10
streamer/src/views/shared/useElapsed.ts
Normal file
10
streamer/src/views/shared/useElapsed.ts
Normal 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));
|
||||
}
|
||||
30
streamer/src/views/shared/useNowPlaying.ts
Normal file
30
streamer/src/views/shared/useNowPlaying.ts
Normal 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;
|
||||
}
|
||||
11
streamer/src/views/shared/useSpectrum.ts
Normal file
11
streamer/src/views/shared/useSpectrum.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue