From 619bd91df4ae5e04595b1b89b87ba8d9cc5d98da Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 19:30:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=D1=81=D0=B0=D1=83=D0=BD=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B4,=20=D0=B7=D0=B2=D1=83=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BB=D0=B8=D1=82=D1=80=D0=B0?= =?UTF-8?q?,=20push-to-talk=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиентская часть Фазы 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. --- web/.prettierignore | 2 + web/src/api/gateway.ts | 5 + web/src/api/gatewayEvents.ts | 65 +++ web/src/api/sounds.ts | 232 +++++++++ web/src/components/chat/MessageItem.tsx | 7 + web/src/components/voice/SoundboardNotice.tsx | 52 ++ web/src/components/voice/VoiceRoomPanel.tsx | 147 +++++- web/src/components/voice/VoiceSoundboard.tsx | 171 +++++++ web/src/components/voice/VoiceStatsButton.tsx | 139 +++++ web/src/components/voice/useGuildSounds.ts | 29 ++ web/src/components/voice/usePushToTalk.ts | 134 +++++ web/src/components/voice/useVoiceOccupants.ts | 25 +- web/src/i18n/locales/en.json | 137 ++++- web/src/i18n/locales/ru.json | 137 ++++- web/src/lib/identity.ts | 36 ++ web/src/lib/livekit.ts | 237 ++++++++- web/src/lib/soundboard.ts | 198 +++++++ web/src/lib/systemMessage.ts | 3 +- web/src/pages/app/AppLayout.tsx | 8 + web/src/pages/settings/GuildSettingsPage.tsx | 16 +- web/src/pages/settings/GuildSoundsSection.tsx | 433 ++++++++++++++++ .../pages/settings/VoiceSettingsSection.tsx | 95 +++- web/src/stores/gateway.ts | 91 +++- web/src/stores/session.ts | 78 ++- web/src/stores/soundboard.ts | 150 ++++++ web/src/stores/voice.ts | 94 +++- web/src/stores/voiceConnection.ts | 90 +++- web/tests/autocomplete.test.tsx | 1 + web/tests/fakeAudio.ts | 63 +++ web/tests/fakeLiveKit.ts | 59 ++- web/tests/guildEmojis.test.tsx | 1 + web/tests/helpers.tsx | 20 + web/tests/markdown.test.tsx | 1 + web/tests/pushToTalk.test.tsx | 216 ++++++++ web/tests/soundRoutes.ts | 124 +++++ web/tests/soundSettings.test.tsx | 318 ++++++++++++ web/tests/soundboard.test.tsx | 482 ++++++++++++++++++ web/tests/voice.test.tsx | 1 + web/tests/voiceConnection.test.ts | 1 + web/tests/voiceStats.test.tsx | 219 ++++++++ 40 files changed, 4269 insertions(+), 48 deletions(-) create mode 100644 web/src/api/sounds.ts create mode 100644 web/src/components/voice/SoundboardNotice.tsx create mode 100644 web/src/components/voice/VoiceSoundboard.tsx create mode 100644 web/src/components/voice/VoiceStatsButton.tsx create mode 100644 web/src/components/voice/useGuildSounds.ts create mode 100644 web/src/components/voice/usePushToTalk.ts create mode 100644 web/src/lib/soundboard.ts create mode 100644 web/src/pages/settings/GuildSoundsSection.tsx create mode 100644 web/src/stores/soundboard.ts create mode 100644 web/tests/fakeAudio.ts create mode 100644 web/tests/pushToTalk.test.tsx create mode 100644 web/tests/soundRoutes.ts create mode 100644 web/tests/soundSettings.test.tsx create mode 100644 web/tests/soundboard.test.tsx create mode 100644 web/tests/voiceStats.test.tsx diff --git a/web/.prettierignore b/web/.prettierignore index 28cb763..b2b15f2 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -2,3 +2,5 @@ dist node_modules coverage *.min.js +test-results +playwright-report diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index edd1eb6..b00a69b 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -1,9 +1,11 @@ import { parseGuildEmojiList } from './emojis'; import { parseChannelPayload } from './gatewayEvents'; +import { parseGuildSoundList } from './sounds'; import { parseVoiceStateList } from './voice'; import { describeUnknown } from '@/lib/http'; import type { GuildEmoji } from './emojis'; +import type { GuildSound } from './sounds'; import type { VoiceState } from './voice'; import type { Channel, Role, UserStatus } from './types'; @@ -49,6 +51,8 @@ export interface GatewayGuild { roles: Role[]; /** Кастомные эмодзи сервера (AGENT.md 7.12). */ emojis: GuildEmoji[]; + /** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */ + sounds: GuildSound[]; /** Кто из участников в голосовых комнатах (AGENT.md 7.14). */ voice_states: VoiceState[]; member_ids: string[]; @@ -240,6 +244,7 @@ function parseGuild(value: unknown): GatewayGuild | null { channels, roles, emojis: parseGuildEmojiList(record['emojis']), + sounds: parseGuildSoundList(record['sounds']), voice_states: parseVoiceStateList(record['voice_states']), member_ids: asStringArray(record['member_ids']), my_role_ids: asStringArray(record['my_role_ids']), diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index 2be8b10..1256d32 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -1,7 +1,9 @@ import { parseGuildEmojiList } from './emojis'; +import { parseGuildSoundList } from './sounds'; import { userStatuses, type Channel, type UserStatus } from './types'; import type { GatewayUser } from './gateway'; import type { GuildEmoji } from './emojis'; +import type { GuildSound } from './sounds'; import type { Message, MessageAttachment, MessageReaction } from './messages'; /** @@ -58,6 +60,22 @@ export interface GuildEmojisUpdateEvent { emojis: GuildEmoji[]; } +export interface GuildSoundsUpdateEvent { + guild_id: string; + sounds: GuildSound[]; +} + +/** Событие `SOUNDBOARD_PLAY` (AGENT.md 7.13). */ +export interface SoundboardPlayEvent { + guild_id: string; + channel_id: string; + sound_id: string; + file_id: string; + name: string; + user_id: string; + played_at: string; +} + function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null; } @@ -203,6 +221,53 @@ export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent | return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) }; } +/** + * `GUILD_SOUNDS_UPDATE`: набор звуков сервера изменился (AGENT.md 7.13). + * Список приходит целиком — тем же форматом, что в снапшоте READY. + */ +export function parseGuildSoundsUpdate(value: unknown): GuildSoundsUpdateEvent | null { + const record = asRecord(value); + const guildId = asString(record?.['guild_id']); + if (record === null || guildId === undefined) { + return null; + } + return { guild_id: guildId, sounds: parseGuildSoundList(record['sounds']) }; +} + +/** + * `SOUNDBOARD_PLAY`: кто-то сыграл звук в голосовой комнате. Событие + * приходит только участникам этой комнаты, и каждый клиент играет звук сам. + */ +export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const guildId = asString(record['guild_id']); + const channelId = asString(record['channel_id']); + const soundId = asString(record['sound_id']); + const fileId = asString(record['file_id']); + const userId = asString(record['user_id']); + if ( + guildId === undefined || + channelId === undefined || + soundId === undefined || + fileId === undefined || + userId === undefined + ) { + return null; + } + return { + guild_id: guildId, + channel_id: channelId, + sound_id: soundId, + file_id: fileId, + name: asString(record['name']) ?? '', + user_id: userId, + played_at: asString(record['played_at']) ?? '', + }; +} + /** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */ export function parseChannelPayload(value: unknown): Channel | null { const record = asRecord(value); diff --git a/web/src/api/sounds.ts b/web/src/api/sounds.ts new file mode 100644 index 0000000..b1d7553 --- /dev/null +++ b/web/src/api/sounds.ts @@ -0,0 +1,232 @@ +import { request } from './client'; + +/** + * Саундборд и звуковая палитра сервера (AGENT.md 7.13). + * + * Контракт один и тот же в трёх местах: `GET /guilds/{id}/sounds`, снапшот + * READY (`guilds[].sounds`) и событие шлюза `GUILD_SOUNDS_UPDATE`, поэтому + * разбор payload'а общий для всех источников. Сам звук — обычный файл + * (`/files/{file_id}`), который клиент скачивает и играет локально. + */ + +/** Вид набора: звуки саундборда или звуки интерфейса/событий. */ +export type SoundKind = 'soundboard' | 'ui'; + +/** + * События, к которым сервер разрешает привязать UI-звук (AGENT.md 7.13). + * Список — контракт: сервер отвергает всё остальное. + */ +export const SOUND_EVENTS = [ + 'member_join', + 'member_leave', + 'mention', + 'message', + 'message_send', + 'voice_join', + 'voice_leave', + 'mic_on', + 'mic_off', + 'camera_on', + 'camera_off', + 'screen_on', + 'screen_off', + 'kick', + 'ban', + 'mute', + 'role_grant', + 'role_revoke', +] as const; + +export type SoundEvent = (typeof SOUND_EVENTS)[number]; + +/** Звук сервера: элемент `sounds` из READY, REST и `GUILD_SOUNDS_UPDATE`. */ +export interface GuildSound { + id: string; + name: string; + file_id: string; + kind: SoundKind; + /** Событие UI-палитры: только у `kind: 'ui'`. */ + event?: SoundEvent; + /** Необязательная эмодзи-иконка для сетки саундборда. */ + emoji?: string; + /** Ссылка на файл (REST); в снапшоте READY её нет. */ + url?: string; +} + +/** Лимиты из контракта: 512 КБ на звук, 30 звуков каждого вида на сервер. */ +export const SOUND_MAX_BYTES = 512 * 1024; +export const SOUND_LIMIT_PER_KIND = 30; + +/** Имя звука: та же проверка, что на сервере. */ +export const SOUND_NAME_PATTERN = /^[a-zA-Z0-9_]{2,32}$/u; + +/** + * Форматы, которые принимает сервер: MP3/OGG/WAV/WebM/M4A. Браузеры + * называют одни и те же контейнеры по-разному, поэтому проверяем и MIME, + * и расширение файла. + */ +export const SOUND_CONTENT_TYPES = [ + 'audio/mpeg', + 'audio/mp3', + 'audio/ogg', + 'application/ogg', + 'audio/wav', + 'audio/wave', + 'audio/x-wav', + 'audio/webm', + 'audio/mp4', + 'audio/m4a', + 'audio/x-m4a', +] as const; + +const SOUND_EXTENSIONS = ['.mp3', '.ogg', '.oga', '.wav', '.webm', '.m4a'] as const; + +export const guildSoundsQueryKey = (guildId: string, kind?: SoundKind) => + kind === undefined + ? (['guilds', guildId, 'sounds'] as const) + : (['guilds', guildId, 'sounds', kind] as const); + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** Разрешён ли файл по формату (размер проверяется отдельно). */ +export function isAllowedSoundType(file: File): boolean { + if ((SOUND_CONTENT_TYPES as readonly string[]).includes(file.type)) { + return true; + } + const name = file.name.toLocaleLowerCase(); + return SOUND_EXTENSIONS.some((extension) => name.endsWith(extension)); +} + +/** Проходит ли имя клиентскую проверку (та же, что на сервере). */ +export function isValidSoundName(name: string): boolean { + return SOUND_NAME_PATTERN.test(name); +} + +/** Событие из контракта или `undefined` (неизвестные имена отбрасываем). */ +export function asSoundEvent(value: unknown): SoundEvent | undefined { + return typeof value === 'string' && (SOUND_EVENTS as readonly string[]).includes(value) + ? (value as SoundEvent) + : undefined; +} + +/** + * Разбор одного звука. Поле `kind` может отсутствовать (старые снапшоты) — + * считаем такой звук саундбордом, как и сервер по умолчанию. + */ +export function parseGuildSound(value: unknown): GuildSound | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const id = asString(record['id']); + const name = asString(record['name']); + const fileId = asString(record['file_id']); + if (id === undefined || name === undefined || fileId === undefined) { + return null; + } + const kind = record['kind'] === 'ui' ? 'ui' : 'soundboard'; + const sound: GuildSound = { id, name, file_id: fileId, kind }; + const event = asSoundEvent(record['event']); + const emoji = asString(record['emoji']); + const url = asString(record['url']); + if (event !== undefined) { + sound.event = event; + } + if (emoji !== undefined) { + sound.emoji = emoji; + } + if (url !== undefined) { + sound.url = url; + } + return sound; +} + +/** Разбор списка звуков: битые записи отбрасываются. */ +export function parseGuildSoundList(value: unknown): GuildSound[] { + if (!Array.isArray(value)) { + return []; + } + return value.map(parseGuildSound).filter((item): item is GuildSound => item !== null); +} + +/** Список звуков сервера; без `kind` сервер отдаёт оба набора. */ +export async function fetchGuildSounds( + guildId: string, + kind?: SoundKind, + signal?: AbortSignal, +): Promise { + const query = kind === undefined ? '' : `?kind=${kind}`; + const payload = await request<{ sounds?: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/sounds${query}`, + signal === undefined ? {} : { signal }, + ); + return parseGuildSoundList(payload.sounds); +} + +/** Тело загрузки: имя, файл, для палитры — событие, опционально эмодзи. */ +export interface SoundUploadInput { + name: string; + file: File; + event?: SoundEvent; + emoji?: string; +} + +/** Загрузка звука: multipart с полями `name`, `file`, `event` и `emoji`. */ +export async function uploadGuildSound( + guildId: string, + kind: SoundKind, + input: SoundUploadInput, +): Promise { + const form = new FormData(); + form.append('name', input.name); + form.append('file', input.file); + if (input.event !== undefined) { + form.append('event', input.event); + } + if (input.emoji !== undefined && input.emoji !== '') { + form.append('emoji', input.emoji); + } + const payload = await request<{ sound?: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/sounds?kind=${kind}`, + { method: 'POST', form }, + ); + return parseGuildSound(payload.sound); +} + +/** Переименование: сервер принимает `name` и необязательный `emoji`. */ +export async function renameGuildSound( + guildId: string, + soundId: string, + input: { name: string; emoji?: string }, +): Promise { + const payload = await request<{ sound?: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`, + { method: 'PATCH', body: input }, + ); + return parseGuildSound(payload.sound); +} + +export async function deleteGuildSound(guildId: string, soundId: string): Promise { + await request<{ ok: true }>( + `/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`, + { method: 'DELETE' }, + ); +} + +/** + * Просьба сыграть звук в голосовой комнате. Сервер проверит право + * `USE_SOUNDBOARD`, присутствие в комнате (`voice.not_connected`) и лимит + * 3 звука за 10 секунд (`rate_limited`). + */ +export async function playSoundboardSound(guildId: string, soundId: string): Promise { + await request<{ ok: true }>( + `/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}/play`, + { method: 'POST' }, + ); +} diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index e52a1bc..b2d0d89 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -177,6 +177,13 @@ function SystemMessageBody({ message, authors }: { message: Message; authors: Au

); } + if (event.kind === 'member_leave') { + return ( +

+ {t('chat.system.memberLeave', { name: nameOf(event.userId) })} +

+ ); + } if (event.kind === 'member_kick') { return (

diff --git a/web/src/components/voice/SoundboardNotice.tsx b/web/src/components/voice/SoundboardNotice.tsx new file mode 100644 index 0000000..b3ad467 --- /dev/null +++ b/web/src/components/voice/SoundboardNotice.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from 'react-i18next'; + +import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; +import { useSoundboardStore } from '@/stores/soundboard'; + +/** + * Всплывающая подпись «{имя} включил звук {name}» (AGENT.md 7.13). + * + * Живёт в оболочке приложения — рядом с подсказкой о выключенном микрофоне, + * поэтому её видно с любого экрана. Гаснет сама: время жизни держит + * `stores/soundboard` (3 секунды), компонент только рисует текст. + */ +export function SoundboardNotice() { + const { t } = useTranslation(); + const notice = useSoundboardStore((state) => state.notice); + const clear = useSoundboardStore((state) => state.clearNotice); + // Имя того, кто сыграл звук, резолвит общий справочник авторов. + const authors = useAuthorDirectory( + notice?.guildId ?? null, + notice === null ? [] : [notice.userId], + ); + + if (notice === null) { + return null; + } + + return ( +

+ +

+ {t('voice.soundboard.playedBy', { + name: authors.resolve(notice.userId).name, + sound: notice.soundName, + })} +

+ +
+ ); +} diff --git a/web/src/components/voice/VoiceRoomPanel.tsx b/web/src/components/voice/VoiceRoomPanel.tsx index ac6396f..75b3d1a 100644 --- a/web/src/components/voice/VoiceRoomPanel.tsx +++ b/web/src/components/voice/VoiceRoomPanel.tsx @@ -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({ {occupant.isLocal ? ( - {t('voice.you')} + <> + {/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */} + + {t('voice.you')} + ) : ( + setScreenMenuOpen(false)} + anchorRef={screenAnchorRef} + placement="bottom" + > + {t('voice.screen.quality')} + {SCREEN_SHARE_PRESET_IDS.map((preset) => ( + { + setScreenMenuOpen(false); + onScreen(preset); + }} + > + {t(`voice.screen.presets.${preset}`)} + + ))} + + + {/* Качество публикации видео: low/medium/high (`setPublishingQuality`). */} + + setQualityMenuOpen(false)} + anchorRef={qualityAnchorRef} + placement="bottom" + > + {t('voice.publishQuality.label')} + {(['low', 'medium', 'high'] as VoicePublishQuality[]).map((quality) => ( + { + setQualityMenuOpen(false); + onVideoQuality(quality); + }} + > + {t(`voice.publishQuality.level.${quality}`)} + + ))} + + + {soundboard} ); @@ -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 ? : 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} diff --git a/web/src/components/voice/VoiceSoundboard.tsx b/web/src/components/voice/VoiceSoundboard.tsx new file mode 100644 index 0000000..e5b10fd --- /dev/null +++ b/web/src/components/voice/VoiceSoundboard.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { playSoundboardSound, type GuildSound } from '@/api/sounds'; +import { apiErrorCode } from '@/api/client'; +import { retryAfterMs } from '@/api/messages'; +import { Popover } from '@/components/ui/Popover'; +import { useGuildSounds } from '@/components/voice/useGuildSounds'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; +import { useSessionStore } from '@/stores/session'; +import { useSoundboardStore } from '@/stores/soundboard'; + +/** Сколько миллисекунд в секунде: обратный отсчёт лимита (429). */ +const SECOND_MS = 1_000; + +/** + * Попап «Саундборд» в панели голосовой комнаты (AGENT.md 7.13). + * + * Звуки берутся из снапшота сервера (READY + `GUILD_SOUNDS_UPDATE`). Нажатие + * уходит на сервер (`POST .../play`), а звук играет локально сразу: событие + * `SOUNDBOARD_PLAY` придёт и остальным участникам комнаты. Лимит сервера — + * 3 звука за 10 секунд: на 429 показываем обратный отсчёт, на + * `voice.not_connected` — подсказку «сначала войдите в комнату». + */ +export function VoiceSoundboard({ guildId, disabled }: { guildId: string; disabled: boolean }) { + const { t } = useTranslation(); + const describeError = useApiErrorMessage(); + const sounds = useGuildSounds(guildId, 'soundboard'); + const myId = useSessionStore((state) => state.user?.id ?? ''); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + /** Активный лимит: `id` отличает новый отсчёт от предыдущего. */ + const [limit, setLimit] = useState<{ id: number; seconds: number } | null>(null); + const limitSeq = useRef(0); + const anchorRef = useRef(null); + const limitId = limit?.id ?? null; + + // Обратный отсчёт лимита: пока он идёт, кнопки проигрывания выключены. + useEffect(() => { + if (limitId === null) { + return; + } + const timer = setInterval(() => { + setLimit((current) => { + if (current === null || current.id !== limitId) { + return current; + } + return current.seconds <= 1 ? null : { ...current, seconds: current.seconds - 1 }; + }); + }, SECOND_MS); + return () => clearInterval(timer); + }, [limitId]); + + const secondsLeft = limit?.seconds ?? 0; + const needle = query.trim().toLocaleLowerCase(); + const visible = + needle === '' + ? sounds + : sounds.filter((sound) => sound.name.toLocaleLowerCase().includes(needle)); + const notConnected = apiErrorCode(error) === 'voice.not_connected'; + + const play = async (sound: GuildSound): Promise => { + if (pending !== null || secondsLeft > 0) { + return; + } + setPending(sound.id); + setError(null); + try { + await playSoundboardSound(guildId, sound.id); + // Свой звук играем сразу: событие шлюза придёт позже и будет распознано + // как эхо (сопоставление по sound_id + user_id + времени). + useSoundboardStore.getState().playMySound(guildId, sound, myId); + } catch (caught) { + setError(caught); + const wait = retryAfterMs(caught); + if (wait !== null) { + limitSeq.current += 1; + setLimit({ id: limitSeq.current, seconds: Math.max(1, Math.ceil(wait / SECOND_MS)) }); + } + } finally { + setPending(null); + } + }; + + return ( + <> + + + setOpen(false)} + anchorRef={anchorRef} + className="w-72" + > +
+ + + {secondsLeft > 0 ? ( +

+ {t('voice.soundboard.rateLimited', { seconds: secondsLeft })} +

+ ) : null} + + {notConnected ? ( +

+ {t('voice.soundboard.notConnected')} +

+ ) : error !== null ? ( +

+ {describeError(error)} +

+ ) : null} + + {sounds.length === 0 ? ( +

{t('voice.soundboard.empty')}

+ ) : visible.length === 0 ? ( +

{t('voice.soundboard.noResults')}

+ ) : ( +
    + {visible.map((sound) => ( +
  • + +
  • + ))} +
+ )} +
+
+ + ); +} diff --git a/web/src/components/voice/VoiceStatsButton.tsx b/web/src/components/voice/VoiceStatsButton.tsx new file mode 100644 index 0000000..df7651b --- /dev/null +++ b/web/src/components/voice/VoiceStatsButton.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Popover } from '@/components/ui/Popover'; +import { StatRow } from '@/components/ui/StatRow'; +import type { VoiceStats } from '@/lib/livekit'; +import { useVoiceConnectionStore } from '@/stores/voiceConnection'; + +/** Период опроса статистики: раз в секунду, пока попап открыт. */ +export const STATS_POLL_INTERVAL_MS = 1_000; + +/** Прочерк вместо значения, которого SDK не отдал. */ +function value(text: string | number | null): string { + return text === null ? '—' : String(text); +} + +/** + * Попап «Статистика» у своей плитки (AGENT.md 7.14): фактические разрешение, + * fps, кодек и битрейт публикации плюс качество канала (RTT, потери, jitter). + * + * Опрос идёт только пока попап открыт: закрытие останавливает таймер, и SDK + * больше не дёргается. Значения приходят из `getRTCStatsReport()`; чего нет — + * показываем прочерком. + */ +export function VoiceStatsButton() { + const { t } = useTranslation(); + const fetchStats = useVoiceConnectionStore((state) => state.fetchStats); + // Вне активной сессии статистики нет: кнопку не показываем. + const connected = useVoiceConnectionStore((state) => state.status === 'connected'); + const [open, setOpen] = useState(false); + const [stats, setStats] = useState(null); + const anchorRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + let cancelled = false; + const poll = async (): Promise => { + const next = await fetchStats(); + if (!cancelled) { + setStats(next); + } + }; + void poll(); + const timer = setInterval(() => { + void poll(); + }, STATS_POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [open, fetchStats]); + + const resolution = + stats?.width === null || stats?.width === undefined || stats.height === null + ? null + : `${stats.width}×${stats.height}`; + + if (!connected) { + return null; + } + + return ( + <> + + + setOpen(false)} + anchorRef={anchorRef} + className="w-72" + > +
+

+ {t('voice.stats.title')} +

+
+ + + + + + + +
+
+
+ + ); +} diff --git a/web/src/components/voice/useGuildSounds.ts b/web/src/components/voice/useGuildSounds.ts new file mode 100644 index 0000000..cc471fe --- /dev/null +++ b/web/src/components/voice/useGuildSounds.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo } from 'react'; + +import type { GuildSound, SoundKind } from '@/api/sounds'; +import { useSessionStore, guildSoundsOf } from '@/stores/session'; + +/** + * Звуки сервера из снапшота (READY и `GUILD_SOUNDS_UPDATE`, AGENT.md 7.13). + * + * Один источник данных для саундборда и звуковой палитры: и попап панели + * комнаты, и обработчики событий читают один и тот же список. Логика разбора + * payload'а живёт в `api/sounds`, здесь — только доступ к снапшоту. + */ + +/** Пустой список — стабильная ссылка: селектор не должен перерисовывать зря. */ +const NO_SOUNDS: GuildSound[] = []; + +/** Звуки сервера: без `kind` — оба набора (саундборд и палитра). */ +export function useGuildSounds(guildId: string | null, kind?: SoundKind): GuildSound[] { + const sounds = useSessionStore( + useCallback( + (state) => (guildId === null ? NO_SOUNDS : guildSoundsOf(state.guilds, guildId)), + [guildId], + ), + ); + return useMemo( + () => (kind === undefined ? sounds : sounds.filter((sound) => sound.kind === kind)), + [sounds, kind], + ); +} diff --git a/web/src/components/voice/usePushToTalk.ts b/web/src/components/voice/usePushToTalk.ts new file mode 100644 index 0000000..586bce5 --- /dev/null +++ b/web/src/components/voice/usePushToTalk.ts @@ -0,0 +1,134 @@ +import { useEffect } from 'react'; + +import { useVoiceConnectionStore } from '@/stores/voiceConnection'; +import { useVoiceStore, type PushToTalkKey } from '@/stores/voice'; + +/** + * Push-to-talk (AGENT.md 7.14): пока режим включён, микрофон выключен, а + * удержание клавиши его включает. + * + * Клавишу слушаем на уровне документа, но только когда окно в фокусе и фокус + * не в поле ввода: иначе пробел в сообщении «зажигал» бы микрофон. Нажатие без + * повторов (`event.repeat` игнорируем), модификаторы браузера (Ctrl/Alt/Meta + * вместе с обычной клавишей) не считаются PTT, а пробел на кнопке остаётся + * кликом. Серверный мьют приоритетнее: при нём микрофон не включается. + */ + +/** Соответствие «левая клавиша → правая»: AltRight тоже считается Alt. */ +const MIRRORED_KEYS: Partial> = { + AltLeft: 'AltRight', + ControlLeft: 'ControlRight', +}; + +/** Совпадает ли нажатие с выбранной клавишей push-to-talk. */ +export function matchesPushToTalk(key: PushToTalkKey, code: string): boolean { + return code === key || code === MIRRORED_KEYS[key]; +} + +/** Идёт ли ввод текста: поля ввода, textarea, select и contenteditable. */ +export function isTextEntryTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + return true; + } + return target.isContentEditable; +} + +/** + * Элемент, на котором пробел нажимает кнопку: активная кнопка/ссылка. Для них + * PTT не перехватывает пробел, чтобы не ломать управление с клавиатуры. + */ +function isButtonLike(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + return ( + target.tagName === 'BUTTON' || + target.tagName === 'A' || + target.getAttribute('role') === 'button' || + target.getAttribute('role') === 'menuitem' + ); +} + +/** Нажат ли модификатор, который не является самой клавишей PTT. */ +function hasForeignModifier(event: KeyboardEvent, key: PushToTalkKey): boolean { + const ctrl = event.ctrlKey && key !== 'ControlLeft'; + const alt = event.altKey && key !== 'AltLeft'; + return ctrl || alt || event.metaKey; +} + +export function usePushToTalk(): void { + const mode = useVoiceStore((state) => state.micActivation); + const key = useVoiceStore((state) => state.pttKey); + const status = useVoiceConnectionStore((state) => state.status); + + // Возврат к голосовой активации: микрофон снова по сохранённому + // предпочтению (в режиме PTT его выключает сам push-to-talk). + useEffect(() => { + if (mode === 'push_to_talk' || status !== 'connected') { + return; + } + const connection = useVoiceConnectionStore.getState(); + const preference = useVoiceStore.getState().micEnabled; + if (connection.micEnabled !== preference) { + void connection.setMicEnabled(preference); + } + }, [mode, status]); + + useEffect(() => { + if (mode !== 'push_to_talk') { + return; + } + if (status !== 'connected' && status !== 'reconnecting') { + return; + } + // Вход в комнату в режиме PTT: микрофон ждёт нажатия клавиши. + useVoiceConnectionStore.getState().setPushToTalkActive(false); + + const onKeyDown = (event: KeyboardEvent): void => { + if (!matchesPushToTalk(key, event.code) || event.repeat) { + return; + } + if (!document.hasFocus() || hasForeignModifier(event, key)) { + return; + } + const target = event.target ?? document.activeElement; + if (isTextEntryTarget(target)) { + return; + } + // Пробел на кнопке — это клик, а не push-to-talk. + if (key === 'Space' && isButtonLike(target)) { + return; + } + // Пробел не должен прокручивать страницу, пока идёт разговор. + event.preventDefault(); + useVoiceConnectionStore.getState().setPushToTalkActive(true); + }; + + const onKeyUp = (event: KeyboardEvent): void => { + if (!matchesPushToTalk(key, event.code)) { + return; + } + useVoiceConnectionStore.getState().setPushToTalkActive(false); + }; + + /** Потеря фокуса окна отпускает клавишу: иначе микрофон остался бы включён. */ + const release = (): void => { + useVoiceConnectionStore.getState().setPushToTalkActive(false); + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', release); + document.addEventListener('visibilitychange', release); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp); + window.removeEventListener('blur', release); + document.removeEventListener('visibilitychange', release); + }; + }, [mode, key, status]); +} diff --git a/web/src/components/voice/useVoiceOccupants.ts b/web/src/components/voice/useVoiceOccupants.ts index 9f1ce21..203822c 100644 --- a/web/src/components/voice/useVoiceOccupants.ts +++ b/web/src/components/voice/useVoiceOccupants.ts @@ -3,7 +3,11 @@ import { useMemo } from 'react'; import type { Channel } from '@/api/types'; import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; import type { VoiceQuality, VoiceParticipant } from '@/lib/livekit'; -import { voiceModerationPermissions, type VoiceModerationPermissions } from '@/lib/identity'; +import { + canUseSoundboard, + voiceModerationPermissions, + type VoiceModerationPermissions, +} from '@/lib/identity'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore, voiceStatesOfChannel } from '@/stores/session'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; @@ -146,6 +150,25 @@ export function useVoiceModeration(guildId: string | null): VoiceModerationPermi ); } +/** + * Может ли пользователь играть звуки саундборда на этом сервере (7.13): + * право `USE_SOUNDBOARD`. Наличие голосовой сессии проверяет вызывающий код — + * сервер всё равно ответит `voice.not_connected`, если мы не в комнате. + */ +export function useCanUseSoundboard(guildId: string | null): boolean { + const currentUser = useCurrentUser(); + const guild = useSessionStore((state) => + guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null), + ); + const sessionUser = useSessionStore((state) => state.user); + if (guild === null) { + return false; + } + const isAdmin = + sessionUser?.is_instance_admin === true || currentUser.data?.is_instance_admin === true; + return canUseSoundboard(guild.my_permissions, isAdmin); +} + /** Голосовые комнаты сервера: цель для перемещения участников. */ export function useVoiceChannels(guildId: string | null): Channel[] { const guild = useSessionStore((state) => diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 834f6ac..2cfcb82 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -70,7 +70,17 @@ }, "voice.disabled": "Voice is not configured on this instance.", "voice.channel_full": "No free slots in this room.", - "voice.token_refresh_failed": "Could not renew the voice session. Please join again." + "voice.token_refresh_failed": "Could not renew the voice session. Please join again.", + "sound": { + "invalid_type": "MP3, OGG, WAV, WebM and M4A are supported.", + "invalid_name": "Sound name: 2–32 characters, latin letters, digits and «_».", + "name_taken": "A sound with this name already exists." + }, + "sounds": { + "limit_reached": "The limit of sounds of this kind is reached.", + "event_required": "Choose an event for a palette sound." + }, + "voice.not_connected": "Join a voice channel first." }, "theme": { "switchToLight": "Light theme", @@ -268,7 +278,14 @@ }, "screen": { "on": "Start screen sharing", - "off": "Stop screen sharing" + "off": "Stop screen sharing", + "quality": "Screen share quality", + "presets": { + "1080p60": "1080p · 60 fps", + "1080p30": "1080p · 30 fps", + "720p30": "720p · 30 fps", + "480p30": "480p · 30 fps" + } }, "quality": { "excellent": "Excellent connection", @@ -289,6 +306,44 @@ }, "channelMenu": { "label": "Voice room {{name}}" + }, + "soundboard": { + "title": "Soundboard", + "open": "Open the soundboard", + "searchLabel": "Search sounds", + "searchPlaceholder": "Sound name", + "empty": "This server has no soundboard sounds yet.", + "noResults": "Nothing found.", + "playLabel": "Play the sound {{name}}", + "rateLimited": "Too fast: try again in {{seconds}} s.", + "notConnected": "Join a voice channel first.", + "playedBy": "{{name}} played «{{sound}}»" + }, + "stats": { + "title": "Statistics", + "open": "Open connection statistics", + "resolution": "Resolution", + "frameRate": "Frame rate", + "codec": "Codec", + "bitrate": "Bitrate", + "kbps": "kbps", + "ms": "ms", + "rtt": "Round-trip time", + "packetLoss": "Packet loss", + "jitter": "Jitter" + }, + "publishQuality": { + "label": "Video quality", + "short": { + "low": "Low", + "medium": "Medium", + "high": "High" + }, + "level": { + "low": "Low quality", + "medium": "Medium quality", + "high": "High quality" + } } }, "friends": { @@ -490,7 +545,8 @@ "memberJoin": "{{name}} joined the server", "memberKick": "{{name}} left the server (kicked)", "channelCreated": "Room #{{name}} created", - "voiceChannelCreated": "Voice room {{name}} created" + "voiceChannelCreated": "Voice room {{name}} created", + "memberLeave": "{{name}} left the server" } }, "user": { @@ -761,6 +817,59 @@ "fileSelected": "{{name}} · {{size}}", "upload": "Upload", "uploading": "Uploading…" + }, + "sounds": { + "title": "Sounds", + "description": "Server soundboard and sound palette: up to {{limit}} sounds of each kind, 512 KB each.", + "counter": "{{count}} of {{limit}}", + "empty": "No sounds of this kind yet.", + "uploadTitle": "Upload a sound", + "uploadHint": "Drop a file below or choose it: MP3, OGG, WAV, WebM or M4A up to {{limit}}.", + "name": "Sound name", + "namePlaceholder": "for example, airhorn", + "nameHint": "2–32 characters: latin letters, digits and «_».", + "emoji": "Emoji", + "emojiPlaceholder": "for example, 📣", + "emojiHint": "Optional icon for the soundboard grid.", + "event": "Event", + "dropHint": "Drop the file here", + "file": "Sound file", + "chooseFile": "Choose a file", + "fileSelected": "{{name}} · {{size}}", + "upload": "Upload", + "uploading": "Uploading…", + "rename": "Rename", + "renameLabel": "Rename the sound {{name}}", + "renameTitle": "Rename the sound", + "delete": "Delete", + "deleteLabel": "Delete the sound {{name}}", + "deleteConfirmTitle": "Delete the sound?", + "deleteConfirmBody": "The sound {{name}} will be deleted permanently.", + "deleteConfirm": "Delete", + "kinds": { + "soundboard": "Soundboard", + "ui": "Sound palette" + }, + "events": { + "member_join": "Member joined", + "member_leave": "Member left", + "mention": "Mention", + "message": "New message", + "message_send": "Message sent", + "voice_join": "Joined a voice channel", + "voice_leave": "Left a voice channel", + "mic_on": "Microphone on", + "mic_off": "Microphone off", + "camera_on": "Camera on", + "camera_off": "Camera off", + "screen_on": "Screen share started", + "screen_off": "Screen share stopped", + "kick": "Member kicked", + "ban": "Member banned", + "mute": "Member muted", + "role_grant": "Role granted", + "role_revoke": "Role revoked" + } } }, "navLabel": "Settings sections", @@ -777,7 +886,27 @@ "output": "Sound", "micVolume": "Microphone volume", "outputVolume": "Sound volume", - "stub": "Changes apply immediately and are stored on this device." + "stub": "Changes apply immediately and are stored on this device.", + "micMode": "Microphone activation", + "micModes": { + "voice": "Voice activity", + "push_to_talk": "Push-to-talk" + }, + "pttKey": "Key", + "pttHint": "Hold the key to talk. Works while the window is focused and the cursor is not in a text field.", + "pttKeys": { + "space": "Space", + "v": "V", + "b": "B", + "n": "N", + "alt": "Alt", + "ctrl": "Ctrl" + }, + "sounds": "Sounds", + "eventSounds": "Server event sounds", + "eventSoundsHint": "Play the server palette sounds: member joins, mentions and other events.", + "soundboard": "Soundboard", + "soundboardHint": "Play soundboard sounds in a voice channel." }, "language": { "title": "Language & region", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5561dd2..bcbf67f 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -70,7 +70,17 @@ }, "voice.disabled": "Голос не настроен на инстансе.", "voice.channel_full": "В комнате нет свободных мест.", - "voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново." + "voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.", + "sound": { + "invalid_type": "Поддерживаются MP3, OGG, WAV, WebM и M4A.", + "invalid_name": "Имя звука: 2–32 символа, латиница, цифры и «_».", + "name_taken": "Звук с таким именем уже есть." + }, + "sounds": { + "limit_reached": "Достигнут лимит звуков этого вида.", + "event_required": "Для звука палитры выберите событие." + }, + "voice.not_connected": "Сначала войдите в голосовую комнату." }, "theme": { "switchToLight": "Светлая тема", @@ -268,7 +278,14 @@ }, "screen": { "on": "Начать демонстрацию экрана", - "off": "Остановить демонстрацию экрана" + "off": "Остановить демонстрацию экрана", + "quality": "Качество демонстрации", + "presets": { + "1080p60": "1080p · 60 fps", + "1080p30": "1080p · 30 fps", + "720p30": "720p · 30 fps", + "480p30": "480p · 30 fps" + } }, "quality": { "excellent": "Отличное соединение", @@ -289,6 +306,44 @@ }, "channelMenu": { "label": "Голосовая комната {{name}}" + }, + "soundboard": { + "title": "Саундборд", + "open": "Открыть саундборд", + "searchLabel": "Поиск звука", + "searchPlaceholder": "Название звука", + "empty": "На сервере пока нет звуков саундборда.", + "noResults": "Ничего не найдено.", + "playLabel": "Проиграть звук {{name}}", + "rateLimited": "Слишком часто: повторите через {{seconds}} с.", + "notConnected": "Сначала войдите в голосовую комнату.", + "playedBy": "{{name}} включил звук «{{sound}}»" + }, + "stats": { + "title": "Статистика", + "open": "Открыть статистику соединения", + "resolution": "Разрешение", + "frameRate": "Частота кадров", + "codec": "Кодек", + "bitrate": "Битрейт", + "kbps": "кбит/с", + "ms": "мс", + "rtt": "Задержка (RTT)", + "packetLoss": "Потери пакетов", + "jitter": "Джиттер" + }, + "publishQuality": { + "label": "Качество видео", + "short": { + "low": "Низкое", + "medium": "Среднее", + "high": "Высокое" + }, + "level": { + "low": "Низкое качество", + "medium": "Среднее качество", + "high": "Высокое качество" + } } }, "friends": { @@ -490,7 +545,8 @@ "memberJoin": "{{name}} присоединился к серверу", "memberKick": "{{name}} покинул сервер (исключён)", "channelCreated": "Комната #{{name}} создана", - "voiceChannelCreated": "Голосовая комната {{name}} создана" + "voiceChannelCreated": "Голосовая комната {{name}} создана", + "memberLeave": "{{name}} покинул сервер" } }, "user": { @@ -761,6 +817,59 @@ "fileSelected": "{{name}} · {{size}}", "upload": "Загрузить", "uploading": "Загружаем…" + }, + "sounds": { + "title": "Звуки", + "description": "Саундборд и звуковая палитра сервера: до {{limit}} звуков каждого вида, каждый до 512 КБ.", + "counter": "{{count}} из {{limit}}", + "empty": "Звуков этого вида пока нет.", + "uploadTitle": "Загрузить звук", + "uploadHint": "Перетащите файл в область ниже или выберите его: MP3, OGG, WAV, WebM или M4A до {{limit}}.", + "name": "Имя звука", + "namePlaceholder": "например, airhorn", + "nameHint": "2–32 символа: латиница, цифры и «_».", + "emoji": "Эмодзи", + "emojiPlaceholder": "например, 📣", + "emojiHint": "Необязательная иконка для сетки саундборда.", + "event": "Событие", + "dropHint": "Перетащите файл сюда", + "file": "Файл звука", + "chooseFile": "Выбрать файл", + "fileSelected": "{{name}} · {{size}}", + "upload": "Загрузить", + "uploading": "Загружаем…", + "rename": "Переименовать", + "renameLabel": "Переименовать звук {{name}}", + "renameTitle": "Переименовать звук", + "delete": "Удалить", + "deleteLabel": "Удалить звук {{name}}", + "deleteConfirmTitle": "Удалить звук?", + "deleteConfirmBody": "Звук {{name}} будет удалён без возможности восстановления.", + "deleteConfirm": "Удалить", + "kinds": { + "soundboard": "Саундборд", + "ui": "Звуковая палитра" + }, + "events": { + "member_join": "Участник присоединился", + "member_leave": "Участник вышел", + "mention": "Упоминание", + "message": "Новое сообщение", + "message_send": "Отправка сообщения", + "voice_join": "Вход в голосовую комнату", + "voice_leave": "Выход из голосовой комнаты", + "mic_on": "Микрофон включён", + "mic_off": "Микрофон выключен", + "camera_on": "Камера включена", + "camera_off": "Камера выключена", + "screen_on": "Демонстрация начата", + "screen_off": "Демонстрация остановлена", + "kick": "Исключение участника", + "ban": "Бан участника", + "mute": "Мьют участника", + "role_grant": "Выдача роли", + "role_revoke": "Снятие роли" + } } }, "navLabel": "Пункты настроек", @@ -777,7 +886,27 @@ "output": "Звук", "micVolume": "Громкость микрофона", "outputVolume": "Громкость звука", - "stub": "Изменения применяются сразу и сохраняются на этом устройстве." + "stub": "Изменения применяются сразу и сохраняются на этом устройстве.", + "micMode": "Активация микрофона", + "micModes": { + "voice": "Голосовая активация", + "push_to_talk": "Push-to-talk" + }, + "pttKey": "Клавиша", + "pttHint": "Удерживайте клавишу, чтобы говорить. Работает, когда окно в фокусе и курсор не в поле ввода.", + "pttKeys": { + "space": "Пробел", + "v": "V", + "b": "B", + "n": "N", + "alt": "Alt", + "ctrl": "Ctrl" + }, + "sounds": "Звуки", + "eventSounds": "Звуки событий сервера", + "eventSoundsHint": "Проигрывать звуки палитры сервера: вход участника, упоминания и другие события.", + "soundboard": "Саундборд", + "soundboardHint": "Проигрывать звуки саундборда в голосовой комнате." }, "language": { "title": "Язык и регион", diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts index b459325..efed8b7 100644 --- a/web/src/lib/identity.ts +++ b/web/src/lib/identity.ts @@ -41,6 +41,8 @@ export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD'; export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS'; export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES'; export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS'; +export const PERMISSION_MANAGE_SOUNDS = 'MANAGE_SOUNDS'; +export const PERMISSION_USE_SOUNDBOARD = 'USE_SOUNDBOARD'; export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES'; export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES'; export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS'; @@ -186,6 +188,40 @@ export function canManageEmojis( ); } +/** + * Может ли пользователь управлять звуками сервера (AGENT.md 7.13): владелец, + * ADMINISTRATOR, MANAGE_SOUNDS или администратор инстанса. + */ +export function canManageSounds( + permissions: readonly string[], + ownerId: string, + userId: string | undefined, + isInstanceAdmin = false, +): boolean { + if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) { + return true; + } + return ( + hasPermission(permissions, PERMISSION_ADMINISTRATOR) || + hasPermission(permissions, PERMISSION_MANAGE_SOUNDS) + ); +} + +/** + * Может ли пользователь играть звуки саундборда (AGENT.md 7.13): право + * `USE_SOUNDBOARD`. Присутствие в голосовой комнате проверяется отдельно — + * сервер отвечает `voice.not_connected`, если мы не в комнате. + */ +export function canUseSoundboard(permissions: readonly string[], isInstanceAdmin = false): boolean { + if (isInstanceAdmin) { + return true; + } + return ( + hasPermission(permissions, PERMISSION_ADMINISTRATOR) || + hasPermission(permissions, PERMISSION_USE_SOUNDBOARD) + ); +} + /** Может ли пользователь отправлять сообщения в комнату. */ export function canSendMessages( permissions: readonly string[], diff --git a/web/src/lib/livekit.ts b/web/src/lib/livekit.ts index a6fedac..992db5f 100644 --- a/web/src/lib/livekit.ts +++ b/web/src/lib/livekit.ts @@ -1,6 +1,6 @@ -import type { Participant, Room, Track } from 'livekit-client'; +import type { LocalVideoTrack, Participant, Room, Track } from 'livekit-client'; -import type { VoiceDeviceKind } from '@/stores/voice'; +import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice'; /** * Обёртка над `livekit-client`: единственное место, где клиент знает про SDK. @@ -46,6 +46,60 @@ export interface VoiceParticipant { export type VoiceRoomStatus = 'connected' | 'reconnecting' | 'reconnected' | 'disconnected'; +/** Пресеты демонстрации экрана: разрешение и fps выбирает пользователь. */ +export type ScreenSharePresetId = '1080p60' | '1080p30' | '720p30' | '480p30'; + +/** Разрешение и fps, которые уходят в `ScreenShareCaptureOptions`. */ +export const SCREEN_SHARE_PRESETS: Record< + ScreenSharePresetId, + { width: number; height: number; frameRate: number } +> = { + '1080p60': { width: 1920, height: 1080, frameRate: 60 }, + '1080p30': { width: 1920, height: 1080, frameRate: 30 }, + '720p30': { width: 1280, height: 720, frameRate: 30 }, + '480p30': { width: 854, height: 480, frameRate: 30 }, +}; + +/** Список пресетов в порядке показа: от лучшего к экономному. */ +export const SCREEN_SHARE_PRESET_IDS: ScreenSharePresetId[] = [ + '1080p60', + '1080p30', + '720p30', + '480p30', +]; + +/** + * Статистика своей публикации (AGENT.md 7.14): фактические разрешение, fps, + * кодек и битрейт, а также качество канала. `null` означает «SDK поле не + * отдал» — интерфейс показывает прочерк. + */ +export interface VoiceStats { + width: number | null; + height: number | null; + frameRate: number | null; + codec: string | null; + /** Битрейт отправки в килобитах в секунду (по приросту `bytesSent`). */ + bitrateKbps: number | null; + roundTripMs: number | null; + jitterMs: number | null; + packetsLost: number | null; + /** Доля потерянных пакетов, 0–100. */ + packetLossPercent: number | null; +} + +/** Пустая статистика: SDK ещё не отдал отчёт. */ +export const EMPTY_VOICE_STATS: VoiceStats = { + width: null, + height: null, + frameRate: null, + codec: null, + bitrateKbps: null, + roundTripMs: null, + jitterMs: null, + packetsLost: null, + packetLossPercent: null, +}; + export interface VoiceRoomCallbacks { /** Изменился состав комнаты или медиа-состояние участников. */ onParticipants: (participants: VoiceParticipant[]) => void; @@ -61,7 +115,12 @@ export interface VoiceRoomHandle { dispose: () => void; setMicrophoneEnabled: (enabled: boolean) => Promise; setCameraEnabled: (enabled: boolean) => Promise; - setScreenShareEnabled: (enabled: boolean) => Promise; + /** Демонстрация экрана: пресет задаёт разрешение и fps захвата. */ + setScreenShareEnabled: (enabled: boolean, preset?: ScreenSharePresetId) => Promise; + /** Качество публикации своей камеры (и шаринга, если он идёт). */ + setVideoQuality: (quality: VoicePublishQuality) => Promise; + /** Статистика своей публикации для попапа «Статистика». */ + fetchStats: () => Promise; switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise; participants: () => VoiceParticipant[]; } @@ -152,6 +211,140 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise { + const local = room.localParticipant; + let fallback: LocalVideoTrack | undefined; + for (const source of [Track.Source.Camera, Track.Source.ScreenShare]) { + const publication = local.getTrackPublication(source); + const track = publication?.track; + if (publication === undefined || track === undefined) { + continue; + } + if (!publication.isMuted) { + return track as LocalVideoTrack; + } + fallback = fallback ?? (track as LocalVideoTrack); + } + return fallback; + }; + + /** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */ + const num = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + + /** Предыдущий замер `bytesSent`: битрейт считается по приросту. */ + let previousSample: { bytes: number; at: number } | null = null; + + /** + * Статистика своей публикации. Источники — те же, что в `chrome://webrtc`: + * `outbound-rtp` (разрешение, fps, кодек, отправленные байты), + * `remote-inbound-rtp` (RTT, jitter, потери) и `candidate-pair` (RTT). + * Чего SDK не отдал — остаётся `null`, интерфейс покажет «—». + */ + const collectStats = async (): Promise => { + const track = videoPublication(); + if (track === undefined || typeof track.getRTCStatsReport !== 'function') { + previousSample = null; + return { ...EMPTY_VOICE_STATS }; + } + let report: RTCStatsReport | undefined; + try { + report = await track.getRTCStatsReport(); + } catch { + return { ...EMPTY_VOICE_STATS }; + } + if (report === undefined) { + return { ...EMPTY_VOICE_STATS }; + } + + const entries: Record[] = []; + report.forEach((entry: unknown) => { + if (typeof entry === 'object' && entry !== null) { + entries.push(entry as Record); + } + }); + const codecs = new Map(); + for (const entry of entries) { + if (entry['type'] === 'codec' && typeof entry['id'] === 'string') { + const mime = entry['mimeType']; + codecs.set(entry['id'], typeof mime === 'string' ? mime : ''); + } + } + + const outbound = entries.find( + (entry) => + entry['type'] === 'outbound-rtp' && + (entry['kind'] === 'video' || entry['mediaType'] === 'video'), + ); + const remoteInbound = entries.find( + (entry) => entry['type'] === 'remote-inbound-rtp' && entry['kind'] !== 'audio', + ); + const candidatePair = entries.find( + (entry) => entry['type'] === 'candidate-pair' && entry['state'] === 'succeeded', + ); + + const stats: VoiceStats = { ...EMPTY_VOICE_STATS }; + const frameWidth = num(outbound?.['frameWidth']); + const frameHeight = num(outbound?.['frameHeight']); + const framesPerSecond = num(outbound?.['framesPerSecond']); + if (frameWidth !== null) { + stats.width = Math.round(frameWidth); + } + if (frameHeight !== null) { + stats.height = Math.round(frameHeight); + } + if (framesPerSecond !== null) { + stats.frameRate = Math.round(framesPerSecond); + } + const codecId = outbound?.['codecId']; + const mimeType = typeof codecId === 'string' ? codecs.get(codecId) : undefined; + if (mimeType !== undefined && mimeType !== '') { + // «video/VP8» → «VP8»: в интерфейсе нужен короткий кодек. + stats.codec = mimeType.split('/')[1]?.toLocaleUpperCase() ?? mimeType; + } + + // Битрейт: разница `bytesSent` между замерами, делённая на интервал. + const bytesSent = num(outbound?.['bytesSent']); + const timestamp = num(outbound?.['timestamp']) ?? Date.now(); + if (bytesSent !== null) { + if (previousSample !== null) { + const elapsedMs = Math.max(1, timestamp - previousSample.at); + const deltaBytes = Math.max(0, bytesSent - previousSample.bytes); + stats.bitrateKbps = Math.round((deltaBytes * 8) / elapsedMs); + } + previousSample = { bytes: bytesSent, at: timestamp }; + } else { + previousSample = null; + } + + const roundTrip = + num(remoteInbound?.['roundTripTime']) ?? num(candidatePair?.['currentRoundTripTime']); + if (roundTrip !== null) { + stats.roundTripMs = Math.round(roundTrip * 1000); + } + const jitter = num(remoteInbound?.['jitter']); + if (jitter !== null) { + stats.jitterMs = Math.round(jitter * 1000); + } + const packetsLost = num(remoteInbound?.['packetsLost']); + const packetsSent = num(outbound?.['packetsSent']); + if (packetsLost !== null) { + stats.packetsLost = Math.max(0, Math.round(packetsLost)); + if (packetsSent !== null && packetsSent + packetsLost > 0) { + stats.packetLossPercent = + Math.round(((packetsLost / (packetsSent + packetsLost)) * 100 + Number.EPSILON) * 10) / + 10; + } + } + return stats; + }; + const room = new Room({ adaptiveStream: true, dynacast: true }); const refresh = (): void => { callbacks.onParticipants(snapshotAll(room)); @@ -210,11 +403,45 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise = + { + low: VideoQuality.LOW, + medium: VideoQuality.MEDIUM, + high: VideoQuality.HIGH, + }; + track.setPublishingQuality(levels[quality]); + }, + + async fetchStats() { + return collectStats(); + }, + async switchActiveDevice(kind, deviceId) { await room.switchActiveDevice(kind, deviceId === '' ? 'default' : deviceId); }, diff --git a/web/src/lib/soundboard.ts b/web/src/lib/soundboard.ts new file mode 100644 index 0000000..86ff002 --- /dev/null +++ b/web/src/lib/soundboard.ts @@ -0,0 +1,198 @@ +import { fileContentUrl } from '@/api/files'; +import { useVoiceStore } from '@/stores/voice'; + +/** + * Локальное воспроизведение звуков саундборда и звуковой палитры + * (AGENT.md 7.13): сервер только рассылает событие, а файл каждый клиент + * скачивает сам (`/files/{file_id}`, тот же origin) и играет локально. + * + * Движок держит кэш object URL (повторное нажатие не качает файл заново) и + * список играющих элементов, чтобы звук можно было остановить: свой звук не + * накладывается сам на себя, разные звуки звучат одновременно, а при выходе + * из комнаты всё глохнет. + * + * Громкость берётся из общей громкости приложения (`stores/voice`). + */ + +/** Сколько звуков может звучать одновременно: защита от «стены» звука. */ +export const SOUNDBOARD_MAX_CONCURRENT = 8; + +/** Кэш ссылок на файлы: `file_id` → object URL (или прямой адрес). */ +const sources = new Map>(); +/** Играющие элементы по `file_id`: их останавливает `stopSoundFile`. */ +const playing = new Map>(); +/** Порядок запусков: самый старый звук глушится при переполнении. */ +const order: HTMLAudioElement[] = []; + +/** + * Общая громкость приложения в долях единицы (0–1). «Звук выключен» (в том + * числе серверный дефен) глушит и саундборд, и звуки событий: 0 означает + * «не играть вовсе». + */ +export function soundVolume(): number { + const { outputVolume, headphonesEnabled } = useVoiceStore.getState(); + if (!headphonesEnabled) { + return 0; + } + return Math.min(1, Math.max(0, outputVolume / 100)); +} + +function clampVolume(value: number): number { + if (!Number.isFinite(value)) { + return 1; + } + return Math.min(1, Math.max(0, value)); +} + +/** + * Ссылка на файл звука: сначала пробуем скачать его в blob (тогда браузер + * играет из памяти и файл не запрашивается дважды), при неудаче — играем по + * прямому адресу `/files/{id}`. Ошибка скачивания не должна ломать звук. + */ +function resolveSource(fileId: string): Promise { + const cached = sources.get(fileId); + if (cached !== undefined) { + return cached; + } + const url = fileContentUrl(fileId); + const pending = (async (): Promise => { + try { + const response = await fetch(url, { credentials: 'same-origin' }); + if (!response.ok) { + return url; + } + const blob = await response.blob(); + if (typeof URL.createObjectURL !== 'function') { + return url; + } + return URL.createObjectURL(blob); + } catch { + return url; + } + })(); + sources.set(fileId, pending); + return pending; +} + +function track(fileId: string, element: HTMLAudioElement): void { + const set = playing.get(fileId) ?? new Set(); + set.add(element); + playing.set(fileId, set); + order.push(element); +} + +function untrack(fileId: string, element: HTMLAudioElement): void { + const set = playing.get(fileId); + if (set !== undefined) { + set.delete(element); + if (set.size === 0) { + playing.delete(fileId); + } + } + const index = order.indexOf(element); + if (index >= 0) { + order.splice(index, 1); + } +} + +/** Сколько звуков играет прямо сейчас. */ +export function playingCount(): number { + return order.length; +} + +/** Останавливает все экземпляры одного звука (например, перед повтором). */ +export function stopSoundFile(fileId: string): void { + const set = playing.get(fileId); + if (set === undefined) { + return; + } + for (const element of [...set]) { + stopElement(element); + untrack(fileId, element); + } +} + +/** Останавливает всё: используется при выходе из комнаты и в тестах. */ +export function stopAllSounds(): void { + for (const [fileId, set] of [...playing]) { + for (const element of [...set]) { + stopElement(element); + untrack(fileId, element); + } + } + order.length = 0; +} + +function stopElement(element: HTMLAudioElement): void { + try { + element.pause(); + element.currentTime = 0; + } catch { + // Элемент мог не начать играть — это не ошибка. + } +} + +/** + * Проигрывает звук. Возвращает `false`, если звук играть не нужно или не + * удалось (звук выключен, автовоспроизведение запрещено, формат не + * поддержан): вызывающий код не должен показывать ошибку — звук не критичен + * для интерфейса. + */ +export async function playSoundFile(fileId: string, volume = soundVolume()): Promise { + if (fileId === '' || volume <= 0) { + return false; + } + // Тот же звук не накладывается сам на себя: повтор начинается заново. + stopSoundFile(fileId); + while (order.length >= SOUNDBOARD_MAX_CONCURRENT) { + const oldest = order[0]; + if (oldest === undefined) { + break; + } + for (const [key, set] of playing) { + if (set.has(oldest)) { + untrack(key, oldest); + break; + } + } + stopElement(oldest); + } + + let element: HTMLAudioElement; + try { + element = new Audio(await resolveSource(fileId)); + } catch { + return false; + } + element.volume = clampVolume(volume); + track(fileId, element); + // Конец звука освобождает место для следующего; в jsdom события нет. + element.addEventListener?.('ended', () => untrack(fileId, element)); + try { + await element.play(); + return true; + } catch { + untrack(fileId, element); + return false; + } +} + +/** Запуск без ожидания: обработчики событий не должны ждать звука. */ +export function playSoundFileInBackground(fileId: string, volume?: number): void { + void playSoundFile(fileId, volume).catch(() => undefined); +} + +/** Полная очистка движка: кэш ссылок, играющие элементы (тесты и выход). */ +export function resetSoundboardEngine(): void { + stopAllSounds(); + for (const pending of sources.values()) { + void pending + .then((url) => { + if (url.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(url); + } + }) + .catch(() => undefined); + } + sources.clear(); +} diff --git a/web/src/lib/systemMessage.ts b/web/src/lib/systemMessage.ts index dbb8c31..b6beceb 100644 --- a/web/src/lib/systemMessage.ts +++ b/web/src/lib/systemMessage.ts @@ -14,6 +14,7 @@ /** Событие системного сообщения. */ export type SystemMessageEvent = | { kind: 'member_join'; userId: string } + | { kind: 'member_leave'; userId: string } | { kind: 'member_kick'; userId: string } | { kind: 'channel_created'; name: string; channelType: string }; @@ -33,7 +34,7 @@ export function parseSystemMessage(content: string): SystemMessageEvent | null { const record = asRecord(payload); const event = record?.['event']; const userId = record?.['user_id']; - if (event === 'member_join' || event === 'member_kick') { + if (event === 'member_join' || event === 'member_leave' || event === 'member_kick') { return typeof userId === 'string' && userId !== '' ? { kind: event, userId } : null; } if (event === 'channel_created') { diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx index 65bdfb3..589120e 100644 --- a/web/src/pages/app/AppLayout.tsx +++ b/web/src/pages/app/AppLayout.tsx @@ -6,7 +6,9 @@ import { Outlet, useLocation, useNavigate } from 'react-router'; import { fetchGuild, guildQueryKey } from '@/api/guilds'; import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users'; import { GatewayBridge } from '@/components/GatewayBridge'; +import { SoundboardNotice } from '@/components/voice/SoundboardNotice'; import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice'; +import { usePushToTalk } from '@/components/voice/usePushToTalk'; import { useCurrentUser, useInstance } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; import { useUiStore } from '@/stores/ui'; @@ -39,6 +41,10 @@ export default function AppLayout() { const upsertGuild = useSessionStore((state) => state.upsertGuild); const openGuildCreate = useUiStore((state) => state.openGuildCreate); + // Push-to-talk слушает клавишу на уровне документа: хук живёт в оболочке, + // чтобы работать на любом экране, пока открыта голосовая сессия. + usePushToTalk(); + const segments = location.pathname.split('/').filter((segment) => segment !== ''); const isFriends = segments[1] === 'friends'; const guildId = isFriends || segments[1] === undefined ? null : decodeURIComponent(segments[1]); @@ -196,6 +202,8 @@ export default function AppLayout() { {/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */} + {/* Подпись «{имя} включил звук {name}» из события SOUNDBOARD_PLAY (7.13). */} + ); } diff --git a/web/src/pages/settings/GuildSettingsPage.tsx b/web/src/pages/settings/GuildSettingsPage.tsx index 680dda7..0e0ac80 100644 --- a/web/src/pages/settings/GuildSettingsPage.tsx +++ b/web/src/pages/settings/GuildSettingsPage.tsx @@ -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 ( <> @@ -163,6 +172,9 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) { {canManageEmojiSet ? ( ) : null} + {canManageSoundSet ? ( + + ) : null} `). + */ +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('soundboard'); + const [name, setName] = useState(''); + const [emoji, setEmoji] = useState(''); + const [event, setEvent] = useState('member_join'); + const [file, setFile] = useState(null); + const [localError, setLocalError] = useState(null); + const [dragging, setDragging] = useState(false); + const [renaming, setRenaming] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [removing, setRemoving] = useState(null); + const fileInputRef = useRef(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): void => { + event_.preventDefault(); + setDragging(false); + pickFile(event_.dataTransfer.files[0]); + }; + + /** Строка списка: имя, событие, эмодзи, переименование и удаление. */ + const renderRow = (sound: GuildSound) => ( +
  • + + {sound.name} + {sound.event === undefined ? null : {t(eventLabelKey(sound.event))}} + + +
  • + ); + + return ( + +
    + {(['soundboard', 'ui'] as SoundKind[]).map((item) => ( + + ))} +
    + +

    + {t('settings.servers.sounds.counter', { + count: current.length, + limit: SOUND_LIMIT_PER_KIND, + })} +

    + + {list.isPending ? ( + + ) : list.isError ? ( + void list.refetch()} /> + ) : current.length === 0 ? ( +

    {t('settings.servers.sounds.empty')}

    + ) : ( +
      + {current.map(renderRow)} +
    + )} + + {remove.isError ? : null} + +
    +

    {t('settings.servers.sounds.uploadTitle')}

    +

    + {t('settings.servers.sounds.uploadHint', { + limit: formatBytes(SOUND_MAX_BYTES, i18n.language), + })} +

    + {full ?

    {t('errors.sounds.limit_reached')}

    : null} + + setName(event_.target.value)} + /> + + setEmoji(event_.target.value)} + /> + + {kind !== 'ui' ? null : ( + + )} + +
    { + 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" + > + {t('settings.servers.sounds.dropHint')} + { + pickFile(event_.target.files?.[0]); + event_.target.value = ''; + }} + /> + + {file === null ? null : ( + + {t('settings.servers.sounds.fileSelected', { + name: file.name, + size: formatBytes(file.size, i18n.language), + })} + + )} +
    + + {localError === null ? null : ( +

    + {t(`errors.${localError.kind}`)} +

    + )} + {upload.isError ? : null} + +
    + +
    +
    + + setRenaming(null)} + footer={ + <> + + + + } + > + setRenameValue(event_.target.value)} + /> + {rename.isError ? : null} + + + setRemoving(null)} + footer={ + <> + + + + } + > +

    + {t('settings.servers.sounds.deleteConfirmBody', { name: removing?.name ?? '' })} +

    +
    +
    + ); +} diff --git a/web/src/pages/settings/VoiceSettingsSection.tsx b/web/src/pages/settings/VoiceSettingsSection.tsx index ac036a4..f735f67 100644 --- a/web/src/pages/settings/VoiceSettingsSection.tsx +++ b/web/src/pages/settings/VoiceSettingsSection.tsx @@ -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 = { + 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 ( + + {/* Режим активации микрофона: голосовая активация или push-to-talk. */} +
    + {t('settings.voice.micMode')} +
    + {(['voice', 'push_to_talk'] as const).map((mode) => ( + + ))} +
    + + {micActivation === 'push_to_talk' ? ( +
    + +

    {t('settings.voice.pttHint')}

    +
    + ) : null} +
    + + {/* Звуки сервера: палитра событий и саундборд (AGENT.md 7.13). */} +
    + {t('settings.voice.sounds')} + +

    {t('settings.voice.eventSoundsHint')}

    + +

    {t('settings.voice.soundboardHint')}

    +
    +

    {t('settings.voice.stub')}

    ); diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index 78d89ed..19aa0f3 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -15,6 +15,7 @@ import { parseDmChannelEvent, parseGuildEmojisUpdate, parseGuildId, + parseGuildSoundsUpdate, parseGuildUpdate, parseMessageDelete, parseMessagePayload, @@ -24,14 +25,19 @@ import { parseReadStateUpdate, parseRelationshipEvent, parseRoleEvent, + parseSoundboardPlay, parseTypingStart, parseUserUpdate, } from '@/api/gatewayEvents'; +import { guildSoundsQueryKey } from '@/api/sounds'; +import { parseSystemMessage } from '@/lib/systemMessage'; import { useFriendsStore } from '@/stores/friends'; import { useMessagesStore } from '@/stores/messages'; -import { useSessionStore } from '@/stores/session'; +import { useSessionStore, type SessionGuild } from '@/stores/session'; +import { playEventSound, useSoundboardStore } from '@/stores/soundboard'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; import { parseVoiceStateEvent } from '@/api/voice'; +import type { Message } from '@/api/messages'; /** * Состояние соединения со шлюзом. Данные приходят в session-store, @@ -207,6 +213,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { messages.applyMessageCreate(message); // В личной беседе «последнее сообщение» — источник бейджа и порядка. session.touchDmChannel(message.channel_id, message.id, message.created_at); + playMessageSound(message); } return; } @@ -297,6 +304,23 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { session.patchDmPresence(presence.user_id, presence.visible_status ?? presence.status); return; } + case 'GUILD_SOUNDS_UPDATE': { + // Набор звуков сервера изменился целиком: снапшот и REST-кэш (7.13). + const update = parseGuildSoundsUpdate(event.d); + if (update !== null) { + session.setGuildSounds(update.guild_id, update.sounds); + void invalidateSoundQueries(update.guild_id); + } + return; + } + case 'SOUNDBOARD_PLAY': { + // Событие приходит только участникам комнаты: играем звук локально. + const played = parseSoundboardPlay(event.d); + if (played !== null) { + useSoundboardStore.getState().applyGatewayPlay(played); + } + return; + } case 'RELATIONSHIP_UPDATE': { // Связь появилась или пропала — перечитываем список целиком. const relationship = parseRelationshipEvent(event.d); @@ -319,10 +343,30 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { if (voice === null) { return; } + const me = session.user?.id ?? null; + const myChannel = useVoiceConnectionStore.getState().channelId; if (voice.kind === 'left') { + // До удаления записи узнаём, из какой комнаты ушёл участник. + const leftChannel = + (session.voiceStates[voice.guildId] ?? []).find((item) => item.user_id === voice.userId) + ?.channel_id ?? null; session.applyVoiceLeave(voice.guildId, voice.userId); + if (voice.userId !== me && leftChannel !== null && leftChannel === myChannel) { + playEventSound(voice.guildId, 'voice_leave'); + } } else { + // Участник мог быть в другой комнате — тогда это перемещение в нашу. + const previous = (session.voiceStates[voice.state.guild_id] ?? []).find( + (item) => item.user_id === voice.state.user_id, + ); session.applyVoiceState(voice.state); + if ( + voice.state.user_id !== me && + voice.state.channel_id === myChannel && + previous?.channel_id !== myChannel + ) { + playEventSound(voice.state.guild_id, 'voice_join'); + } } // server_mute/server_deaf и перемещение себя применяем к своей сессии. useVoiceConnectionStore.getState().syncServerState(); @@ -334,6 +378,45 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { } } +/** + * Звук события UI-палитры для нового сообщения (AGENT.md 7.13): системные + * сообщения — это вход/выход участника, остальные — упоминание или обычное + * сообщение. Свои сообщения звуком не сопровождаются. + */ +function playMessageSound(message: Message): void { + const session = useSessionStore.getState(); + const guildId = guildIdOfChannel(session.guilds, message.channel_id); + if (guildId === null) { + return; + } + if (message.type === 'system') { + const event = parseSystemMessage(message.content); + if (event?.kind === 'member_join') { + playEventSound(guildId, 'member_join'); + return; + } + if (event?.kind === 'member_leave' || event?.kind === 'member_kick') { + playEventSound(guildId, 'member_leave'); + } + return; + } + const me = session.user?.id ?? null; + if (me !== null && message.author_id === me) { + return; + } + playEventSound(guildId, me !== null && message.mentions.includes(me) ? 'mention' : 'message'); +} + +/** Сервер, которому принадлежит комната: в личной беседе его нет. */ +function guildIdOfChannel(guilds: SessionGuild[], channelId: string): string | null { + for (const guild of guilds) { + if (guild.channels.some((channel) => channel.id === channelId)) { + return guild.id; + } + } + return null; +} + /** Сервер появился (создан или найден): забираем сводку из REST. */ async function addGuildFromRest(guildId: string): Promise { const [{ getQueryClient }, users] = await Promise.all([ @@ -392,6 +475,12 @@ async function invalidateEmojiQueries(guildId: string): Promise { await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) }); } +/** Инвалидация списка звуков сервера после `GUILD_SOUNDS_UPDATE` (7.13). */ +async function invalidateSoundQueries(guildId: string): Promise { + const { getQueryClient } = await import('@/lib/queryClient'); + await getQueryClient().invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) }); +} + /** Держит URL в согласии с выбранным сервером/комнатой после удалений. */ function syncUrlWithSelection(): void { if (typeof window === 'undefined') { diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 3c00386..0a086da 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import type { GuildEmoji } from '@/api/emojis'; import { fetchDirectChannels } from '@/api/friends'; import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway'; +import type { GuildSound, SoundKind } from '@/api/sounds'; import type { VoiceState } from '@/api/voice'; import type { Channel, GuildSummary, User, UserStatus } from '@/api/types'; @@ -17,6 +18,8 @@ export interface SessionGuild extends GuildSummary { channels: Channel[]; /** Кастомные эмодзи сервера: READY и `GUILD_EMOJIS_UPDATE` (AGENT.md 7.12). */ emojis: GuildEmoji[]; + /** Саундборд и звуковая палитра: READY и `GUILD_SOUNDS_UPDATE` (7.13). */ + sounds: GuildSound[]; my_nickname?: string; } @@ -67,6 +70,15 @@ interface SessionState { upsertGuildEmoji: (guildId: string, emoji: GuildEmoji) => void; /** Эмодзи удалён: убираем его из снапшота (реакции остаются как есть). */ removeGuildEmoji: (guildId: string, emojiId: string) => void; + /** + * Набор звуков сервера целиком: READY и `GUILD_SOUNDS_UPDATE` шлют его + * одним и тем же списком (AGENT.md 7.13). + */ + setGuildSounds: (guildId: string, sounds: GuildSound[]) => void; + /** Звук загружен или переименован: заменяем запись по id. */ + upsertGuildSound: (guildId: string, sound: GuildSound) => void; + /** Звук удалён: убираем его из снапшота. */ + removeGuildSound: (guildId: string, soundId: string) => void; /** Личные беседы: список целиком (READY или `GET /users/@me/channels`). */ setDmChannels: (channels: Channel[]) => void; upsertDmChannel: (channel: Channel) => void; @@ -120,6 +132,7 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild { my_permissions: guild.my_permissions ?? [], channels: guild.channels, emojis: guild.emojis, + sounds: guild.sounds, }; if (guild.icon_file_id !== undefined) { summary.icon_file_id = guild.icon_file_id; @@ -211,19 +224,20 @@ export const useSessionStore = create((set, get) => ({ const guilds = get().guilds; const index = guilds.findIndex((item) => item.id === guild.id); if (index === -1) { - set({ guilds: [...guilds, { ...guild, channels: [], emojis: [] }] }); + set({ guilds: [...guilds, { ...guild, channels: [], emojis: [], sounds: [] }] }); return; } const existing = guilds[index]; if (existing === undefined) { return; } - // Сводка из REST не знает ни комнат, ни эмодзи — накопленное не теряем. + // Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем. const merged: SessionGuild = { ...existing, ...guild, channels: existing.channels, emojis: existing.emojis, + sounds: existing.sounds, }; const next = [...guilds]; next[index] = merged; @@ -352,6 +366,39 @@ export const useSessionStore = create((set, get) => ({ }); }, + setGuildSounds: (guildId, sounds) => { + set({ + guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, sounds } : guild)), + }); + }, + + upsertGuildSound: (guildId, sound) => { + set({ + guilds: get().guilds.map((guild) => { + if (guild.id !== guildId) { + return guild; + } + const index = guild.sounds.findIndex((item) => item.id === sound.id); + if (index === -1) { + return { ...guild, sounds: [...guild.sounds, sound] }; + } + const sounds = [...guild.sounds]; + sounds[index] = sound; + return { ...guild, sounds }; + }), + }); + }, + + removeGuildSound: (guildId, soundId) => { + set({ + guilds: get().guilds.map((guild) => + guild.id === guildId + ? { ...guild, sounds: guild.sounds.filter((sound) => sound.id !== soundId) } + : guild, + ), + }); + }, + setDmChannels: (channels) => { set({ dmChannels: channels }); }, @@ -479,3 +526,30 @@ export function myVoiceState( } return (voiceStates[guildId] ?? []).find((state) => state.user_id === userId) ?? null; } + +/** Пустой список — стабильная ссылка: селекторы не должны перерисовывать зря. */ +const NO_SOUNDS: GuildSound[] = []; + +/** + * Звуки сервера из снапшота (READY и `GUILD_SOUNDS_UPDATE`). Без `kind` + * возвращаются оба набора: саундборд и звуковая палитра. + */ +export function guildSoundsOf( + guilds: readonly SessionGuild[], + guildId: string | null, + kind?: SoundKind, +): GuildSound[] { + if (guildId === null) { + return NO_SOUNDS; + } + const guild = guilds.find((item) => item.id === guildId); + if (guild === undefined) { + return NO_SOUNDS; + } + return kind === undefined ? guild.sounds : guild.sounds.filter((sound) => sound.kind === kind); +} + +/** Готовый селектор для `useSessionStore`: звуки сервера одного вида. */ +export function selectGuildSounds(guildId: string | null, kind?: SoundKind) { + return (state: SessionState): GuildSound[] => guildSoundsOf(state.guilds, guildId, kind); +} diff --git a/web/src/stores/soundboard.ts b/web/src/stores/soundboard.ts new file mode 100644 index 0000000..9b4e749 --- /dev/null +++ b/web/src/stores/soundboard.ts @@ -0,0 +1,150 @@ +import { create } from 'zustand'; + +import type { GuildSound, SoundEvent } from '@/api/sounds'; +import { playSoundFileInBackground, stopAllSounds } from '@/lib/soundboard'; +import { guildSoundsOf, useSessionStore } from '@/stores/session'; +import { useVoiceStore } from '@/stores/voice'; +import type { SoundboardPlayEvent } from '@/api/gatewayEvents'; + +/** + * Саундборд и звуковая палитра (AGENT.md 7.13). + * + * Сервер не транслирует звук: он присылает событие `SOUNDBOARD_PLAY`, а каждый + * клиент скачивает файл и играет его локально. Здесь живёт то, что нельзя + * размазать по компонентам: подпись «кто включил звук» и защита от двойного + * проигрывания — своё нажатие звучит сразу (оптимистично), а пришедшее следом + * событие шлюза уже не играет второй раз. + */ + +/** Окно, в котором событие шлюза считается эхом своего нажатия. */ +export const SOUNDBOARD_DEDUP_WINDOW_MS = 3_000; +/** Сколько держится подпись «{имя} включил звук {name}». */ +export const SOUNDBOARD_NOTICE_MS = 3_000; +/** Сколько последних проигрываний помним для сопоставления. */ +const RECENT_LIMIT = 20; + +export interface SoundboardNotice { + guildId: string; + userId: string; + soundName: string; +} + +interface RecentPlay { + soundId: string; + userId: string; + at: number; +} + +interface SoundboardState { + /** Подпись о последнем сыгранном звуке (гаснет сама через 3 секунды). */ + notice: SoundboardNotice | null; + /** Недавние проигрывания: сопоставление оптимистичного звука с событием. */ + recent: RecentPlay[]; + /** Своё нажатие «Проиграть»: звук играет сразу, не дожидаясь шлюза. */ + playMySound: (guildId: string, sound: GuildSound, userId: string) => void; + /** Событие `SOUNDBOARD_PLAY`: играем чужой звук (свой уже сыграл). */ + applyGatewayPlay: (event: SoundboardPlayEvent) => void; + clearNotice: () => void; + reset: () => void; +} + +let noticeTimer: ReturnType | null = null; + +function clearNoticeTimer(): void { + if (noticeTimer !== null) { + clearTimeout(noticeTimer); + noticeTimer = null; + } +} + +export const useSoundboardStore = create((set, get) => { + /** Общая часть: звук играет, подпись появляется, проигрывание запоминается. */ + const play = (guildId: string, userId: string, soundId: string, fileId: string, name: string) => { + playSoundFileInBackground(fileId); + const recent = [ + { soundId, userId, at: Date.now() }, + ...get().recent.filter((item) => !(item.soundId === soundId && item.userId === userId)), + ].slice(0, RECENT_LIMIT); + clearNoticeTimer(); + set({ recent, notice: { guildId, userId, soundName: name } }); + noticeTimer = setTimeout(() => { + noticeTimer = null; + set({ notice: null }); + }, SOUNDBOARD_NOTICE_MS); + }; + + return { + notice: null, + recent: [], + + playMySound: (guildId, sound, userId) => { + if (!useVoiceStore.getState().soundboardEnabled) { + return; + } + play(guildId, userId, sound.id, sound.file_id, sound.name); + }, + + applyGatewayPlay: (event) => { + if (!useVoiceStore.getState().soundboardEnabled) { + return; + } + const now = Date.now(); + const mine = get().recent.some( + (item) => + item.soundId === event.sound_id && + item.userId === event.user_id && + now - item.at <= SOUNDBOARD_DEDUP_WINDOW_MS, + ); + if (mine) { + // Свой звук уже сыграл оптимистично — второй раз не играем. + return; + } + const known = guildSoundsOf(useSessionStore.getState().guilds, event.guild_id).find( + (sound) => sound.id === event.sound_id, + ); + const name = event.name !== '' ? event.name : (known?.name ?? ''); + play(event.guild_id, event.user_id, event.sound_id, event.file_id, name); + }, + + clearNotice: () => { + clearNoticeTimer(); + set({ notice: null }); + }, + + reset: () => { + clearNoticeTimer(); + stopAllSounds(); + set({ notice: null, recent: [] }); + }, + }; +}); + +/** Звук сервера, привязанный к событию UI-палитры (или `null`). */ +export function findEventSound(guildId: string | null, event: SoundEvent): GuildSound | null { + if (guildId === null) { + return null; + } + const sounds = guildSoundsOf(useSessionStore.getState().guilds, guildId, 'ui'); + return sounds.find((sound) => sound.event === event) ?? null; +} + +/** + * Проигрывает звук события сервера, если он задан и звуки событий включены. + * Вызывается из диспетчера шлюза и из голосовой сессии: молчание — норма, + * когда звук не настроен или пользователь его выключил. + */ +export function playEventSound(guildId: string | null, event: SoundEvent): void { + if (guildId === null || !useVoiceStore.getState().eventSoundsEnabled) { + return; + } + const sound = findEventSound(guildId, event); + if (sound === null) { + return; + } + playSoundFileInBackground(sound.file_id); +} + +/** Полная остановка звуков: выход из комнаты, сброс сессии (тесты). */ +export function stopSoundboardPlayback(): void { + stopAllSounds(); +} diff --git a/web/src/stores/voice.ts b/web/src/stores/voice.ts index 7ac2c58..85a93b4 100644 --- a/web/src/stores/voice.ts +++ b/web/src/stores/voice.ts @@ -1,8 +1,11 @@ import { create } from 'zustand'; +import type { ScreenSharePresetId } from '@/lib/livekit'; + /** * Локальные настройки «голосовой» панели: состояния кнопок микрофона, камеры и - * наушников, выбранные устройства и громкости. + * наушников, выбранные устройства, громкости, режим активации микрофона + * (push-to-talk) и качество публикации (AGENT.md 7.13/7.14). * * Это только предпочтения клиента: они хранятся в localStorage и не уходят на * сервер. Реальная медиа-сессия живёт в `stores/voiceConnection.ts` и читает @@ -12,7 +15,30 @@ import { create } from 'zustand'; /** Виды устройств, которые отдаёт `navigator.mediaDevices.enumerateDevices()`. */ export type VoiceDeviceKind = 'audioinput' | 'videoinput' | 'audiooutput'; -interface VoicePreferences { +/** Как включается микрофон: голосовая активация или удержание клавиши. */ +export type MicActivationMode = 'voice' | 'push_to_talk'; + +/** Качество публикации видео (`VideoQuality` из LiveKit). */ +export type VoicePublishQuality = 'low' | 'medium' | 'high'; + +/** + * Клавиши push-to-talk. `code` не зависит от раскладки — так клавиша работает + * одинаково и в русской, и в английской. Для Alt/Ctrl берём левые варианты, + * но правая клавиша тоже считается нажатием (см. `matchesPushToTalk`). + */ +export const PUSH_TO_TALK_KEYS = [ + 'Space', + 'KeyV', + 'KeyB', + 'KeyN', + 'AltLeft', + 'ControlLeft', +] as const; + +export type PushToTalkKey = (typeof PUSH_TO_TALK_KEYS)[number]; + +/** Предпочтения клиента: то, что уходит в localStorage. */ +export interface VoicePreferences { micEnabled: boolean; cameraEnabled: boolean; headphonesEnabled: boolean; @@ -25,6 +51,18 @@ interface VoicePreferences { outputVolume: number; /** Личная громкость участников голосовой комнаты, 0–100 (AGENT.md 7.14). */ participantVolumes: Record; + /** Режим активации микрофона (AGENT.md 7.14: push-to-talk). */ + micActivation: MicActivationMode; + /** Клавиша push-to-talk. */ + pttKey: PushToTalkKey; + /** Звуки событий сервера (UI-палитра) включены (AGENT.md 7.13). */ + eventSoundsEnabled: boolean; + /** Саундборд включён. */ + soundboardEnabled: boolean; + /** Качество своей публикации видео. */ + videoQuality: VoicePublishQuality; + /** Пресет разрешения и fps для демонстрации экрана. */ + screenSharePreset: ScreenSharePresetId; } interface VoiceState extends VoicePreferences { @@ -39,6 +77,12 @@ interface VoiceState extends VoicePreferences { setOutputVolume: (value: number) => void; /** Громкость конкретного участника: хранится только на этом устройстве. */ setParticipantVolume: (userId: string, value: number) => void; + setMicActivation: (mode: MicActivationMode) => void; + setPttKey: (key: PushToTalkKey) => void; + setEventSoundsEnabled: (enabled: boolean) => void; + setSoundboardEnabled: (enabled: boolean) => void; + setVideoQuality: (quality: VoicePublishQuality) => void; + setScreenSharePreset: (preset: ScreenSharePresetId) => void; /** Вернуть исходные предпочтения (используется в тестах). */ reset: () => void; } @@ -55,6 +99,12 @@ const defaults: VoicePreferences = { micVolume: 100, outputVolume: 100, participantVolumes: {}, + micActivation: 'voice', + pttKey: 'Space', + eventSoundsEnabled: true, + soundboardEnabled: true, + videoQuality: 'high', + screenSharePreset: '1080p30', }; function clampVolume(value: number): number { @@ -90,6 +140,17 @@ function asParticipantVolumes(value: unknown): Record { return volumes; } +/** Значение из фиксированного списка: чужие строки отбрасываем. */ +function asChoice(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === 'string' && (allowed as readonly string[]).includes(value) + ? (value as T) + : fallback; +} + +const MIC_ACTIVATION_MODES: readonly MicActivationMode[] = ['voice', 'push_to_talk']; +const PUBLISH_QUALITIES: readonly VoicePublishQuality[] = ['low', 'medium', 'high']; +const SCREEN_PRESETS: readonly ScreenSharePresetId[] = ['1080p60', '1080p30', '720p30', '480p30']; + /** Начальные значения: то, что уже лежит в localStorage, поверх умолчаний. */ function initialPreferences(): VoicePreferences { if (typeof window === 'undefined') { @@ -119,6 +180,16 @@ function initialPreferences(): VoicePreferences { micVolume: asVolume(stored['micVolume'], defaults.micVolume), outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume), participantVolumes: asParticipantVolumes(stored['participantVolumes']), + micActivation: asChoice(stored['micActivation'], MIC_ACTIVATION_MODES, defaults.micActivation), + pttKey: asChoice(stored['pttKey'], PUSH_TO_TALK_KEYS, defaults.pttKey), + eventSoundsEnabled: asBoolean(stored['eventSoundsEnabled'], defaults.eventSoundsEnabled), + soundboardEnabled: asBoolean(stored['soundboardEnabled'], defaults.soundboardEnabled), + videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality), + screenSharePreset: asChoice( + stored['screenSharePreset'], + SCREEN_PRESETS, + defaults.screenSharePreset, + ), }; } @@ -138,6 +209,12 @@ function persist(state: VoicePreferences): void { micVolume: state.micVolume, outputVolume: state.outputVolume, participantVolumes: state.participantVolumes, + micActivation: state.micActivation, + pttKey: state.pttKey, + eventSoundsEnabled: state.eventSoundsEnabled, + soundboardEnabled: state.soundboardEnabled, + videoQuality: state.videoQuality, + screenSharePreset: state.screenSharePreset, } satisfies VoicePreferences), ); } @@ -183,6 +260,13 @@ export const useVoiceStore = create((set, get) => { patch({ participantVolumes: { ...get().participantVolumes, [userId]: clampVolume(value) } }); }, + setMicActivation: (mode) => patch({ micActivation: mode }), + setPttKey: (key) => patch({ pttKey: key }), + setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }), + setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }), + setVideoQuality: (quality) => patch({ videoQuality: quality }), + setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }), + reset: () => { set({ ...defaults }); if (typeof window !== 'undefined') { @@ -204,5 +288,11 @@ function preferencesOf(state: VoiceState): VoicePreferences { micVolume: state.micVolume, outputVolume: state.outputVolume, participantVolumes: state.participantVolumes, + micActivation: state.micActivation, + pttKey: state.pttKey, + eventSoundsEnabled: state.eventSoundsEnabled, + soundboardEnabled: state.soundboardEnabled, + videoQuality: state.videoQuality, + screenSharePreset: state.screenSharePreset, }; } diff --git a/web/src/stores/voiceConnection.ts b/web/src/stores/voiceConnection.ts index ce422f0..23ccc95 100644 --- a/web/src/stores/voiceConnection.ts +++ b/web/src/stores/voiceConnection.ts @@ -14,9 +14,11 @@ import { tokenExpiresAt, type VoiceParticipant, type VoiceRoomHandle, + type VoiceStats, } from '@/lib/livekit'; import { myVoiceState, useSessionStore } from '@/stores/session'; -import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice'; +import { playEventSound, stopSoundboardPlayback } from '@/stores/soundboard'; +import { useVoiceStore, type VoiceDeviceKind, type VoicePublishQuality } from '@/stores/voice'; /** * Голосовая сессия LiveKit: подключение к комнате, публикация своих дорожек, @@ -69,9 +71,18 @@ export interface VoiceConnectionState { join: (guildId: string, channelId: string) => Promise; leave: () => Promise; setMicEnabled: (enabled: boolean) => Promise; + /** + * Push-to-talk: включает микрофон на время удержания клавиши, не меняя + * сохранённое предпочтение (AGENT.md 7.14). Серверный мьют приоритетнее. + */ + setPushToTalkActive: (active: boolean) => void; setDeafened: (enabled: boolean) => Promise; setCameraEnabled: (enabled: boolean) => Promise; setScreenShareEnabled: (enabled: boolean) => Promise; + /** Качество публикации своего видео: `low` | `medium` | `high`. */ + setVideoQuality: (quality: VoicePublishQuality) => Promise; + /** Статистика своей публикации для попапа «Статистика» (раз в секунду). */ + fetchStats: () => Promise; /** Смена устройства на лету: применяется к активной дорожке (AGENT.md 7.14). */ switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise; /** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */ @@ -162,6 +173,15 @@ export const useVoiceConnectionStore = create((set, get) = } }; + /** Звук события сервера играет только из активной комнаты (AGENT.md 7.13). */ + const playSound = (event: Parameters[1]): void => { + const { guildId, status } = get(); + if (guildId === null || status !== 'connected') { + return; + } + playEventSound(guildId, event); + }; + /** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */ const closeRoom = async (): Promise => { const handle = room; @@ -184,6 +204,8 @@ export const useVoiceConnectionStore = create((set, get) = clearTokenTimer(); clearWarningTimer(); await closeRoom(); + // Звуки саундборда не должны доигрывать после выхода из комнаты. + stopSoundboardPlayback(); const channel = joinedChannelId; joinedChannelId = null; if (channel !== null) { @@ -323,6 +345,8 @@ export const useVoiceConnectionStore = create((set, get) = await get().leave(); } const preferences = useVoiceStore.getState(); + // В режиме push-to-talk микрофон до нажатия клавиши выключен. + const pushToTalk = preferences.micActivation === 'push_to_talk'; const mine = (generation += 1); set({ status: 'connecting', @@ -330,7 +354,7 @@ export const useVoiceConnectionStore = create((set, get) = channelId, error: null, participants: [], - micEnabled: preferences.micEnabled, + micEnabled: preferences.micEnabled && !pushToTalk, deafened: !preferences.headphonesEnabled, cameraEnabled: preferences.cameraEnabled, screenEnabled: false, @@ -386,8 +410,10 @@ export const useVoiceConnectionStore = create((set, get) = } return; } - await handle.setMicrophoneEnabled(preferences.micEnabled); + await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk); await handle.setCameraEnabled(preferences.cameraEnabled); + // Качество публикации — из предпочтений клиента. + await handle.setVideoQuality(preferences.videoQuality); set({ status: 'connected', participants: handle.participants() }); get().syncServerState(); } catch (error) { @@ -418,6 +444,7 @@ export const useVoiceConnectionStore = create((set, get) = const guildId = get().guildId; const me = useSessionStore.getState().user?.id ?? null; await closeRoom(); + stopSoundboardPlayback(); const channel = joinedChannelId; joinedChannelId = null; if (guildId !== null && me !== null) { @@ -440,9 +467,27 @@ export const useVoiceConnectionStore = create((set, get) = } useVoiceStore.getState().setMicEnabled(enabled); await applyMic(enabled); + playSound(enabled ? 'mic_on' : 'mic_off'); await patchSelf({ self_mute: !enabled }); }, + setPushToTalkActive: (active) => { + const { status, serverMuted, micEnabled } = get(); + if (status !== 'connected' && status !== 'reconnecting') { + return; + } + // Серверный мьют приоритетнее: модератор выключил микрофон — молчим. + if (active && serverMuted) { + return; + } + if (micEnabled === active) { + return; + } + // Звуки палитры на нажатие клавиши не играем: это была бы пулемётная очередь. + void applyMic(active); + void patchSelf({ self_mute: !active }); + }, + setDeafened: async (enabled) => { useVoiceStore.getState().setHeadphonesEnabled(!enabled); set({ deafened: enabled }); @@ -461,6 +506,7 @@ export const useVoiceConnectionStore = create((set, get) = set({ error }); } } + playSound(enabled ? 'camera_on' : 'camera_off'); await patchSelf({ camera: enabled }); }, @@ -469,15 +515,43 @@ export const useVoiceConnectionStore = create((set, get) = const handle = room; if (handle !== null) { try { - await handle.setScreenShareEnabled(enabled); + // Разрешение и fps берём из выбранного пресета (AGENT.md 7.14). + await handle.setScreenShareEnabled(enabled, useVoiceStore.getState().screenSharePreset); set({ participants: handle.participants() }); } catch (error) { set({ error }); } } + playSound(enabled ? 'screen_on' : 'screen_off'); await patchSelf({ screen: enabled }); }, + setVideoQuality: async (quality) => { + useVoiceStore.getState().setVideoQuality(quality); + const handle = room; + if (handle === null) { + return; + } + try { + await handle.setVideoQuality(quality); + } catch (error) { + set({ error }); + } + }, + + fetchStats: async () => { + const handle = room; + if (handle === null) { + return null; + } + try { + return await handle.fetchStats(); + } catch { + // Отчёт WebRTC может быть недоступен — попап покажет прочерки. + return null; + } + }, + setVolume: (userId, volume) => { useVoiceStore.getState().setParticipantVolume(userId, volume); }, @@ -535,8 +609,13 @@ export const useVoiceConnectionStore = create((set, get) = } const serverMuted = state.server_mute; const serverDeafened = state.server_deaf; - if (serverMuted !== get().serverMuted || serverDeafened !== get().serverDeafened) { + const wasServerMuted = get().serverMuted; + if (serverMuted !== wasServerMuted || serverDeafened !== get().serverDeafened) { set({ serverMuted, serverDeafened }); + // Модератор только что выключил нам микрофон — звук события «mute». + if (serverMuted && !wasServerMuted) { + playSound('mute'); + } } // Модератор выключил микрофон — гасим публикацию, предпочтение не трогаем. if (serverMuted && micEnabled) { @@ -560,6 +639,7 @@ export const useVoiceConnectionStore = create((set, get) = generation += 1; clearTokenTimer(); clearWarningTimer(); + stopSoundboardPlayback(); const handle = room; room = null; joinedChannelId = null; diff --git a/web/tests/autocomplete.test.tsx b/web/tests/autocomplete.test.tsx index 97fb4d3..443baea 100644 --- a/web/tests/autocomplete.test.tsx +++ b/web/tests/autocomplete.test.tsx @@ -91,6 +91,7 @@ const guild: SessionGuild = { { id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false }, ], emojis: [], + sounds: [], }; const authors: AuthorDirectory = { diff --git a/web/tests/fakeAudio.ts b/web/tests/fakeAudio.ts new file mode 100644 index 0000000..a65a934 --- /dev/null +++ b/web/tests/fakeAudio.ts @@ -0,0 +1,63 @@ +import { vi } from 'vitest'; + +/** + * Подставной `Audio` для тестов саундборда: в jsdom `play()` не реализован, + * поэтому звук подменяется целиком. Экземпляры собираются в массив — по ним + * проверяют, что звук скачали, проиграли или остановили. + */ + +export class FakeAudio { + src: string; + volume = 1; + currentTime = 0; + paused = true; + playCalls = 0; + pauseCalls = 0; + /** Считаем и нативные, и «событийные» подписки: `ended` глушит звук. */ + readonly listeners = new Map void>>(); + + constructor(src = '') { + this.src = src; + } + + addEventListener(type: string, handler: () => void): void { + const set = this.listeners.get(type) ?? new Set<() => void>(); + set.add(handler); + this.listeners.set(type, set); + } + + removeEventListener(type: string, handler: () => void): void { + this.listeners.get(type)?.delete(handler); + } + + play(): Promise { + this.playCalls += 1; + this.paused = false; + return Promise.resolve(); + } + + pause(): void { + this.pauseCalls += 1; + this.paused = true; + } + + /** Сообщить о конце звука: движок снимает элемент с учёта. */ + emit(type: string): void { + for (const handler of [...(this.listeners.get(type) ?? [])]) { + handler(); + } + } +} + +/** Ставит подставной `Audio` и возвращает список созданных элементов. */ +export function installFakeAudio(): FakeAudio[] { + const instances: FakeAudio[] = []; + class StubAudio extends FakeAudio { + constructor(src = '') { + super(src); + instances.push(this); + } + } + vi.stubGlobal('Audio', StubAudio); + return instances; +} diff --git a/web/tests/fakeLiveKit.ts b/web/tests/fakeLiveKit.ts index ee2a4bd..7768d87 100644 --- a/web/tests/fakeLiveKit.ts +++ b/web/tests/fakeLiveKit.ts @@ -31,6 +31,13 @@ export const ConnectionQuality = { Unknown: 'unknown', } as const; +/** Уровни качества публикации: значения совпадают с `VideoQuality` из SDK. */ +export const VideoQuality = { + LOW: 0, + MEDIUM: 1, + HIGH: 2, +} as const; + export const Track = { Source: { Camera: 'camera', @@ -44,9 +51,15 @@ export const Track = { export class FakeTrack { readonly kind: 'audio' | 'video'; readonly attached: HTMLMediaElement[] = []; + /** Качество публикации, выставленное `setPublishingQuality` (тесты ждут). */ + readonly publishingQuality: number[] = []; + /** Сколько раз спросили отчёт WebRTC: по нему проверяют опрос статистики. */ + statsRequests = 0; + private readonly report: () => [string, unknown][]; - constructor(kind: 'audio' | 'video') { + constructor(kind: 'audio' | 'video', report: () => [string, unknown][] = () => []) { this.kind = kind; + this.report = report; } attach(element: HTMLMediaElement): HTMLMediaElement { @@ -61,6 +74,17 @@ export class FakeTrack { } return []; } + + /** Отчёт WebRTC: `Map`, как настоящий `RTCStatsReport`. */ + getRTCStatsReport(): Promise> { + this.statsRequests += 1; + return Promise.resolve(new Map(this.report())); + } + + /** Смена качества публикации: SDK-метод `LocalVideoTrack`. */ + setPublishingQuality(quality: number): void { + this.publishingQuality.push(quality); + } } export class FakePublication { @@ -82,6 +106,8 @@ export class FakeParticipant { readonly isLocal: boolean; /** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */ readonly calls: { method: string; value: unknown }[] = []; + /** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */ + readonly screenOptions: { enabled: boolean; options: unknown }[] = []; private readonly publications = new Map(); constructor( @@ -113,15 +139,18 @@ export class FakeParticipant { /** Публикация дорожки: используется и для своих, и для чужих участников. */ setPublication(source: string, muted = false): FakePublication { - const publication = new FakePublication( - source, - new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'), - muted, - ); + const publication = new FakePublication(source, this.newTrack(source), muted); this.publications.set(source, publication); return publication; } + /** Дорожка знает про отчёт WebRTC комнаты: тесты задают его через `setStats`. */ + private newTrack(source: string): FakeTrack { + return new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video', () => + this.room.statsEntries(), + ); + } + removePublication(source: string): void { this.publications.delete(source); this.room.emit(RoomEvent.TrackUnsubscribed); @@ -139,8 +168,7 @@ export class FakeParticipant { private setLocal(source: string, enabled: boolean, method: string): void { this.calls.push({ method, value: enabled }); const existing = this.publications.get(source); - const track = - existing?.track ?? new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'); + const track = existing?.track ?? this.newTrack(source); this.publications.set(source, new FakePublication(source, track, !enabled)); this.room.emit(RoomEvent.LocalTrackPublished); } @@ -155,7 +183,9 @@ export class FakeParticipant { return Promise.resolve(); } - setScreenShareEnabled(enabled: boolean): Promise { + /** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */ + setScreenShareEnabled(enabled: boolean, options?: unknown): Promise { + this.screenOptions.push({ enabled, options }); this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled'); return Promise.resolve(); } @@ -190,6 +220,8 @@ export class FakeRoom { readonly deviceSwitches: { kind: string; deviceId: string }[] = []; disconnectCalls = 0; startAudioCalls = 0; + /** Отчёт WebRTC: пары «ключ → объект», как у `RTCStatsReport`. */ + private stats: [string, unknown][] = []; private readonly handlers = new Map>(); constructor(readonly options?: unknown) { @@ -197,6 +229,15 @@ export class FakeRoom { this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true); } + /** Отчёт WebRTC, который отдаёт `getRTCStatsReport()` своих дорожек. */ + statsEntries(): [string, unknown][] { + return this.stats; + } + + setStats(entries: [string, unknown][]): void { + this.stats = entries; + } + on(event: string, handler: Handler): this { const handlers = this.handlers.get(event) ?? new Set(); handlers.add(handler); diff --git a/web/tests/guildEmojis.test.tsx b/web/tests/guildEmojis.test.tsx index db7545f..14459df 100644 --- a/web/tests/guildEmojis.test.tsx +++ b/web/tests/guildEmojis.test.tsx @@ -61,6 +61,7 @@ function guildWithEmojis(emojis: SessionGuild['emojis'] = [pepe, parrot]): Sessi my_permissions: [], channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }], emojis, + sounds: [], }; } diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx index 8c06d0c..b3353f0 100644 --- a/web/tests/helpers.tsx +++ b/web/tests/helpers.tsx @@ -11,6 +11,7 @@ import { useFriendsStore } from '@/stores/friends'; import { useGatewayStore } from '@/stores/gateway'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; +import { useSoundboardStore } from '@/stores/soundboard'; import { useUiStore } from '@/stores/ui'; import { useVoiceStore } from '@/stores/voice'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; @@ -152,6 +153,8 @@ export function resetStores(): void { useVoiceStore.getState().reset(); // Голосовая сессия могла остаться от прошлого теста: гасим её без запросов. useVoiceConnectionStore.getState().reset(); + // Саундборд: подпись и очередь звуков не должны переживать тест. + useSoundboardStore.getState().reset(); useFriendsStore.getState().reset(); resetUiStore(); // `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста @@ -221,6 +224,15 @@ interface SnapshotGuildInput { my_nickname?: string; /** Кастомные эмодзи сервера (AGENT.md 7.12). */ emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[]; + /** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */ + sounds?: { + id: string; + name: string; + file_id: string; + kind?: 'soundboard' | 'ui'; + event?: string; + emoji?: string; + }[]; /** Кто сидит в голосовых комнатах (AGENT.md 7.14). */ voice_states?: { user_id: string; @@ -297,6 +309,14 @@ export function makeReadySnapshot( animated: emoji.animated ?? false, token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`, })), + sounds: (guild.sounds ?? []).map((sound) => ({ + id: sound.id, + name: sound.name, + file_id: sound.file_id, + kind: sound.kind ?? 'soundboard', + ...(sound.event === undefined ? {} : { event: sound.event }), + ...(sound.emoji === undefined ? {} : { emoji: sound.emoji }), + })), voice_states: (guild.voice_states ?? []).map((state, index) => ({ user_id: state.user_id, channel_id: state.channel_id, diff --git a/web/tests/markdown.test.tsx b/web/tests/markdown.test.tsx index 580aadc..b8ddeaf 100644 --- a/web/tests/markdown.test.tsx +++ b/web/tests/markdown.test.tsx @@ -138,6 +138,7 @@ describe('разметка: упоминания каналов', () => { my_permissions: [], channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }], emojis: [], + sounds: [], }; it('подсвечивает канал из снапшота и не трогает неизвестный', () => { diff --git a/web/tests/pushToTalk.test.tsx b/web/tests/pushToTalk.test.tsx new file mode 100644 index 0000000..9afbda0 --- /dev/null +++ b/web/tests/pushToTalk.test.tsx @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, renderHook } from '@testing-library/react'; + +vi.mock('livekit-client', async () => { + const fake = await import('./fakeLiveKit'); + return { + Room: fake.FakeRoom, + RoomEvent: fake.RoomEvent, + ConnectionQuality: fake.ConnectionQuality, + Track: fake.Track, + VideoQuality: fake.VideoQuality, + }; +}); + +import { FakeRoom } from './fakeLiveKit'; +import { installFetch, makeUser, resetStores, type FetchRoute } from './helpers'; +import { makeVoiceState, voiceRoutes } from './voiceRoutes'; +import { usePushToTalk } from '@/components/voice/usePushToTalk'; +import { VOICE_STORAGE_KEY, useVoiceStore, type VoicePreferences } from '@/stores/voice'; +import { useSessionStore } from '@/stores/session'; +import { useVoiceConnectionStore } from '@/stores/voiceConnection'; + +/** + * Push-to-talk (AGENT.md 7.14): клавиша включает и выключает микрофон, + * серверный мьют приоритетнее, ввод текста и повторы клавиши игнорируются, + * а режим и клавиша сохраняются в localStorage. + */ + +/** Поднимает настоящую голосовую сессию на фейковом SDK. */ +async function connectVoice(): Promise { + const voice = voiceRoutes({ + channelIds: ['c-voice'], + states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })], + }); + installFetch([ + { match: '/api/v1/users/@me', response: () => Response.json({ user: makeUser() }) }, + ...voice.routes, + ] as FetchRoute[]); + + useSessionStore.setState({ + user: { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + is_instance_admin: false, + badges: [], + }, + guilds: [ + { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 1, + my_role_ids: [], + my_permissions: [], + channels: [{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 }], + emojis: [], + sounds: [], + }, + ], + }); + + await act(async () => { + await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); + }); +} + +/** Микрофон в SDK: последний вызов `setMicrophoneEnabled`. */ +function micCalls(): unknown[] { + return FakeRoom.latest() + .localParticipant.calls.filter((call) => call.method === 'setMicrophoneEnabled') + .map((call) => call.value); +} + +/** Сохранённые предпочтения из localStorage. */ +function storedPreferences(): VoicePreferences { + return JSON.parse(window.localStorage.getItem(VOICE_STORAGE_KEY) ?? '{}') as VoicePreferences; +} + +/** Дать React применить состояние после события клавиатуры. */ +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +beforeEach(() => { + FakeRoom.reset(); + resetStores(); + // В jsdom окно «не в фокусе», пока что-нибудь не сфокусировано: для PTT + // считаем окно активным, а отдельный тест проверяет обратное поведение. + vi.spyOn(document, 'hasFocus').mockReturnValue(true); +}); + +describe('push-to-talk', () => { + it('включает микрофон на удержании и выключает при отпускании', async () => { + useVoiceStore.getState().setMicActivation('push_to_talk'); + await connectVoice(); + // В режиме PTT вход в комнату — с выключенным микрофоном. + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + + renderHook(() => usePushToTalk()); + + fireEvent.keyDown(document, { code: 'Space', key: ' ' }); + await flush(); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(true); + expect(micCalls()).toContain(true); + + fireEvent.keyUp(document, { code: 'Space', key: ' ' }); + await flush(); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + expect(micCalls().at(-1)).toBe(false); + }); + + it('игнорирует повтор клавиши и ввод текста', async () => { + useVoiceStore.getState().setMicActivation('push_to_talk'); + await connectVoice(); + renderHook(() => usePushToTalk()); + + fireEvent.keyDown(document, { code: 'Space', key: ' ', repeat: true }); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + + // Фокус в поле ввода: пробел печатает текст, а не включает микрофон. + const input = document.createElement('input'); + document.body.append(input); + input.focus(); + fireEvent.keyDown(input, { code: 'Space', key: ' ' }); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + input.remove(); + }); + + it('серверный мьют приоритетнее: PTT не включает микрофон', async () => { + useVoiceStore.getState().setMicActivation('push_to_talk'); + await connectVoice(); + renderHook(() => usePushToTalk()); + + act(() => { + useVoiceConnectionStore.setState({ serverMuted: true, micEnabled: false }); + }); + const before = micCalls().length; + + fireEvent.keyDown(document, { code: 'Space', key: ' ' }); + await flush(); + + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + expect(micCalls()).toHaveLength(before); + }); + + it('в режиме голосовой активации клавиша ничего не делает', async () => { + await connectVoice(); + renderHook(() => usePushToTalk()); + + const before = micCalls().length; + fireEvent.keyDown(document, { code: 'Space', key: ' ' }); + await flush(); + + expect(useVoiceConnectionStore.getState().micEnabled).toBe(true); + expect(micCalls()).toHaveLength(before); + }); + + it('слушает выбранную клавишу', async () => { + useVoiceStore.getState().setMicActivation('push_to_talk'); + useVoiceStore.getState().setPttKey('KeyV'); + await connectVoice(); + renderHook(() => usePushToTalk()); + + fireEvent.keyDown(document, { code: 'Space', key: ' ' }); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + + fireEvent.keyDown(document, { code: 'KeyV', key: 'v' }); + await flush(); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(true); + + fireEvent.keyUp(document, { code: 'KeyV', key: 'v' }); + await flush(); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + }); + + it('сохраняет режим и клавишу в localStorage', () => { + useVoiceStore.getState().setMicActivation('push_to_talk'); + useVoiceStore.getState().setPttKey('KeyB'); + + const stored = storedPreferences(); + expect(stored.micActivation).toBe('push_to_talk'); + expect(stored.pttKey).toBe('KeyB'); + }); + + it('не реагирует, когда окно не в фокусе', async () => { + vi.spyOn(document, 'hasFocus').mockReturnValue(false); + useVoiceStore.getState().setMicActivation('push_to_talk'); + await connectVoice(); + renderHook(() => usePushToTalk()); + + fireEvent.keyDown(document, { code: 'Space', key: ' ' }); + await flush(); + + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + }); + + it('возвращает микрофон по предпочтению при выходе из режима PTT', async () => { + useVoiceStore.getState().setMicActivation('push_to_talk'); + await connectVoice(); + const view = renderHook(() => usePushToTalk()); + expect(useVoiceConnectionStore.getState().micEnabled).toBe(false); + + // Пользователь вернулся к голосовой активации — микрофон снова включён. + await act(async () => { + useVoiceStore.getState().setMicActivation('voice'); + await Promise.resolve(); + }); + + expect(useVoiceConnectionStore.getState().micEnabled).toBe(true); + view.unmount(); + }); +}); diff --git a/web/tests/soundRoutes.ts b/web/tests/soundRoutes.ts new file mode 100644 index 0000000..b41834b --- /dev/null +++ b/web/tests/soundRoutes.ts @@ -0,0 +1,124 @@ +import { apiError, json, type FetchRoute } from './helpers'; +import type { GuildSound } from '@/api/sounds'; + +/** + * Маршруты ручек звуков для тестов: список, загрузка, переименование, + * удаление и просьба сыграть. Состояние живёт в замыкании, поэтому ответы + * согласованы между запросами (как у настоящего сервера). + */ + +export interface SoundRoutesOptions { + guildId?: string; + /** Набор звуков «сервера»: его же проверяют тесты. */ + sounds?: GuildSound[]; + /** Ошибка на `POST .../play` (лимит 429, `voice.not_connected` и т.п.). */ + playError?: { code: string; status: number; retryAfterMs?: number }; + /** Ошибка на загрузку звука (`sound.name_taken`, `sounds.limit_reached`). */ + uploadError?: { code: string; status: number }; +} + +export interface SoundRoutes { + routes: FetchRoute[]; + sounds: GuildSound[]; + /** Сколько раз просили сыграть звук. */ + playCount: () => number; +} + +export function soundRoutes(options: SoundRoutesOptions = {}): SoundRoutes { + const guildId = options.guildId ?? 'g-1'; + const sounds = [...(options.sounds ?? [])]; + let plays = 0; + + const list = (kind: string | null): GuildSound[] => + kind === null ? sounds : sounds.filter((sound) => sound.kind === kind); + + const routes: FetchRoute[] = [ + { + match: `/guilds/${guildId}/sounds/`, + method: 'POST', + response: ({ url }) => { + plays += 1; + if (options.playError !== undefined) { + const { code, status, retryAfterMs } = options.playError; + return json( + { + error: { + code, + message: 'error', + ...(retryAfterMs === undefined ? {} : { retry_after_ms: retryAfterMs }), + }, + }, + status, + ); + } + // `/guilds/{id}/sounds/{sound_id}/play` + const soundId = url.split('/sounds/')[1]?.split('/')[0] ?? ''; + const known = sounds.find((sound) => sound.id === soundId); + return known === undefined ? apiError('sounds.not_found', 404) : json({ ok: true }); + }, + }, + { + match: `/guilds/${guildId}/sounds`, + method: 'POST', + response: ({ url }) => { + if (options.uploadError !== undefined) { + return apiError(options.uploadError.code, options.uploadError.status); + } + const kind = url.includes('kind=ui') ? 'ui' : 'soundboard'; + const created: GuildSound = { + id: `s-${sounds.length + 1}`, + name: 'uploaded', + file_id: '950', + kind, + ...(kind === 'ui' ? { event: 'member_join' as const } : {}), + }; + sounds.push(created); + return json({ sound: created }); + }, + }, + { + match: `/guilds/${guildId}/sounds/`, + method: 'PATCH', + response: ({ url, body }) => { + const soundId = url.split('/sounds/')[1]?.split(/[/?]/u)[0] ?? ''; + const patch = (body ?? {}) as Record; + const index = sounds.findIndex((sound) => sound.id === soundId); + const current = sounds[index]; + if (current === undefined) { + return apiError('sounds.not_found', 404); + } + const next: GuildSound = { + ...current, + ...(typeof patch['name'] === 'string' ? { name: patch['name'] } : {}), + }; + sounds[index] = next; + return json({ sound: next }); + }, + }, + { + match: `/guilds/${guildId}/sounds/`, + method: 'DELETE', + response: ({ url }) => { + const soundId = url.split('/sounds/')[1]?.split(/[/?]/u)[0] ?? ''; + const index = sounds.findIndex((sound) => sound.id === soundId); + if (index >= 0) { + sounds.splice(index, 1); + } + return json({ ok: true }); + }, + }, + { + match: `/guilds/${guildId}/sounds`, + response: ({ url }) => { + const kind = url.includes('kind=ui') + ? 'ui' + : url.includes('kind=soundboard') + ? 'soundboard' + : null; + return json({ sounds: list(kind) }); + }, + }, + ]; + + return { routes, sounds, playCount: () => plays }; +} diff --git a/web/tests/soundSettings.test.tsx b/web/tests/soundSettings.test.tsx new file mode 100644 index 0000000..4544643 --- /dev/null +++ b/web/tests/soundSettings.test.tsx @@ -0,0 +1,318 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + installFetch, + json, + makeUser, + renderApp, + requestUrl, + resetStores, + type FetchRoute, +} from './helpers'; +import { soundRoutes } from './soundRoutes'; +import type { GuildSound } from '@/api/sounds'; +import { SOUND_LIMIT_PER_KIND } from '@/api/sounds'; + +/** + * Секция «Звуки» в настройках сервера (AGENT.md 7.13): список саундборда и + * палитры со счётчиками, загрузка с выбором события, переименование и + * удаление. Снапшот обновляется точечно, запросы инвалидируются — попап + * саундборда видит изменения сразу. + */ + +const user = makeUser(); + +const airhorn: GuildSound = { + id: 's-1', + name: 'airhorn', + file_id: '900', + kind: 'soundboard', + emoji: '📣', +}; +const quack: GuildSound = { id: 's-2', name: 'quack', file_id: '901', kind: 'soundboard' }; +const joinSound: GuildSound = { + id: 'u-1', + name: 'hello', + file_id: '910', + kind: 'ui', + event: 'member_join', +}; + +function guildDetail(permissions: string[]) { + return { + id: 'g-1', + name: 'Сервер', + description: '', + owner_id: 'user-9', + is_main: true, + member_count: 1, + roles: [], + my_role_ids: [], + my_permissions: permissions, + }; +} + +interface RoutesOptions { + permissions?: string[]; + sounds?: GuildSound[]; + extra?: FetchRoute[]; + uploadError?: { code: string; status: number }; +} + +/** Маршруты оболочки, карточки сервера и ручек звуков. */ +function routes(options: RoutesOptions = {}) { + const permissions = options.permissions ?? ['MANAGE_SOUNDS']; + const sounds = soundRoutes({ + sounds: options.sounds ?? [airhorn, quack, joinSound], + ...(options.uploadError === undefined ? {} : { uploadError: options.uploadError }), + }); + return [ + ...(options.extra ?? []), + ...sounds.routes, + { + match: '/api/v1/users/@me/guilds', + response: () => json({ guilds: [guildDetail(permissions)] }), + }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, + { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, + { match: '/api/v1/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) }, + ] satisfies FetchRoute[]; +} + +/** Открывает настройки сервера и дожидается окна. */ +async function openSettings(options: RoutesOptions = {}) { + const fetchMock = installFetch(routes(options)); + renderApp('/settings/servers/g-1'); + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + return { dialog, fetchMock }; +} + +/** Открывает настройки сервера и дожидается загруженной секции «Звуки». */ +async function openSoundsSection(options: RoutesOptions = {}) { + const { dialog, fetchMock } = await openSettings(options); + const counter = await within(dialog).findByTestId('guild-sounds-counter'); + // Список приходит запросом: дожидаемся его, иначе счётчик ещё нулевой. + await within(dialog).findByTestId('guild-sounds'); + return { dialog, counter, fetchMock }; +} + +/** Тело multipart-запроса загрузки звука. */ +function uploadForm(fetchMock: ReturnType): FormData | undefined { + const call = fetchMock.mock.calls.find(([input, init]) => { + const method = (init?.method ?? 'GET').toUpperCase(); + return method === 'POST' && requestUrl(input).includes('/guilds/g-1/sounds'); + }); + return call?.[1]?.body as FormData | undefined; +} + +/** URL запроса загрузки: по нему проверяют `kind`. */ +function uploadUrl(fetchMock: ReturnType): string { + const call = fetchMock.mock.calls.find(([input, init]) => { + const method = (init?.method ?? 'GET').toUpperCase(); + return method === 'POST' && requestUrl(input).includes('/guilds/g-1/sounds'); + }); + return call === undefined ? '' : requestUrl(call[0]); +} + +/** Один звук нужного размера и формата. */ +function soundFile(name = 'airhorn.mp3', type = 'audio/mpeg', size?: number): File { + const file = new File(['звук'], name, { type }); + if (size !== undefined) { + Object.defineProperty(file, 'size', { value: size }); + } + return file; +} + +beforeEach(() => { + resetStores(); +}); + +describe('настройки сервера: секция «Звуки»', () => { + it('показывает саундборд со счётчиком, переключается на палитру', async () => { + const { dialog, counter } = await openSoundsSection(); + + expect(counter).toHaveTextContent('2 из 30'); + const list = await within(dialog).findByTestId('guild-sounds'); + expect(within(list).getByTestId('guild-sound-s-1')).toHaveTextContent('airhorn'); + expect(within(list).queryByTestId('guild-sound-u-1')).toBeNull(); + + await userEvent.click(within(dialog).getByTestId('guild-sounds-tab-ui')); + + expect(within(dialog).getByTestId('guild-sounds-counter')).toHaveTextContent('1 из 30'); + const palette = within(dialog).getByTestId('guild-sounds'); + expect(within(palette).getByTestId('guild-sound-u-1')).toHaveTextContent('hello'); + // У звука палитры видно привязанное событие. + expect(within(palette).getByTestId('guild-sound-u-1')).toHaveTextContent( + 'Участник присоединился', + ); + }); + + it('скрыта без права MANAGE_SOUNDS', async () => { + const { dialog } = await openSettings({ permissions: ['VIEW_GUILD'] }); + + expect(within(dialog).queryByTestId('guild-sounds-counter')).toBeNull(); + }); + + it('загружает звук палитры: kind в URL, событие и имя в multipart', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + await userEvent.click(within(dialog).getByTestId('guild-sounds-tab-ui')); + + await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'notify'); + await userEvent.selectOptions(within(dialog).getByTestId('guild-sounds-event'), 'mention'); + fireEvent.change(within(dialog).getByLabelText('Файл звука'), { + target: { files: [soundFile()] }, + }); + await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' })); + + await waitFor(() => { + expect(uploadForm(fetchMock)).toBeDefined(); + }); + expect(uploadUrl(fetchMock)).toContain('kind=ui'); + const form = uploadForm(fetchMock); + expect(form?.get('name')).toBe('notify'); + expect(form?.get('event')).toBe('mention'); + expect(form?.get('file')).toBeInstanceOf(File); + }); + + it('загружает звук саундборда без события', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + + await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'horn2'); + await userEvent.type(within(dialog).getByLabelText('Эмодзи'), '📢'); + fireEvent.change(within(dialog).getByLabelText('Файл звука'), { + target: { files: [soundFile('second.ogg', 'audio/ogg')] }, + }); + await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' })); + + await waitFor(() => { + expect(uploadForm(fetchMock)).toBeDefined(); + }); + expect(uploadUrl(fetchMock)).toContain('kind=soundboard'); + expect(uploadForm(fetchMock)?.get('event')).toBeNull(); + expect(uploadForm(fetchMock)?.get('emoji')).toBe('📢'); + }); + + it('принимает файл перетаскиванием в область загрузки', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + + fireEvent.drop(within(dialog).getByTestId('sound-dropzone'), { + dataTransfer: { files: [soundFile('drop.wav', 'audio/wav')] }, + }); + await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'dropped'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' })); + + await waitFor(() => { + expect(uploadForm(fetchMock)?.get('name')).toBe('dropped'); + }); + }); + + it('не отправляет файл неподдерживаемого формата', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + + fireEvent.change(within(dialog).getByLabelText('Файл звука'), { + target: { files: [soundFile('sound.txt', 'text/plain')] }, + }); + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'Поддерживаются MP3, OGG, WAV, WebM и M4A.', + ); + expect(uploadForm(fetchMock)).toBeUndefined(); + }); + + it('не отправляет файл больше 512 КБ', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + + fireEvent.change(within(dialog).getByLabelText('Файл звука'), { + target: { files: [soundFile('big.mp3', 'audio/mpeg', 512 * 1024 + 1)] }, + }); + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'Файл больше допустимого размера.', + ); + expect(uploadForm(fetchMock)).toBeUndefined(); + }); + + it('проверяет имя по формату сервера', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + + await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'плохое имя'); + fireEvent.change(within(dialog).getByLabelText('Файл звука'), { + target: { files: [soundFile()] }, + }); + await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' })); + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'Имя звука: 2–32 символа, латиница, цифры и «_».', + ); + expect(uploadForm(fetchMock)).toBeUndefined(); + }); + + it('показывает ошибку лимита саундборда', async () => { + const many: GuildSound[] = Array.from({ length: SOUND_LIMIT_PER_KIND }, (_value, index) => ({ + id: `s-${String(index)}`, + name: `sound_${String(index)}`, + file_id: `${String(1000 + index)}`, + kind: 'soundboard', + })); + const { dialog } = await openSoundsSection({ sounds: many }); + + expect(within(dialog).getByTestId('guild-sounds-counter')).toHaveTextContent('30 из 30'); + expect(dialog).toHaveTextContent('Достигнут лимит звуков этого вида.'); + expect(within(dialog).getByRole('button', { name: 'Загрузить' })).toBeDisabled(); + }); + + it('переименовывает звук через PATCH', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + + await userEvent.click(within(dialog).getByLabelText('Переименовать звук airhorn')); + // В окне переименования своё поле имени: ищем его внутри модального окна. + const modal = await screen.findByRole('dialog', { name: 'Переименовать звук' }); + const field = within(modal).getByLabelText('Имя звука'); + await userEvent.clear(field); + await userEvent.type(field, 'airhorn2'); + await userEvent.click(within(modal).getByRole('button', { name: 'Сохранить' })); + + await waitFor(() => { + const request = fetchMock.mock.calls.find( + ([input, init]) => + (init?.method ?? 'GET').toUpperCase() === 'PATCH' && + requestUrl(input).includes('/sounds/s-1'), + ); + expect(request).toBeDefined(); + }); + }); + + it('удаляет звук после подтверждения', async () => { + const { dialog, fetchMock } = await openSoundsSection(); + + await userEvent.click(within(dialog).getByLabelText('Удалить звук quack')); + await userEvent.click(await screen.findByRole('button', { name: 'Удалить' })); + + await waitFor(() => { + const request = fetchMock.mock.calls.find( + ([input, init]) => + (init?.method ?? 'GET').toUpperCase() === 'DELETE' && + requestUrl(input).includes('/sounds/s-2'), + ); + expect(request).toBeDefined(); + }); + }); + + it('показывает ошибку сервера понятным текстом', async () => { + const { dialog } = await openSoundsSection({ + uploadError: { code: 'sound.name_taken', status: 409 }, + }); + + await userEvent.type(within(dialog).getByLabelText('Имя звука'), 'airhorn'); + fireEvent.change(within(dialog).getByLabelText('Файл звука'), { + target: { files: [soundFile()] }, + }); + await userEvent.click(within(dialog).getByRole('button', { name: 'Загрузить' })); + + expect(await within(dialog).findByText('Звук с таким именем уже есть.')).toBeVisible(); + }); +}); diff --git a/web/tests/soundboard.test.tsx b/web/tests/soundboard.test.tsx new file mode 100644 index 0000000..a6c535e --- /dev/null +++ b/web/tests/soundboard.test.tsx @@ -0,0 +1,482 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('livekit-client', async () => { + const fake = await import('./fakeLiveKit'); + return { + Room: fake.FakeRoom, + RoomEvent: fake.RoomEvent, + ConnectionQuality: fake.ConnectionQuality, + Track: fake.Track, + VideoQuality: fake.VideoQuality, + }; +}); + +import { FakeRoom } from './fakeLiveKit'; +import { installFakeAudio } from './fakeAudio'; +import { + createTestQueryClient, + installFetch, + json, + makeUser, + resetStores, + type FetchRoute, +} from './helpers'; +import { soundRoutes } from './soundRoutes'; +import { makeVoiceState, voiceRoutes } from './voiceRoutes'; +import type { Channel } from '@/api/types'; +import type { GuildSound } from '@/api/sounds'; +import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel'; +import { SoundboardNotice } from '@/components/voice/SoundboardNotice'; +import { setQueryClient } from '@/lib/queryClient'; +import { resetSoundboardEngine } from '@/lib/soundboard'; +import { useSessionStore } from '@/stores/session'; +import { useSoundboardStore } from '@/stores/soundboard'; +import { useVoiceStore } from '@/stores/voice'; +import { useVoiceConnectionStore } from '@/stores/voiceConnection'; +import { dispatchGatewayEvent } from '@/stores/gateway'; + +/** + * Саундборд (AGENT.md 7.13): попап панели комнаты, оптимистичное + * воспроизведение, защита от двойного звука по событию `SOUNDBOARD_PLAY`, + * лимит 429 и подсказка `voice.not_connected`. Плюс звуковая палитра: + * звук события играется при системном сообщении и упоминании. + */ + +const voiceChannel: Channel = { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 }; + +const airhorn: GuildSound = { + id: 's-1', + name: 'airhorn', + file_id: '900', + kind: 'soundboard', + emoji: '📣', +}; +const quack: GuildSound = { id: 's-2', name: 'quack', file_id: '901', kind: 'soundboard' }; +const joinSound: GuildSound = { + id: 'u-1', + name: 'join', + file_id: '777', + kind: 'ui', + event: 'member_join', +}; +const mentionSound: GuildSound = { + id: 'u-2', + name: 'ping', + file_id: '778', + kind: 'ui', + event: 'mention', +}; + +const members = [ + { + user_id: 'user-1', + username: 'alice', + display_name: 'Alice', + status: 'online', + is_instance_admin: false, + joined_at: '2026-09-19T09:00:00Z', + role_ids: [], + }, + { + user_id: 'user-2', + username: 'bob', + display_name: 'Bob', + status: 'online', + is_instance_admin: false, + joined_at: '2026-09-19T09:00:00Z', + role_ids: [], + }, +]; + +/** Снапшот сессии: сервер со звуками и собой в голосовой комнате. */ +function seedSession(sounds: GuildSound[], permissions: string[]): void { + useSessionStore.setState({ + user: { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + is_instance_admin: false, + badges: [], + }, + guilds: [ + { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 2, + my_role_ids: [], + my_permissions: permissions, + channels: [voiceChannel], + emojis: [], + sounds, + }, + ], + voiceStates: { 'g-1': [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })] }, + }); +} + +interface PanelOptions { + sounds?: GuildSound[]; + permissions?: string[]; + playError?: { code: string; status: number; retryAfterMs?: number }; +} + +/** Рендер панели комнаты с поднятой голосовой сессией (фейковый SDK). */ +async function bootPanel(options: PanelOptions = {}) { + const queryClient = createTestQueryClient(); + setQueryClient(queryClient); + resetStores(); + resetSoundboardEngine(); + + const sounds = options.sounds ?? [airhorn, quack]; + seedSession(sounds, options.permissions ?? ['VIEW_GUILD', 'USE_SOUNDBOARD']); + + const sound = soundRoutes({ + sounds, + ...(options.playError ? { playError: options.playError } : {}), + }); + const voice = voiceRoutes({ + channelIds: ['c-voice'], + states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })], + }); + const fetchMock = installFetch([ + { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) }, + { match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) }, + { match: '/api/v1/guilds/g-1/members', response: () => json({ members }) }, + { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, + ...sound.routes, + ...voice.routes, + ] as FetchRoute[]); + + const audio = installFakeAudio(); + render( + + + + , + ); + + await act(async () => { + await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); + }); + + return { sound, voice, fetchMock, audio }; +} + +/** Открыть попап саундборда. */ +async function openSoundboard(): Promise { + await userEvent.click(await screen.findByTestId('voice-soundboard-button')); + return screen.findByTestId('voice-soundboard-panel'); +} + +beforeEach(() => { + FakeRoom.reset(); +}); + +describe('саундборд: панель комнаты', () => { + it('кнопка появляется только в комнате и с правом USE_SOUNDBOARD', async () => { + // Без права кнопки нет, хотя сессия поднята. + await bootPanel({ permissions: ['VIEW_GUILD'] }); + expect(screen.queryByTestId('voice-soundboard-button')).toBeNull(); + }); + + it('проигрывает звук: POST на сервер и локальное воспроизведение', async () => { + const { sound, audio } = await bootPanel(); + + const panel = await openSoundboard(); + await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1')); + + await waitFor(() => { + expect(sound.playCount()).toBe(1); + }); + expect(audio).toHaveLength(1); + expect(audio[0]?.src).toBe('/files/900'); + // Оптимистичная подпись «кто включил звук» появляется сразу. + expect(await screen.findByTestId('soundboard-notice')).toHaveTextContent( + 'Alice включил звук «airhorn»', + ); + }); + + it('не проигрывает свой звук дважды по событию шлюза', async () => { + const { audio } = await bootPanel(); + + const panel = await openSoundboard(); + await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1')); + await waitFor(() => { + expect(audio).toHaveLength(1); + }); + + // Эхо своего же нажатия: сопоставление по sound_id + user_id + времени. + act(() => { + useSoundboardStore.getState().applyGatewayPlay({ + guild_id: 'g-1', + channel_id: 'c-voice', + sound_id: 's-1', + file_id: '900', + name: 'airhorn', + user_id: 'user-1', + played_at: '2026-09-19T10:00:00Z', + }); + }); + + expect(audio).toHaveLength(1); + }); + + it('играет звук другого участника по событию SOUNDBOARD_PLAY', async () => { + const { audio } = await bootPanel(); + + act(() => { + useSoundboardStore.getState().applyGatewayPlay({ + guild_id: 'g-1', + channel_id: 'c-voice', + sound_id: 's-2', + file_id: '901', + name: 'quack', + user_id: 'user-2', + played_at: '2026-09-19T10:00:00Z', + }); + }); + + await waitFor(() => { + expect(audio).toHaveLength(1); + }); + expect(audio[0]?.src).toBe('/files/901'); + expect(await screen.findByTestId('soundboard-notice')).toHaveTextContent( + 'Bob включил звук «quack»', + ); + }); + + it('на 429 показывает обратный отсчёт', async () => { + await bootPanel({ + playError: { code: 'rate_limited', status: 429, retryAfterMs: 5_000 }, + }); + + const panel = await openSoundboard(); + await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1')); + + expect(await screen.findByTestId('voice-soundboard-limit')).toHaveTextContent( + /Слишком часто: повторите через \d с\./u, + ); + // Пока идёт лимит, кнопки проигрывания выключены. + expect(within(panel).getByTestId('voice-soundboard-sound-s-1')).toBeDisabled(); + }); + + it('на voice.not_connected подсказывает войти в комнату', async () => { + await bootPanel({ + playError: { code: 'voice.not_connected', status: 403 }, + }); + + const panel = await openSoundboard(); + await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1')); + + expect(await screen.findByTestId('voice-soundboard-error')).toHaveTextContent( + 'Сначала войдите в голосовую комнату.', + ); + }); + + it('фильтрует звуки по имени и показывает пустой набор', async () => { + await bootPanel(); + + const panel = await openSoundboard(); + await userEvent.type(within(panel).getByTestId('voice-soundboard-search'), 'quack'); + + expect(within(panel).queryByTestId('voice-soundboard-sound-s-1')).toBeNull(); + expect(within(panel).getByTestId('voice-soundboard-sound-s-2')).toBeVisible(); + }); + + it('без звуков показывает понятный текст', async () => { + await bootPanel({ sounds: [] }); + + const panel = await openSoundboard(); + expect(panel).toHaveTextContent('На сервере пока нет звуков саундборда.'); + expect(screen.queryByTestId('voice-soundboard-sound-s-1')).toBeNull(); + }); + + it('не играет звуки, когда саундборд выключен в настройках', async () => { + const { sound, audio } = await bootPanel(); + useVoiceStore.getState().setSoundboardEnabled(false); + + const panel = await openSoundboard(); + await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1')); + + // Запрос на сервер уходит, но локально звук не играет: выключено в настройках. + await waitFor(() => { + expect(sound.playCount()).toBe(1); + }); + expect(audio).toHaveLength(0); + }); + + it('не играет звуки при выключенном звуке приложения (дефен)', async () => { + const { sound, audio } = await bootPanel(); + // Кнопка «Звук выключен» глушит и саундборд. + useVoiceStore.getState().setHeadphonesEnabled(false); + + const panel = await openSoundboard(); + await userEvent.click(within(panel).getByTestId('voice-soundboard-sound-s-1')); + + await waitFor(() => { + expect(sound.playCount()).toBe(1); + }); + expect(audio).toHaveLength(0); + }); + + it('обновляет набор звуков по событию GUILD_SOUNDS_UPDATE', async () => { + await bootPanel({ sounds: [airhorn] }); + + expect(await screen.findByTestId('voice-soundboard-button')).toBeVisible(); + act(() => { + dispatchGatewayEvent({ + op: 0, + t: 'GUILD_SOUNDS_UPDATE', + s: 1, + d: { + guild_id: 'g-1', + sounds: [ + { id: 's-1', name: 'airhorn', file_id: '900', kind: 'soundboard' }, + { id: 's-3', name: 'bell', file_id: '902', kind: 'soundboard' }, + ], + }, + }); + }); + + const panel = await openSoundboard(); + await waitFor(() => { + expect(within(panel).getByTestId('voice-soundboard-sound-s-3')).toBeVisible(); + }); + }); + + it('играет звук mic_off при выключении своего микрофона', async () => { + const micOff: GuildSound = { + id: 'u-3', + name: 'click', + file_id: '779', + kind: 'ui', + event: 'mic_off', + }; + const { audio } = await bootPanel({ sounds: [airhorn, micOff] }); + + // Кнопка микрофона в панели своей плитки. + const controls = screen.getByLabelText('Управление голосом'); + await userEvent.click(within(controls).getByLabelText('Выключить микрофон')); + + await waitFor(() => { + expect(audio).toHaveLength(1); + }); + expect(audio[0]?.src).toBe('/files/779'); + }); +}); + +describe('звуковая палитра: события', () => { + interface MessageInput { + id: string; + content: string; + type?: string; + author_id?: string; + mentions?: string[]; + } + + /** Событие MESSAGE_CREATE так, как его присылает шлюз. */ + function messageCreate(input: MessageInput): void { + dispatchGatewayEvent({ + op: 0, + t: 'MESSAGE_CREATE', + s: 1, + d: { + id: input.id, + channel_id: 'c-voice', + content: input.content, + type: input.type ?? 'default', + pinned: false, + attachments: [], + mentions: input.mentions ?? [], + reactions: [], + created_at: '2026-09-19T10:00:00Z', + ...(input.author_id === undefined ? {} : { author_id: input.author_id }), + }, + }); + } + + it('играет звук member_join при системном сообщении', async () => { + resetStores(); + resetSoundboardEngine(); + seedSession([joinSound], ['VIEW_GUILD']); + const audio = installFakeAudio(); + + messageCreate({ + id: 'm-1', + content: JSON.stringify({ event: 'member_join', user_id: 'user-2' }), + type: 'system', + }); + + await waitFor(() => { + expect(audio).toHaveLength(1); + }); + expect(audio[0]?.src).toBe('/files/777'); + }); + + it('играет звук mention при упоминании и молчит на своё сообщение', async () => { + resetStores(); + resetSoundboardEngine(); + seedSession([mentionSound], ['VIEW_GUILD']); + const audio = installFakeAudio(); + + messageCreate({ + id: 'm-2', + content: 'привет <@user-1>', + author_id: 'user-2', + mentions: ['user-1'], + }); + await waitFor(() => { + expect(audio).toHaveLength(1); + }); + + // Своё сообщение звуком не сопровождается. + messageCreate({ id: 'm-3', content: 'моё', author_id: 'user-1' }); + expect(audio).toHaveLength(1); + }); + + it('не играет звуки событий, когда они выключены в настройках', () => { + resetStores(); + resetSoundboardEngine(); + seedSession([joinSound], ['VIEW_GUILD']); + const audio = installFakeAudio(); + useVoiceStore.getState().setEventSoundsEnabled(false); + + messageCreate({ + id: 'm-4', + content: JSON.stringify({ event: 'member_join', user_id: 'user-2' }), + type: 'system', + }); + + expect(audio).toHaveLength(0); + }); + + it('играет звук voice_join, когда участник входит в мою комнату', async () => { + resetStores(); + resetSoundboardEngine(); + seedSession( + [{ id: 'u-4', name: 'whoosh', file_id: '780', kind: 'ui', event: 'voice_join' }], + ['VIEW_GUILD'], + ); + useVoiceConnectionStore.setState({ guildId: 'g-1', channelId: 'c-voice', status: 'connected' }); + const audio = installFakeAudio(); + + act(() => { + dispatchGatewayEvent({ + op: 0, + t: 'VOICE_STATE_UPDATE', + s: 1, + d: makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }), + }); + }); + + await waitFor(() => { + expect(audio).toHaveLength(1); + }); + expect(audio[0]?.src).toBe('/files/780'); + }); +}); diff --git a/web/tests/voice.test.tsx b/web/tests/voice.test.tsx index f2479ba..4c510e5 100644 --- a/web/tests/voice.test.tsx +++ b/web/tests/voice.test.tsx @@ -9,6 +9,7 @@ vi.mock('livekit-client', async () => { RoomEvent: fake.RoomEvent, ConnectionQuality: fake.ConnectionQuality, Track: fake.Track, + VideoQuality: fake.VideoQuality, }; }); diff --git a/web/tests/voiceConnection.test.ts b/web/tests/voiceConnection.test.ts index 1a68041..d84b62a 100644 --- a/web/tests/voiceConnection.test.ts +++ b/web/tests/voiceConnection.test.ts @@ -7,6 +7,7 @@ vi.mock('livekit-client', async () => { RoomEvent: fake.RoomEvent, ConnectionQuality: fake.ConnectionQuality, Track: fake.Track, + VideoQuality: fake.VideoQuality, }; }); diff --git a/web/tests/voiceStats.test.tsx b/web/tests/voiceStats.test.tsx new file mode 100644 index 0000000..872694c --- /dev/null +++ b/web/tests/voiceStats.test.tsx @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('livekit-client', async () => { + const fake = await import('./fakeLiveKit'); + return { + Room: fake.FakeRoom, + RoomEvent: fake.RoomEvent, + ConnectionQuality: fake.ConnectionQuality, + Track: fake.Track, + VideoQuality: fake.VideoQuality, + }; +}); + +import { FakeRoom, VideoQuality } from './fakeLiveKit'; +import { + createTestQueryClient, + installFetch, + json, + makeUser, + resetStores, + type FetchRoute, +} from './helpers'; +import { makeVoiceState, voiceRoutes } from './voiceRoutes'; +import type { Channel } from '@/api/types'; +import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel'; +import { setQueryClient } from '@/lib/queryClient'; +import { resetSoundboardEngine } from '@/lib/soundboard'; +import { useSessionStore } from '@/stores/session'; +import { useVoiceStore } from '@/stores/voice'; +import { useVoiceConnectionStore } from '@/stores/voiceConnection'; + +/** + * Статистика своей публикации, выбор качества видео и пресетов демонстрации + * экрана (AGENT.md 7.14). Значения берутся из отчёта WebRTC подставного SDK, + * опрос идёт раз в секунду и останавливается при закрытии попапа. + */ + +const voiceChannel: Channel = { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 }; + +/** Отчёт WebRTC: кодек, отправка видео, обратный канал (RTT, потери). */ +function statsReport(bytesSent: number, timestamp: number): [string, unknown][] { + return [ + ['codec-1', { type: 'codec', id: 'codec-1', mimeType: 'video/VP8' }], + [ + 'out-1', + { + type: 'outbound-rtp', + kind: 'video', + frameWidth: 1920, + frameHeight: 1080, + framesPerSecond: 30, + bytesSent, + packetsSent: 197, + codecId: 'codec-1', + timestamp, + }, + ], + [ + 'in-1', + { + type: 'remote-inbound-rtp', + kind: 'video', + roundTripTime: 0.042, + jitter: 0.003, + packetsLost: 3, + }, + ], + ]; +} + +/** Рендер панели комнаты с поднятой голосовой сессией и включённой камерой. */ +async function bootPanel() { + const queryClient = createTestQueryClient(); + setQueryClient(queryClient); + resetStores(); + resetSoundboardEngine(); + + useSessionStore.setState({ + user: { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + is_instance_admin: false, + badges: [], + }, + guilds: [ + { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 1, + my_role_ids: [], + my_permissions: ['VIEW_GUILD'], + channels: [voiceChannel], + emojis: [], + sounds: [], + }, + ], + voiceStates: { 'g-1': [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })] }, + }); + + const voice = voiceRoutes({ + channelIds: ['c-voice'], + states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })], + }); + installFetch([ + { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) }, + { match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) }, + { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, + { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, + ...voice.routes, + ] as FetchRoute[]); + + render( + + + , + ); + + await act(async () => { + await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); + await useVoiceConnectionStore.getState().setCameraEnabled(true); + }); + + return { room: FakeRoom.latest() }; +} + +beforeEach(() => { + FakeRoom.reset(); +}); + +describe('статистика соединения', () => { + it('показывает данные SDK и обновляет их раз в секунду', async () => { + const { room } = await bootPanel(); + room.setStats(statsReport(0, 1_000)); + + await userEvent.click(await screen.findByTestId('voice-stats-button')); + const panel = await screen.findByTestId('voice-stats-panel'); + + // Разрешение, fps и кодек приходят из отчёта; битрейт — по двум замерам. + expect(panel).toHaveTextContent('1920×1080'); + expect(panel).toHaveTextContent('30 fps'); + expect(panel).toHaveTextContent('VP8'); + expect(panel).toHaveTextContent('42 мс'); + expect(panel).toHaveTextContent('3 мс'); + expect(panel).toHaveTextContent('1.5%'); + + room.setStats(statsReport(125_000, 2_000)); + await waitFor( + () => { + expect(screen.getByTestId('voice-stats-panel')).toHaveTextContent('1000 кбит/с'); + }, + { timeout: 2_500 }, + ); + }); + + it('показывает прочерк, когда SDK не отдал поле', async () => { + const { room } = await bootPanel(); + room.setStats([]); + + await userEvent.click(await screen.findByTestId('voice-stats-button')); + const panel = await screen.findByTestId('voice-stats-panel'); + + expect(within(panel).getAllByText('—').length).toBeGreaterThanOrEqual(5); + }); + + it('останавливает опрос при закрытии попапа', async () => { + const { room } = await bootPanel(); + room.setStats(statsReport(0, 1_000)); + + await userEvent.click(await screen.findByTestId('voice-stats-button')); + await screen.findByTestId('voice-stats-panel'); + const track = room.localParticipant.getTrackPublication('camera')?.track; + const before = track?.statsRequests ?? 0; + expect(before).toBeGreaterThan(0); + + await userEvent.click(screen.getByTestId('voice-stats-button')); + await waitFor(() => { + expect(screen.queryByTestId('voice-stats-panel')).toBeNull(); + }); + + // Ждём больше периода опроса: счётчик обращений к SDK не должен расти. + await new Promise((resolve) => setTimeout(resolve, 1_200)); + expect(track?.statsRequests).toBe(before); + }); + + it('передаёт SDK выбранное качество публикации', async () => { + const { room } = await bootPanel(); + + await userEvent.click(await screen.findByTestId('voice-quality-button')); + await userEvent.click(await screen.findByTestId('voice-publish-quality-low')); + + await waitFor(() => { + expect(useVoiceStore.getState().videoQuality).toBe('low'); + }); + const track = room.localParticipant.getTrackPublication('camera')?.track; + expect(track?.publishingQuality).toContain(VideoQuality.LOW); + }); + + it('публикует экран с выбранным разрешением и fps', async () => { + const { room } = await bootPanel(); + + await userEvent.click(await screen.findByTestId('voice-screen-button')); + await userEvent.click(await screen.findByTestId('voice-screen-preset-720p30')); + + await waitFor(() => { + expect(useVoiceConnectionStore.getState().screenEnabled).toBe(true); + }); + expect(useVoiceStore.getState().screenSharePreset).toBe('720p30'); + expect(room.localParticipant.screenOptions.at(-1)).toEqual({ + enabled: true, + options: { resolution: { width: 1280, height: 720, frameRate: 30 } }, + }); + }); +});