From f069eef8a8ad17d7c13a0d8019fcc3f748422205 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 09:34:49 +0600 Subject: [PATCH 01/10] feat(frontend): add Tape presentational component --- frontend/src/components/Tape.tsx | 30 +++++++++++++++++++++++++ frontend/src/styles/components/tape.css | 23 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 frontend/src/components/Tape.tsx create mode 100644 frontend/src/styles/components/tape.css diff --git a/frontend/src/components/Tape.tsx b/frontend/src/components/Tape.tsx new file mode 100644 index 0000000..c154e6c --- /dev/null +++ b/frontend/src/components/Tape.tsx @@ -0,0 +1,30 @@ +import type { Station } from '@lib/types'; + +interface Props { + station: Station; + index: number; + active: boolean; + onSelect: (id: string) => void; + listeners?: number | null; +} + +export function Tape({ station, index, active, onSelect, listeners }: Props) { + return ( + + ); +} diff --git a/frontend/src/styles/components/tape.css b/frontend/src/styles/components/tape.css new file mode 100644 index 0000000..db5cdcb --- /dev/null +++ b/frontend/src/styles/components/tape.css @@ -0,0 +1,23 @@ +.tape { + font-family: var(--f-mono); + background: var(--cream); color: var(--ink); + padding: 10px 12px 12px; + border: 2px solid var(--ink); + box-shadow: 3px 3px 0 var(--ink); + cursor: pointer; text-align: left; + transition: transform 120ms ease-out, box-shadow 120ms ease-out; + width: 100%; +} +.tape:hover { transform: translate(-2px,-2px); box-shadow: 5px 5px 0 var(--ink); } +.tape.active { + background: var(--pink); color: var(--cream); + transform: translate(-3px,-3px) rotate(-1deg); + box-shadow: 6px 6px 0 var(--lemon); +} +.tape-num { font-family: var(--f-mono); font-size: 10px; opacity: 0.65; letter-spacing: 1.5px; } +.tape-name { font-family: var(--f-pixel); font-size: 16px; line-height: 1.1; margin-top: 2px; } +.tape-jp { font-family: var(--f-pixel); font-size: 11px; opacity: 0.85; letter-spacing: 1.5px; margin-top: 2px; } +.tape-meta { font-family: var(--f-mono); font-size: 10px; margin-top: 6px; display: flex; justify-content: space-between; opacity: 0.75; } +.tape-holes { display: flex; gap: 4px; margin-top: 6px; } +.tape-holes span { width: 14px; height: 14px; border-radius: 50%; background: var(--ink); box-shadow: inset 0 0 0 3px var(--cream); } +.tape.active .tape-holes span { box-shadow: inset 0 0 0 3px var(--pink); } From 7ab3c850bf6f151ee5d35c2239ec50d6fb41dc2c Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 09:36:35 +0600 Subject: [PATCH 02/10] feat(frontend): add player store + WebAudio Spectrum --- frontend/src/components/Spectrum.tsx | 104 ++++++++++++++++++++ frontend/src/lib/store.ts | 18 ++++ frontend/src/styles/components/spectrum.css | 10 ++ 3 files changed, 132 insertions(+) create mode 100644 frontend/src/components/Spectrum.tsx create mode 100644 frontend/src/lib/store.ts create mode 100644 frontend/src/styles/components/spectrum.css diff --git a/frontend/src/components/Spectrum.tsx b/frontend/src/components/Spectrum.tsx new file mode 100644 index 0000000..27d9c6e --- /dev/null +++ b/frontend/src/components/Spectrum.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef } from 'react'; +import { playerStore } from '@lib/store'; + +const BARS = 36; + +export function Spectrum() { + const containerRef = useRef(null); + const barRefs = useRef([]); + + useEffect(() => { + let raf = 0; + let ctx: AudioContext | null = null; + let analyser: AnalyserNode | null = null; + let buf: Uint8Array | null = null; + let connected = false; + + const tryConnect = () => { + if (connected) return; + const audio = playerStore.getAudio(); + if (!audio) return; + try { + if (!ctx) { + const Ctor = window.AudioContext + ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) throw new Error('no AudioContext'); + ctx = new Ctor(); + } + const src = ctx.createMediaElementSource(audio); + analyser = ctx.createAnalyser(); + analyser.fftSize = 128; + src.connect(analyser); + analyser.connect(ctx.destination); + buf = new Uint8Array(analyser.frequencyBinCount) as Uint8Array; + connected = true; + } catch (err) { + console.warn('[spectrum] webaudio unavailable, using fake', err); + } + }; + + const fakeFrame = (t: number) => { + for (let i = 0; i < BARS; i++) { + const a = Math.sin(t * 0.0021 + i * 0.7) * 0.5 + 0.5; + const f = i / BARS; + const env = 0.4 + 0.6 * Math.exp(-f * 2.2); + const v = a * env * 0.3; + const el = barRefs.current[i]; + if (el) el.style.height = `${Math.max(4, v * 100)}%`; + } + }; + + const realFrame = () => { + if (!analyser || !buf) return; + analyser.getByteFrequencyData(buf); + for (let i = 0; i < BARS; i++) { + const v = (buf[i] ?? 0) / 255; + const el = barRefs.current[i]; + if (el) el.style.height = `${Math.max(4, v * 100)}%`; + } + }; + + const tick = (t: number) => { + tryConnect(); + const audio = playerStore.getAudio(); + if (connected && audio && !audio.paused) { + if (ctx?.state === 'suspended') { + void ctx.resume(); + } + realFrame(); + } else { + fakeFrame(t); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + + const unsub = playerStore.subscribe(() => { + tryConnect(); + }); + + return () => { + cancelAnimationFrame(raf); + unsub(); + try { + void ctx?.close(); + } catch { + // ignore + } + }; + }, []); + + return ( +
+ {Array.from({ length: BARS }).map((_, i) => ( +
{ + if (el) barRefs.current[i] = el; + }} + /> + ))} +
+ ); +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..09b6ff2 --- /dev/null +++ b/frontend/src/lib/store.ts @@ -0,0 +1,18 @@ +let audioRef: HTMLAudioElement | null = null; +const subs = new Set<() => void>(); + +export const playerStore = { + setAudio(el: HTMLAudioElement | null) { + audioRef = el; + subs.forEach((fn) => fn()); + }, + getAudio(): HTMLAudioElement | null { + return audioRef; + }, + subscribe(fn: () => void): () => void { + subs.add(fn); + return () => { + subs.delete(fn); + }; + }, +}; diff --git a/frontend/src/styles/components/spectrum.css b/frontend/src/styles/components/spectrum.css new file mode 100644 index 0000000..d9d8f24 --- /dev/null +++ b/frontend/src/styles/components/spectrum.css @@ -0,0 +1,10 @@ +.deck-spectrum { + display: flex; align-items: flex-end; gap: 2px; + height: 80px; margin-top: 18px; + padding: 10px; background: var(--ink); border: 2px solid var(--ink); +} +.spec-bar { + flex: 1; min-height: 3px; + background: linear-gradient(180deg, var(--pink) 0%, var(--lemon) 50%, var(--green) 100%); + transition: height 80ms linear; +} From 65d88be0328c6a6b27cc21c8c5f6a00252f317e8 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 09:41:07 +0600 Subject: [PATCH 03/10] feat(frontend): add HeroPlayer interactive island --- frontend/src/components/HeroPlayer.tsx | 324 ++++++++++++++++++++++++ frontend/src/styles/components/deck.css | 95 +++++++ frontend/src/styles/components/hero.css | 106 ++++++++ 3 files changed, 525 insertions(+) create mode 100644 frontend/src/components/HeroPlayer.tsx create mode 100644 frontend/src/styles/components/deck.css create mode 100644 frontend/src/styles/components/hero.css diff --git a/frontend/src/components/HeroPlayer.tsx b/frontend/src/components/HeroPlayer.tsx new file mode 100644 index 0000000..bfc6fe2 --- /dev/null +++ b/frontend/src/components/HeroPlayer.tsx @@ -0,0 +1,324 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { Station, NowPlaying, IcecastStatusJson } from '@lib/types'; +import { Tape } from './Tape'; +import { Spectrum } from './Spectrum'; +import { playerStore } from '@lib/store'; +import { fmtMs, fmtJpDate, fmtJpTime } from '@lib/format'; + +const PUBLIC_ORIGIN = 'https://denpa.femboy.page'; +const POLL_NOW_MS = 5_000; +const POLL_LISTENERS_MS = 30_000; + +interface Props { + initialStations: Station[]; +} + +type Format = 'mp3' | 'opus'; + +const lsGet = (k: string): string | null => { + try { return localStorage.getItem(k); } catch { return null; } +}; +const lsSet = (k: string, v: string) => { + try { localStorage.setItem(k, v); } catch { /* ignore */ } +}; + +export function HeroPlayer({ initialStations }: Props) { + const [stations, setStations] = useState(initialStations); + const [selectedId, setSelectedId] = useState(() => { + const saved = lsGet('denpa:station'); + if (saved && initialStations.some((s) => s.id === saved)) return saved; + return initialStations[0]?.id ?? ''; + }); + const [format, setFormat] = useState(() => (lsGet('denpa:format') === 'opus' ? 'opus' : 'mp3')); + const [vol, setVol] = useState(() => { + const v = parseFloat(lsGet('denpa:vol') ?? '0.72'); + return isNaN(v) ? 0.72 : Math.max(0, Math.min(1, v)); + }); + const [muted, setMuted] = useState(false); + const [playing, setPlaying] = useState(false); + const [now, setNow] = useState(null); + const [listeners, setListeners] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [copyState, setCopyState] = useState<'idle' | 'ok'>('idle'); + const [clockNow, setClockNow] = useState(() => new Date()); + + const audioRef = useRef(null); + const station = stations.find((s) => s.id === selectedId); + const streamUrl = station ? `${PUBLIC_ORIGIN}${station.mounts[format]}` : ''; + + // refresh stations periodically (cheap; backend caches) + useEffect(() => { + let aborted = false; + const refresh = async () => { + try { + const r = await fetch('/api/stations.json'); + if (!r.ok) return; + const ss = await r.json() as Station[]; + if (!aborted && ss.length) { + setStations(ss); + if (!ss.some((s) => s.id === selectedId)) { + const first = ss[0]; + if (first) setSelectedId(first.id); + } + } + } catch { /* ignore */ } + }; + const id = setInterval(refresh, 60_000); + return () => { aborted = true; clearInterval(id); }; + }, [selectedId]); + + // share audio element with Spectrum + useEffect(() => { + playerStore.setAudio(audioRef.current); + return () => playerStore.setAudio(null); + }, []); + + // wire