4ea2965892
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера показывает баннер сразу после подключения (AGENT.md 7.4) - GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки - кнопка приглашения в шапке сервера со ссылкой и копированием - встроенные звуки интерфейса на Web Audio с разблокировкой по жесту - object URL превью через безопасные обёртки (jsdom и урезанные webview)
185 lines
8.2 KiB
TypeScript
185 lines
8.2 KiB
TypeScript
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 { 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) и применяются на лету:
|
|
* выбранное устройство сразу переключается в активной голосовой сессии.
|
|
*/
|
|
export function VoiceSettingsSection() {
|
|
const { t } = useTranslation();
|
|
const mic = useDeviceChoice('audioinput');
|
|
const camera = useDeviceChoice('videoinput');
|
|
const output = useDeviceChoice('audiooutput');
|
|
const micVolume = useVoiceStore((state) => state.micVolume);
|
|
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 latencySharingEnabled = useVoiceStore((state) => state.latencySharingEnabled);
|
|
const interfaceSoundsEnabled = useVoiceStore((state) => state.interfaceSoundsEnabled);
|
|
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
|
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
|
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
|
const setLatencySharingEnabled = useVoiceStore((state) => state.setLatencySharingEnabled);
|
|
const setInterfaceSoundsEnabled = useVoiceStore((state) => state.setInterfaceSoundsEnabled);
|
|
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
|
|
|
return (
|
|
<SettingsSection
|
|
title={t('settings.voice.title')}
|
|
description={t('settings.voice.description')}
|
|
>
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<VoiceDeviceList
|
|
kind="audioinput"
|
|
label={t('settings.voice.input')}
|
|
value={mic.value}
|
|
onChange={mic.select}
|
|
/>
|
|
<VoiceDeviceList
|
|
kind="videoinput"
|
|
label={t('settings.voice.camera')}
|
|
value={camera.value}
|
|
onChange={camera.select}
|
|
/>
|
|
<VoiceDeviceList
|
|
kind="audiooutput"
|
|
label={t('settings.voice.output')}
|
|
value={output.value}
|
|
onChange={output.select}
|
|
/>
|
|
<div className="flex flex-col justify-end gap-2">
|
|
<VolumeSlider
|
|
label={t('settings.voice.micVolume')}
|
|
value={micVolume}
|
|
onChange={setMicVolume}
|
|
/>
|
|
<VolumeSlider
|
|
label={t('settings.voice.outputVolume')}
|
|
value={outputVolume}
|
|
onChange={setOutputVolume}
|
|
/>
|
|
</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={interfaceSoundsEnabled}
|
|
aria-label={t('settings.voice.interfaceSounds')}
|
|
onChange={(event) => setInterfaceSoundsEnabled(event.target.checked)}
|
|
/>
|
|
<span>{t('settings.voice.interfaceSounds')}</span>
|
|
</label>
|
|
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.interfaceSoundsHint')}</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>
|
|
|
|
{/* Пинг участников: задержка в комнате и публикация своей (AGENT.md 7.14). */}
|
|
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
|
<legend className="text-sm font-semibold">{t('settings.voice.latency')}</legend>
|
|
<label className="mt-2 flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={latencySharingEnabled}
|
|
aria-label={t('settings.voice.latencySharing')}
|
|
onChange={(event) => setLatencySharingEnabled(event.target.checked)}
|
|
/>
|
|
<span>{t('settings.voice.latencySharing')}</span>
|
|
</label>
|
|
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.latencySharingHint')}</p>
|
|
</fieldset>
|
|
|
|
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
|
|
</SettingsSection>
|
|
);
|
|
}
|