mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
// Hotel radio stations. Copy this file to `radio-stations.json5` (without the
|
||||||
|
// .example suffix) and add your own stations — each entry is just a streaming
|
||||||
|
// URL the client plays with the HTML5 Audio API. JSON5: // comments and
|
||||||
|
// trailing commas are allowed. Add / remove / reorder freely, no rebuild needed.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// id - unique key (string)
|
||||||
|
// name - label shown in the radio widget
|
||||||
|
// genre - optional subtitle
|
||||||
|
// url - the audio stream URL (mp3/aac/ogg Icecast or Shoutcast)
|
||||||
|
// logo - optional image URL shown next to the station
|
||||||
|
//
|
||||||
|
// The first station autostarts (quietly) on client load. The list can later
|
||||||
|
// be moved to the CMS (website_settings) so it's editable from the admin.
|
||||||
|
stations: [
|
||||||
|
// { id: 'mystation', name: 'My Station', genre: 'Hotel Radio', url: 'https://your-stream-host/stream' },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -9,5 +9,9 @@
|
|||||||
"soundboard.title": "Soundboard",
|
"soundboard.title": "Soundboard",
|
||||||
"soundboard.empty": "No sounds available",
|
"soundboard.empty": "No sounds available",
|
||||||
"soundboard.lastplayed": "Played by %user%",
|
"soundboard.lastplayed": "Played by %user%",
|
||||||
"soundboard.room.setting.desc": "Let people in this room play sound effects"
|
"soundboard.room.setting.desc": "Let people in this room play sound effects",
|
||||||
|
"radio.title": "Radio",
|
||||||
|
"radio.empty": "No stations",
|
||||||
|
"radio.error": "Couldn't load stations",
|
||||||
|
"radio.stop": "Stop"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,9 @@
|
|||||||
"soundboard.title": "Soundboard",
|
"soundboard.title": "Soundboard",
|
||||||
"soundboard.empty": "Nessun suono disponibile",
|
"soundboard.empty": "Nessun suono disponibile",
|
||||||
"soundboard.lastplayed": "Suonato da %user%",
|
"soundboard.lastplayed": "Suonato da %user%",
|
||||||
"soundboard.room.setting.desc": "Permetti ai presenti di suonare effetti audio in questa stanza"
|
"soundboard.room.setting.desc": "Permetti ai presenti di suonare effetti audio in questa stanza",
|
||||||
|
"radio.title": "Radio",
|
||||||
|
"radio.empty": "Nessuna stazione",
|
||||||
|
"radio.error": "Impossibile caricare le stazioni",
|
||||||
|
"radio.stop": "Stop"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { HousekeepingView } from './housekeeping/HousekeepingView';
|
|||||||
import { RareValuesView } from './rare-values/RareValuesView';
|
import { RareValuesView } from './rare-values/RareValuesView';
|
||||||
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
|
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
|
||||||
import { SoundboardView } from './soundboard/SoundboardView';
|
import { SoundboardView } from './soundboard/SoundboardView';
|
||||||
|
import { RadioView } from './radio/RadioView';
|
||||||
import { InventoryView } from './inventory/InventoryView';
|
import { InventoryView } from './inventory/InventoryView';
|
||||||
import { ModToolsView } from './mod-tools/ModToolsView';
|
import { ModToolsView } from './mod-tools/ModToolsView';
|
||||||
import { NavigatorView } from './navigator/NavigatorView';
|
import { NavigatorView } from './navigator/NavigatorView';
|
||||||
@@ -182,6 +183,7 @@ export const MainView: FC<{}> = props =>
|
|||||||
<RareValuesView />
|
<RareValuesView />
|
||||||
<FortuneWheelView />
|
<FortuneWheelView />
|
||||||
<SoundboardView />
|
<SoundboardView />
|
||||||
|
<RadioView />
|
||||||
<ExternalPluginLoader />
|
<ExternalPluginLoader />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
import { FaBroadcastTower, FaChevronDown, FaPlay, FaStop } from 'react-icons/fa';
|
||||||
|
import { LocalizeText } from '../../api';
|
||||||
|
import { LayoutImage } from '../../common';
|
||||||
|
import { RadioStation, useRadio } from '../../hooks';
|
||||||
|
|
||||||
|
const RADIO_STYLES = `
|
||||||
|
.radio-widget { font-feature-settings: "tnum"; }
|
||||||
|
.radio-eq { display: flex; align-items: flex-end; gap: 2px; height: 12px; }
|
||||||
|
.radio-eq span { width: 3px; height: 30%; border-radius: 2px; background: #38bdf8; opacity: .55; }
|
||||||
|
.radio-eq.is-live span { opacity: 1; animation: radioEq .9s ease-in-out infinite; }
|
||||||
|
.radio-eq span:nth-child(2) { animation-delay: .18s; }
|
||||||
|
.radio-eq span:nth-child(3) { animation-delay: .36s; }
|
||||||
|
.radio-eq span:nth-child(4) { animation-delay: .12s; }
|
||||||
|
@keyframes radioEq { 0%, 100% { height: 22%; } 50% { height: 100%; } }
|
||||||
|
.radio-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
|
.radio-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 3px; }
|
||||||
|
.radio-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.radio-vol { accent-color: #38bdf8; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Compact, polished top-left radio widget. Shows the selected station with a
|
||||||
|
// dropdown (3 visible, scrolls if more) to switch. Nudged down so it clears the
|
||||||
|
// CMS top bar most hotels render there.
|
||||||
|
export const RadioView: FC<{}> = () =>
|
||||||
|
{
|
||||||
|
const { stations, currentId, isPlaying, volume, loadError, play, stop, setVolume } = useRadio();
|
||||||
|
const [ open, setOpen ] = useState(false);
|
||||||
|
const [ selectedId, setSelectedId ] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(!selectedId && stations.length) setSelectedId(stations[0].id);
|
||||||
|
}, [ stations, selectedId ]);
|
||||||
|
|
||||||
|
const selected: RadioStation | null = stations.find(s => s.id === selectedId) ?? stations[0] ?? null;
|
||||||
|
const selectedPlaying = !!selected && (currentId === selected.id) && isPlaying;
|
||||||
|
|
||||||
|
const onPlayToggle = () =>
|
||||||
|
{
|
||||||
|
if(!selected) return;
|
||||||
|
if(selectedPlaying) stop();
|
||||||
|
else play(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPick = (station: RadioStation) =>
|
||||||
|
{
|
||||||
|
setSelectedId(station.id);
|
||||||
|
setOpen(false);
|
||||||
|
play(station);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="radio-widget fixed left-2 top-12 z-40 w-[244px] max-w-[64vw] select-none overflow-hidden rounded-xl border border-white/10 bg-gradient-to-b from-[rgba(22,24,30,0.94)] to-[rgba(10,11,14,0.94)] text-white shadow-[0_8px_24px_rgba(0,0,0,0.4)] backdrop-blur-sm">
|
||||||
|
<style>{ RADIO_STYLES }</style>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-1.5">
|
||||||
|
<FaBroadcastTower className={ `text-[11px] ${ isPlaying ? 'text-sky-400' : 'text-white/45' }` } />
|
||||||
|
<span className="grow text-[10px] font-bold uppercase tracking-[0.14em] text-white/55">{ LocalizeText('radio.title') }</span>
|
||||||
|
<div className={ `radio-eq ${ isPlaying ? 'is-live' : '' }` }>
|
||||||
|
<span /><span /><span /><span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={ onPlayToggle }
|
||||||
|
disabled={ !selected }
|
||||||
|
title={ selectedPlaying ? LocalizeText('radio.stop') : LocalizeText('radio.title') }
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-xs text-white shadow-inner transition-all hover:bg-emerald-400 disabled:opacity-40">
|
||||||
|
{ selectedPlaying ? <FaStop /> : <FaPlay className="translate-x-px" /> }
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 grow">
|
||||||
|
<div className="truncate text-sm font-bold leading-tight">{ selected ? selected.name : LocalizeText('radio.title') }</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5">
|
||||||
|
{ selectedPlaying &&
|
||||||
|
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wide text-sky-400">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" /> Live
|
||||||
|
</span> }
|
||||||
|
{ selected?.genre &&
|
||||||
|
<span className="truncate text-[10px] text-white/45">{ selected.genre }</span> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={ () => setOpen(value => !value) }
|
||||||
|
title={ LocalizeText('radio.title') }
|
||||||
|
className={ `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors ${ open ? 'bg-white/20' : 'bg-white/8 hover:bg-white/15' }` }>
|
||||||
|
<FaChevronDown className={ `text-[10px] transition-transform ${ open ? 'rotate-180' : '' }` } />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ selectedPlaying &&
|
||||||
|
<div className="flex items-center gap-2 px-3 pb-2.5">
|
||||||
|
<span className="text-xs text-white/55">🔊</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={ 0 }
|
||||||
|
max={ 1 }
|
||||||
|
step={ 0.01 }
|
||||||
|
value={ volume }
|
||||||
|
onChange={ e => setVolume(e.target.valueAsNumber) }
|
||||||
|
className="radio-vol h-1 grow cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div> }
|
||||||
|
|
||||||
|
{ open &&
|
||||||
|
<div className="border-t border-white/10 bg-black/20 p-1.5">
|
||||||
|
{ loadError &&
|
||||||
|
<div className="px-2 py-2 text-[11px] text-red-400">{ LocalizeText('radio.error') }</div> }
|
||||||
|
{ !loadError && !stations.length &&
|
||||||
|
<div className="px-2 py-2 text-[11px] text-white/50">{ LocalizeText('radio.empty') }</div> }
|
||||||
|
{ /* ~3 rows tall, scrolls when there are more */ }
|
||||||
|
<div className="radio-scroll flex max-h-[156px] flex-col gap-1 overflow-y-auto pr-0.5">
|
||||||
|
{ stations.map(station =>
|
||||||
|
{
|
||||||
|
const isActive = station.id === selectedId;
|
||||||
|
const playingThis = (currentId === station.id) && isPlaying;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ station.id }
|
||||||
|
onClick={ () => onPick(station) }
|
||||||
|
className={ `flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors ${ isActive ? 'bg-sky-500/15 ring-1 ring-sky-400/40' : 'hover:bg-white/8' }` }>
|
||||||
|
{ station.logo
|
||||||
|
? <LayoutImage imageUrl={ station.logo } className="h-7 w-7 shrink-0 rounded bg-contain bg-center bg-no-repeat" />
|
||||||
|
: <div className={ `flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[11px] ${ playingThis ? 'bg-sky-500/80' : 'bg-white/10' }` }>
|
||||||
|
{ playingThis ? <FaStop /> : <FaPlay className="translate-x-px" /> }
|
||||||
|
</div> }
|
||||||
|
<div className="min-w-0 grow">
|
||||||
|
<div className="truncate text-xs font-bold leading-tight">{ station.name }</div>
|
||||||
|
{ station.genre &&
|
||||||
|
<div className="truncate text-[10px] text-white/45">{ station.genre }</div> }
|
||||||
|
</div>
|
||||||
|
{ playingThis &&
|
||||||
|
<div className="radio-eq is-live shrink-0">
|
||||||
|
<span /><span /><span /><span />
|
||||||
|
</div> }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) }
|
||||||
|
</div>
|
||||||
|
</div> }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ export * from './mod-tools';
|
|||||||
export * from './navigator';
|
export * from './navigator';
|
||||||
export * from './notification';
|
export * from './notification';
|
||||||
export * from './purse';
|
export * from './purse';
|
||||||
|
export * from './radio/useRadio';
|
||||||
export * from './rare-values/useRareValues';
|
export * from './rare-values/useRareValues';
|
||||||
export * from './rooms';
|
export * from './rooms';
|
||||||
export * from './rooms/engine';
|
export * from './rooms/engine';
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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);
|
||||||
Reference in New Issue
Block a user