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;
+}