265 lines
14 KiB
TypeScript
265 lines
14 KiB
TypeScript
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; branding: boolean; }
|
|
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">
|
|
{cfg.branding && (
|
|
<>
|
|
<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 */}
|
|
{cfg.branding && (
|
|
<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>
|
|
);
|
|
}
|