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