Files
glchat/web/src/stores/voiceConnection.ts
T
grendervill 619bd91df4 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.
2026-09-20 19:30:43 +03:00

660 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { create } from 'zustand';
import {
joinVoiceChannel,
leaveVoiceChannel,
moderateVoiceState,
moveVoiceMember,
updateMyVoiceState,
type VoiceSelfPatch,
} from '@/api/voice';
import { describeUnknown } from '@/lib/http';
import {
createVoiceRoom,
tokenExpiresAt,
type VoiceParticipant,
type VoiceRoomHandle,
type VoiceStats,
} from '@/lib/livekit';
import { myVoiceState, useSessionStore } from '@/stores/session';
import { playEventSound, stopSoundboardPlayback } from '@/stores/soundboard';
import { useVoiceStore, type VoiceDeviceKind, type VoicePublishQuality } from '@/stores/voice';
/**
* Голосовая сессия LiveKit: подключение к комнате, публикация своих дорожек,
* список участников с индикацией речи и качества связи, модерация.
*
* Кто в какой комнате — знает стор сессий (`voiceStates` из READY, событий
* `VOICE_STATE_UPDATE` и ответов голосовых ручек). Здесь только медиа-сессия:
* микрофон/камера/экран, громкости и переподключения. Список участников
* комнаты приходит из SDK и обогащает серверные данные (речь, качество,
* дорожки), но не заменяет их.
*/
export type VoiceConnectionStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
/** За сколько до истечения токена запрашиваем новый (AGENT.md §7.14: TTL 10 минут). */
export const TOKEN_REFRESH_LEAD_MS = 60_000;
/** Минимальная задержка переиздания: короткие токены не крутят цикл мгновенно. */
export const TOKEN_REFRESH_MIN_DELAY_MS = 1_000;
/** Сколько раз подряд пробуем переподключиться с новым токеном. */
export const TOKEN_REFRESH_ATTEMPTS = 3;
/** Подсказка «вы говорите с выключенным микрофоном» — не чаще раза в 5 секунд. */
export const SPEAKING_WARNING_INTERVAL_MS = 5_000;
/** Сколько держится подсказка на экране. */
export const SPEAKING_WARNING_VISIBLE_MS = 4_000;
/** Код ошибки, когда не удалось продлить голосовую сессию. */
export const VOICE_SESSION_EXPIRED_CODE = 'voice.token_refresh_failed';
export interface VoiceConnectionState {
status: VoiceConnectionStatus;
guildId: string | null;
channelId: string | null;
/** Участники комнаты глазами SDK: речь, качество, дорожки. */
participants: VoiceParticipant[];
/** Опубликован ли свой микрофон. */
micEnabled: boolean;
/** Локальное глушение: чужой звук не слышен. */
deafened: boolean;
cameraEnabled: boolean;
screenEnabled: boolean;
/** Модератор выключил микрофон: кнопка заблокирована. */
serverMuted: boolean;
/** Модератор выключил звук: кнопка заблокирована. */
serverDeafened: boolean;
/** Идёт переиздание токена и переподключение к комнате. */
refreshing: boolean;
error: unknown;
speakingWhileMuted: boolean;
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: хранится в предпочтениях клиента. */
setVolume: (userId: string, volume: number) => void;
/** Серверный мьют участника: право `MUTE_MEMBERS` (модерация без входа в комнату). */
setParticipantMuted: (guildId: string, userId: string, muted: boolean) => Promise<void>;
/** Серверное глушение участника: право `DEAFEN_MEMBERS`. */
setParticipantDeafened: (guildId: string, userId: string, deafened: boolean) => Promise<void>;
/** Перемещение участника: право `MOVE_MEMBERS`. */
moveParticipant: (guildId: string, userId: string, channelId: string) => Promise<void>;
/** Переносит серверные флаги (server_mute/server_deaf) в локальную сессию. */
syncServerState: () => void;
dismissSpeakingWarning: () => void;
/** Тихая очистка без запросов: сессия мертва (INVALID_SESSION, выход). */
reset: () => void;
}
const defaults = {
status: 'idle' as VoiceConnectionStatus,
guildId: null,
channelId: null,
participants: [] as VoiceParticipant[],
micEnabled: true,
deafened: false,
cameraEnabled: false,
screenEnabled: false,
serverMuted: false,
serverDeafened: false,
refreshing: false,
error: null as unknown,
speakingWhileMuted: false,
};
/** Активная комната SDK. Живёт вне стора: подписки и таймеры не сериализуются. */
let room: VoiceRoomHandle | null = null;
/** Канал, о котором уже знает сервер: только для него шлём PATCH и leave. */
let joinedChannelId: string | null = null;
/** Номер подключения: ответы устаревших операций не должны менять состояние. */
let generation = 0;
let tokenTimer: ReturnType<typeof setTimeout> | null = null;
let warningTimer: ReturnType<typeof setTimeout> | null = null;
let lastWarningAt = 0;
/** Разрыв инициировали мы сами (leave или переиздание токена). */
let plannedDisconnect = false;
function clearTokenTimer(): void {
if (tokenTimer !== null) {
clearTimeout(tokenTimer);
tokenTimer = null;
}
}
function clearWarningTimer(): void {
if (warningTimer !== null) {
clearTimeout(warningTimer);
warningTimer = null;
}
}
export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) => {
/** PATCH своих флагов: сервер знает о нас, только пока мы в комнате. */
const patchSelf = async (patch: VoiceSelfPatch): Promise<void> => {
const guildId = get().guildId;
if (guildId === null || joinedChannelId === null) {
return;
}
try {
const states = await updateMyVoiceState(guildId, patch);
useSessionStore.getState().setGuildVoiceStates(guildId, states);
} catch (error) {
// Флаг не доехал до сервера: у остальных он «мигнёт» позже, но сессия жива.
console.warn(`voice: не удалось обновить состояние (${describeUnknown(error)})`);
}
};
/** Публикация микрофона без изменения предпочтений (server mute). */
const applyMic = async (enabled: boolean): Promise<void> => {
set({ micEnabled: enabled });
const handle = room;
if (handle === null) {
return;
}
try {
await handle.setMicrophoneEnabled(enabled);
set({ participants: handle.participants() });
} catch (error) {
set({ error });
}
};
/** Звук события сервера играет только из активной комнаты (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;
room = null;
if (handle === null) {
return;
}
plannedDisconnect = true;
try {
await handle.disconnect();
} catch {
// Комната могла уже отвалиться — это не ошибка.
}
handle.dispose();
plannedDisconnect = false;
};
/** Модератор или сеть оборвали сессию: возвращаемся в idle и сообщаем серверу. */
const finishSession = async (): Promise<void> => {
clearTokenTimer();
clearWarningTimer();
await closeRoom();
// Звуки саундборда не должны доигрывать после выхода из комнаты.
stopSoundboardPlayback();
const channel = joinedChannelId;
joinedChannelId = null;
if (channel !== null) {
try {
await leaveVoiceChannel(channel);
} catch {
// Комната уже могла быть закрыта: состояние на сервере поправит webhook.
}
}
set({ ...defaults });
};
/** Не удалось продлить сессию: выходим из комнаты и показываем ошибку. */
const failRefresh = async (guildId: string, channelId: string): Promise<void> => {
clearTokenTimer();
await closeRoom();
const channel = joinedChannelId;
joinedChannelId = null;
if (channel !== null) {
try {
await leaveVoiceChannel(channel);
} catch {
// Состояние на сервере очистится по таймауту участника.
}
}
set({
...defaults,
// Комнату оставляем в состоянии: так подсказка видна у канала и в панели.
status: 'error',
guildId,
channelId,
error: { code: VOICE_SESSION_EXPIRED_CODE },
});
};
/**
* Переиздание токена: сервер выдаёт его на 10 минут, поэтому задолго до
* истечения просим новый и переподключаемся, сохраняя флаги.
*/
const refreshToken = async (current: number): Promise<void> => {
const channelId = get().channelId;
const guildId = get().guildId;
if (channelId === null || guildId === null || current !== generation) {
return;
}
set({ refreshing: true });
for (let attempt = 1; attempt <= TOKEN_REFRESH_ATTEMPTS; attempt += 1) {
try {
const result = await joinVoiceChannel(channelId);
if (current !== generation) {
return;
}
const handle = room;
if (handle === null) {
set({ refreshing: false });
return;
}
// Переподключаемся тем же объектом комнаты: подписки на события целы.
plannedDisconnect = true;
try {
await handle.disconnect();
} finally {
plannedDisconnect = false;
}
await handle.connect(result.url, result.token);
if (current !== generation) {
return;
}
useSessionStore.getState().applyVoiceState(result.state);
const { micEnabled, cameraEnabled, screenEnabled, serverMuted, deafened } = get();
await handle.setMicrophoneEnabled(micEnabled && !serverMuted);
await handle.setCameraEnabled(cameraEnabled);
await handle.setScreenShareEnabled(screenEnabled);
set({
status: 'connected',
refreshing: false,
participants: handle.participants(),
});
scheduleTokenRefresh(result.token, current);
// Сервер уже знает о нас из join — подтверждаем флаги, чтобы они не «мигали».
await patchSelf({
self_mute: !micEnabled,
self_deaf: deafened,
camera: cameraEnabled,
screen: screenEnabled,
});
return;
} catch {
if (attempt === TOKEN_REFRESH_ATTEMPTS) {
await failRefresh(guildId, channelId);
return;
}
}
}
};
const scheduleTokenRefresh = (token: string, current: number): void => {
clearTokenTimer();
const now = Date.now();
const expiresAt = tokenExpiresAt(token, now);
const delay = Math.max(TOKEN_REFRESH_MIN_DELAY_MS, expiresAt - now - TOKEN_REFRESH_LEAD_MS);
tokenTimer = setTimeout(() => {
tokenTimer = null;
void refreshToken(current);
}, delay);
};
/** Подсказка о речи при выключенном микрофоне: не чаще раза в 5 секунд. */
const warnSpeakingWhileMuted = (): void => {
const now = Date.now();
if (now - lastWarningAt < SPEAKING_WARNING_INTERVAL_MS) {
return;
}
lastWarningAt = now;
clearWarningTimer();
set({ speakingWhileMuted: true });
warningTimer = setTimeout(() => {
warningTimer = null;
set({ speakingWhileMuted: false });
}, SPEAKING_WARNING_VISIBLE_MS);
};
return {
...defaults,
join: async (guildId, channelId) => {
const current = get();
if (
current.channelId === channelId &&
(current.status === 'connected' ||
current.status === 'connecting' ||
current.status === 'reconnecting')
) {
return;
}
if (current.channelId !== null) {
await get().leave();
}
const preferences = useVoiceStore.getState();
// В режиме push-to-talk микрофон до нажатия клавиши выключен.
const pushToTalk = preferences.micActivation === 'push_to_talk';
const mine = (generation += 1);
set({
status: 'connecting',
guildId,
channelId,
error: null,
participants: [],
micEnabled: preferences.micEnabled && !pushToTalk,
deafened: !preferences.headphonesEnabled,
cameraEnabled: preferences.cameraEnabled,
screenEnabled: false,
serverMuted: false,
serverDeafened: false,
refreshing: false,
});
try {
const result = await joinVoiceChannel(channelId);
if (mine !== generation) {
// Пока шёл запрос, пользователь ушёл или переключился в другую комнату.
void leaveVoiceChannel(channelId).catch(() => undefined);
return;
}
joinedChannelId = channelId;
useSessionStore.getState().applyVoiceState(result.state);
scheduleTokenRefresh(result.token, mine);
const handle = await createVoiceRoom({
onParticipants: (participants) => {
if (mine === generation) {
set({ participants });
}
},
onStatus: (status) => {
if (mine !== generation || plannedDisconnect) {
return;
}
if (status === 'reconnecting') {
set({ status: 'reconnecting' });
return;
}
if (status === 'reconnected') {
set({ status: 'connected' });
return;
}
// SDK исчерпал попытки переподключения — выходим из комнаты.
void finishSession();
},
onSpeakingWhileMuted: warnSpeakingWhileMuted,
});
if (mine !== generation) {
handle.dispose();
return;
}
room = handle;
await handle.connect(result.url, result.token);
if (mine !== generation) {
handle.dispose();
if (room === handle) {
room = null;
}
return;
}
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) {
clearTokenTimer();
const handle = room;
room = null;
handle?.dispose();
// Сервер уже записал наше состояние (join прошёл) — сообщаем об уходе.
if (joinedChannelId === channelId) {
joinedChannelId = null;
void leaveVoiceChannel(channelId).catch(() => undefined);
}
set({
status: 'error',
error,
participants: [],
guildId,
channelId,
refreshing: false,
});
}
},
leave: async () => {
generation += 1;
clearTokenTimer();
clearWarningTimer();
const guildId = get().guildId;
const me = useSessionStore.getState().user?.id ?? null;
await closeRoom();
stopSoundboardPlayback();
const channel = joinedChannelId;
joinedChannelId = null;
if (guildId !== null && me !== null) {
// Своё состояние убираем сразу: событие шлюза может прийти позже.
useSessionStore.getState().applyVoiceLeave(guildId, me);
}
set({ ...defaults });
if (channel !== null) {
try {
await leaveVoiceChannel(channel);
} catch (error) {
console.warn(`voice: не удалось выйти из комнаты (${describeUnknown(error)})`);
}
}
},
setMicEnabled: async (enabled) => {
if (get().serverMuted && enabled) {
return;
}
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 });
await patchSelf({ self_deaf: enabled });
},
setCameraEnabled: async (enabled) => {
useVoiceStore.getState().setCameraEnabled(enabled);
set({ cameraEnabled: enabled });
const handle = room;
if (handle !== null) {
try {
await handle.setCameraEnabled(enabled);
set({ participants: handle.participants() });
} catch (error) {
set({ error });
}
}
playSound(enabled ? 'camera_on' : 'camera_off');
await patchSelf({ camera: enabled });
},
setScreenShareEnabled: async (enabled) => {
set({ screenEnabled: enabled });
const handle = room;
if (handle !== null) {
try {
// Разрешение и 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);
},
switchActiveDevice: async (kind, deviceId) => {
const handle = room;
if (handle === null) {
return;
}
try {
await handle.switchActiveDevice(kind, deviceId);
} catch (error) {
// Устройство могло исчезнуть (отключили гарнитуру) — сессия продолжается.
console.warn(`voice: не удалось переключить устройство (${describeUnknown(error)})`);
}
},
setParticipantMuted: async (guildId, userId, muted) => {
try {
const states = await moderateVoiceState(guildId, userId, { server_mute: muted });
useSessionStore.getState().setGuildVoiceStates(guildId, states);
get().syncServerState();
} catch (error) {
set({ error });
}
},
setParticipantDeafened: async (guildId, userId, deafened) => {
try {
const states = await moderateVoiceState(guildId, userId, { server_deaf: deafened });
useSessionStore.getState().setGuildVoiceStates(guildId, states);
get().syncServerState();
} catch (error) {
set({ error });
}
},
moveParticipant: async (guildId, userId, channelId) => {
try {
const states = await moveVoiceMember(guildId, userId, channelId);
useSessionStore.getState().setGuildVoiceStates(guildId, states);
get().syncServerState();
} catch (error) {
set({ error });
}
},
syncServerState: () => {
const { guildId, channelId, status, micEnabled, deafened } = get();
const session = useSessionStore.getState();
const me = session.user?.id ?? null;
const state = myVoiceState(session.voiceStates, guildId, me);
if (state === null) {
return;
}
const serverMuted = state.server_mute;
const serverDeafened = state.server_deaf;
const wasServerMuted = get().serverMuted;
if (serverMuted !== wasServerMuted || serverDeafened !== get().serverDeafened) {
set({ serverMuted, serverDeafened });
// Модератор только что выключил нам микрофон — звук события «mute».
if (serverMuted && !wasServerMuted) {
playSound('mute');
}
}
// Модератор выключил микрофон — гасим публикацию, предпочтение не трогаем.
if (serverMuted && micEnabled) {
void applyMic(false);
}
if (serverDeafened && !deafened) {
set({ deafened: true });
}
// Нас переместили в другую комнату: переподключаемся туда.
if (status === 'connected' && channelId !== null && state.channel_id !== channelId) {
void get().join(guildId ?? '', state.channel_id);
}
},
dismissSpeakingWarning: () => {
clearWarningTimer();
set({ speakingWhileMuted: false });
},
reset: () => {
generation += 1;
clearTokenTimer();
clearWarningTimer();
stopSoundboardPlayback();
const handle = room;
room = null;
joinedChannelId = null;
if (handle !== null) {
plannedDisconnect = true;
void handle
.disconnect()
.catch(() => undefined)
.finally(() => {
handle.dispose();
plannedDisconnect = false;
});
}
set({ ...defaults });
},
};
});