Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/config.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions modular_zzmeta/code/modules/client/audio_browser.dm
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tgstation.dme
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
164 changes: 164 additions & 0 deletions tgui/packages/tgui/interfaces/AudioBrowser.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const [copiedPath, setCopiedPath] = useState<string | null>(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<HTMLDivElement>): 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 (
<Window title="Audio Browser" width={500} height={550}>
<Window.Content onKeyDown={handleKeyDown}>
<Stack fill vertical>
<Stack.Item>
<Input
autoFocus
autoSelect
expensive
fluid
onChange={handleSearch}
placeholder={`Search ${sounds.length} sounds...`}
value={query}
/>
</Stack.Item>
<Stack.Item grow>
<Section fill scrollable>
<Autofocus />
<VirtualList>
{filteredSounds.map((path, index) => (
<Stack
key={path}
id={index.toString()}
className="candystripe"
align="center"
fill
onClick={() => setSelected(index)}
onDoubleClick={() => handlePreview(path)}
style={{ padding: '2px 4px' }}
>
<Stack.Item grow>
<Box
color={index === selected ? 'black' : undefined}
backgroundColor={
index === selected ? 'white' : undefined
}
style={{ wordBreak: 'break-all' }}
>
{path}
</Box>
</Stack.Item>
<Stack.Item>
<Button
icon="play"
tooltip="Preview"
onClick={() => handlePreview(path)}
/>
</Stack.Item>
<Stack.Item>
<Button
icon={copiedPath === path ? 'check' : 'copy'}
tooltip="Copy path"
onClick={() => handleCopy(path)}
/>
</Stack.Item>
</Stack>
))}
</VirtualList>
</Section>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
}
Loading