import { loadGamedata } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; import { GetConfigurationValue } from '../../api'; export type RadioStation = { id: string; name: string; genre?: string; url: string; logo?: string; }; // Hotel radio: a list of streaming URLs played client-side with HTML5 Audio. // The station list comes from a JSON5 config file (loadGamedata accepts plain // JSON and JSON5). Shared via useBetween so playback is a single instance no // matter how many components read it. const useRadioState = () => { const [ stations, setStations ] = useState([]); const [ currentId, setCurrentId ] = useState(null); const [ isPlaying, setIsPlaying ] = useState(false); const [ loadError, setLoadError ] = useState(null); const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive const audioRef = useRef(null); const loadStartedRef = useRef(false); const autoStartedRef = useRef(false); useEffect(() => { if(loadStartedRef.current) return; loadStartedRef.current = true; const url = GetConfigurationValue('radio.stations.url') || 'configuration/radio-stations.json5'; (async () => { try { const json = await loadGamedata<{ stations?: RadioStation[] }>(url); const list = Array.isArray(json?.stations) ? json.stations.filter(s => s && s.id && s.url) : []; setStations(list); } catch(error) { setLoadError(String((error as Error)?.message ?? error)); } })(); }, []); // Tear down the stream when the hook instance goes away. useEffect(() => () => { if(audioRef.current) { audioRef.current.pause(); audioRef.current.src = ''; audioRef.current = null; } }, []); const stop = useCallback(() => { if(audioRef.current) { audioRef.current.pause(); audioRef.current.src = ''; audioRef.current = null; } setIsPlaying(false); setCurrentId(null); }, []); // Browsers block audio that starts without a user gesture (autoplay policy), // so the startup autostart may be refused. When that happens, resume on the // very first click / keypress anywhere. const armResumeOnGesture = useCallback(() => { const resume = () => { window.removeEventListener('pointerdown', resume); window.removeEventListener('keydown', resume); if(audioRef.current) void audioRef.current.play().then(() => setIsPlaying(true)).catch(() => {}); }; window.addEventListener('pointerdown', resume, { once: true }); window.addEventListener('keydown', resume, { once: true }); }, []); const play = useCallback((station: RadioStation) => { if(!station?.url) return; if(audioRef.current) { audioRef.current.pause(); audioRef.current.src = ''; audioRef.current = null; } try { const audio = new Audio(station.url); audio.volume = volume; audioRef.current = audio; setCurrentId(station.id); void audio.play().then(() => setIsPlaying(true)).catch(() => { // Likely autoplay-blocked — keep the station selected and resume // on the first user interaction instead of dropping it. setIsPlaying(false); armResumeOnGesture(); }); } catch { setIsPlaying(false); setCurrentId(null); } }, [ volume, armResumeOnGesture ]); // Autostart the first station once on client load (quiet, see initial volume). useEffect(() => { if(autoStartedRef.current || !stations.length) return; autoStartedRef.current = true; play(stations[0]); }, [ stations, play ]); const toggle = useCallback((station: RadioStation) => { if(currentId === station.id) stop(); else play(station); }, [ currentId, play, stop ]); const setVolume = useCallback((value: number) => { const v = Math.max(0, Math.min(1, value)); setVolumeState(v); if(audioRef.current) audioRef.current.volume = v; }, []); return { stations, currentId, isPlaying, volume, loadError, play, stop, toggle, setVolume }; }; export const useRadio = () => useBetween(useRadioState);