619bd91df4
Клиентская часть Фазы 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.
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
/**
|
||
* Подставной `livekit-client` для тестов: комната, участники и события под
|
||
* управлением теста. В jsdom настоящий SDK не работает (нет WebRTC), поэтому
|
||
* `vi.mock('livekit-client', () => import('./fakeLiveKit'))` подменяет его
|
||
* целиком, а логика `lib/livekit.ts` и стора проверяется настоящая.
|
||
*/
|
||
|
||
export const RoomEvent = {
|
||
ParticipantConnected: 'participantConnected',
|
||
ParticipantDisconnected: 'participantDisconnected',
|
||
TrackSubscribed: 'trackSubscribed',
|
||
TrackUnsubscribed: 'trackUnsubscribed',
|
||
TrackPublished: 'trackPublished',
|
||
TrackUnpublished: 'trackUnpublished',
|
||
TrackMuted: 'trackMuted',
|
||
TrackUnmuted: 'trackUnmuted',
|
||
LocalTrackPublished: 'localTrackPublished',
|
||
LocalTrackUnpublished: 'localTrackUnpublished',
|
||
ConnectionQualityChanged: 'connectionQualityChanged',
|
||
ActiveSpeakersChanged: 'activeSpeakersChanged',
|
||
Reconnecting: 'reconnecting',
|
||
Reconnected: 'reconnected',
|
||
Disconnected: 'disconnected',
|
||
} as const;
|
||
|
||
export const ConnectionQuality = {
|
||
Excellent: 'excellent',
|
||
Good: 'good',
|
||
Poor: 'poor',
|
||
Lost: 'lost',
|
||
Unknown: 'unknown',
|
||
} as const;
|
||
|
||
/** Уровни качества публикации: значения совпадают с `VideoQuality` из SDK. */
|
||
export const VideoQuality = {
|
||
LOW: 0,
|
||
MEDIUM: 1,
|
||
HIGH: 2,
|
||
} as const;
|
||
|
||
export const Track = {
|
||
Source: {
|
||
Camera: 'camera',
|
||
Microphone: 'microphone',
|
||
ScreenShare: 'screen_share',
|
||
ScreenShareAudio: 'screen_share_audio',
|
||
},
|
||
} as const;
|
||
|
||
/** Дорожка: помнит, к каким элементам её подключили. */
|
||
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', report: () => [string, unknown][] = () => []) {
|
||
this.kind = kind;
|
||
this.report = report;
|
||
}
|
||
|
||
attach(element: HTMLMediaElement): HTMLMediaElement {
|
||
this.attached.push(element);
|
||
return element;
|
||
}
|
||
|
||
detach(element: HTMLMediaElement): HTMLMediaElement[] {
|
||
const index = this.attached.indexOf(element);
|
||
if (index >= 0) {
|
||
this.attached.splice(index, 1);
|
||
}
|
||
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 {
|
||
constructor(
|
||
readonly source: string,
|
||
readonly track: FakeTrack,
|
||
public isMuted: boolean,
|
||
) {}
|
||
}
|
||
|
||
type Handler = (...args: never[]) => void;
|
||
|
||
/** Участник комнаты: публикации, флаги и записанные вызовы SDK. */
|
||
export class FakeParticipant {
|
||
isSpeaking = false;
|
||
connectionQuality: string = ConnectionQuality.Excellent;
|
||
identity: string;
|
||
name: string;
|
||
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(
|
||
private readonly room: FakeRoom,
|
||
identity: string,
|
||
name: string,
|
||
isLocal = false,
|
||
) {
|
||
this.identity = identity;
|
||
this.name = name;
|
||
this.isLocal = isLocal;
|
||
}
|
||
|
||
get isMicrophoneEnabled(): boolean {
|
||
return this.isLive(Track.Source.Microphone);
|
||
}
|
||
|
||
get isCameraEnabled(): boolean {
|
||
return this.isLive(Track.Source.Camera);
|
||
}
|
||
|
||
get isScreenShareEnabled(): boolean {
|
||
return this.isLive(Track.Source.ScreenShare);
|
||
}
|
||
|
||
getTrackPublication(source: string): FakePublication | undefined {
|
||
return this.publications.get(source);
|
||
}
|
||
|
||
/** Публикация дорожки: используется и для своих, и для чужих участников. */
|
||
setPublication(source: string, muted = false): FakePublication {
|
||
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);
|
||
}
|
||
|
||
setSpeaking(speaking: boolean): void {
|
||
this.isSpeaking = speaking;
|
||
}
|
||
|
||
private isLive(source: string): boolean {
|
||
const publication = this.publications.get(source);
|
||
return publication !== undefined && !publication.isMuted;
|
||
}
|
||
|
||
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 ?? this.newTrack(source);
|
||
this.publications.set(source, new FakePublication(source, track, !enabled));
|
||
this.room.emit(RoomEvent.LocalTrackPublished);
|
||
}
|
||
|
||
setMicrophoneEnabled(enabled: boolean): Promise<void> {
|
||
this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled');
|
||
return Promise.resolve();
|
||
}
|
||
|
||
setCameraEnabled(enabled: boolean): Promise<void> {
|
||
this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled');
|
||
return Promise.resolve();
|
||
}
|
||
|
||
/** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */
|
||
setScreenShareEnabled(enabled: boolean, options?: unknown): Promise<void> {
|
||
this.screenOptions.push({ enabled, options });
|
||
this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled');
|
||
return Promise.resolve();
|
||
}
|
||
}
|
||
|
||
export interface RemoteOptions {
|
||
muted?: boolean;
|
||
camera?: boolean;
|
||
screen?: boolean;
|
||
quality?: string;
|
||
}
|
||
|
||
/** Подставная комната: те же методы, что использует `lib/livekit.ts`. */
|
||
export class FakeRoom {
|
||
static instances: FakeRoom[] = [];
|
||
|
||
static reset(): void {
|
||
FakeRoom.instances = [];
|
||
}
|
||
|
||
static latest(): FakeRoom {
|
||
const room = FakeRoom.instances.at(-1);
|
||
if (room === undefined) {
|
||
throw new Error('комната LiveKit ещё не создавалась');
|
||
}
|
||
return room;
|
||
}
|
||
|
||
readonly localParticipant: FakeParticipant;
|
||
readonly remoteParticipants = new Map<string, FakeParticipant>();
|
||
readonly connectCalls: { url: string; token: string }[] = [];
|
||
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) {
|
||
FakeRoom.instances.push(this);
|
||
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);
|
||
this.handlers.set(event, handlers);
|
||
return this;
|
||
}
|
||
|
||
off(event: string, handler: Handler): this {
|
||
this.handlers.get(event)?.delete(handler);
|
||
return this;
|
||
}
|
||
|
||
removeAllListeners(): this {
|
||
this.handlers.clear();
|
||
return this;
|
||
}
|
||
|
||
connect(url: string, token: string): Promise<void> {
|
||
this.connectCalls.push({ url, token });
|
||
return Promise.resolve();
|
||
}
|
||
|
||
disconnect(): Promise<void> {
|
||
this.disconnectCalls += 1;
|
||
this.emit(RoomEvent.Disconnected);
|
||
return Promise.resolve();
|
||
}
|
||
|
||
startAudio(): Promise<void> {
|
||
this.startAudioCalls += 1;
|
||
return Promise.resolve();
|
||
}
|
||
|
||
switchActiveDevice(kind: string, deviceId: string): Promise<boolean> {
|
||
this.deviceSwitches.push({ kind, deviceId });
|
||
return Promise.resolve(true);
|
||
}
|
||
|
||
emit(event: string, ...args: unknown[]): void {
|
||
for (const handler of [...(this.handlers.get(event) ?? [])]) {
|
||
(handler as (...values: unknown[]) => void)(...args);
|
||
}
|
||
}
|
||
|
||
/** Подключает удалённого участника: как `ParticipantConnected` из SDK. */
|
||
addRemote(userId: string, name: string, options: RemoteOptions = {}): FakeParticipant {
|
||
const participant = new FakeParticipant(this, userId, name);
|
||
participant.connectionQuality = options.quality ?? ConnectionQuality.Excellent;
|
||
participant.setPublication(Track.Source.Microphone, options.muted ?? false);
|
||
if (options.camera === true) {
|
||
participant.setPublication(Track.Source.Camera);
|
||
}
|
||
if (options.screen === true) {
|
||
participant.setPublication(Track.Source.ScreenShare);
|
||
}
|
||
this.remoteParticipants.set(userId, participant);
|
||
this.emit(RoomEvent.ParticipantConnected, participant);
|
||
this.emit(RoomEvent.TrackSubscribed);
|
||
return participant;
|
||
}
|
||
|
||
removeRemote(userId: string): void {
|
||
const participant = this.remoteParticipants.get(userId);
|
||
if (participant === undefined) {
|
||
return;
|
||
}
|
||
this.remoteParticipants.delete(userId);
|
||
this.emit(RoomEvent.ParticipantDisconnected, participant);
|
||
}
|
||
|
||
/** Кто-то заговорил: `ActiveSpeakersChanged` из SDK. */
|
||
activeSpeakers(userIds: string[]): void {
|
||
const speakerIds = new Set(userIds);
|
||
this.localParticipant.setSpeaking(speakerIds.has(this.localParticipant.identity));
|
||
for (const participant of this.remoteParticipants.values()) {
|
||
participant.setSpeaking(speakerIds.has(participant.identity));
|
||
}
|
||
this.emit(RoomEvent.ActiveSpeakersChanged, []);
|
||
}
|
||
|
||
setQuality(userId: string, quality: string): void {
|
||
const participant = this.remoteParticipants.get(userId);
|
||
if (participant !== undefined) {
|
||
participant.connectionQuality = quality;
|
||
this.emit(RoomEvent.ConnectionQualityChanged, participant);
|
||
}
|
||
}
|
||
}
|