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; /** Опубликован ли свой микрофон. */ micEnabled: boolean; /** Локальное глушение: чужой звук не слышен. */ deafened: boolean; cameraEnabled: boolean; screenEnabled: boolean; /** Модератор выключил микрофон: кнопка заблокирована. */ serverMuted: boolean; /** Модератор выключил звук: кнопка заблокирована. */ serverDeafened: boolean; /** Идёт переиздание токена и переподключение к комнате. */ refreshing: boolean; error: unknown; speakingWhileMuted: boolean; join: (guildId: string, channelId: string) => Promise; leave: () => Promise; setMicEnabled: (enabled: boolean) => Promise; /** * Push-to-talk: включает микрофон на время удержания клавиши, не меняя * сохранённое предпочтение (AGENT.md 7.14). Серверный мьют приоритетнее. */ setPushToTalkActive: (active: boolean) => void; setDeafened: (enabled: boolean) => Promise; setCameraEnabled: (enabled: boolean) => Promise; setScreenShareEnabled: (enabled: boolean) => Promise; /** Качество публикации своего видео: `low` | `medium` | `high`. */ setVideoQuality: (quality: VoicePublishQuality) => Promise; /** Статистика своей публикации для попапа «Статистика» (раз в секунду). */ fetchStats: () => Promise; /** Смена устройства на лету: применяется к активной дорожке (AGENT.md 7.14). */ switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise; /** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */ setVolume: (userId: string, volume: number) => void; /** Серверный мьют участника: право `MUTE_MEMBERS` (модерация без входа в комнату). */ setParticipantMuted: (guildId: string, userId: string, muted: boolean) => Promise; /** Серверное глушение участника: право `DEAFEN_MEMBERS`. */ setParticipantDeafened: (guildId: string, userId: string, deafened: boolean) => Promise; /** Перемещение участника: право `MOVE_MEMBERS`. */ moveParticipant: (guildId: string, userId: string, channelId: string) => Promise; /** Переносит серверные флаги (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, 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 | null = null; let warningTimer: ReturnType | 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((set, get) => { /** PATCH своих флагов: сервер знает о нас, только пока мы в комнате. */ const patchSelf = async (patch: VoiceSelfPatch): Promise => { 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 => { 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[1]): void => { const { guildId, status } = get(); if (guildId === null || status !== 'connected') { return; } playEventSound(guildId, event); }; /** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */ const closeRoom = async (): Promise => { 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 => { 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 => { 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 => { 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 }); }, }; });