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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user