Files
Nitro-V3/src/hooks/radio/useRadio.ts
T
medievalshell acb3dd7ef1 feat: hotel radio widget (client-side, multi-station)
Adds a compact collapsible radio widget (top-left) that plays internet
radio streams with the HTML5 Audio API — no server/renderer changes.

- station list loaded from a JSON5 config file (loadGamedata: JSON + JSON5),
  shipped as radio-stations.json5.example so each hotel fills in its own
- shows the selected station + a dropdown (3 visible, scrolls if more) to
  switch; volume slider; animated equalizer + LIVE indicator
- first station autostarts quietly (5%) on load, with a resume-on-first-
  gesture fallback for browser autoplay policy
2026-05-28 10:20:15 +02:00

148 lines
4.7 KiB
TypeScript

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<RadioStation[]>([]);
const [ currentId, setCurrentId ] = useState<string | null>(null);
const [ isPlaying, setIsPlaying ] = useState(false);
const [ loadError, setLoadError ] = useState<string | null>(null);
const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive
const audioRef = useRef<HTMLAudioElement | null>(null);
const loadStartedRef = useRef(false);
const autoStartedRef = useRef(false);
useEffect(() =>
{
if(loadStartedRef.current) return;
loadStartedRef.current = true;
const url = GetConfigurationValue<string>('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);