From acb3dd7ef1e976164e9ad48c4c5bf6b1c24902fc Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 10:20:15 +0200 Subject: [PATCH] feat: hotel radio widget (client-side, multi-station) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../radio-stations.json5.example | 19 +++ public/configuration/wheel-texts-en.example | 6 +- public/configuration/wheel-texts-it.example | 6 +- src/components/MainView.tsx | 2 + src/components/radio/RadioView.tsx | 146 +++++++++++++++++ src/hooks/index.ts | 1 + src/hooks/radio/useRadio.ts | 147 ++++++++++++++++++ 7 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 public/configuration/radio-stations.json5.example create mode 100644 src/components/radio/RadioView.tsx create mode 100644 src/hooks/radio/useRadio.ts diff --git a/public/configuration/radio-stations.json5.example b/public/configuration/radio-stations.json5.example new file mode 100644 index 0000000..f79cd89 --- /dev/null +++ b/public/configuration/radio-stations.json5.example @@ -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' }, + ], +} diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example index f10f302..0ba7b3e 100644 --- a/public/configuration/wheel-texts-en.example +++ b/public/configuration/wheel-texts-en.example @@ -9,5 +9,9 @@ "soundboard.title": "Soundboard", "soundboard.empty": "No sounds available", "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" } diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example index 0cd342e..dadcbc7 100644 --- a/public/configuration/wheel-texts-it.example +++ b/public/configuration/wheel-texts-it.example @@ -9,5 +9,9 @@ "soundboard.title": "Soundboard", "soundboard.empty": "Nessun suono disponibile", "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" } diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 39ae1b8..aeb5d91 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -27,6 +27,7 @@ import { HousekeepingView } from './housekeeping/HousekeepingView'; import { RareValuesView } from './rare-values/RareValuesView'; import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; import { SoundboardView } from './soundboard/SoundboardView'; +import { RadioView } from './radio/RadioView'; import { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -182,6 +183,7 @@ export const MainView: FC<{}> = props => + ); diff --git a/src/components/radio/RadioView.tsx b/src/components/radio/RadioView.tsx new file mode 100644 index 0000000..15e7c95 --- /dev/null +++ b/src/components/radio/RadioView.tsx @@ -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(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 ( +
+ + +
+ + { LocalizeText('radio.title') } +
+ +
+
+ +
+ +
+
{ selected ? selected.name : LocalizeText('radio.title') }
+
+ { selectedPlaying && + + Live + } + { selected?.genre && + { selected.genre } } +
+
+ +
+ + { selectedPlaying && +
+ 🔊 + setVolume(e.target.valueAsNumber) } + className="radio-vol h-1 grow cursor-pointer" + /> +
} + + { open && +
+ { loadError && +
{ LocalizeText('radio.error') }
} + { !loadError && !stations.length && +
{ LocalizeText('radio.empty') }
} + { /* ~3 rows tall, scrolls when there are more */ } +
+ { stations.map(station => + { + const isActive = station.id === selectedId; + const playingThis = (currentId === station.id) && isPlaying; + return ( +
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 + ? + :
+ { playingThis ? : } +
} +
+
{ station.name }
+ { station.genre && +
{ station.genre }
} +
+ { playingThis && +
+ +
} +
+ ); + }) } +
+
} +
+ ); +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index dc3d9a6..8a85f2a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -15,6 +15,7 @@ export * from './mod-tools'; export * from './navigator'; export * from './notification'; export * from './purse'; +export * from './radio/useRadio'; export * from './rare-values/useRareValues'; export * from './rooms'; export * from './rooms/engine'; diff --git a/src/hooks/radio/useRadio.ts b/src/hooks/radio/useRadio.ts new file mode 100644 index 0000000..88bff81 --- /dev/null +++ b/src/hooks/radio/useRadio.ts @@ -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([]); + 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);