mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat: soundboard (room-scoped custom audio pads)
Client side of the soundboard. Room owners enable it in Room Settings >
Misc (next to the YouTube TV toggle). When enabled, a soundboard icon
appears in the toolbar for everyone in the room; pressing a pad broadcasts
the sound so all occupants hear it. Incoming SoundboardPlay is played via
the HTML5 Audio API.
Also: fix FloorplanCanvasSVG to use ReactElement instead of the removed
global JSX namespace (React 19), and pair the client Dev branch with the
renderer fork that carries the custom features in CI.
How sounds are managed (works with any CMS):
Sounds are rows in the `soundboard_sounds` table:
id, name, url, enabled, sort_order
The emulator loads every row with enabled=1 (ordered by sort_order, id)
and sends the list to clients on room enter; the client plays `url`
directly, so any publicly reachable audio URL works (mp3/ogg/wav).
To add a sound from an admin/housekeeping panel of any CMS:
1. Upload the audio file to wherever the CMS stores public assets
(same approach as custom badge images).
2. INSERT a row into `soundboard_sounds` with the display name and the
public URL of the uploaded file, enabled = 1.
3. Reload the emulator soundboard (or restart) to pick it up.
Relative urls resolve against the `soundboard.url.prefix` config key
(falls back to `asset.url`); absolute urls are used as-is.
This commit is contained in:
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- Dev
|
||||
- 'feat/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
@@ -93,6 +94,13 @@ jobs:
|
||||
AUTO_REPO="duckietm/Nitro_Render_V3"
|
||||
AUTO_REF="main"
|
||||
;;
|
||||
Dev)
|
||||
# The client `Dev` branch carries the custom features
|
||||
# (rare values, fortune wheel, soundboard); they live on
|
||||
# the matching renderer fork branch, not upstream.
|
||||
AUTO_REPO="medievalshell/Nitro_Render_V3"
|
||||
AUTO_REF="dev"
|
||||
;;
|
||||
feat/housekeeping-panel)
|
||||
AUTO_REPO="simoleo89/Nitro_Render_V3"
|
||||
AUTO_REF="feat/housekeeping-packets"
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
"wheel.spin": "SPIN",
|
||||
"wheel.buy": "Buy spin",
|
||||
"wheel.winners": "Latest winners",
|
||||
"wheel.winners.empty": "No winners yet"
|
||||
"wheel.winners.empty": "No winners yet",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
"wheel.spin": "GIRA",
|
||||
"wheel.buy": "Compra giro",
|
||||
"wheel.winners": "Ultimi vincitori",
|
||||
"wheel.winners.empty": "Ancora nessun vincitore"
|
||||
"wheel.winners.empty": "Ancora nessun vincitore",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export * from './purse';
|
||||
export * from './room';
|
||||
export * from './room/events';
|
||||
export * from './room/widgets';
|
||||
export * from './soundboard';
|
||||
export * from './ui-settings';
|
||||
export * from './user';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
let _soundboardEnabled = false;
|
||||
|
||||
export const getSoundboardRoomEnabled = () => _soundboardEnabled;
|
||||
export const setSoundboardRoomEnabled = (enabled: boolean) =>
|
||||
{
|
||||
_soundboardEnabled = enabled;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SoundboardRoomState';
|
||||
@@ -26,6 +26,7 @@ import { HotelView } from './hotel-view/HotelView';
|
||||
import { HousekeepingView } from './housekeeping/HousekeepingView';
|
||||
import { RareValuesView } from './rare-values/RareValuesView';
|
||||
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
|
||||
import { SoundboardView } from './soundboard/SoundboardView';
|
||||
import { InventoryView } from './inventory/InventoryView';
|
||||
import { ModToolsView } from './mod-tools/ModToolsView';
|
||||
import { NavigatorView } from './navigator/NavigatorView';
|
||||
@@ -180,6 +181,7 @@ export const MainView: FC<{}> = props =>
|
||||
<FurniEditorView />
|
||||
<RareValuesView />
|
||||
<FortuneWheelView />
|
||||
<SoundboardView />
|
||||
<ExternalPluginLoader />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
|
||||
import { Dispatch, FC, PointerEvent as ReactPointerEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
|
||||
import { FaCrosshairs, FaSearchMinus, FaSearchPlus, FaSyncAlt } from 'react-icons/fa';
|
||||
import { FloorplanAction, FloorplanState } from '../state/types';
|
||||
import { FloorplanTile } from './FloorplanTile';
|
||||
@@ -140,7 +140,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
const quarter = TILE_SIZE / 4;
|
||||
const tilesRows = state.tiles.length;
|
||||
const tilesCols = state.tiles[0]?.length ?? 0;
|
||||
const out: JSX.Element[] = [];
|
||||
const out: ReactElement[] = [];
|
||||
for(const key of state.selection)
|
||||
{
|
||||
const [ rStr, cStr ] = key.split(',');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { YouTubeRoomSettingsComposer, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useState } from 'react';
|
||||
import { getYoutubeRoomEnabled, IRoomData, LocalizeText, SendMessageComposer, setYoutubeRoomEnabled } from '../../../../api';
|
||||
import { useMessageEvent } from '../../../../hooks';
|
||||
import { useMessageEvent, useSoundboard } from '../../../../hooks';
|
||||
|
||||
interface NavigatorRoomSettingsMiscTabViewProps
|
||||
{
|
||||
@@ -13,6 +13,7 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
|
||||
const { roomData = null } = props;
|
||||
const [ youtubeEnabled, setYoutubeEnabled ] = useState(getYoutubeRoomEnabled());
|
||||
const [ cooldown, setCooldown ] = useState(false);
|
||||
const { enabled: soundboardEnabled, setRoomEnabled: setSoundboardEnabled } = useSoundboard();
|
||||
|
||||
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
|
||||
{
|
||||
@@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
|
||||
setTimeout(() => setCooldown(false), 300);
|
||||
};
|
||||
|
||||
const toggleSoundboard = (enabled: boolean) =>
|
||||
{
|
||||
if (cooldown) return;
|
||||
setSoundboardEnabled(enabled);
|
||||
setCooldown(true);
|
||||
setTimeout(() => setCooldown(false), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC<NavigatorRoomSettingsMiscTabVi
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded transition-colors ${cooldown ? 'bg-gray-200 opacity-60' : 'bg-gray-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-sm">🔊 { LocalizeText('soundboard.title') }</div>
|
||||
<div className="text-xs text-gray-500">{ LocalizeText('soundboard.room.setting.desc') }</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ soundboardEnabled }
|
||||
disabled={ cooldown }
|
||||
onChange={ e => toggleSoundboard(e.target.checked) }
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { LocalizeText } from '../../api';
|
||||
import { Column, Flex, Text } from '../../common';
|
||||
import { useSoundboard } from '../../hooks';
|
||||
import { NitroCard } from '../../layout';
|
||||
|
||||
export const SoundboardView: FC<{}> = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const { enabled, sounds, lastPlayed, play } = useSoundboard();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const linkTracker: ILinkEventTracker = {
|
||||
linkReceived: (url: string) =>
|
||||
{
|
||||
const parts = url.split('/');
|
||||
if(parts.length < 2) return;
|
||||
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'toggle': setIsVisible(prev => !prev); return;
|
||||
}
|
||||
},
|
||||
eventUrlPrefix: 'soundboard/',
|
||||
};
|
||||
|
||||
AddLinkEventTracker(linkTracker);
|
||||
|
||||
return () => RemoveLinkEventTracker(linkTracker);
|
||||
}, []);
|
||||
|
||||
// The soundboard belongs to the room — close it when the room turns it off.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!enabled) setIsVisible(false);
|
||||
}, [ enabled ]);
|
||||
|
||||
if(!isVisible || !enabled) return null;
|
||||
|
||||
return (
|
||||
<NitroCard className="w-[420px] max-w-[96vw]" uniqueKey="soundboard">
|
||||
<NitroCard.Header headerText={ LocalizeText('soundboard.title') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCard.Content>
|
||||
<Column gap={ 2 }>
|
||||
{ !sounds.length &&
|
||||
<Text small className="text-black/50">{ LocalizeText('soundboard.empty') }</Text> }
|
||||
{ !!sounds.length &&
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ sounds.map(sound => (
|
||||
<button
|
||||
key={ sound.id }
|
||||
onClick={ () => play(sound.id) }
|
||||
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">
|
||||
<span className="text-2xl leading-none">🔊</span>
|
||||
<span className="line-clamp-2 text-center text-[11px] font-bold leading-tight">{ sound.name }</span>
|
||||
</button>
|
||||
)) }
|
||||
</div> }
|
||||
{ lastPlayed &&
|
||||
<Flex alignItems="center" justifyContent="center" className="pt-1">
|
||||
<Text small className="text-[#2f6f95]">
|
||||
{ LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) }
|
||||
</Text>
|
||||
</Flex> }
|
||||
</Column>
|
||||
</NitroCard.Content>
|
||||
</NitroCard>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
||||
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
|
||||
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
|
||||
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks';
|
||||
import { ToolbarItemView } from './ToolbarItemView';
|
||||
import { ToolbarMeView } from './ToolbarMeView';
|
||||
import { YouTubePlayerView } from './YouTubePlayerView';
|
||||
@@ -42,6 +42,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
const { requests = [] } = useFriends();
|
||||
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
||||
const { openMonitor, showToolbarButton } = useWiredTools();
|
||||
const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard();
|
||||
const isMod = useHasPermission('acc_supporttool');
|
||||
const isHk = useHasPermission('acc_housekeeping');
|
||||
const hkEnabled = useMemo(() => isHousekeepingEnabled(), []);
|
||||
@@ -99,8 +100,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
{
|
||||
setYoutubeEnabled(false);
|
||||
setYoutubeRoomEnabled(false);
|
||||
resetSoundboard();
|
||||
}
|
||||
}, [ isInRoom ]);
|
||||
}, [ isInRoom, resetSoundboard ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -268,6 +270,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isInRoom && soundboardEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ isMod &&
|
||||
<motion.div variants={ itemVariants } className="relative">
|
||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||
@@ -386,6 +392,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isInRoom && soundboardEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ isMod &&
|
||||
<motion.div variants={ itemVariants } className="relative">
|
||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||
|
||||
@@ -216,6 +216,13 @@
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-soundboard {
|
||||
background-image: url("@/assets/images/toolbar/icons/game.png");
|
||||
width: 44px;
|
||||
height: 25px;
|
||||
filter: hue-rotate(90deg) saturate(1.5);
|
||||
}
|
||||
|
||||
.nitro-icon.icon-message {
|
||||
background-image: url("@/assets/images/toolbar/icons/message.png");
|
||||
width: 36px;
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from './rooms/promotes';
|
||||
export * from './rooms/widgets';
|
||||
export * from './rooms/widgets/furniture';
|
||||
export * from './session';
|
||||
export * from './soundboard/useSoundboard';
|
||||
export * from './translation';
|
||||
export * from './useLocalStorage';
|
||||
export * from './useSharedVisibility';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ISoundboardSound, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
|
||||
// Resolve a stored sound url (which may be relative, like custom badges) to an
|
||||
// absolute one against the asset host.
|
||||
const resolveUrl = (url: string): string =>
|
||||
{
|
||||
if(!url) return '';
|
||||
if(/^https?:\/\//i.test(url) || url.startsWith('//')) return url;
|
||||
|
||||
const base = (GetConfigurationValue<string>('soundboard.url.prefix') || GetConfigurationValue<string>('asset.url') || '').replace(/\/+$/, '');
|
||||
return base ? `${ base }/${ url.replace(/^\/+/, '') }` : url;
|
||||
};
|
||||
|
||||
// Soundboard state + actions. Shared via useBetween so the event listeners
|
||||
// register once regardless of how many components read it (toolbar + view).
|
||||
const useSoundboardState = () =>
|
||||
{
|
||||
const [ enabled, setEnabled ] = useState(false);
|
||||
const [ sounds, setSounds ] = useState<ISoundboardSound[]>([]);
|
||||
const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null);
|
||||
|
||||
useMessageEvent<SoundboardSettingsEvent>(SoundboardSettingsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setEnabled(parser.enabled);
|
||||
setSounds(parser.sounds);
|
||||
setSoundboardRoomEnabled(parser.enabled);
|
||||
});
|
||||
|
||||
useMessageEvent<SoundboardPlayEvent>(SoundboardPlayEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const url = 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 });
|
||||
});
|
||||
|
||||
const play = useCallback((soundId: number) => SendMessageComposer(new SoundboardPlayComposer(soundId)), []);
|
||||
const setRoomEnabled = useCallback((value: boolean) =>
|
||||
{
|
||||
setEnabled(value);
|
||||
setSoundboardRoomEnabled(value);
|
||||
SendMessageComposer(new SoundboardSetEnabledComposer(value));
|
||||
}, []);
|
||||
|
||||
// Local-only clear (e.g. when leaving the room) — does not notify the server.
|
||||
const reset = useCallback(() =>
|
||||
{
|
||||
setEnabled(false);
|
||||
setSounds([]);
|
||||
setLastPlayed(null);
|
||||
setSoundboardRoomEnabled(false);
|
||||
}, []);
|
||||
|
||||
return { enabled, sounds, lastPlayed, play, setRoomEnabled, reset };
|
||||
};
|
||||
|
||||
export const useSoundboard = () => useBetween(useSoundboardState);
|
||||
Reference in New Issue
Block a user