2026-09-20 18:38:54 +03:00
|
|
|
|
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,
|
2026-09-20 19:30:43 +03:00
|
|
|
|
type VoiceStats,
|
2026-09-20 18:38:54 +03:00
|
|
|
|
} from '@/lib/livekit';
|
|
|
|
|
|
import { myVoiceState, useSessionStore } from '@/stores/session';
|
2026-09-20 19:30:43 +03:00
|
|
|
|
import { playEventSound, stopSoundboardPlayback } from '@/stores/soundboard';
|
|
|
|
|
|
import { useVoiceStore, type VoiceDeviceKind, type VoicePublishQuality } from '@/stores/voice';
|
2026-09-20 18:38:54 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Голосовая сессия 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[];
|
2026-09-20 22:10:17 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Пинг участников до инстанса, мс: своё значение измеряем по статистике
|
|
|
|
|
|
* WebRTC, чужие приходят по data-каналу (AGENT.md 7.14).
|
|
|
|
|
|
*/
|
|
|
|
|
|
latencies: Record<string, number>;
|
2026-09-20 18:38:54 +03:00
|
|
|
|
/** Опубликован ли свой микрофон. */
|
|
|
|
|
|
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>;
|
2026-09-20 19:30:43 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Push-to-talk: включает микрофон на время удержания клавиши, не меняя
|
|
|
|
|
|
* сохранённое предпочтение (AGENT.md 7.14). Серверный мьют приоритетнее.
|
|
|
|
|
|
*/
|
|
|
|
|
|
setPushToTalkActive: (active: boolean) => void;
|
2026-09-20 18:38:54 +03:00
|
|
|
|
setDeafened: (enabled: boolean) => Promise<void>;
|
|
|
|
|
|
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
|
|
|
|
|
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
2026-09-20 19:30:43 +03:00
|
|
|
|
/** Качество публикации своего видео: `low` | `medium` | `high`. */
|
|
|
|
|
|
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
|
|
|
|
|
/** Статистика своей публикации для попапа «Статистика» (раз в секунду). */
|
|
|
|
|
|
fetchStats: () => Promise<VoiceStats | null>;
|
2026-09-20 18:38:54 +03:00
|
|
|
|
/** Смена устройства на лету: применяется к активной дорожке (AGENT.md 7.14). */
|
|
|
|
|
|
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
|
|
|
|
|
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
|
|
|
|
|
|
setVolume: (userId: string, volume: number) => void;
|
2026-09-20 18:45:14 +03:00
|
|
|
|
/** Серверный мьют участника: право `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>;
|
2026-09-20 18:38:54 +03:00
|
|
|
|
/** Переносит серверные флаги (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[],
|
2026-09-20 22:10:17 +03:00
|
|
|
|
latencies: {} as Record<string, number>,
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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;
|
2026-09-20 22:10:17 +03:00
|
|
|
|
/** Остановка публикации пинга: живёт вместе с активной комнатой. */
|
|
|
|
|
|
let stopLatencyReporting: (() => void) | null = null;
|
2026-09-20 18:38:54 +03:00
|
|
|
|
/** Канал, о котором уже знает сервер: только для него шлём 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 19:30:43 +03:00
|
|
|
|
/** Звук события сервера играет только из активной комнаты (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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 18:38:54 +03:00
|
|
|
|
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
|
|
|
|
|
|
const closeRoom = async (): Promise<void> => {
|
2026-09-20 22:10:17 +03:00
|
|
|
|
stopLatencyReporting?.();
|
|
|
|
|
|
stopLatencyReporting = null;
|
|
|
|
|
|
set({ latencies: {} });
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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();
|
2026-09-20 19:30:43 +03:00
|
|
|
|
// Звуки саундборда не должны доигрывать после выхода из комнаты.
|
|
|
|
|
|
stopSoundboardPlayback();
|
2026-09-20 18:38:54 +03:00
|
|
|
|
const channel = joinedChannelId;
|
|
|
|
|
|
joinedChannelId = null;
|
|
|
|
|
|
if (channel !== null) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await leaveVoiceChannel(channel);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Комната уже могла быть закрыта: состояние на сервере поправит webhook.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
set({ ...defaults });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** Не удалось продлить сессию: выходим из комнаты и показываем ошибку. */
|
2026-09-20 18:51:31 +03:00
|
|
|
|
const failRefresh = async (guildId: string, channelId: string): Promise<void> => {
|
2026-09-20 18:38:54 +03:00
|
|
|
|
clearTokenTimer();
|
|
|
|
|
|
await closeRoom();
|
|
|
|
|
|
const channel = joinedChannelId;
|
|
|
|
|
|
joinedChannelId = null;
|
|
|
|
|
|
if (channel !== null) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await leaveVoiceChannel(channel);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Состояние на сервере очистится по таймауту участника.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
set({
|
|
|
|
|
|
...defaults,
|
2026-09-20 18:51:31 +03:00
|
|
|
|
// Комнату оставляем в состоянии: так подсказка видна у канала и в панели.
|
2026-09-20 18:38:54 +03:00
|
|
|
|
status: 'error',
|
2026-09-20 18:51:31 +03:00
|
|
|
|
guildId,
|
|
|
|
|
|
channelId,
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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) {
|
2026-09-20 18:51:31 +03:00
|
|
|
|
await failRefresh(guildId, channelId);
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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();
|
2026-09-20 19:30:43 +03:00
|
|
|
|
// В режиме push-to-talk микрофон до нажатия клавиши выключен.
|
|
|
|
|
|
const pushToTalk = preferences.micActivation === 'push_to_talk';
|
2026-09-20 18:38:54 +03:00
|
|
|
|
const mine = (generation += 1);
|
|
|
|
|
|
set({
|
|
|
|
|
|
status: 'connecting',
|
|
|
|
|
|
guildId,
|
|
|
|
|
|
channelId,
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
participants: [],
|
2026-09-20 19:30:43 +03:00
|
|
|
|
micEnabled: preferences.micEnabled && !pushToTalk,
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-09-20 18:51:31 +03:00
|
|
|
|
const handle = await createVoiceRoom({
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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,
|
2026-09-20 22:10:17 +03:00
|
|
|
|
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 };
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-09-20 18:38:54 +03:00
|
|
|
|
});
|
2026-09-20 18:51:31 +03:00
|
|
|
|
if (mine !== generation) {
|
|
|
|
|
|
handle.dispose();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-20 18:38:54 +03:00
|
|
|
|
room = handle;
|
|
|
|
|
|
await handle.connect(result.url, result.token);
|
|
|
|
|
|
if (mine !== generation) {
|
|
|
|
|
|
handle.dispose();
|
|
|
|
|
|
if (room === handle) {
|
|
|
|
|
|
room = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-20 22:10:17 +03:00
|
|
|
|
// Пинг публикуется раз в секунду и виден всем участникам комнаты.
|
|
|
|
|
|
stopLatencyReporting = handle.startLatencyReporting(
|
|
|
|
|
|
() => useVoiceStore.getState().latencySharingEnabled,
|
|
|
|
|
|
);
|
2026-09-20 19:30:43 +03:00
|
|
|
|
await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk);
|
2026-09-20 18:38:54 +03:00
|
|
|
|
await handle.setCameraEnabled(preferences.cameraEnabled);
|
2026-09-20 19:30:43 +03:00
|
|
|
|
// Качество публикации — из предпочтений клиента.
|
|
|
|
|
|
await handle.setVideoQuality(preferences.videoQuality);
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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();
|
2026-09-20 19:30:43 +03:00
|
|
|
|
stopSoundboardPlayback();
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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);
|
2026-09-20 19:30:43 +03:00
|
|
|
|
playSound(enabled ? 'mic_on' : 'mic_off');
|
2026-09-20 18:38:54 +03:00
|
|
|
|
await patchSelf({ self_mute: !enabled });
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 19:30:43 +03:00
|
|
|
|
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 });
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-20 19:30:43 +03:00
|
|
|
|
playSound(enabled ? 'camera_on' : 'camera_off');
|
2026-09-20 18:38:54 +03:00
|
|
|
|
await patchSelf({ camera: enabled });
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
setScreenShareEnabled: async (enabled) => {
|
|
|
|
|
|
set({ screenEnabled: enabled });
|
|
|
|
|
|
const handle = room;
|
|
|
|
|
|
if (handle !== null) {
|
|
|
|
|
|
try {
|
2026-09-20 19:30:43 +03:00
|
|
|
|
// Разрешение и fps берём из выбранного пресета (AGENT.md 7.14).
|
|
|
|
|
|
await handle.setScreenShareEnabled(enabled, useVoiceStore.getState().screenSharePreset);
|
2026-09-20 18:38:54 +03:00
|
|
|
|
set({ participants: handle.participants() });
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
set({ error });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-20 19:30:43 +03:00
|
|
|
|
playSound(enabled ? 'screen_on' : 'screen_off');
|
2026-09-20 18:38:54 +03:00
|
|
|
|
await patchSelf({ screen: enabled });
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 19:30:43 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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)})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 18:45:14 +03:00
|
|
|
|
setParticipantMuted: async (guildId, userId, muted) => {
|
2026-09-20 18:38:54 +03:00
|
|
|
|
try {
|
|
|
|
|
|
const states = await moderateVoiceState(guildId, userId, { server_mute: muted });
|
|
|
|
|
|
useSessionStore.getState().setGuildVoiceStates(guildId, states);
|
|
|
|
|
|
get().syncServerState();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
set({ error });
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 18:45:14 +03:00
|
|
|
|
setParticipantDeafened: async (guildId, userId, deafened) => {
|
2026-09-20 18:38:54 +03:00
|
|
|
|
try {
|
|
|
|
|
|
const states = await moderateVoiceState(guildId, userId, { server_deaf: deafened });
|
|
|
|
|
|
useSessionStore.getState().setGuildVoiceStates(guildId, states);
|
|
|
|
|
|
get().syncServerState();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
set({ error });
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-09-20 18:45:14 +03:00
|
|
|
|
moveParticipant: async (guildId, userId, channelId) => {
|
2026-09-20 18:38:54 +03:00
|
|
|
|
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;
|
2026-09-20 19:30:43 +03:00
|
|
|
|
const wasServerMuted = get().serverMuted;
|
|
|
|
|
|
if (serverMuted !== wasServerMuted || serverDeafened !== get().serverDeafened) {
|
2026-09-20 18:38:54 +03:00
|
|
|
|
set({ serverMuted, serverDeafened });
|
2026-09-20 19:30:43 +03:00
|
|
|
|
// Модератор только что выключил нам микрофон — звук события «mute».
|
|
|
|
|
|
if (serverMuted && !wasServerMuted) {
|
|
|
|
|
|
playSound('mute');
|
|
|
|
|
|
}
|
2026-09-20 18:38:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
// Модератор выключил микрофон — гасим публикацию, предпочтение не трогаем.
|
|
|
|
|
|
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();
|
2026-09-20 19:30:43 +03:00
|
|
|
|
stopSoundboardPlayback();
|
2026-09-20 18:38:54 +03:00
|
|
|
|
const handle = room;
|
|
|
|
|
|
room = null;
|
|
|
|
|
|
joinedChannelId = null;
|
|
|
|
|
|
if (handle !== null) {
|
|
|
|
|
|
plannedDisconnect = true;
|
|
|
|
|
|
void handle
|
|
|
|
|
|
.disconnect()
|
|
|
|
|
|
.catch(() => undefined)
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
handle.dispose();
|
|
|
|
|
|
plannedDisconnect = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
set({ ...defaults });
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|