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) { // ssr-safe defaults; localStorage + Date reads happen post-mount to avoid // hydration mismatches (#418, #423, #425). const [stations, setStations] = useState(initialStations); const [selectedId, setSelectedId] = useState(initialStations[0]?.id ?? ''); const [format, setFormat] = useState('mp3'); const [vol, setVol] = useState(0.72); 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 [clockText, setClockText] = useState<{ date: string; time: string }>({ date: '', time: '' }); // restore persisted UI state once on the client useEffect(() => { const savedStation = lsGet('denpa:station'); if (savedStation && initialStations.some((s) => s.id === savedStation)) { setSelectedId(savedStation); } if (lsGet('denpa:format') === 'opus') setFormat('opus'); const savedVol = parseFloat(lsGet('denpa:vol') ?? ''); if (!isNaN(savedVol)) setVol(Math.max(0, Math.min(1, savedVol))); }, [initialStations]); 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