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.
This commit is contained in:
@@ -7,7 +7,7 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canAdministerGuild, canManageEmojis } from '@/lib/identity';
|
||||
import { canAdministerGuild, canManageEmojis, canManageSounds } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
@@ -16,10 +16,11 @@ import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||
import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection';
|
||||
|
||||
/**
|
||||
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
||||
* общее (имя и описание), участники, роли, эмодзи и «Опасная зона».
|
||||
* общее (имя и описание), участники, роли, эмодзи, звуки и «Опасная зона».
|
||||
*
|
||||
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
|
||||
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
|
||||
@@ -150,6 +151,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
|
||||
const canManageSoundSet = canManageSounds(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
currentUser.data?.id,
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
@@ -163,6 +172,9 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
{canManageEmojiSet ? (
|
||||
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
|
||||
) : null}
|
||||
{canManageSoundSet ? (
|
||||
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
|
||||
) : null}
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls';
|
||||
import { PUSH_TO_TALK_KEYS, useVoiceStore, type PushToTalkKey } from '@/stores/voice';
|
||||
import { useDeviceChoice } from '@/lib/useMediaDevices';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
/**
|
||||
* «Аудио-видео»: выбор устройств ввода/вывода и громкости.
|
||||
* Подписи клавиш push-to-talk: `code` не зависит от раскладки, поэтому
|
||||
* хранится код, а показывается человекочитаемое имя.
|
||||
*/
|
||||
const PTT_KEY_LABELS: Record<PushToTalkKey, string> = {
|
||||
Space: 'settings.voice.pttKeys.space',
|
||||
KeyV: 'settings.voice.pttKeys.v',
|
||||
KeyB: 'settings.voice.pttKeys.b',
|
||||
KeyN: 'settings.voice.pttKeys.n',
|
||||
AltLeft: 'settings.voice.pttKeys.alt',
|
||||
ControlLeft: 'settings.voice.pttKeys.ctrl',
|
||||
};
|
||||
|
||||
/**
|
||||
* «Аудио-видео»: устройства ввода/вывода, громкости, режим активации
|
||||
* микрофона (голосовая активация или push-to-talk) и переключатели звуков
|
||||
* сервера (AGENT.md 7.13/7.14).
|
||||
*
|
||||
* Значения хранятся локально (zustand + localStorage) и применяются на лету:
|
||||
* выбранное устройство сразу переключается в активной голосовой сессии.
|
||||
@@ -20,6 +35,14 @@ export function VoiceSettingsSection() {
|
||||
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
||||
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
|
||||
const setOutputVolume = useVoiceStore((state) => state.setOutputVolume);
|
||||
const micActivation = useVoiceStore((state) => state.micActivation);
|
||||
const pttKey = useVoiceStore((state) => state.pttKey);
|
||||
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
||||
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
||||
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
||||
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
||||
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
||||
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -58,6 +81,74 @@ export function VoiceSettingsSection() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Режим активации микрофона: голосовая активация или push-to-talk. */}
|
||||
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
||||
<legend className="text-sm font-semibold">{t('settings.voice.micMode')}</legend>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{(['voice', 'push_to_talk'] as const).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="mic_activation"
|
||||
value={mode}
|
||||
checked={micActivation === mode}
|
||||
aria-label={t(`settings.voice.micModes.${mode}`)}
|
||||
onChange={() => setMicActivation(mode)}
|
||||
/>
|
||||
<span>{t(`settings.voice.micModes.${mode}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{micActivation === 'push_to_talk' ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-fg-muted">{t('settings.voice.pttKey')}</span>
|
||||
<select
|
||||
value={pttKey}
|
||||
aria-label={t('settings.voice.pttKey')}
|
||||
data-testid="ptt-key-select"
|
||||
onChange={(event) => setPttKey(event.target.value as PushToTalkKey)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
|
||||
>
|
||||
{PUSH_TO_TALK_KEYS.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{t(PTT_KEY_LABELS[key])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t('settings.voice.pttHint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
{/* Звуки сервера: палитра событий и саундборд (AGENT.md 7.13). */}
|
||||
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
||||
<legend className="text-sm font-semibold">{t('settings.voice.sounds')}</legend>
|
||||
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventSoundsEnabled}
|
||||
aria-label={t('settings.voice.eventSounds')}
|
||||
onChange={(event) => setEventSoundsEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.voice.eventSounds')}</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.eventSoundsHint')}</p>
|
||||
<label className="mt-3 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundboardEnabled}
|
||||
aria-label={t('settings.voice.soundboard')}
|
||||
onChange={(event) => setSoundboardEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.voice.soundboard')}</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
|
||||
</fieldset>
|
||||
|
||||
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user