denpa-radio/frontend/src/components/HeroPlayer.tsx

335 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Station[]>(initialStations);
const [selectedId, setSelectedId] = useState<string>(initialStations[0]?.id ?? '');
const [format, setFormat] = useState<Format>('mp3');
const [vol, setVol] = useState<number>(0.72);
const [muted, setMuted] = useState(false);
const [playing, setPlaying] = useState(false);
const [now, setNow] = useState<NowPlaying | null>(null);
const [listeners, setListeners] = useState<number | null>(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<HTMLAudioElement | null>(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 <audio> events
useEffect(() => {
const a = audioRef.current;
if (!a) return;
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onError = () => setPlaying(false);
a.addEventListener('play', onPlay);
a.addEventListener('pause', onPause);
a.addEventListener('error', onError);
return () => {
a.removeEventListener('play', onPlay);
a.removeEventListener('pause', onPause);
a.removeEventListener('error', onError);
};
}, []);
// volume sync
useEffect(() => {
const a = audioRef.current;
if (a) a.volume = muted ? 0 : vol;
lsSet('denpa:vol', String(vol));
}, [vol, muted]);
// persist selections
useEffect(() => { lsSet('denpa:station', selectedId); }, [selectedId]);
useEffect(() => { lsSet('denpa:format', format); }, [format]);
// poll now-playing
useEffect(() => {
if (!selectedId) return;
const ctrl = new AbortController();
let timer: ReturnType<typeof setInterval> | null = null;
const fetchNow = async () => {
try {
const r = await fetch(`/now-playing/${selectedId}.json`, { signal: ctrl.signal });
if (!r.ok) { setNow(null); return; }
const np = await r.json() as NowPlaying;
setNow(np);
} catch (err) {
if ((err as Error).name !== 'AbortError') setNow(null);
}
};
void fetchNow();
timer = setInterval(fetchNow, POLL_NOW_MS);
return () => { ctrl.abort(); if (timer) clearInterval(timer); };
}, [selectedId]);
// poll listeners
useEffect(() => {
if (!selectedId) return;
const ctrl = new AbortController();
let timer: ReturnType<typeof setInterval> | null = null;
const fetchListeners = async () => {
try {
const r = await fetch('/status-json.xsl', { signal: ctrl.signal });
if (!r.ok) return;
const j = await r.json() as IcecastStatusJson;
const sources = j?.icestats?.source;
const arr = Array.isArray(sources) ? sources : sources ? [sources] : [];
const want = `/${selectedId}.${format}`;
const m = arr.find((s) => s.listenurl?.endsWith(want));
if (m && typeof m.listeners === 'number') setListeners(m.listeners);
} catch (err) {
if ((err as Error).name !== 'AbortError') setListeners(null);
}
};
void fetchListeners();
timer = setInterval(fetchListeners, POLL_LISTENERS_MS);
return () => { ctrl.abort(); if (timer) clearInterval(timer); };
}, [selectedId, format]);
// elapsed ticker
useEffect(() => {
if (!now?.started_at) { setElapsed(0); return; }
const start = parseInt(now.started_at, 10) * 1000;
const update = () => setElapsed(Math.max(0, Math.floor((Date.now() - start) / 1000)));
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [now?.started_at]);
// wall clock for header — only ticks after mount so ssr/hydrate match (empty strings)
useEffect(() => {
const tick = () => {
const d = new Date();
setClockText({ date: fmtJpDate(d), time: fmtJpTime(d) });
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// keyboard shortcuts
const togglePlay = useCallback(() => {
const a = audioRef.current;
if (!a) return;
if (a.paused) a.play().catch(() => { /* ignore — autoplay etc. */ });
else a.pause();
}, []);
const cycleStation = useCallback((dir: 1 | -1) => {
if (!stations.length) return;
const i = stations.findIndex((s) => s.id === selectedId);
const next = stations[(i + dir + stations.length) % stations.length];
if (next) setSelectedId(next.id);
}, [stations, selectedId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tgt = e.target as HTMLElement | null;
if (tgt && /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName)) return;
switch (e.key) {
case ' ': e.preventDefault(); togglePlay(); break;
case 'ArrowUp': e.preventDefault(); setVol((v) => Math.min(1, v + 0.05)); break;
case 'ArrowDown': e.preventDefault(); setVol((v) => Math.max(0, v - 0.05)); break;
case 'ArrowLeft': e.preventDefault(); cycleStation(-1); break;
case 'ArrowRight': e.preventDefault(); cycleStation(1); break;
case 'm': case 'M': e.preventDefault(); setMuted((m) => !m); break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [togglePlay, cycleStation]);
// copy URL
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(streamUrl);
setCopyState('ok');
} catch {
const ta = document.createElement('textarea');
ta.value = streamUrl;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopyState('ok');
}
setTimeout(() => setCopyState('idle'), 1500);
};
// when station/format changes, reload + maybe play
useEffect(() => {
const a = audioRef.current;
if (!a || !station) return;
const wasPlaying = !a.paused;
a.src = `${station.mounts[format]}`;
a.load();
if (wasPlaying) a.play().catch(() => { /* ignore */ });
}, [selectedId, format]);
if (!station) {
return <section className="hero"><p>no stations available add a station folder under <code>library/</code> on the storage box.</p></section>;
}
const dur = parseInt(now?.duration ?? '', 10);
const hasDur = !isNaN(dur) && dur > 0;
const pct = hasDur ? Math.min(100, (elapsed / dur) * 100) : 0;
return (
<section className="hero">
<div className="hero-header">
<div className="hero-wordmark">
<div className="eyebrow"> TRANSMISSION</div>
<div className="big">denpa.fm</div>
</div>
<div className="hero-tagline">
tune in.<br/>tune out.<br/>melt yr brain {'<3'}
</div>
<div className="hero-status">
<div>{clockText.date || ' '}</div>
<div>{clockText.time || ' '}</div>
<div className="live">[<span className="blink">{playing ? 'REC' : 'OFF'}</span>] {playing ? 'LIVE' : 'PAUSED'} · {listeners ?? '—'} listening</div>
</div>
</div>
<div className="hero-body">
<aside className="hero-stations">
<div className="hero-stations-label">&gt;&gt; / STATIONS</div>
{stations.map((s, i) => (
<Tape
key={s.id}
station={s}
index={i}
active={s.id === selectedId}
onSelect={setSelectedId}
listeners={s.id === selectedId ? listeners : null}
/>
))}
</aside>
<div className="hero-deck-wrap">
<audio ref={audioRef} crossOrigin="anonymous" preload="none" />
<div className="hero-deck">
<div className="deck-screen">
<div className="deck-screen-row">
<span>&gt;&gt; NOW PLAYING</span>
<span>{format === 'mp3' ? '192k MP3' : '96k OPUS'}</span>
</div>
<div className="deck-now">{now?.title || '—'}</div>
<div className="deck-artist">{now?.artist || '—'}</div>
<div className="deck-screen-row">
<span>album · {now?.album || '—'}</span>
<span>{station.tags.join(' · ')}</span>
</div>
<div className="deck-progress-row">
<span className="deck-time">{fmtMs(elapsed)}</span>
<div className="deck-progress">
{hasDur ? <div className="deck-progress-fill" style={{ width: pct + '%' }} /> : null}
</div>
<span className="deck-time">{hasDur ? fmtMs(dur) : '—'}</span>
</div>
</div>
<div className="deck-reels">
<div className={`reel ${playing ? 'spinning' : ''}`} />
<div className="reel-strip" />
<div className={`reel ${playing ? 'spinning' : ''}`} />
</div>
<Spectrum />
<div className="deck-controls">
<button className="deck-btn play" onClick={togglePlay} type="button">
{playing ? '|| PAUSE' : '>> PLAY'}
</button>
<button className="deck-btn format" data-active={format === 'mp3'} onClick={() => setFormat('mp3')} type="button">MP3</button>
<button className="deck-btn format" data-active={format === 'opus'} onClick={() => setFormat('opus')} type="button">OPUS</button>
<div className="deck-vol">
<label>VOL</label>
<input
type="range" min={0} max={1} step={0.01} value={muted ? 0 : vol}
onChange={(e) => { setVol(parseFloat(e.target.value)); setMuted(false); }}
/>
<span className="deck-vol-pct">{Math.round((muted ? 0 : vol) * 100)}%</span>
</div>
</div>
</div>
<div className="hero-footer">
<button className="share-btn" onClick={copyUrl} type="button">
{copyState === 'ok' ? '[ok] copied!' : '[ ] copy stream URL'}
</button>
<div className="ticker">
<div className="ticker-inner">
no ads · no algorithms · just signal adding a station = mkdir on storage box space=play =switch =vol m=mute
</div>
</div>
</div>
</div>
</div>
</section>
);
}