mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
@@ -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' },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user