Files
glchat/web/src/stores/voiceConnection.ts
T
grendervill 3fe634c9bc feat(web): второй ряд дашборда и пинг участников в голосовой комнате
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер
  инстанса» (лимиты профиля) и «Сервер целиком» (вся машина);
- голосовая комната: рядом с качеством связи видно пинг участника
  («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT
  измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit
  (`glchat.latency`, lossy), поэтому пинг виден для каждого участника;
- статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был
  недоступен (отчёт читался только с видео);
- в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»:
  выключенный не опрашивает SDK и не публикует своё значение;
- тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника,
  публикация по служебной теме, выключенный пинг не дёргает SDK.
2026-09-20 22:10:17 +03:00

689 lines
26 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[];
/**
* Пинг участников до инстанса, мс: своё значение измеряем по статистике
* WebRTC, чужие приходят по data-каналу (AGENT.md 7.14).
*/
latencies: Record<string, number>;
/** Опубликован ли свой микрофон. */
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[],
latencies: {} as Record<string, number>,
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;
/** Остановка публикации пинга: живёт вместе с активной комнатой. */
let stopLatencyReporting: (() => void) | 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> => {
stopLatencyReporting?.();
stopLatencyReporting = null;
set({ latencies: {} });
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,
onLatency: (identity, latency) => {
if (mine !== generation) {
return;
}
set((state) => {
const latencies = { ...state.latencies };
if (latency === null) {
delete latencies[identity];
} else {
latencies[identity] = latency;
}
return { latencies };
});
},
});
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;
}
// Пинг публикуется раз в секунду и виден всем участникам комнаты.
stopLatencyReporting = handle.startLatencyReporting(
() => useVoiceStore.getState().latencySharingEnabled,
);
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 });
},
};
});