619bd91df4
Клиентская часть Фазы 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.
434 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|