feat: soundboard pads can load from a JSON5 file (DB fallback)

When the server (soundboard_sounds table) returns no pads, the client now
loads them from a JSON5 config file (loadGamedata accepts plain JSON and
JSON5). Useful when the DB / CMS isn't set up yet.

File-defined pads play locally for the clicker; DB-backed pads still go
through the server broadcast so everyone in the room hears them. Ships a
radio-style soundboard-sounds.json5.example template.
This commit is contained in:
medievalshell
2026-05-28 10:19:16 +02:00
parent 48ed3ad7ba
commit 4833ab8447
3 changed files with 88 additions and 21 deletions
@@ -0,0 +1,20 @@
{
// Soundboard pads loaded from a file — used as a FALLBACK when the server
// (soundboard_sounds DB table) returns no sounds. Copy this file to
// `soundboard-sounds.json5` (without .example) and add your sounds. JSON5:
// // comments and trailing commas are allowed.
//
// Fields:
// id - unique number (pad key)
// name - label shown on the pad
// url - audio file URL (mp3/ogg/wav). Relative urls resolve against
// `soundboard.url.prefix` (falls back to `asset.url`).
//
// NOTE: file-defined pads play LOCALLY for the person who clicks them. To
// broadcast a pad to everyone in the room, the sound must exist server-side
// in the soundboard_sounds table (same flow as custom badges). The file is
// the no-DB / offline option; the DB is the multiplayer one.
sounds: [
// { id: 1, name: 'Airhorn', url: 'https://your-host/airhorn.mp3' },
],
}
+1 -1
View File
@@ -53,7 +53,7 @@ export const SoundboardView: FC<{}> = () =>
{ sounds.map(sound => ( { sounds.map(sound => (
<button <button
key={ sound.id } key={ sound.id }
onClick={ () => play(sound.id) } onClick={ () => play(sound) }
title={ sound.name } title={ sound.name }
className="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg bg-[#3a7bb5] px-2 text-white shadow transition-transform hover:bg-[#336ea3] active:scale-95"> className="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg bg-[#3a7bb5] px-2 text-white shadow transition-transform hover:bg-[#336ea3] active:scale-95">
<span className="text-2xl leading-none">🔊</span> <span className="text-2xl leading-none">🔊</span>
+67 -20
View File
@@ -1,15 +1,32 @@
import { ISoundboardSound, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer'; import { ISoundboardSound, loadGamedata, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between'; import { useBetween } from 'use-between';
import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api'; import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api';
import { useMessageEvent } from '../events'; import { useMessageEvent } from '../events';
// A pad as the client uses it. `local` marks pads that came from the JSON5 file
// fallback rather than the server (DB) — those play locally on click because the
// server can't resolve their id to broadcast them.
export type ClientSoundboardSound = ISoundboardSound & { local?: boolean };
const playLocal = (url: string) =>
{
if(!url) return;
try
{
const audio = new Audio(url);
audio.volume = 0.8;
void audio.play().catch(() => {});
}
catch {}
};
// Resolve a stored sound url (which may be relative, like custom badges) to an // Resolve a stored sound url (which may be relative, like custom badges) to an
// absolute one against the asset host. // absolute one against the asset host.
const resolveUrl = (url: string): string => const resolveUrl = (url: string): string =>
{ {
if(!url) return ''; if(!url) return '';
if(/^https?:\/\//i.test(url) || url.startsWith('//')) return url; if(/^https?:\/\//i.test(url) || url.startsWith('//') || url.startsWith('/')) return url;
const base = (GetConfigurationValue<string>('soundboard.url.prefix') || GetConfigurationValue<string>('asset.url') || '').replace(/\/+$/, ''); const base = (GetConfigurationValue<string>('soundboard.url.prefix') || GetConfigurationValue<string>('asset.url') || '').replace(/\/+$/, '');
return base ? `${ base }/${ url.replace(/^\/+/, '') }` : url; return base ? `${ base }/${ url.replace(/^\/+/, '') }` : url;
@@ -20,37 +37,67 @@ const resolveUrl = (url: string): string =>
const useSoundboardState = () => const useSoundboardState = () =>
{ {
const [ enabled, setEnabled ] = useState(false); const [ enabled, setEnabled ] = useState(false);
const [ sounds, setSounds ] = useState<ISoundboardSound[]>([]); const [ serverSounds, setServerSounds ] = useState<ISoundboardSound[]>([]);
const [ fileSounds, setFileSounds ] = useState<ClientSoundboardSound[]>([]);
const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null); const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null);
const fileLoadStartedRef = useRef(false);
useMessageEvent<SoundboardSettingsEvent>(SoundboardSettingsEvent, event => useMessageEvent<SoundboardSettingsEvent>(SoundboardSettingsEvent, event =>
{ {
const parser = event.getParser(); const parser = event.getParser();
setEnabled(parser.enabled); setEnabled(parser.enabled);
setSounds(parser.sounds); setServerSounds(parser.sounds);
setSoundboardRoomEnabled(parser.enabled); setSoundboardRoomEnabled(parser.enabled);
}); });
useMessageEvent<SoundboardPlayEvent>(SoundboardPlayEvent, event => useMessageEvent<SoundboardPlayEvent>(SoundboardPlayEvent, event =>
{ {
const parser = event.getParser(); const parser = event.getParser();
const url = resolveUrl(parser.url); playLocal(resolveUrl(parser.url));
if(url)
{
try
{
const audio = new Audio(url);
audio.volume = 0.8;
void audio.play().catch(() => {});
}
catch {}
}
setLastPlayed({ soundId: parser.soundId, username: parser.username }); setLastPlayed({ soundId: parser.soundId, username: parser.username });
}); });
const play = useCallback((soundId: number) => SendMessageComposer(new SoundboardPlayComposer(soundId)), []); // Fallback: when the soundboard is on but the server (DB) provided no pads,
// load them from the JSON5 file once. loadGamedata accepts plain JSON and
// JSON5 (// comments) — same loader used for the avatar effect map.
useEffect(() =>
{
if(!enabled || serverSounds.length || fileLoadStartedRef.current) return;
fileLoadStartedRef.current = true;
const url = GetConfigurationValue<string>('soundboard.sounds.url') || 'configuration/soundboard-sounds.json5';
(async () =>
{
try
{
const json = await loadGamedata<{ sounds?: ISoundboardSound[] }>(url);
const list = Array.isArray(json?.sounds)
? json.sounds
.filter(s => s && s.url)
.map(s => ({ id: s.id, name: s.name, url: s.url, local: true }))
: [];
setFileSounds(list);
}
catch {}
})();
}, [ enabled, serverSounds.length ]);
const sounds: ClientSoundboardSound[] = serverSounds.length ? serverSounds : fileSounds;
const play = useCallback((sound: ClientSoundboardSound) =>
{
if(!sound) return;
// File-defined pad: the server doesn't know it, so play it locally.
if(sound.local)
{
playLocal(resolveUrl(sound.url));
return;
}
// DB-backed pad: let the server broadcast it to everyone in the room.
SendMessageComposer(new SoundboardPlayComposer(sound.id));
}, []);
const setRoomEnabled = useCallback((value: boolean) => const setRoomEnabled = useCallback((value: boolean) =>
{ {
setEnabled(value); setEnabled(value);
@@ -62,7 +109,7 @@ const useSoundboardState = () =>
const reset = useCallback(() => const reset = useCallback(() =>
{ {
setEnabled(false); setEnabled(false);
setSounds([]); setServerSounds([]);
setLastPlayed(null); setLastPlayed(null);
setSoundboardRoomEnabled(false); setSoundboardRoomEnabled(false);
}, []); }, []);