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:
@@ -3,13 +3,17 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||
import { Card, Button } from '@/components/ui/primitives';
|
||||
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
||||
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
|
||||
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
||||
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
||||
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
|
||||
import {
|
||||
useCanUseSoundboard,
|
||||
useVoiceChannels,
|
||||
useVoiceModeration,
|
||||
useVoiceOccupants,
|
||||
@@ -18,8 +22,9 @@ import {
|
||||
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||
import { speakingRingClass } from '@/components/voice/voiceStyles';
|
||||
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||
import { SCREEN_SHARE_PRESET_IDS, type ScreenSharePresetId } from '@/lib/livekit';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { useVoiceStore, type VoicePublishQuality } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
|
||||
@@ -105,7 +110,11 @@ function VoiceTile({
|
||||
</span>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
{occupant.isLocal ? (
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
<>
|
||||
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
||||
<VoiceStatsButton />
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={anchorRef}
|
||||
@@ -157,10 +166,14 @@ function VoiceControlsRow({
|
||||
screenEnabled,
|
||||
serverMuted,
|
||||
serverDeafened,
|
||||
screenPreset,
|
||||
videoQuality,
|
||||
soundboard,
|
||||
onMic,
|
||||
onSound,
|
||||
onCamera,
|
||||
onScreen,
|
||||
onVideoQuality,
|
||||
onLeave,
|
||||
}: {
|
||||
micEnabled: boolean;
|
||||
@@ -169,13 +182,26 @@ function VoiceControlsRow({
|
||||
screenEnabled: boolean;
|
||||
serverMuted: boolean;
|
||||
serverDeafened: boolean;
|
||||
/** Выбранный пресет шаринга: показывается в меню демонстрации экрана. */
|
||||
screenPreset: ScreenSharePresetId;
|
||||
/** Качество публикации видео. */
|
||||
videoQuality: VoicePublishQuality;
|
||||
/** Кнопка саундборда: рисуется, когда есть право `USE_SOUNDBOARD`. */
|
||||
soundboard: ReactNode;
|
||||
onMic: () => void;
|
||||
onSound: () => void;
|
||||
onCamera: () => void;
|
||||
onScreen: () => void;
|
||||
/** `null` — остановить демонстрацию, иначе — начать с этим пресетом. */
|
||||
onScreen: (preset: ScreenSharePresetId | null) => void;
|
||||
onVideoQuality: (quality: VoicePublishQuality) => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [screenMenuOpen, setScreenMenuOpen] = useState(false);
|
||||
const [qualityMenuOpen, setQualityMenuOpen] = useState(false);
|
||||
const screenAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const qualityAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
@@ -204,12 +230,94 @@ function VoiceControlsRow({
|
||||
active={cameraEnabled}
|
||||
onClick={onCamera}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="🖥"
|
||||
label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
active={screenEnabled}
|
||||
onClick={onScreen}
|
||||
/>
|
||||
{/* Демонстрация экрана: перед публикацией выбираем разрешение и fps. */}
|
||||
<button
|
||||
ref={screenAnchorRef}
|
||||
type="button"
|
||||
aria-pressed={screenEnabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={screenMenuOpen}
|
||||
aria-label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
title={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
data-testid="voice-screen-button"
|
||||
onClick={() => {
|
||||
if (screenEnabled) {
|
||||
onScreen(null);
|
||||
return;
|
||||
}
|
||||
setScreenMenuOpen((value) => !value);
|
||||
}}
|
||||
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 ${
|
||||
screenEnabled
|
||||
? 'border-border/60 bg-surface-2 text-fg'
|
||||
: 'border-danger/60 bg-danger/10 text-danger'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">🖥</span>
|
||||
</button>
|
||||
<Menu
|
||||
open={screenMenuOpen}
|
||||
label={t('voice.screen.quality')}
|
||||
onClose={() => setScreenMenuOpen(false)}
|
||||
anchorRef={screenAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.screen.quality')}</MenuGroupLabel>
|
||||
{SCREEN_SHARE_PRESET_IDS.map((preset) => (
|
||||
<MenuItem
|
||||
key={preset}
|
||||
role="menuitemradio"
|
||||
selected={screenPreset === preset}
|
||||
data-testid={`voice-screen-preset-${preset}`}
|
||||
onClick={() => {
|
||||
setScreenMenuOpen(false);
|
||||
onScreen(preset);
|
||||
}}
|
||||
>
|
||||
{t(`voice.screen.presets.${preset}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* Качество публикации видео: low/medium/high (`setPublishingQuality`). */}
|
||||
<button
|
||||
ref={qualityAnchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={qualityMenuOpen}
|
||||
aria-label={t('voice.publishQuality.label')}
|
||||
title={t('voice.publishQuality.label')}
|
||||
data-testid="voice-quality-button"
|
||||
onClick={() => setQualityMenuOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1.5 text-xs leading-none text-fg transition-colors duration-[var(--duration-fast)]"
|
||||
>
|
||||
{t(`voice.publishQuality.short.${videoQuality}`)}
|
||||
</button>
|
||||
<Menu
|
||||
open={qualityMenuOpen}
|
||||
label={t('voice.publishQuality.label')}
|
||||
onClose={() => setQualityMenuOpen(false)}
|
||||
anchorRef={qualityAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.publishQuality.label')}</MenuGroupLabel>
|
||||
{(['low', 'medium', 'high'] as VoicePublishQuality[]).map((quality) => (
|
||||
<MenuItem
|
||||
key={quality}
|
||||
role="menuitemradio"
|
||||
selected={videoQuality === quality}
|
||||
data-testid={`voice-publish-quality-${quality}`}
|
||||
onClick={() => {
|
||||
setQualityMenuOpen(false);
|
||||
onVideoQuality(quality);
|
||||
}}
|
||||
>
|
||||
{t(`voice.publishQuality.level.${quality}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{soundboard}
|
||||
<VoiceControlButton glyph="⏻" label={t('voice.disconnect')} active onClick={onLeave} />
|
||||
</div>
|
||||
);
|
||||
@@ -263,7 +371,11 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
|
||||
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||
const permissions = useVoiceModeration(guildId);
|
||||
const canUseSoundboard = useCanUseSoundboard(guildId);
|
||||
const moveTargets = useVoiceChannels(guildId).filter((item) => item.id !== channel.id);
|
||||
const screenPreset = useVoiceStore((state) => state.screenSharePreset);
|
||||
const setScreenSharePreset = useVoiceStore((state) => state.setScreenSharePreset);
|
||||
const videoQuality = useVoiceStore((state) => state.videoQuality);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||
const error = useVoiceConnectionStore((state) => state.error);
|
||||
@@ -280,6 +392,7 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
|
||||
const setVideoQuality = useVoiceConnectionStore((state) => state.setVideoQuality);
|
||||
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||
@@ -367,10 +480,24 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
screenEnabled={screenEnabled}
|
||||
serverMuted={serverMuted}
|
||||
serverDeafened={serverDeafened}
|
||||
screenPreset={screenPreset}
|
||||
videoQuality={videoQuality}
|
||||
soundboard={
|
||||
canUseSoundboard ? <VoiceSoundboard guildId={guildId} disabled={false} /> : null
|
||||
}
|
||||
onMic={() => void setMicEnabled(!micEnabled)}
|
||||
onSound={() => void setDeafened(!deafened)}
|
||||
onCamera={() => void setCameraEnabled(!cameraEnabled)}
|
||||
onScreen={() => void setScreenShareEnabled(!screenEnabled)}
|
||||
onScreen={(preset) => {
|
||||
if (preset === null) {
|
||||
void setScreenShareEnabled(false);
|
||||
return;
|
||||
}
|
||||
// Пресет сохраняем до публикации: стор читает его при старте.
|
||||
setScreenSharePreset(preset);
|
||||
void setScreenShareEnabled(true);
|
||||
}}
|
||||
onVideoQuality={(quality) => void setVideoQuality(quality)}
|
||||
onLeave={() => void leave()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user