fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
@@ -30,6 +30,8 @@ import {
|
||||
import { useFriendsStore } from '@/stores/friends';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { parseVoiceStateEvent } from '@/api/voice';
|
||||
|
||||
/**
|
||||
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
||||
@@ -78,6 +80,8 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
useSessionStore.getState().reset();
|
||||
// Сессия мертва: лента сообщений тоже больше не наша.
|
||||
useMessagesStore.getState().reset();
|
||||
// Голосовую комнату покидаем без запроса: сессии уже нет.
|
||||
useVoiceConnectionStore.getState().reset();
|
||||
void invalidateCurrentUser();
|
||||
},
|
||||
onResumeIncomplete: () => {
|
||||
@@ -309,6 +313,21 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'VOICE_STATE_UPDATE': {
|
||||
// Лёг из комнаты или сменил флаги: единственный источник «кто в голосе».
|
||||
const voice = parseVoiceStateEvent(event.d);
|
||||
if (voice === null) {
|
||||
return;
|
||||
}
|
||||
if (voice.kind === 'left') {
|
||||
session.applyVoiceLeave(voice.guildId, voice.userId);
|
||||
} else {
|
||||
session.applyVoiceState(voice.state);
|
||||
}
|
||||
// server_mute/server_deaf и перемещение себя применяем к своей сессии.
|
||||
useVoiceConnectionStore.getState().syncServerState();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// Неизвестные события игнорируем: контракт может расширяться.
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
||||
import type { GuildEmoji } from '@/api/emojis';
|
||||
import { fetchDirectChannels } from '@/api/friends';
|
||||
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
||||
import type { VoiceState } from '@/api/voice';
|
||||
import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,12 @@ interface SessionState {
|
||||
user: GatewayUser | null;
|
||||
guilds: SessionGuild[];
|
||||
dmChannels: Channel[];
|
||||
/**
|
||||
* Кто в голосовых комнатах: единственный источник правды для интерфейса.
|
||||
* Наполняется снапшотом READY, событиями `VOICE_STATE_UPDATE` и ответами
|
||||
* голосовых ручек (AGENT.md 7.14).
|
||||
*/
|
||||
voiceStates: Record<string, VoiceState[]>;
|
||||
sessionId: string | null;
|
||||
selectedGuildId: string | null;
|
||||
selectedChannelId: string | null;
|
||||
@@ -45,6 +52,12 @@ interface SessionState {
|
||||
removeGuild: (guildId: string) => void;
|
||||
setGuildChannels: (guildId: string, channels: Channel[]) => void;
|
||||
upsertChannel: (guildId: string, channel: Channel) => void;
|
||||
/** Список голосовых состояний сервера целиком (READY или REST). */
|
||||
setGuildVoiceStates: (guildId: string, states: VoiceState[]) => void;
|
||||
/** `VOICE_STATE_UPDATE` с состоянием участника: заменяем запись по user_id. */
|
||||
applyVoiceState: (state: VoiceState) => void;
|
||||
/** `VOICE_STATE_UPDATE` с `left: true`: убираем участника из комнаты. */
|
||||
applyVoiceLeave: (guildId: string, userId: string) => void;
|
||||
/**
|
||||
* Набор эмодзи сервера целиком: READY и `GUILD_EMOJIS_UPDATE` шлют его
|
||||
* одним и тем же списком.
|
||||
@@ -78,6 +91,7 @@ interface SessionDefaults {
|
||||
user: null;
|
||||
guilds: SessionGuild[];
|
||||
dmChannels: Channel[];
|
||||
voiceStates: Record<string, VoiceState[]>;
|
||||
sessionId: null;
|
||||
selectedGuildId: null;
|
||||
selectedChannelId: null;
|
||||
@@ -88,6 +102,7 @@ const defaults: SessionDefaults = {
|
||||
user: null,
|
||||
guilds: [],
|
||||
dmChannels: [],
|
||||
voiceStates: {},
|
||||
sessionId: null,
|
||||
selectedGuildId: null,
|
||||
selectedChannelId: null,
|
||||
@@ -139,6 +154,13 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
user: snapshot.user,
|
||||
guilds,
|
||||
dmChannels: snapshot.dm_channels,
|
||||
// READY несёт `guilds[].voice_states`: кто где сидит на момент входа.
|
||||
voiceStates: snapshot.guilds.reduce<Record<string, VoiceState[]>>((states, guild) => {
|
||||
if (guild.voice_states.length > 0) {
|
||||
states[guild.id] = guild.voice_states;
|
||||
}
|
||||
return states;
|
||||
}, {}),
|
||||
sessionId: snapshot.session_id,
|
||||
selectedGuildId,
|
||||
selectedChannelId,
|
||||
@@ -228,8 +250,10 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
const selectionLost = state.selectedGuildId === guildId;
|
||||
// Если удалили выбранный сервер — выбираем первый оставшийся.
|
||||
const nextGuild = selectionLost ? (guilds[0] ?? null) : null;
|
||||
const { [guildId]: _removed, ...voiceStates } = state.voiceStates;
|
||||
set({
|
||||
guilds,
|
||||
voiceStates,
|
||||
selectedGuildId: selectionLost ? (nextGuild?.id ?? null) : state.selectedGuildId,
|
||||
selectedChannelId: selectionLost
|
||||
? nextGuild === null
|
||||
@@ -262,6 +286,39 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
setGuildVoiceStates: (guildId, states) => {
|
||||
set({ voiceStates: { ...get().voiceStates, [guildId]: states } });
|
||||
},
|
||||
|
||||
applyVoiceState: (state) => {
|
||||
const guildId = state.guild_id;
|
||||
if (guildId === '') {
|
||||
return;
|
||||
}
|
||||
const current = get().voiceStates[guildId] ?? [];
|
||||
const index = current.findIndex((item) => item.user_id === state.user_id);
|
||||
const states = [...current];
|
||||
if (index === -1) {
|
||||
states.push(state);
|
||||
} else {
|
||||
states[index] = state;
|
||||
}
|
||||
set({ voiceStates: { ...get().voiceStates, [guildId]: states } });
|
||||
},
|
||||
|
||||
applyVoiceLeave: (guildId, userId) => {
|
||||
const current = get().voiceStates[guildId];
|
||||
if (current === undefined) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
voiceStates: {
|
||||
...get().voiceStates,
|
||||
[guildId]: current.filter((state) => state.user_id !== userId),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setGuildEmojis: (guildId, emojis) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, emojis } : guild)),
|
||||
@@ -394,3 +451,31 @@ export function selectGuildById(guildId: string | null) {
|
||||
return (state: SessionState): SessionGuild | null =>
|
||||
guildId === null ? null : (state.guilds.find((guild) => guild.id === guildId) ?? null);
|
||||
}
|
||||
|
||||
/** Голосовые состояния одной комнаты (без учёта порядка — его задаёт сервер). */
|
||||
export function voiceStatesOfChannel(
|
||||
voiceStates: Record<string, VoiceState[]>,
|
||||
channelId: string,
|
||||
): VoiceState[] {
|
||||
const result: VoiceState[] = [];
|
||||
for (const guildStates of Object.values(voiceStates)) {
|
||||
for (const state of guildStates) {
|
||||
if (state.channel_id === channelId) {
|
||||
result.push(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Своё голосовое состояние на сервере (нужно для server_mute/server_deaf). */
|
||||
export function myVoiceState(
|
||||
voiceStates: Record<string, VoiceState[]>,
|
||||
guildId: string | null,
|
||||
userId: string | null,
|
||||
): VoiceState | null {
|
||||
if (guildId === null || userId === null) {
|
||||
return null;
|
||||
}
|
||||
return (voiceStates[guildId] ?? []).find((state) => state.user_id === userId) ?? null;
|
||||
}
|
||||
|
||||
+33
-2
@@ -4,8 +4,9 @@ import { create } from 'zustand';
|
||||
* Локальные настройки «голосовой» панели: состояния кнопок микрофона, камеры и
|
||||
* наушников, выбранные устройства и громкости.
|
||||
*
|
||||
* Голос появится в Фазе 3, поэтому сейчас это только предпочтения клиента:
|
||||
* они хранятся в localStorage и не уходят на сервер.
|
||||
* Это только предпочтения клиента: они хранятся в localStorage и не уходят на
|
||||
* сервер. Реальная медиа-сессия живёт в `stores/voiceConnection.ts` и читает
|
||||
* отсюда начальные флаги.
|
||||
*/
|
||||
|
||||
/** Виды устройств, которые отдаёт `navigator.mediaDevices.enumerateDevices()`. */
|
||||
@@ -22,6 +23,8 @@ interface VoicePreferences {
|
||||
/** Громкость микрофона и звука, 0–100. */
|
||||
micVolume: number;
|
||||
outputVolume: number;
|
||||
/** Личная громкость участников голосовой комнаты, 0–100 (AGENT.md 7.14). */
|
||||
participantVolumes: Record<string, number>;
|
||||
}
|
||||
|
||||
interface VoiceState extends VoicePreferences {
|
||||
@@ -34,6 +37,8 @@ interface VoiceState extends VoicePreferences {
|
||||
selectDevice: (kind: VoiceDeviceKind, deviceId: string) => void;
|
||||
setMicVolume: (value: number) => void;
|
||||
setOutputVolume: (value: number) => void;
|
||||
/** Громкость конкретного участника: хранится только на этом устройстве. */
|
||||
setParticipantVolume: (userId: string, value: number) => void;
|
||||
/** Вернуть исходные предпочтения (используется в тестах). */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -49,6 +54,7 @@ const defaults: VoicePreferences = {
|
||||
outputDeviceId: '',
|
||||
micVolume: 100,
|
||||
outputVolume: 100,
|
||||
participantVolumes: {},
|
||||
};
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
@@ -70,6 +76,20 @@ function asVolume(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' ? clampVolume(value) : fallback;
|
||||
}
|
||||
|
||||
/** Громкости участников: только числа 0–100, чужие ключи отбрасываем. */
|
||||
function asParticipantVolumes(value: unknown): Record<string, number> {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return {};
|
||||
}
|
||||
const volumes: Record<string, number> = {};
|
||||
for (const [userId, volume] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof volume === 'number') {
|
||||
volumes[userId] = clampVolume(volume);
|
||||
}
|
||||
}
|
||||
return volumes;
|
||||
}
|
||||
|
||||
/** Начальные значения: то, что уже лежит в localStorage, поверх умолчаний. */
|
||||
function initialPreferences(): VoicePreferences {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -98,6 +118,7 @@ function initialPreferences(): VoicePreferences {
|
||||
outputDeviceId: asString(stored['outputDeviceId'], defaults.outputDeviceId),
|
||||
micVolume: asVolume(stored['micVolume'], defaults.micVolume),
|
||||
outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume),
|
||||
participantVolumes: asParticipantVolumes(stored['participantVolumes']),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +137,7 @@ function persist(state: VoicePreferences): void {
|
||||
outputDeviceId: state.outputDeviceId,
|
||||
micVolume: state.micVolume,
|
||||
outputVolume: state.outputVolume,
|
||||
participantVolumes: state.participantVolumes,
|
||||
} satisfies VoicePreferences),
|
||||
);
|
||||
}
|
||||
@@ -154,6 +176,13 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
||||
setMicVolume: (value) => patch({ micVolume: clampVolume(value) }),
|
||||
setOutputVolume: (value) => patch({ outputVolume: clampVolume(value) }),
|
||||
|
||||
setParticipantVolume: (userId, value) => {
|
||||
if (userId === '') {
|
||||
return;
|
||||
}
|
||||
patch({ participantVolumes: { ...get().participantVolumes, [userId]: clampVolume(value) } });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({ ...defaults });
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -163,6 +192,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
||||
};
|
||||
});
|
||||
|
||||
/** Предпочтения без действий: то, что уходит в localStorage. */
|
||||
function preferencesOf(state: VoiceState): VoicePreferences {
|
||||
return {
|
||||
micEnabled: state.micEnabled,
|
||||
@@ -173,5 +203,6 @@ function preferencesOf(state: VoiceState): VoicePreferences {
|
||||
outputDeviceId: state.outputDeviceId,
|
||||
micVolume: state.micVolume,
|
||||
outputVolume: state.outputVolume,
|
||||
participantVolumes: state.participantVolumes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
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,
|
||||
} from '@/lib/livekit';
|
||||
import { myVoiceState, useSessionStore } from '@/stores/session';
|
||||
import { useVoiceStore, type VoiceDeviceKind } 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>;
|
||||
setDeafened: (enabled: boolean) => Promise<void>;
|
||||
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||
/** Смена устройства на лету: применяется к активной дорожке (AGENT.md 7.14). */
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
|
||||
setVolume: (userId: string, volume: number) => void;
|
||||
setParticipantMuted: (userId: string, muted: boolean) => Promise<void>;
|
||||
setParticipantDeafened: (userId: string, deafened: boolean) => Promise<void>;
|
||||
moveParticipant: (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 });
|
||||
}
|
||||
};
|
||||
|
||||
/** Отключение от 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();
|
||||
const channel = joinedChannelId;
|
||||
joinedChannelId = null;
|
||||
if (channel !== null) {
|
||||
try {
|
||||
await leaveVoiceChannel(channel);
|
||||
} catch {
|
||||
// Комната уже могла быть закрыта: состояние на сервере поправит webhook.
|
||||
}
|
||||
}
|
||||
set({ ...defaults });
|
||||
};
|
||||
|
||||
/** Не удалось продлить сессию: выходим из комнаты и показываем ошибку. */
|
||||
const failRefresh = async (): Promise<void> => {
|
||||
clearTokenTimer();
|
||||
await closeRoom();
|
||||
const channel = joinedChannelId;
|
||||
joinedChannelId = null;
|
||||
if (channel !== null) {
|
||||
try {
|
||||
await leaveVoiceChannel(channel);
|
||||
} catch {
|
||||
// Состояние на сервере очистится по таймауту участника.
|
||||
}
|
||||
}
|
||||
set({
|
||||
...defaults,
|
||||
status: 'error',
|
||||
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();
|
||||
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();
|
||||
const mine = (generation += 1);
|
||||
set({
|
||||
status: 'connecting',
|
||||
guildId,
|
||||
channelId,
|
||||
error: null,
|
||||
participants: [],
|
||||
micEnabled: preferences.micEnabled,
|
||||
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 = 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,
|
||||
});
|
||||
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);
|
||||
await handle.setCameraEnabled(preferences.cameraEnabled);
|
||||
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();
|
||||
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);
|
||||
await patchSelf({ self_mute: !enabled });
|
||||
},
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
await patchSelf({ camera: enabled });
|
||||
},
|
||||
|
||||
setScreenShareEnabled: async (enabled) => {
|
||||
set({ screenEnabled: enabled });
|
||||
const handle = room;
|
||||
if (handle !== null) {
|
||||
try {
|
||||
await handle.setScreenShareEnabled(enabled);
|
||||
set({ participants: handle.participants() });
|
||||
} catch (error) {
|
||||
set({ error });
|
||||
}
|
||||
}
|
||||
await patchSelf({ screen: enabled });
|
||||
},
|
||||
|
||||
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 (userId, muted) => {
|
||||
const guildId = get().guildId;
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const states = await moderateVoiceState(guildId, userId, { server_mute: muted });
|
||||
useSessionStore.getState().setGuildVoiceStates(guildId, states);
|
||||
get().syncServerState();
|
||||
} catch (error) {
|
||||
set({ error });
|
||||
}
|
||||
},
|
||||
|
||||
setParticipantDeafened: async (userId, deafened) => {
|
||||
const guildId = get().guildId;
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const states = await moderateVoiceState(guildId, userId, { server_deaf: deafened });
|
||||
useSessionStore.getState().setGuildVoiceStates(guildId, states);
|
||||
get().syncServerState();
|
||||
} catch (error) {
|
||||
set({ error });
|
||||
}
|
||||
},
|
||||
|
||||
moveParticipant: async (userId, channelId) => {
|
||||
const guildId = get().guildId;
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
if (serverMuted !== get().serverMuted || serverDeafened !== get().serverDeafened) {
|
||||
set({ serverMuted, serverDeafened });
|
||||
}
|
||||
// Модератор выключил микрофон — гасим публикацию, предпочтение не трогаем.
|
||||
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();
|
||||
const handle = room;
|
||||
room = null;
|
||||
joinedChannelId = null;
|
||||
if (handle !== null) {
|
||||
plannedDisconnect = true;
|
||||
void handle
|
||||
.disconnect()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
handle.dispose();
|
||||
plannedDisconnect = false;
|
||||
});
|
||||
}
|
||||
set({ ...defaults });
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user