Add sound selector to widgets
This commit is contained in:
@@ -10,6 +10,7 @@ RUN apk add --no-cache php83 php83-fpm php83-curl php83-ctype
|
|||||||
RUN echo 'clear_env = no' >> /etc/php83/php-fpm.d/www.conf
|
RUN echo 'clear_env = no' >> /etc/php83/php-fpm.d/www.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY deploy/php/media_proxy.php /usr/share/nginx/html/media_proxy.php
|
COPY deploy/php/media_proxy.php /usr/share/nginx/html/media_proxy.php
|
||||||
|
COPY deploy/php/sounds_list.php /usr/share/nginx/html/sounds_list.php
|
||||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["sh", "-c", "php-fpm83 && exec nginx -g 'daemon off;'"]
|
CMD ["sh", "-c", "php-fpm83 && exec nginx -g 'daemon off;'"]
|
||||||
|
|||||||
0
client/public/sounds/widgets/.gitkeep
Normal file
0
client/public/sounds/widgets/.gitkeep
Normal file
@@ -47,6 +47,8 @@ type EditorDeps = {
|
|||||||
updateStatus: (message: string) => void;
|
updateStatus: (message: string) => void;
|
||||||
sfxUiBlip: () => void;
|
sfxUiBlip: () => void;
|
||||||
sfxUiCancel: () => void;
|
sfxUiCancel: () => void;
|
||||||
|
openSoundPropertyPicker?: (item: WorldItem, key: string) => void;
|
||||||
|
previewSound?: (soundPath: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,9 +189,13 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
deps.openItemPropertyOptionSelect(item, selectedKey);
|
deps.openItemPropertyOptionSelect(item, selectedKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (metadata?.valueType === 'sound' && deps.openSoundPropertyPicker) {
|
||||||
|
deps.openSoundPropertyPicker(item, selectedKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
deps.state.mode = 'itemPropertyEdit';
|
deps.state.mode = 'itemPropertyEdit';
|
||||||
deps.state.editingPropertyKey = selectedKey;
|
deps.state.editingPropertyKey = selectedKey;
|
||||||
const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey);
|
const selectedMetadata = metadata;
|
||||||
deps.state.nicknameInput =
|
deps.state.nicknameInput =
|
||||||
selectedKey === 'title'
|
selectedKey === 'title'
|
||||||
? item.title
|
? item.title
|
||||||
@@ -369,6 +375,13 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
const nextIndex = (deps.state.itemPropertyOptionIndex + delta + length * 1000) % length;
|
const nextIndex = (deps.state.itemPropertyOptionIndex + delta + length * 1000) % length;
|
||||||
deps.state.itemPropertyOptionIndex = nextIndex;
|
deps.state.itemPropertyOptionIndex = nextIndex;
|
||||||
deps.updateStatus(deps.state.itemPropertyOptionValues[nextIndex]);
|
deps.updateStatus(deps.state.itemPropertyOptionValues[nextIndex]);
|
||||||
|
const pageItem = deps.state.items.get(itemId!);
|
||||||
|
if (pageItem) {
|
||||||
|
const pageMeta = deps.getItemPropertyMetadata(pageItem.type, propertyKey);
|
||||||
|
if (pageMeta?.valueType === 'sound') {
|
||||||
|
deps.previewSound?.(deps.state.itemPropertyOptionValues[nextIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
deps.sfxUiBlip();
|
deps.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -383,6 +396,13 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
if (control.type === 'move') {
|
if (control.type === 'move') {
|
||||||
deps.state.itemPropertyOptionIndex = control.index;
|
deps.state.itemPropertyOptionIndex = control.index;
|
||||||
deps.updateStatus(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
|
deps.updateStatus(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
|
||||||
|
const moveItem = deps.state.items.get(itemId!);
|
||||||
|
if (moveItem) {
|
||||||
|
const moveMeta = deps.getItemPropertyMetadata(moveItem.type, propertyKey);
|
||||||
|
if (moveMeta?.valueType === 'sound') {
|
||||||
|
deps.previewSound?.(deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
deps.sfxUiBlip();
|
deps.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1010,6 +1010,60 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
|||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the list of widget sounds from the server. Returns [] on error. */
|
||||||
|
async function fetchWidgetSounds(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/sounds_list.php');
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const names = (await response.json()) as string[];
|
||||||
|
if (!Array.isArray(names)) return [];
|
||||||
|
return names.map((name: string) => `sounds/widgets/${name}`);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Plays a sound preview with debounce, for use while navigating sound picker. */
|
||||||
|
function previewSound(soundPath: string): void {
|
||||||
|
if (previewDebounceTimer !== null) {
|
||||||
|
clearTimeout(previewDebounceTimer);
|
||||||
|
}
|
||||||
|
previewDebounceTimer = setTimeout(() => {
|
||||||
|
previewDebounceTimer = null;
|
||||||
|
if (soundPath) {
|
||||||
|
void audio.playSample(soundPath, 0.7);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the sound picker for a sound-typed item property, falling back to text edit if no sounds are found. */
|
||||||
|
async function openSoundPropertyPicker(item: WorldItem, key: string): Promise<void> {
|
||||||
|
updateStatus('Loading sounds...');
|
||||||
|
const sounds = await fetchWidgetSounds();
|
||||||
|
if (sounds.length === 0) {
|
||||||
|
state.mode = 'itemPropertyEdit';
|
||||||
|
state.editingPropertyKey = key;
|
||||||
|
const currentValue = String(item.params[key] ?? '');
|
||||||
|
state.nicknameInput = currentValue;
|
||||||
|
state.cursorPos = currentValue.length;
|
||||||
|
replaceTextOnNextType = true;
|
||||||
|
updateStatus(`Edit ${itemPropertyLabel(key)}: ${currentValue}`);
|
||||||
|
audio.sfxUiBlip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = ['', ...sounds];
|
||||||
|
state.mode = 'itemPropertyOptionSelect';
|
||||||
|
state.editingPropertyKey = key;
|
||||||
|
state.itemPropertyOptionValues = options;
|
||||||
|
const currentValue = String(item.params[key] ?? '').trim();
|
||||||
|
const currentIndex = options.indexOf(currentValue);
|
||||||
|
state.itemPropertyOptionIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||||
|
updateStatus(`Select ${itemPropertyLabel(key)}: ${options[state.itemPropertyOptionIndex] || 'none'}`);
|
||||||
|
audio.sfxUiBlip();
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns the active text-input max length for the current UI mode, if applicable. */
|
/** Returns the active text-input max length for the current UI mode, if applicable. */
|
||||||
function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
|
function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
|
||||||
if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
|
if (mode === 'nickname') return NICKNAME_MAX_LENGTH;
|
||||||
@@ -2573,6 +2627,8 @@ const itemPropertyEditor = createItemPropertyEditor({
|
|||||||
updateStatus,
|
updateStatus,
|
||||||
sfxUiBlip: () => audio.sfxUiBlip(),
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
sfxUiCancel: () => audio.sfxUiCancel(),
|
sfxUiCancel: () => audio.sfxUiCancel(),
|
||||||
|
openSoundPropertyPicker: (item, key) => { void openSoundPropertyPicker(item, key); },
|
||||||
|
previewSound,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Handles nickname edit mode submission/cancel and text editing keys. */
|
/** Handles nickname edit mode submission/cancel and text editing keys. */
|
||||||
|
|||||||
41
deploy/php/sounds_list.php
Normal file
41
deploy/php/sounds_list.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chat Grid sounds list endpoint.
|
||||||
|
*
|
||||||
|
* Returns a JSON array of filenames found in sounds/widgets/ relative to
|
||||||
|
* the document root. Filters to .ogg, .mp3, and .wav files.
|
||||||
|
* Returns [] if the directory does not exist or contains no audio files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
$dir = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . '/sounds/widgets';
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
echo '[]';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = scandir($dir);
|
||||||
|
if ($files === false) {
|
||||||
|
echo '[]';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = ['ogg', 'mp3', 'wav'];
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($file === '.' || $file === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
|
if (in_array($ext, $allowed, true)) {
|
||||||
|
$results[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($results);
|
||||||
|
echo json_encode($results, JSON_UNESCAPED_SLASHES);
|
||||||
Reference in New Issue
Block a user