Files
glchat/web/src/stores/voiceConnection.ts
T

660 lines
25 KiB
TypeScript
Raw Normal View History

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 });
},
};
});