diff --git a/config/config.txt b/config/config.txt index 8a3c83243994b..3d6b869fed637 100644 --- a/config/config.txt +++ b/config/config.txt @@ -49,6 +49,10 @@ LOBBY_COUNTDOWN 120 ## Round End Time: This is the amount of time after the round ends that players have to murder death kill each other. ROUND_END_COUNTDOWN 90 +## The Audio Browser (search/preview/copy-path for every ingame sound) is restricted to admins with R_SOUND by default. +## Uncomment this and set it to 0 to let all players use it instead. +#AUDIO_BROWSER_ADMIN_ONLY 0 + ## Comment this out if you want to use the SQL based admin system, the legacy system uses admins.txt. ## You need to set up your database to use the SQL based system. ## This flag is automatically enabled if SQL_ENABLED isn't diff --git a/modular_zzmeta/code/modules/client/audio_browser.dm b/modular_zzmeta/code/modules/client/audio_browser.dm new file mode 100644 index 0000000000000..fb2e2a2ace4b8 --- /dev/null +++ b/modular_zzmeta/code/modules/client/audio_browser.dm @@ -0,0 +1,54 @@ +/// Lets players browse, search, preview, and copy the resource path of every sound file shipped with the game. +/// Restricted to admins (R_SOUND) by default - set AUDIO_BROWSER_ADMIN_ONLY to 0 in config.txt to let all players use it. +GLOBAL_DATUM_INIT(audio_browser, /datum/audio_browser, new) + +/datum/config_entry/flag/audio_browser_admin_only + default = TRUE + +/datum/audio_browser + +/datum/audio_browser/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "AudioBrowser") + ui.open() + +/datum/audio_browser/ui_state(mob/user) + if(CONFIG_GET(flag/audio_browser_admin_only)) + return ADMIN_STATE(R_SOUND) + return GLOB.always_state + +/datum/audio_browser/ui_assets(mob/user) + return list( + get_asset_datum(/datum/asset/json/audio_browser), + ) + +/datum/audio_browser/ui_act(action, list/params, datum/tgui/ui) + . = ..() + if(.) + return + switch(action) + if("preview") + var/path = params["path"] + if(!(path in SSsounds.all_sounds)) + return + SEND_SOUND(ui.user, sound(path)) + return TRUE + +/// Ships the full list of playable sound resource paths to the client as a static JSON asset. +/datum/asset/json/audio_browser + name = "audio_browser_sounds" + +/datum/asset/json/audio_browser/generate() + return SSsounds.all_sounds + +/client/verb/open_audio_browser() + set category = "OOC" + set name = "Open Audio Browser" + set desc = "Browse, search, and preview every sound in the game, and copy their resource paths." + + if(CONFIG_GET(flag/audio_browser_admin_only) && !check_rights_for(src, R_SOUND)) + to_chat(usr, span_warning("This feature is restricted to admins.")) + return + + GLOB.audio_browser.ui_interact(usr) diff --git a/tgstation.dme b/tgstation.dme index 9d8a733886bba..5a42f5f473f47 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -10514,6 +10514,7 @@ #include "modular_zzmeta\code\game\atoms_movable.dm" #include "modular_zzmeta\code\game\objects\items\plushes.dm" #include "modular_zzmeta\code\game\sound\sound.dm" +#include "modular_zzmeta\code\modules\client\audio_browser.dm" #include "modular_zzmeta\code\modules\client\preferences\blooper.dm" #include "modular_zzmeta\code\modules\client\preferences\middleware\blooper.dm" #include "modular_zzmeta\code\modules\emotes\species_crys.dm" diff --git a/tgui/packages/tgui/interfaces/AudioBrowser.tsx b/tgui/packages/tgui/interfaces/AudioBrowser.tsx new file mode 100644 index 0000000000000..b3d3fb36359c7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/AudioBrowser.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Autofocus, + Box, + Button, + Input, + Section, + Stack, + VirtualList, +} from 'tgui-core/components'; +import { fetchRetry } from 'tgui-core/http'; +import { KEY_DOWN, KEY_ENTER, KEY_UP } from 'tgui-core/keycodes'; +import { resolveAsset } from '../assets'; +import { useBackend } from '../backend'; +import { Window } from '../layouts'; +import { logger } from '../logging'; + +function copyText(text: string): void { + const input = document.createElement('input'); + input.value = text; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); +} + +export function AudioBrowser() { + const { act } = useBackend(); + + const [sounds, setSounds] = useState([]); + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState(0); + const [copiedPath, setCopiedPath] = useState(null); + + useEffect(() => { + fetchRetry(resolveAsset('audio_browser_sounds.json')) + .then((response) => response.json()) + .then((data: string[]) => setSounds(data)) + .catch((error) => { + logger.log( + 'Failed to fetch audio_browser_sounds.json', + JSON.stringify(error), + ); + }); + }, []); + + const filteredSounds = useMemo(() => { + if (query.length === 0) return sounds; + const lowerQuery = query.toLowerCase(); + return sounds.filter((path) => path.toLowerCase().includes(lowerQuery)); + }, [query, sounds]); + + function handlePreview(path?: string): void { + if (!path) return; + act('preview', { path }); + } + + function handleCopy(path?: string): void { + if (!path) return; + copyText(path); + setCopiedPath(path); + } + + function handleSearch(newQuery: string): void { + if (newQuery === query) return; + setQuery(newQuery); + setSelected(0); + } + + function handleArrowKey(key: number): void { + if (!filteredSounds.length) return; + const len = filteredSounds.length - 1; + if (key === KEY_DOWN) { + const next = selected >= len ? 0 : selected + 1; + setSelected(next); + document + ?.getElementById(next.toString()) + ?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } else if (key === KEY_UP) { + const prev = selected <= 0 ? len : selected - 1; + setSelected(prev); + document + ?.getElementById(prev.toString()) + ?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } + + function handleKeyDown(event: React.KeyboardEvent): void { + const keyCode = window.event ? event.which : event.keyCode; + if (keyCode === KEY_DOWN || keyCode === KEY_UP) { + event.preventDefault(); + handleArrowKey(keyCode); + } + if (keyCode === KEY_ENTER) { + event.preventDefault(); + handlePreview(filteredSounds[selected]); + } + } + + return ( + + + + + + + +
+ + + {filteredSounds.map((path, index) => ( + setSelected(index)} + onDoubleClick={() => handlePreview(path)} + style={{ padding: '2px 4px' }} + > + + + {path} + + + +
+
+
+
+
+ ); +}