feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.
- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
участника, упоминание, сообщение, вход/выход из голосовой, свои
микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
комнату с выключенным микрофоном, удержание включает публикацию, отпускание
и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
`useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
расширенный фейковый LiveKit SDK.
This commit is contained in:
@@ -2,3 +2,5 @@ dist
|
||||
node_modules
|
||||
coverage
|
||||
*.min.js
|
||||
test-results
|
||||
playwright-report
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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);
|
||||
|
||||
@@ -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<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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<GuildSound[]> {
|
||||
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<GuildSound | null> {
|
||||
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<GuildSound | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}/play`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
@@ -177,6 +177,13 @@ function SystemMessageBody({ message, authors }: { message: Message; authors: Au
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (event.kind === 'member_leave') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('chat.system.memberLeave', { name: nameOf(event.userId) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (event.kind === 'member_kick') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="soundboard-notice"
|
||||
className="pointer-events-auto fixed bottom-40 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2 text-sm shadow-[var(--shadow-3)]"
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
<p>
|
||||
{t('voice.soundboard.playedBy', {
|
||||
name: authors.resolve(notice.userId).name,
|
||||
sound: notice.soundName,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.dismiss')}
|
||||
title={t('common.dismiss')}
|
||||
onClick={clear}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||
import { Card, Button } from '@/components/ui/primitives';
|
||||
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
||||
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
|
||||
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
||||
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
||||
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
|
||||
import {
|
||||
useCanUseSoundboard,
|
||||
useVoiceChannels,
|
||||
useVoiceModeration,
|
||||
useVoiceOccupants,
|
||||
@@ -18,8 +22,9 @@ import {
|
||||
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||
import { speakingRingClass } from '@/components/voice/voiceStyles';
|
||||
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||
import { SCREEN_SHARE_PRESET_IDS, type ScreenSharePresetId } from '@/lib/livekit';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { useVoiceStore, type VoicePublishQuality } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
|
||||
@@ -105,7 +110,11 @@ function VoiceTile({
|
||||
</span>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
{occupant.isLocal ? (
|
||||
<>
|
||||
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
||||
<VoiceStatsButton />
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={anchorRef}
|
||||
@@ -157,10 +166,14 @@ function VoiceControlsRow({
|
||||
screenEnabled,
|
||||
serverMuted,
|
||||
serverDeafened,
|
||||
screenPreset,
|
||||
videoQuality,
|
||||
soundboard,
|
||||
onMic,
|
||||
onSound,
|
||||
onCamera,
|
||||
onScreen,
|
||||
onVideoQuality,
|
||||
onLeave,
|
||||
}: {
|
||||
micEnabled: boolean;
|
||||
@@ -169,13 +182,26 @@ function VoiceControlsRow({
|
||||
screenEnabled: boolean;
|
||||
serverMuted: boolean;
|
||||
serverDeafened: boolean;
|
||||
/** Выбранный пресет шаринга: показывается в меню демонстрации экрана. */
|
||||
screenPreset: ScreenSharePresetId;
|
||||
/** Качество публикации видео. */
|
||||
videoQuality: VoicePublishQuality;
|
||||
/** Кнопка саундборда: рисуется, когда есть право `USE_SOUNDBOARD`. */
|
||||
soundboard: ReactNode;
|
||||
onMic: () => void;
|
||||
onSound: () => void;
|
||||
onCamera: () => void;
|
||||
onScreen: () => void;
|
||||
/** `null` — остановить демонстрацию, иначе — начать с этим пресетом. */
|
||||
onScreen: (preset: ScreenSharePresetId | null) => void;
|
||||
onVideoQuality: (quality: VoicePublishQuality) => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [screenMenuOpen, setScreenMenuOpen] = useState(false);
|
||||
const [qualityMenuOpen, setQualityMenuOpen] = useState(false);
|
||||
const screenAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const qualityAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
@@ -204,12 +230,94 @@ function VoiceControlsRow({
|
||||
active={cameraEnabled}
|
||||
onClick={onCamera}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="🖥"
|
||||
label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
active={screenEnabled}
|
||||
onClick={onScreen}
|
||||
/>
|
||||
{/* Демонстрация экрана: перед публикацией выбираем разрешение и fps. */}
|
||||
<button
|
||||
ref={screenAnchorRef}
|
||||
type="button"
|
||||
aria-pressed={screenEnabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={screenMenuOpen}
|
||||
aria-label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
title={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
data-testid="voice-screen-button"
|
||||
onClick={() => {
|
||||
if (screenEnabled) {
|
||||
onScreen(null);
|
||||
return;
|
||||
}
|
||||
setScreenMenuOpen((value) => !value);
|
||||
}}
|
||||
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 ${
|
||||
screenEnabled
|
||||
? 'border-border/60 bg-surface-2 text-fg'
|
||||
: 'border-danger/60 bg-danger/10 text-danger'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">🖥</span>
|
||||
</button>
|
||||
<Menu
|
||||
open={screenMenuOpen}
|
||||
label={t('voice.screen.quality')}
|
||||
onClose={() => setScreenMenuOpen(false)}
|
||||
anchorRef={screenAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.screen.quality')}</MenuGroupLabel>
|
||||
{SCREEN_SHARE_PRESET_IDS.map((preset) => (
|
||||
<MenuItem
|
||||
key={preset}
|
||||
role="menuitemradio"
|
||||
selected={screenPreset === preset}
|
||||
data-testid={`voice-screen-preset-${preset}`}
|
||||
onClick={() => {
|
||||
setScreenMenuOpen(false);
|
||||
onScreen(preset);
|
||||
}}
|
||||
>
|
||||
{t(`voice.screen.presets.${preset}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* Качество публикации видео: low/medium/high (`setPublishingQuality`). */}
|
||||
<button
|
||||
ref={qualityAnchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={qualityMenuOpen}
|
||||
aria-label={t('voice.publishQuality.label')}
|
||||
title={t('voice.publishQuality.label')}
|
||||
data-testid="voice-quality-button"
|
||||
onClick={() => setQualityMenuOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1.5 text-xs leading-none text-fg transition-colors duration-[var(--duration-fast)]"
|
||||
>
|
||||
{t(`voice.publishQuality.short.${videoQuality}`)}
|
||||
</button>
|
||||
<Menu
|
||||
open={qualityMenuOpen}
|
||||
label={t('voice.publishQuality.label')}
|
||||
onClose={() => setQualityMenuOpen(false)}
|
||||
anchorRef={qualityAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.publishQuality.label')}</MenuGroupLabel>
|
||||
{(['low', 'medium', 'high'] as VoicePublishQuality[]).map((quality) => (
|
||||
<MenuItem
|
||||
key={quality}
|
||||
role="menuitemradio"
|
||||
selected={videoQuality === quality}
|
||||
data-testid={`voice-publish-quality-${quality}`}
|
||||
onClick={() => {
|
||||
setQualityMenuOpen(false);
|
||||
onVideoQuality(quality);
|
||||
}}
|
||||
>
|
||||
{t(`voice.publishQuality.level.${quality}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{soundboard}
|
||||
<VoiceControlButton glyph="⏻" label={t('voice.disconnect')} active onClick={onLeave} />
|
||||
</div>
|
||||
);
|
||||
@@ -263,7 +371,11 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
|
||||
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||
const permissions = useVoiceModeration(guildId);
|
||||
const canUseSoundboard = useCanUseSoundboard(guildId);
|
||||
const moveTargets = useVoiceChannels(guildId).filter((item) => item.id !== channel.id);
|
||||
const screenPreset = useVoiceStore((state) => state.screenSharePreset);
|
||||
const setScreenSharePreset = useVoiceStore((state) => state.setScreenSharePreset);
|
||||
const videoQuality = useVoiceStore((state) => state.videoQuality);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||
const error = useVoiceConnectionStore((state) => state.error);
|
||||
@@ -280,6 +392,7 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
|
||||
const setVideoQuality = useVoiceConnectionStore((state) => state.setVideoQuality);
|
||||
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||
@@ -367,10 +480,24 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
screenEnabled={screenEnabled}
|
||||
serverMuted={serverMuted}
|
||||
serverDeafened={serverDeafened}
|
||||
screenPreset={screenPreset}
|
||||
videoQuality={videoQuality}
|
||||
soundboard={
|
||||
canUseSoundboard ? <VoiceSoundboard guildId={guildId} disabled={false} /> : null
|
||||
}
|
||||
onMic={() => void setMicEnabled(!micEnabled)}
|
||||
onSound={() => void setDeafened(!deafened)}
|
||||
onCamera={() => void setCameraEnabled(!cameraEnabled)}
|
||||
onScreen={() => void setScreenShareEnabled(!screenEnabled)}
|
||||
onScreen={(preset) => {
|
||||
if (preset === null) {
|
||||
void setScreenShareEnabled(false);
|
||||
return;
|
||||
}
|
||||
// Пресет сохраняем до публикации: стор читает его при старте.
|
||||
setScreenSharePreset(preset);
|
||||
void setScreenShareEnabled(true);
|
||||
}}
|
||||
onVideoQuality={(quality) => void setVideoQuality(quality)}
|
||||
onLeave={() => void leave()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
/** Активный лимит: `id` отличает новый отсчёт от предыдущего. */
|
||||
const [limit, setLimit] = useState<{ id: number; seconds: number } | null>(null);
|
||||
const limitSeq = useRef(0);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(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<void> => {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t('voice.soundboard.open')}
|
||||
title={t('voice.soundboard.open')}
|
||||
disabled={disabled}
|
||||
data-testid="voice-soundboard-button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-1.5 text-base leading-none text-fg transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
label={t('voice.soundboard.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
className="w-72"
|
||||
>
|
||||
<div data-testid="voice-soundboard-panel" className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('voice.soundboard.searchLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label={t('voice.soundboard.searchLabel')}
|
||||
placeholder={t('voice.soundboard.searchPlaceholder')}
|
||||
data-testid="voice-soundboard-search"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{secondsLeft > 0 ? (
|
||||
<p role="status" data-testid="voice-soundboard-limit" className="text-xs text-warning">
|
||||
{t('voice.soundboard.rateLimited', { seconds: secondsLeft })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{notConnected ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-warning">
|
||||
{t('voice.soundboard.notConnected')}
|
||||
</p>
|
||||
) : error !== null ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-danger">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sounds.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.empty')}</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.noResults')}</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-1">
|
||||
{visible.map((sound) => (
|
||||
<li key={sound.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`voice-soundboard-sound-${sound.id}`}
|
||||
aria-label={t('voice.soundboard.playLabel', { name: sound.name })}
|
||||
title={sound.name}
|
||||
disabled={pending !== null || secondsLeft > 0}
|
||||
onClick={() => void play(sound)}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">{sound.emoji ?? '🎵'}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{sound.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<VoiceStats | null>(null);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async (): Promise<void> => {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t('voice.stats.open')}
|
||||
title={t('voice.stats.open')}
|
||||
data-testid="voice-stats-button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span aria-hidden="true">📊</span>
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
label={t('voice.stats.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
className="w-72"
|
||||
>
|
||||
<div data-testid="voice-stats-panel">
|
||||
<h3 className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('voice.stats.title')}
|
||||
</h3>
|
||||
<dl className="flex flex-col text-sm">
|
||||
<StatRow label={t('voice.stats.resolution')} value={value(resolution)} />
|
||||
<StatRow
|
||||
label={t('voice.stats.frameRate')}
|
||||
value={
|
||||
stats?.frameRate === null || stats?.frameRate === undefined
|
||||
? '—'
|
||||
: `${stats.frameRate} fps`
|
||||
}
|
||||
/>
|
||||
<StatRow label={t('voice.stats.codec')} value={value(stats?.codec ?? null)} />
|
||||
<StatRow
|
||||
label={t('voice.stats.bitrate')}
|
||||
value={
|
||||
stats?.bitrateKbps === null || stats?.bitrateKbps === undefined
|
||||
? '—'
|
||||
: `${stats.bitrateKbps} ${t('voice.stats.kbps')}`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.rtt')}
|
||||
value={
|
||||
stats?.roundTripMs === null || stats?.roundTripMs === undefined
|
||||
? '—'
|
||||
: `${stats.roundTripMs} ${t('voice.stats.ms')}`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.packetLoss')}
|
||||
value={
|
||||
stats?.packetLossPercent === null || stats?.packetLossPercent === undefined
|
||||
? '—'
|
||||
: `${stats.packetLossPercent}%`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.jitter')}
|
||||
value={
|
||||
stats?.jitterMs === null || stats?.jitterMs === undefined
|
||||
? '—'
|
||||
: `${stats.jitterMs} ${t('voice.stats.ms')}`
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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<Record<PushToTalkKey, string>> = {
|
||||
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]);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Язык и регион",
|
||||
|
||||
@@ -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[],
|
||||
|
||||
+232
-5
@@ -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<void>;
|
||||
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||
/** Демонстрация экрана: пресет задаёт разрешение и fps захвата. */
|
||||
setScreenShareEnabled: (enabled: boolean, preset?: ScreenSharePresetId) => Promise<void>;
|
||||
/** Качество публикации своей камеры (и шаринга, если он идёт). */
|
||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||
/** Статистика своей публикации для попапа «Статистика». */
|
||||
fetchStats: () => Promise<VoiceStats>;
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
participants: () => VoiceParticipant[];
|
||||
}
|
||||
@@ -152,6 +211,140 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
return participants;
|
||||
};
|
||||
|
||||
/**
|
||||
* Своя видеодорожка для смены качества публикации и статистики: сначала
|
||||
* активная (камера, затем демонстрация экрана), иначе — выключенная.
|
||||
* `setPublishingQuality` есть только у `LocalVideoTrack`, поэтому проверяем
|
||||
* метод, а не тип.
|
||||
*/
|
||||
const videoPublication = (): LocalVideoTrack | undefined => {
|
||||
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<VoiceStats> => {
|
||||
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<string, unknown>[] = [];
|
||||
report.forEach((entry: unknown) => {
|
||||
if (typeof entry === 'object' && entry !== null) {
|
||||
entries.push(entry as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
const codecs = new Map<string, string>();
|
||||
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,9 +403,43 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setScreenShareEnabled(enabled) {
|
||||
await room.localParticipant.setScreenShareEnabled(enabled);
|
||||
async setScreenShareEnabled(enabled, preset) {
|
||||
if (!enabled) {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
// Разрешение и fps выбирает пользователь: они уходят в захват экрана.
|
||||
const resolution = SCREEN_SHARE_PRESETS[preset ?? '1080p30'];
|
||||
await room.localParticipant.setScreenShareEnabled(true, {
|
||||
resolution: {
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
frameRate: resolution.frameRate,
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setVideoQuality(quality) {
|
||||
const track = videoPublication();
|
||||
// `setPublishingQuality` есть только у локальной видеодорожки: если
|
||||
// публикации нет, менять нечего — предпочтение применится при старте.
|
||||
if (track === undefined || typeof track.setPublishingQuality !== 'function') {
|
||||
return;
|
||||
}
|
||||
const { VideoQuality } = await import('livekit-client');
|
||||
const levels: Record<VoicePublishQuality, (typeof VideoQuality)[keyof typeof VideoQuality]> =
|
||||
{
|
||||
low: VideoQuality.LOW,
|
||||
medium: VideoQuality.MEDIUM,
|
||||
high: VideoQuality.HIGH,
|
||||
};
|
||||
track.setPublishingQuality(levels[quality]);
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
return collectStats();
|
||||
},
|
||||
|
||||
async switchActiveDevice(kind, deviceId) {
|
||||
|
||||
@@ -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<string, Promise<string>>();
|
||||
/** Играющие элементы по `file_id`: их останавливает `stopSoundFile`. */
|
||||
const playing = new Map<string, Set<HTMLAudioElement>>();
|
||||
/** Порядок запусков: самый старый звук глушится при переполнении. */
|
||||
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<string> {
|
||||
const cached = sources.get(fileId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const url = fileContentUrl(fileId);
|
||||
const pending = (async (): Promise<string> => {
|
||||
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<HTMLAudioElement>();
|
||||
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<boolean> {
|
||||
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();
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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() {
|
||||
<CreateGuildModal />
|
||||
{/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */}
|
||||
<VoiceSpeakingNotice />
|
||||
{/* Подпись «{имя} включил звук {name}» из события SOUNDBOARD_PLAY (7.13). */}
|
||||
<SoundboardNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canAdministerGuild, canManageEmojis } from '@/lib/identity';
|
||||
import { canAdministerGuild, canManageEmojis, canManageSounds } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
@@ -16,10 +16,11 @@ import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||
import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection';
|
||||
|
||||
/**
|
||||
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
||||
* общее (имя и описание), участники, роли, эмодзи и «Опасная зона».
|
||||
* общее (имя и описание), участники, роли, эмодзи, звуки и «Опасная зона».
|
||||
*
|
||||
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
|
||||
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
|
||||
@@ -150,6 +151,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
|
||||
const canManageSoundSet = canManageSounds(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
currentUser.data?.id,
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
@@ -163,6 +172,9 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
{canManageEmojiSet ? (
|
||||
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
|
||||
) : null}
|
||||
{canManageSoundSet ? (
|
||||
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
|
||||
) : null}
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
SOUND_EVENTS,
|
||||
SOUND_LIMIT_PER_KIND,
|
||||
SOUND_MAX_BYTES,
|
||||
deleteGuildSound,
|
||||
fetchGuildSounds,
|
||||
guildSoundsQueryKey,
|
||||
isAllowedSoundType,
|
||||
isValidSoundName,
|
||||
renameGuildSound,
|
||||
uploadGuildSound,
|
||||
type GuildSound,
|
||||
type SoundEvent,
|
||||
type SoundKind,
|
||||
} from '@/api/sounds';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Badge, Button } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
interface GuildSoundsSectionProps {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная ошибка проверки: текст показываем сразу, на сервер не ходим.
|
||||
* Ключ перевода — как у серверных ошибок (`errors.<code>`).
|
||||
*/
|
||||
type LocalError =
|
||||
| { kind: 'file.too_large' }
|
||||
| { kind: 'sound.invalid_type' }
|
||||
| { kind: 'sound.invalid_name' }
|
||||
| { kind: 'sounds.event_required' };
|
||||
|
||||
/** Названия событий палитры: подписи берём из локалей. */
|
||||
function eventLabelKey(event: SoundEvent): string {
|
||||
return `settings.servers.sounds.events.${event}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Звуки»: саундборд и звуковая палитра сервера (AGENT.md 7.13, право
|
||||
* MANAGE_SOUNDS). Список читается по REST, а снапшот обновляется точечно —
|
||||
* и загрузкой, и событием шлюза `GUILD_SOUNDS_UPDATE`.
|
||||
*/
|
||||
export function GuildSoundsSection({ guildId }: GuildSoundsSectionProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [kind, setKind] = useState<SoundKind>('soundboard');
|
||||
const [name, setName] = useState('');
|
||||
const [emoji, setEmoji] = useState('');
|
||||
const [event, setEvent] = useState<SoundEvent>('member_join');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [localError, setLocalError] = useState<LocalError | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [renaming, setRenaming] = useState<GuildSound | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [removing, setRemoving] = useState<GuildSound | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: guildSoundsQueryKey(guildId),
|
||||
queryFn: ({ signal }) => fetchGuildSounds(guildId, undefined, signal),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const sounds = list.data ?? [];
|
||||
const soundboard = sounds.filter((sound) => sound.kind === 'soundboard');
|
||||
const palette = sounds.filter((sound) => sound.kind === 'ui');
|
||||
const current = kind === 'soundboard' ? soundboard : palette;
|
||||
const full = current.length >= SOUND_LIMIT_PER_KIND;
|
||||
|
||||
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
|
||||
const refresh = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) });
|
||||
};
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: (input: {
|
||||
name: string;
|
||||
file: File;
|
||||
kind: SoundKind;
|
||||
event?: SoundEvent;
|
||||
emoji?: string;
|
||||
}) => uploadGuildSound(guildId, input.kind, input),
|
||||
onSuccess: (sound) => {
|
||||
if (sound !== null) {
|
||||
useSessionStore.getState().upsertGuildSound(guildId, sound);
|
||||
}
|
||||
setName('');
|
||||
setEmoji('');
|
||||
setFile(null);
|
||||
setLocalError(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: (input: { soundId: string; name: string; emoji?: string }) =>
|
||||
renameGuildSound(guildId, input.soundId, {
|
||||
name: input.name,
|
||||
...(input.emoji === undefined || input.emoji === '' ? {} : { emoji: input.emoji }),
|
||||
}),
|
||||
onSuccess: (sound) => {
|
||||
if (sound !== null) {
|
||||
useSessionStore.getState().upsertGuildSound(guildId, sound);
|
||||
}
|
||||
setRenaming(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (soundId: string) => deleteGuildSound(guildId, soundId),
|
||||
onSuccess: (_result, soundId) => {
|
||||
useSessionStore.getState().removeGuildSound(guildId, soundId);
|
||||
setRemoving(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
/** Проверки на клиенте: формат, размер и имя — до запроса на сервер. */
|
||||
const pickFile = (next: File | undefined): void => {
|
||||
if (next === undefined) {
|
||||
return;
|
||||
}
|
||||
upload.reset();
|
||||
if (!isAllowedSoundType(next)) {
|
||||
setFile(null);
|
||||
setLocalError({ kind: 'sound.invalid_type' });
|
||||
return;
|
||||
}
|
||||
if (next.size > SOUND_MAX_BYTES) {
|
||||
setFile(null);
|
||||
setLocalError({ kind: 'file.too_large' });
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setFile(next);
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (file === null) {
|
||||
setLocalError({ kind: 'sound.invalid_type' });
|
||||
return;
|
||||
}
|
||||
if (!isValidSoundName(name)) {
|
||||
setLocalError({ kind: 'sound.invalid_name' });
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
upload.mutate({
|
||||
name,
|
||||
file,
|
||||
kind,
|
||||
...(kind === 'ui' ? { event } : {}),
|
||||
...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }),
|
||||
});
|
||||
};
|
||||
|
||||
const onDrop = (event_: DragEvent<HTMLDivElement>): void => {
|
||||
event_.preventDefault();
|
||||
setDragging(false);
|
||||
pickFile(event_.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
/** Строка списка: имя, событие, эмодзи, переименование и удаление. */
|
||||
const renderRow = (sound: GuildSound) => (
|
||||
<li
|
||||
key={sound.id}
|
||||
data-testid={`guild-sound-${sound.id}`}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||
>
|
||||
<span aria-hidden="true" className="w-6 shrink-0 text-center">
|
||||
{sound.emoji ?? '🎵'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{sound.name}</span>
|
||||
{sound.event === undefined ? null : <Badge>{t(eventLabelKey(sound.event))}</Badge>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={t('settings.servers.sounds.renameLabel', { name: sound.name })}
|
||||
onClick={() => {
|
||||
rename.reset();
|
||||
setRenameValue(sound.name);
|
||||
setRenaming(sound);
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.sounds.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
aria-label={t('settings.servers.sounds.deleteLabel', { name: sound.name })}
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setRemoving(sound);
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.sounds.delete')}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.servers.sounds.title')}
|
||||
description={t('settings.servers.sounds.description', { limit: SOUND_LIMIT_PER_KIND })}
|
||||
>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{(['soundboard', 'ui'] as SoundKind[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={kind === item}
|
||||
data-testid={`guild-sounds-tab-${item}`}
|
||||
onClick={() => setKind(item)}
|
||||
className={`rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
kind === item
|
||||
? 'bg-surface-3 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
{/* Счётчик вида прямо в переключателе: «Саундборд · 2 из 30». */}
|
||||
{t(`settings.servers.sounds.kinds.${item}`)}
|
||||
{' · '}
|
||||
{t('settings.servers.sounds.counter', {
|
||||
count: item === 'soundboard' ? soundboard.length : palette.length,
|
||||
limit: SOUND_LIMIT_PER_KIND,
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-fg-muted" data-testid="guild-sounds-counter">
|
||||
{t('settings.servers.sounds.counter', {
|
||||
count: current.length,
|
||||
limit: SOUND_LIMIT_PER_KIND,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{list.isPending ? (
|
||||
<SkeletonLines className="mt-4" rows={3} />
|
||||
) : list.isError ? (
|
||||
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
|
||||
) : current.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.sounds.empty')}</p>
|
||||
) : (
|
||||
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-sounds">
|
||||
{current.map(renderRow)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4">
|
||||
<h3 className="text-sm font-semibold">{t('settings.servers.sounds.uploadTitle')}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('settings.servers.sounds.uploadHint', {
|
||||
limit: formatBytes(SOUND_MAX_BYTES, i18n.language),
|
||||
})}
|
||||
</p>
|
||||
{full ? <p className="text-xs text-warning">{t('errors.sounds.limit_reached')}</p> : null}
|
||||
|
||||
<Field
|
||||
label={t('settings.servers.sounds.name')}
|
||||
name="sound_name"
|
||||
value={name}
|
||||
maxLength={32}
|
||||
placeholder={t('settings.servers.sounds.namePlaceholder')}
|
||||
hint={t('settings.servers.sounds.nameHint')}
|
||||
onChange={(event_) => setName(event_.target.value)}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t('settings.servers.sounds.emoji')}
|
||||
name="sound_emoji"
|
||||
value={emoji}
|
||||
maxLength={8}
|
||||
placeholder={t('settings.servers.sounds.emojiPlaceholder')}
|
||||
hint={t('settings.servers.sounds.emojiHint')}
|
||||
onChange={(event_) => setEmoji(event_.target.value)}
|
||||
/>
|
||||
|
||||
{kind !== 'ui' ? null : (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('settings.servers.sounds.event')}
|
||||
</span>
|
||||
<select
|
||||
value={event}
|
||||
aria-label={t('settings.servers.sounds.event')}
|
||||
data-testid="guild-sounds-event"
|
||||
onChange={(event_) => setEvent(event_.target.value as SoundEvent)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{SOUND_EVENTS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{t(eventLabelKey(item))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={(event_) => {
|
||||
event_.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-dashed px-3 py-2 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
dragging ? 'border-accent bg-accent/10' : 'border-border/60 bg-surface-2/40'
|
||||
}`}
|
||||
data-testid="sound-dropzone"
|
||||
>
|
||||
<span className="text-fg-muted">{t('settings.servers.sounds.dropHint')}</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,.mp3,.ogg,.wav,.webm,.m4a"
|
||||
aria-label={t('settings.servers.sounds.file')}
|
||||
onChange={(event_) => {
|
||||
pickFile(event_.target.files?.[0]);
|
||||
event_.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" onClick={() => fileInputRef.current?.click()}>
|
||||
{t('settings.servers.sounds.chooseFile')}
|
||||
</Button>
|
||||
{file === null ? null : (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('settings.servers.sounds.fileSelected', {
|
||||
name: file.name,
|
||||
size: formatBytes(file.size, i18n.language),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{localError === null ? null : (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{t(`errors.${localError.kind}`)}
|
||||
</p>
|
||||
)}
|
||||
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={upload.isPending || file === null || name.trim() === '' || full}
|
||||
onClick={submit}
|
||||
>
|
||||
{t(
|
||||
upload.isPending
|
||||
? 'settings.servers.sounds.uploading'
|
||||
: 'settings.servers.sounds.upload',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={renaming !== null}
|
||||
title={t('settings.servers.sounds.renameTitle')}
|
||||
onClose={() => setRenaming(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRenaming(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={rename.isPending || !isValidSoundName(renameValue.trim())}
|
||||
onClick={() => {
|
||||
if (renaming !== null) {
|
||||
rename.mutate({ soundId: renaming.id, name: renameValue.trim() });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(rename.isPending ? 'common.saving' : 'common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
label={t('settings.servers.sounds.name')}
|
||||
name="sound_rename"
|
||||
value={renameValue}
|
||||
maxLength={32}
|
||||
hint={t('settings.servers.sounds.nameHint')}
|
||||
onChange={(event_) => setRenameValue(event_.target.value)}
|
||||
/>
|
||||
{rename.isError ? <ErrorNotice className="mt-3" error={rename.error} /> : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={removing !== null}
|
||||
title={t('settings.servers.sounds.deleteConfirmTitle')}
|
||||
onClose={() => setRemoving(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRemoving(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (removing !== null) {
|
||||
remove.mutate(removing.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.sounds.deleteConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('settings.servers.sounds.deleteConfirmBody', { name: removing?.name ?? '' })}
|
||||
</p>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls';
|
||||
import { PUSH_TO_TALK_KEYS, useVoiceStore, type PushToTalkKey } from '@/stores/voice';
|
||||
import { useDeviceChoice } from '@/lib/useMediaDevices';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
/**
|
||||
* «Аудио-видео»: выбор устройств ввода/вывода и громкости.
|
||||
* Подписи клавиш push-to-talk: `code` не зависит от раскладки, поэтому
|
||||
* хранится код, а показывается человекочитаемое имя.
|
||||
*/
|
||||
const PTT_KEY_LABELS: Record<PushToTalkKey, string> = {
|
||||
Space: 'settings.voice.pttKeys.space',
|
||||
KeyV: 'settings.voice.pttKeys.v',
|
||||
KeyB: 'settings.voice.pttKeys.b',
|
||||
KeyN: 'settings.voice.pttKeys.n',
|
||||
AltLeft: 'settings.voice.pttKeys.alt',
|
||||
ControlLeft: 'settings.voice.pttKeys.ctrl',
|
||||
};
|
||||
|
||||
/**
|
||||
* «Аудио-видео»: устройства ввода/вывода, громкости, режим активации
|
||||
* микрофона (голосовая активация или push-to-talk) и переключатели звуков
|
||||
* сервера (AGENT.md 7.13/7.14).
|
||||
*
|
||||
* Значения хранятся локально (zustand + localStorage) и применяются на лету:
|
||||
* выбранное устройство сразу переключается в активной голосовой сессии.
|
||||
@@ -20,6 +35,14 @@ export function VoiceSettingsSection() {
|
||||
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
||||
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
|
||||
const setOutputVolume = useVoiceStore((state) => state.setOutputVolume);
|
||||
const micActivation = useVoiceStore((state) => state.micActivation);
|
||||
const pttKey = useVoiceStore((state) => state.pttKey);
|
||||
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
||||
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
||||
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
||||
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
||||
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
||||
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -58,6 +81,74 @@ export function VoiceSettingsSection() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Режим активации микрофона: голосовая активация или push-to-talk. */}
|
||||
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
||||
<legend className="text-sm font-semibold">{t('settings.voice.micMode')}</legend>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{(['voice', 'push_to_talk'] as const).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="mic_activation"
|
||||
value={mode}
|
||||
checked={micActivation === mode}
|
||||
aria-label={t(`settings.voice.micModes.${mode}`)}
|
||||
onChange={() => setMicActivation(mode)}
|
||||
/>
|
||||
<span>{t(`settings.voice.micModes.${mode}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{micActivation === 'push_to_talk' ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-fg-muted">{t('settings.voice.pttKey')}</span>
|
||||
<select
|
||||
value={pttKey}
|
||||
aria-label={t('settings.voice.pttKey')}
|
||||
data-testid="ptt-key-select"
|
||||
onChange={(event) => setPttKey(event.target.value as PushToTalkKey)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
|
||||
>
|
||||
{PUSH_TO_TALK_KEYS.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{t(PTT_KEY_LABELS[key])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t('settings.voice.pttHint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
{/* Звуки сервера: палитра событий и саундборд (AGENT.md 7.13). */}
|
||||
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
||||
<legend className="text-sm font-semibold">{t('settings.voice.sounds')}</legend>
|
||||
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventSoundsEnabled}
|
||||
aria-label={t('settings.voice.eventSounds')}
|
||||
onChange={(event) => setEventSoundsEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.voice.eventSounds')}</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.eventSoundsHint')}</p>
|
||||
<label className="mt-3 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundboardEnabled}
|
||||
aria-label={t('settings.voice.soundboard')}
|
||||
onChange={(event) => setSoundboardEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.voice.soundboard')}</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
|
||||
</fieldset>
|
||||
|
||||
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
const [{ getQueryClient }, users] = await Promise.all([
|
||||
@@ -392,6 +475,12 @@ async function invalidateEmojiQueries(guildId: string): Promise<void> {
|
||||
await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
|
||||
}
|
||||
|
||||
/** Инвалидация списка звуков сервера после `GUILD_SOUNDS_UPDATE` (7.13). */
|
||||
async function invalidateSoundQueries(guildId: string): Promise<void> {
|
||||
const { getQueryClient } = await import('@/lib/queryClient');
|
||||
await getQueryClient().invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) });
|
||||
}
|
||||
|
||||
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
|
||||
function syncUrlWithSelection(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -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<SessionState>((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<SessionState>((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);
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
|
||||
function clearNoticeTimer(): void {
|
||||
if (noticeTimer !== null) {
|
||||
clearTimeout(noticeTimer);
|
||||
noticeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const useSoundboardStore = create<SoundboardState>((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();
|
||||
}
|
||||
+92
-2
@@ -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<string, number>;
|
||||
/** Режим активации микрофона (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<string, number> {
|
||||
return volumes;
|
||||
}
|
||||
|
||||
/** Значение из фиксированного списка: чужие строки отбрасываем. */
|
||||
function asChoice<T extends string>(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<VoiceState>((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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
leave: () => Promise<void>;
|
||||
setMicEnabled: (enabled: boolean) => Promise<void>;
|
||||
/**
|
||||
* Push-to-talk: включает микрофон на время удержания клавиши, не меняя
|
||||
* сохранённое предпочтение (AGENT.md 7.14). Серверный мьют приоритетнее.
|
||||
*/
|
||||
setPushToTalkActive: (active: boolean) => void;
|
||||
setDeafened: (enabled: boolean) => Promise<void>;
|
||||
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||
/** Качество публикации своего видео: `low` | `medium` | `high`. */
|
||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||
/** Статистика своей публикации для попапа «Статистика» (раз в секунду). */
|
||||
fetchStats: () => Promise<VoiceStats | null>;
|
||||
/** Смена устройства на лету: применяется к активной дорожке (AGENT.md 7.14). */
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
|
||||
@@ -162,6 +173,15 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
};
|
||||
|
||||
/** Звук события сервера играет только из активной комнаты (AGENT.md 7.13). */
|
||||
const playSound = (event: Parameters<typeof playEventSound>[1]): void => {
|
||||
const { guildId, status } = get();
|
||||
if (guildId === null || status !== 'connected') {
|
||||
return;
|
||||
}
|
||||
playEventSound(guildId, event);
|
||||
};
|
||||
|
||||
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
|
||||
const closeRoom = async (): Promise<void> => {
|
||||
const handle = room;
|
||||
@@ -184,6 +204,8 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
clearTokenTimer();
|
||||
clearWarningTimer();
|
||||
await closeRoom();
|
||||
// Звуки саундборда не должны доигрывать после выхода из комнаты.
|
||||
stopSoundboardPlayback();
|
||||
const channel = joinedChannelId;
|
||||
joinedChannelId = null;
|
||||
if (channel !== null) {
|
||||
@@ -323,6 +345,8 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((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<VoiceConnectionState>((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<VoiceConnectionState>((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<VoiceConnectionState>((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<VoiceConnectionState>((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<VoiceConnectionState>((set, get) =
|
||||
set({ error });
|
||||
}
|
||||
}
|
||||
playSound(enabled ? 'camera_on' : 'camera_off');
|
||||
await patchSelf({ camera: enabled });
|
||||
},
|
||||
|
||||
@@ -469,15 +515,43 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((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<VoiceConnectionState>((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<VoiceConnectionState>((set, get) =
|
||||
generation += 1;
|
||||
clearTokenTimer();
|
||||
clearWarningTimer();
|
||||
stopSoundboardPlayback();
|
||||
const handle = room;
|
||||
room = null;
|
||||
joinedChannelId = null;
|
||||
|
||||
@@ -91,6 +91,7 @@ const guild: SessionGuild = {
|
||||
{ id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false },
|
||||
],
|
||||
emojis: [],
|
||||
sounds: [],
|
||||
};
|
||||
|
||||
const authors: AuthorDirectory = {
|
||||
|
||||
@@ -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<string, Set<() => 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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Map<string, unknown>> {
|
||||
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<string, FakePublication>();
|
||||
|
||||
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<void> {
|
||||
/** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */
|
||||
setScreenShareEnabled(enabled: boolean, options?: unknown): Promise<void> {
|
||||
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<string, Set<Handler>>();
|
||||
|
||||
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<Handler>();
|
||||
handlers.add(handler);
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -138,6 +138,7 @@ describe('разметка: упоминания каналов', () => {
|
||||
my_permissions: [],
|
||||
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
|
||||
emojis: [],
|
||||
sounds: [],
|
||||
};
|
||||
|
||||
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
@@ -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<typeof installFetch>): 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<typeof installFetch>): 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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<VoiceRoomPanel guildId="g-1" channel={voiceChannel} />
|
||||
<SoundboardNotice />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
||||
});
|
||||
|
||||
return { sound, voice, fetchMock, audio };
|
||||
}
|
||||
|
||||
/** Открыть попап саундборда. */
|
||||
async function openSoundboard(): Promise<HTMLElement> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ vi.mock('livekit-client', async () => {
|
||||
RoomEvent: fake.RoomEvent,
|
||||
ConnectionQuality: fake.ConnectionQuality,
|
||||
Track: fake.Track,
|
||||
VideoQuality: fake.VideoQuality,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('livekit-client', async () => {
|
||||
RoomEvent: fake.RoomEvent,
|
||||
ConnectionQuality: fake.ConnectionQuality,
|
||||
Track: fake.Track,
|
||||
VideoQuality: fake.VideoQuality,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<VoiceRoomPanel guildId="g-1" channel={voiceChannel} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user