Files
glchat/web/src/pages/settings/GuildSoundsSection.tsx
T
grendervill 619bd91df4 feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.

- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
  play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
  наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
  попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
  подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
  участника, упоминание, сообщение, вход/выход из голосовой, свои
  микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
  счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
  удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
  комнату с выключенным микрофоном, удержание включает публикацию, отпускание
  и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
  и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
  публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
  `useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
  расширенный фейковый LiveKit SDK.
2026-09-20 19:30:43 +03:00

434 lines
15 KiB
TypeScript

import { useRef, useState, type DragEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
SOUND_EVENTS,
SOUND_LIMIT_PER_KIND,
SOUND_MAX_BYTES,
deleteGuildSound,
fetchGuildSounds,
guildSoundsQueryKey,
isAllowedSoundType,
isValidSoundName,
renameGuildSound,
uploadGuildSound,
type GuildSound,
type SoundEvent,
type SoundKind,
} from '@/api/sounds';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Badge, Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatBytes } from '@/lib/format';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildSoundsSectionProps {
guildId: string;
}
/**
* Локальная ошибка проверки: текст показываем сразу, на сервер не ходим.
* Ключ перевода — как у серверных ошибок (`errors.<code>`).
*/
type LocalError =
| { kind: 'file.too_large' }
| { kind: 'sound.invalid_type' }
| { kind: 'sound.invalid_name' }
| { kind: 'sounds.event_required' };
/** Названия событий палитры: подписи берём из локалей. */
function eventLabelKey(event: SoundEvent): string {
return `settings.servers.sounds.events.${event}`;
}
/**
* «Звуки»: саундборд и звуковая палитра сервера (AGENT.md 7.13, право
* MANAGE_SOUNDS). Список читается по REST, а снапшот обновляется точечно —
* и загрузкой, и событием шлюза `GUILD_SOUNDS_UPDATE`.
*/
export function GuildSoundsSection({ guildId }: GuildSoundsSectionProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [kind, setKind] = useState<SoundKind>('soundboard');
const [name, setName] = useState('');
const [emoji, setEmoji] = useState('');
const [event, setEvent] = useState<SoundEvent>('member_join');
const [file, setFile] = useState<File | null>(null);
const [localError, setLocalError] = useState<LocalError | null>(null);
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState<GuildSound | null>(null);
const [renameValue, setRenameValue] = useState('');
const [removing, setRemoving] = useState<GuildSound | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const list = useQuery({
queryKey: guildSoundsQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildSounds(guildId, undefined, signal),
retry: 0,
});
const sounds = list.data ?? [];
const soundboard = sounds.filter((sound) => sound.kind === 'soundboard');
const palette = sounds.filter((sound) => sound.kind === 'ui');
const current = kind === 'soundboard' ? soundboard : palette;
const full = current.length >= SOUND_LIMIT_PER_KIND;
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
const refresh = (): void => {
void queryClient.invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) });
};
const upload = useMutation({
mutationFn: (input: {
name: string;
file: File;
kind: SoundKind;
event?: SoundEvent;
emoji?: string;
}) => uploadGuildSound(guildId, input.kind, input),
onSuccess: (sound) => {
if (sound !== null) {
useSessionStore.getState().upsertGuildSound(guildId, sound);
}
setName('');
setEmoji('');
setFile(null);
setLocalError(null);
refresh();
},
});
const rename = useMutation({
mutationFn: (input: { soundId: string; name: string; emoji?: string }) =>
renameGuildSound(guildId, input.soundId, {
name: input.name,
...(input.emoji === undefined || input.emoji === '' ? {} : { emoji: input.emoji }),
}),
onSuccess: (sound) => {
if (sound !== null) {
useSessionStore.getState().upsertGuildSound(guildId, sound);
}
setRenaming(null);
refresh();
},
});
const remove = useMutation({
mutationFn: (soundId: string) => deleteGuildSound(guildId, soundId),
onSuccess: (_result, soundId) => {
useSessionStore.getState().removeGuildSound(guildId, soundId);
setRemoving(null);
refresh();
},
});
/** Проверки на клиенте: формат, размер и имя — до запроса на сервер. */
const pickFile = (next: File | undefined): void => {
if (next === undefined) {
return;
}
upload.reset();
if (!isAllowedSoundType(next)) {
setFile(null);
setLocalError({ kind: 'sound.invalid_type' });
return;
}
if (next.size > SOUND_MAX_BYTES) {
setFile(null);
setLocalError({ kind: 'file.too_large' });
return;
}
setLocalError(null);
setFile(next);
};
const submit = (): void => {
if (file === null) {
setLocalError({ kind: 'sound.invalid_type' });
return;
}
if (!isValidSoundName(name)) {
setLocalError({ kind: 'sound.invalid_name' });
return;
}
setLocalError(null);
upload.mutate({
name,
file,
kind,
...(kind === 'ui' ? { event } : {}),
...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }),
});
};
const onDrop = (event_: DragEvent<HTMLDivElement>): void => {
event_.preventDefault();
setDragging(false);
pickFile(event_.dataTransfer.files[0]);
};
/** Строка списка: имя, событие, эмодзи, переименование и удаление. */
const renderRow = (sound: GuildSound) => (
<li
key={sound.id}
data-testid={`guild-sound-${sound.id}`}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
<span aria-hidden="true" className="w-6 shrink-0 text-center">
{sound.emoji ?? '🎵'}
</span>
<span className="min-w-0 flex-1 truncate text-sm">{sound.name}</span>
{sound.event === undefined ? null : <Badge>{t(eventLabelKey(sound.event))}</Badge>}
<Button
variant="ghost"
aria-label={t('settings.servers.sounds.renameLabel', { name: sound.name })}
onClick={() => {
rename.reset();
setRenameValue(sound.name);
setRenaming(sound);
}}
>
{t('settings.servers.sounds.rename')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
aria-label={t('settings.servers.sounds.deleteLabel', { name: sound.name })}
onClick={() => {
remove.reset();
setRemoving(sound);
}}
>
{t('settings.servers.sounds.delete')}
</Button>
</li>
);
return (
<SettingsSection
title={t('settings.servers.sounds.title')}
description={t('settings.servers.sounds.description', { limit: SOUND_LIMIT_PER_KIND })}
>
<div className="mt-3 flex flex-wrap items-center gap-2">
{(['soundboard', 'ui'] as SoundKind[]).map((item) => (
<button
key={item}
type="button"
aria-pressed={kind === item}
data-testid={`guild-sounds-tab-${item}`}
onClick={() => setKind(item)}
className={`rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
kind === item
? 'bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
{/* Счётчик вида прямо в переключателе: «Саундборд · 2 из 30». */}
{t(`settings.servers.sounds.kinds.${item}`)}
{' · '}
{t('settings.servers.sounds.counter', {
count: item === 'soundboard' ? soundboard.length : palette.length,
limit: SOUND_LIMIT_PER_KIND,
})}
</button>
))}
</div>
<p className="mt-2 text-sm text-fg-muted" data-testid="guild-sounds-counter">
{t('settings.servers.sounds.counter', {
count: current.length,
limit: SOUND_LIMIT_PER_KIND,
})}
</p>
{list.isPending ? (
<SkeletonLines className="mt-4" rows={3} />
) : list.isError ? (
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
) : current.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.sounds.empty')}</p>
) : (
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-sounds">
{current.map(renderRow)}
</ul>
)}
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
<div className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4">
<h3 className="text-sm font-semibold">{t('settings.servers.sounds.uploadTitle')}</h3>
<p className="text-xs text-fg-muted">
{t('settings.servers.sounds.uploadHint', {
limit: formatBytes(SOUND_MAX_BYTES, i18n.language),
})}
</p>
{full ? <p className="text-xs text-warning">{t('errors.sounds.limit_reached')}</p> : null}
<Field
label={t('settings.servers.sounds.name')}
name="sound_name"
value={name}
maxLength={32}
placeholder={t('settings.servers.sounds.namePlaceholder')}
hint={t('settings.servers.sounds.nameHint')}
onChange={(event_) => setName(event_.target.value)}
/>
<Field
label={t('settings.servers.sounds.emoji')}
name="sound_emoji"
value={emoji}
maxLength={8}
placeholder={t('settings.servers.sounds.emojiPlaceholder')}
hint={t('settings.servers.sounds.emojiHint')}
onChange={(event_) => setEmoji(event_.target.value)}
/>
{kind !== 'ui' ? null : (
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-fg-muted">
{t('settings.servers.sounds.event')}
</span>
<select
value={event}
aria-label={t('settings.servers.sounds.event')}
data-testid="guild-sounds-event"
onChange={(event_) => setEvent(event_.target.value as SoundEvent)}
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1.5 text-sm"
>
{SOUND_EVENTS.map((item) => (
<option key={item} value={item}>
{t(eventLabelKey(item))}
</option>
))}
</select>
</label>
)}
<div
onDragOver={(event_) => {
event_.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={`flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-dashed px-3 py-2 text-sm transition-colors duration-[var(--duration-fast)] ${
dragging ? 'border-accent bg-accent/10' : 'border-border/60 bg-surface-2/40'
}`}
data-testid="sound-dropzone"
>
<span className="text-fg-muted">{t('settings.servers.sounds.dropHint')}</span>
<input
ref={fileInputRef}
type="file"
hidden
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,.mp3,.ogg,.wav,.webm,.m4a"
aria-label={t('settings.servers.sounds.file')}
onChange={(event_) => {
pickFile(event_.target.files?.[0]);
event_.target.value = '';
}}
/>
<Button variant="ghost" onClick={() => fileInputRef.current?.click()}>
{t('settings.servers.sounds.chooseFile')}
</Button>
{file === null ? null : (
<span className="text-xs text-fg-muted">
{t('settings.servers.sounds.fileSelected', {
name: file.name,
size: formatBytes(file.size, i18n.language),
})}
</span>
)}
</div>
{localError === null ? null : (
<p role="alert" className="text-xs text-danger">
{t(`errors.${localError.kind}`)}
</p>
)}
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
<div className="flex justify-end">
<Button
disabled={upload.isPending || file === null || name.trim() === '' || full}
onClick={submit}
>
{t(
upload.isPending
? 'settings.servers.sounds.uploading'
: 'settings.servers.sounds.upload',
)}
</Button>
</div>
</div>
<Modal
open={renaming !== null}
title={t('settings.servers.sounds.renameTitle')}
onClose={() => setRenaming(null)}
footer={
<>
<Button variant="ghost" onClick={() => setRenaming(null)}>
{t('common.cancel')}
</Button>
<Button
disabled={rename.isPending || !isValidSoundName(renameValue.trim())}
onClick={() => {
if (renaming !== null) {
rename.mutate({ soundId: renaming.id, name: renameValue.trim() });
}
}}
>
{t(rename.isPending ? 'common.saving' : 'common.save')}
</Button>
</>
}
>
<Field
label={t('settings.servers.sounds.name')}
name="sound_rename"
value={renameValue}
maxLength={32}
hint={t('settings.servers.sounds.nameHint')}
onChange={(event_) => setRenameValue(event_.target.value)}
/>
{rename.isError ? <ErrorNotice className="mt-3" error={rename.error} /> : null}
</Modal>
<Modal
open={removing !== null}
title={t('settings.servers.sounds.deleteConfirmTitle')}
onClose={() => setRemoving(null)}
footer={
<>
<Button variant="ghost" onClick={() => setRemoving(null)}>
{t('common.cancel')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
disabled={remove.isPending}
onClick={() => {
if (removing !== null) {
remove.mutate(removing.id);
}
}}
>
{t('settings.servers.sounds.deleteConfirm')}
</Button>
</>
}
>
<p className="text-sm text-fg-muted">
{t('settings.servers.sounds.deleteConfirmBody', { name: removing?.name ?? '' })}
</p>
</Modal>
</SettingsSection>
);
}