From e0d93b03ae30ed95aea75d580deb7d6329bf0e52 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 15:26:57 +0600 Subject: [PATCH] feat(streamer): react views ported from prototype with sse hooks --- streamer/src/views/App.tsx | 41 ++++ streamer/src/views/build.ts | 19 ++ streamer/src/views/denpa.tsx | 259 ++++++++++++++++++++ streamer/src/views/index.html | 23 ++ streamer/src/views/modern.tsx | 265 +++++++++++++++++++++ streamer/src/views/shared/format.ts | 14 ++ streamer/src/views/shared/useElapsed.ts | 10 + streamer/src/views/shared/useNowPlaying.ts | 30 +++ streamer/src/views/shared/useSpectrum.ts | 11 + 9 files changed, 672 insertions(+) create mode 100644 streamer/src/views/App.tsx create mode 100644 streamer/src/views/build.ts create mode 100644 streamer/src/views/denpa.tsx create mode 100644 streamer/src/views/index.html create mode 100644 streamer/src/views/modern.tsx create mode 100644 streamer/src/views/shared/format.ts create mode 100644 streamer/src/views/shared/useElapsed.ts create mode 100644 streamer/src/views/shared/useNowPlaying.ts create mode 100644 streamer/src/views/shared/useSpectrum.ts diff --git a/streamer/src/views/App.tsx b/streamer/src/views/App.tsx new file mode 100644 index 0000000..1bfc2af --- /dev/null +++ b/streamer/src/views/App.tsx @@ -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" + ? + : ; +} + +createRoot(document.getElementById("root")!).render(); diff --git a/streamer/src/views/build.ts b/streamer/src/views/build.ts new file mode 100644 index 0000000..fcdf420 --- /dev/null +++ b/streamer/src/views/build.ts @@ -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); diff --git a/streamer/src/views/denpa.tsx b/streamer/src/views/denpa.tsx new file mode 100644 index 0000000..ac67654 --- /dev/null +++ b/streamer/src/views/denpa.tsx @@ -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
; + + 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 ( +
+ + +
+
+
電波 TRANSMISSION
+ denpa.fm +
+
+ {channel} ←
+ {nameJp}
+ {"<3"} block radio +
+
+
[REC] ON AIR
+
{fmtJpDate(now)}
+
{fmtClockSec(now)} JST
+
{listeners.current} listening · peak {listeners.peak}
+
+
+ +
+
+
+
+ >> NOW PLAYING + {bitrate}kbps · {codec} +
+
{np.title}
+
{np.artist} — {np.album}
+
+ {fmtMs(elapsed)} +
+ {fmtMs(duration)} +
+
+
+
+
+
+
+
+ {spectrum.map((v, i) => ( +
+ ))} +
+
+ +
+
+
次の曲
+
// up next
+
auto-DJ · no ads · no skips · forever
+
+ {upNext.map((q, i) => ( +
+ 0{i + 1} + + {q.title}
+ {q.artist} +
+ {fmtMs(Number(q.duration))} +
+ ))} +
+
+ +
+
放送局情報
+
// station info
+
+
+
CHANNEL
+
{channel}
+
{genre}
+
+
+
UPTIME
+
{uptime}
+
since 2024
+
+
+
LISTENING
+
{listeners.current}
+
peak {listeners.peak} today
+
+
+
BITRATE
+
{bitrate}k
+
{codec}
+
+
+
+
+
+ + {/* Sticker bombs */} +
// 24/7 ON AIR //
+
SIDE A · CH.01
+ + {/* Bottom strip */} +
+
>> tune in @ {cfg.tuneInUrl}
+
+
+ ※ 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(" ※ ")} ※ +
+
+
{fmtClock(now)}
+
+
+ ); +} diff --git a/streamer/src/views/index.html b/streamer/src/views/index.html new file mode 100644 index 0000000..393a74f --- /dev/null +++ b/streamer/src/views/index.html @@ -0,0 +1,23 @@ + + + + +denpa streamer + + + + + + +
+ + + diff --git a/streamer/src/views/modern.tsx b/streamer/src/views/modern.tsx new file mode 100644 index 0000000..84a8f6c --- /dev/null +++ b/streamer/src/views/modern.tsx @@ -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
; + + const pct = duration > 0 ? (elapsed / duration) * 100 : 0; + const upNext = np.up_next.slice(0, 3); + const bitrate = 192; + + return ( +
+ + +
+
+ +
+ + denpa.fm — {cfg.station} +
+ +
+
Live
+
{fmtClock(now)} JST
+
+ + {/* Artwork (left) */} +
+ +
+ + {/* Right column */} +
+
+ Now playing + {cfg.station} +
+

{np.title}

+
{np.artist}
+
{np.album} · {bitrate}kbps
+ +
+
+ {fmtMs(elapsed)} + {fmtMs(duration)} +
+ +
+ {spectrum.map((v, i) => ( +
+ ))} +
+ +
+
Up next
+
+ {upNext.map((q, i) => ( +
+
+ {q.title} + {q.artist} +
+
{fmtMs(Number(q.duration))}
+
+ ))} +
+
+
+ +
+ Tune in at {cfg.tuneInUrl} + {listeners.current} listeners · 24/7 · auto-DJ + {cfg.tuneInUrl} +
+
+ ); +} diff --git a/streamer/src/views/shared/format.ts b/streamer/src/views/shared/format.ts new file mode 100644 index 0000000..0af186f --- /dev/null +++ b/streamer/src/views/shared/format.ts @@ -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()}日`; diff --git a/streamer/src/views/shared/useElapsed.ts b/streamer/src/views/shared/useElapsed.ts new file mode 100644 index 0000000..2c3ac38 --- /dev/null +++ b/streamer/src/views/shared/useElapsed.ts @@ -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)); +} diff --git a/streamer/src/views/shared/useNowPlaying.ts b/streamer/src/views/shared/useNowPlaying.ts new file mode 100644 index 0000000..899b1c9 --- /dev/null +++ b/streamer/src/views/shared/useNowPlaying.ts @@ -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(null); + useEffect(() => { + const es = new EventSource("/now-playing"); + es.onmessage = (e) => setState(JSON.parse(e.data)); + return () => es.close(); + }, []); + return state; +} diff --git a/streamer/src/views/shared/useSpectrum.ts b/streamer/src/views/shared/useSpectrum.ts new file mode 100644 index 0000000..1450a2f --- /dev/null +++ b/streamer/src/views/shared/useSpectrum.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export function useSpectrum(): number[] { + const [bars, setBars] = useState([]); + useEffect(() => { + const es = new EventSource("/spectrum"); + es.onmessage = (e) => setBars(JSON.parse(e.data)); + return () => es.close(); + }, []); + return bars; +}