diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acb525..2760f66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example index 71b3b1b..f10f302 100644 --- a/public/configuration/wheel-texts-en.example +++ b/public/configuration/wheel-texts-en.example @@ -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" } diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example index f5bb934..0cd342e 100644 --- a/public/configuration/wheel-texts-it.example +++ b/public/configuration/wheel-texts-it.example @@ -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" } diff --git a/src/api/index.ts b/src/api/index.ts index 6108472..424bb4f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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'; diff --git a/src/api/soundboard/SoundboardRoomState.ts b/src/api/soundboard/SoundboardRoomState.ts new file mode 100644 index 0000000..cded2a1 --- /dev/null +++ b/src/api/soundboard/SoundboardRoomState.ts @@ -0,0 +1,7 @@ +let _soundboardEnabled = false; + +export const getSoundboardRoomEnabled = () => _soundboardEnabled; +export const setSoundboardRoomEnabled = (enabled: boolean) => +{ + _soundboardEnabled = enabled; +}; diff --git a/src/api/soundboard/index.ts b/src/api/soundboard/index.ts new file mode 100644 index 0000000..2ccb6a5 --- /dev/null +++ b/src/api/soundboard/index.ts @@ -0,0 +1 @@ +export * from './SoundboardRoomState'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 494e948..39ae1b8 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -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 => + ); diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx index 55e9c60..3951ecd 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx @@ -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 = ({ 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(','); diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx index 5d151d3..d82e0ba 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx @@ -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(YouTubeRoomSettingsEvent, event => { @@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC setCooldown(false), 300); }; + const toggleSoundboard = (enabled: boolean) => + { + if (cooldown) return; + setSoundboardEnabled(enabled); + setCooldown(true); + setTimeout(() => setCooldown(false), 300); + }; + return ( <>
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC
+
+
+
+
🔊 { LocalizeText('soundboard.title') }
+
{ LocalizeText('soundboard.room.setting.desc') }
+
+ +
+
); diff --git a/src/components/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx new file mode 100644 index 0000000..6508d78 --- /dev/null +++ b/src/components/soundboard/SoundboardView.tsx @@ -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 ( + + setIsVisible(false) } /> + + + { !sounds.length && + { LocalizeText('soundboard.empty') } } + { !!sounds.length && +
+ { sounds.map(sound => ( + + )) } +
} + { lastPlayed && + + + { LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) } + + } +
+
+
+ ); +}; diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index e088a62..adf3285 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -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 => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> @@ -386,6 +392,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 0a81781..17a903f 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -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; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4b3c1c3..dc3d9a6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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'; diff --git a/src/hooks/soundboard/useSoundboard.ts b/src/hooks/soundboard/useSoundboard.ts new file mode 100644 index 0000000..d436962 --- /dev/null +++ b/src/hooks/soundboard/useSoundboard.ts @@ -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('soundboard.url.prefix') || GetConfigurationValue('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([]); + const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null); + + useMessageEvent(SoundboardSettingsEvent, event => + { + const parser = event.getParser(); + setEnabled(parser.enabled); + setSounds(parser.sounds); + setSoundboardRoomEnabled(parser.enabled); + }); + + useMessageEvent(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);