fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
@@ -1491,6 +1491,26 @@ build_or_pull_image() {
|
|||||||
ok "образ собран: $(docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' "$APP_IMAGE" | head -1)"
|
ok "образ собран: $(docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' "$APP_IMAGE" | head -1)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# restart_livekit_if_config_changed сравнивает хэш livekit.yaml с сохранённым и
|
||||||
|
# перезапускает контейнер: иначе новые настройки (например вебхуки) не применятся.
|
||||||
|
restart_livekit_if_config_changed() {
|
||||||
|
local config="${TARGET_DIR}/livekit/livekit.yaml"
|
||||||
|
local marker="${TARGET_DIR}/.livekit-config.sha256"
|
||||||
|
[ -f "$config" ] || return 0
|
||||||
|
local hash
|
||||||
|
hash="$(sha256sum "$config" | awk '{print $1}')"
|
||||||
|
if [ -f "$marker" ] && [ "$(cat "$marker")" = "$hash" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
info "конфиг LiveKit изменился — перезапускаем сервис"
|
||||||
|
if (cd "$TARGET_DIR" && docker compose -f compose.yaml restart livekit >/dev/null 2>&1); then
|
||||||
|
printf '%s' "$hash" >"$marker"
|
||||||
|
ok "LiveKit перезапущен с новым конфигом"
|
||||||
|
else
|
||||||
|
warn "не удалось перезапустить LiveKit: проверьте вручную (glchat restart livekit)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
start_stack() {
|
start_stack() {
|
||||||
if [ "$SKIP_IMAGE" = "1" ]; then
|
if [ "$SKIP_IMAGE" = "1" ]; then
|
||||||
return 0
|
return 0
|
||||||
@@ -1504,6 +1524,11 @@ start_stack() {
|
|||||||
[ "$PROXY_MODE" = "embedded" ] && compose_args+=(--profile embedded-proxy)
|
[ "$PROXY_MODE" = "embedded" ] && compose_args+=(--profile embedded-proxy)
|
||||||
(cd "$TARGET_DIR" && docker compose "${compose_args[@]}" pull --quiet 2>/dev/null || true)
|
(cd "$TARGET_DIR" && docker compose "${compose_args[@]}" pull --quiet 2>/dev/null || true)
|
||||||
(cd "$TARGET_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось запустить стек"
|
(cd "$TARGET_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось запустить стек"
|
||||||
|
|
||||||
|
# LiveKit читает конфиг только при старте, а compose не видит изменений в
|
||||||
|
# примонтированном файле: перезапускаем сервис, если конфиг изменился.
|
||||||
|
restart_livekit_if_config_changed
|
||||||
|
|
||||||
info "ожидаем готовности приложения"
|
info "ожидаем готовности приложения"
|
||||||
local waited=0
|
local waited=0
|
||||||
until curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1; do
|
until curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1; do
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { parseGuildEmojiList } from './emojis';
|
import { parseGuildEmojiList } from './emojis';
|
||||||
import { parseChannelPayload } from './gatewayEvents';
|
import { parseChannelPayload } from './gatewayEvents';
|
||||||
|
import { parseVoiceStateList } from './voice';
|
||||||
import { describeUnknown } from '@/lib/http';
|
import { describeUnknown } from '@/lib/http';
|
||||||
|
|
||||||
import type { GuildEmoji } from './emojis';
|
import type { GuildEmoji } from './emojis';
|
||||||
|
import type { VoiceState } from './voice';
|
||||||
import type { Channel, Role, UserStatus } from './types';
|
import type { Channel, Role, UserStatus } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +49,8 @@ export interface GatewayGuild {
|
|||||||
roles: Role[];
|
roles: Role[];
|
||||||
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
||||||
emojis: GuildEmoji[];
|
emojis: GuildEmoji[];
|
||||||
|
/** Кто из участников в голосовых комнатах (AGENT.md 7.14). */
|
||||||
|
voice_states: VoiceState[];
|
||||||
member_ids: string[];
|
member_ids: string[];
|
||||||
my_role_ids: string[];
|
my_role_ids: string[];
|
||||||
my_nickname?: string;
|
my_nickname?: string;
|
||||||
@@ -236,6 +240,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
|
|||||||
channels,
|
channels,
|
||||||
roles,
|
roles,
|
||||||
emojis: parseGuildEmojiList(record['emojis']),
|
emojis: parseGuildEmojiList(record['emojis']),
|
||||||
|
voice_states: parseVoiceStateList(record['voice_states']),
|
||||||
member_ids: asStringArray(record['member_ids']),
|
member_ids: asStringArray(record['member_ids']),
|
||||||
my_role_ids: asStringArray(record['my_role_ids']),
|
my_role_ids: asStringArray(record['my_role_ids']),
|
||||||
my_permissions: asStringArray(record['my_permissions']),
|
my_permissions: asStringArray(record['my_permissions']),
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { request } from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Голосовые комнаты (AGENT.md §7.14): вход/выход, флаги участника, модерация
|
||||||
|
* и перемещение. Медиа живёт в LiveKit, а здесь — только REST-состояние
|
||||||
|
* `voice_states`, которое сервер рассылает ещё и событием `VOICE_STATE_UPDATE`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Голосовое состояние участника (`voice_states` из READY и событий). */
|
||||||
|
export interface VoiceState {
|
||||||
|
user_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
guild_id: string;
|
||||||
|
session_id?: string;
|
||||||
|
self_mute: boolean;
|
||||||
|
self_deaf: boolean;
|
||||||
|
server_mute: boolean;
|
||||||
|
server_deaf: boolean;
|
||||||
|
camera: boolean;
|
||||||
|
screen: boolean;
|
||||||
|
joined_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответ `POST /channels/{id}/voice/join`: токен и адрес LiveKit. */
|
||||||
|
export interface VoiceJoinResult {
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
room: string;
|
||||||
|
channel_id: string;
|
||||||
|
guild_id: string;
|
||||||
|
state: VoiceState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело `PATCH /guilds/{id}/voice-states/@me`. */
|
||||||
|
export interface VoiceSelfPatch {
|
||||||
|
self_mute?: boolean;
|
||||||
|
self_deaf?: boolean;
|
||||||
|
camera?: boolean;
|
||||||
|
screen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело `PATCH /guilds/{id}/voice-states/{user_id}` (права модератора). */
|
||||||
|
export interface VoiceModerationPatch {
|
||||||
|
server_mute?: boolean;
|
||||||
|
server_deaf?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разобранное событие `VOICE_STATE_UPDATE`: состояние или уход из комнаты. */
|
||||||
|
export type VoiceStateEvent =
|
||||||
|
| { kind: 'state'; state: VoiceState }
|
||||||
|
| { kind: 'left'; guildId: string; userId: string };
|
||||||
|
|
||||||
|
export const guildVoiceStatesQueryKey = (guildId: string) =>
|
||||||
|
['guilds', guildId, 'voice-states'] as const;
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбирает голосовое состояние, отбрасывая кадры не по контракту. */
|
||||||
|
export function parseVoiceState(value: unknown): VoiceState | null {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (record === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const userId = asString(record['user_id']);
|
||||||
|
const channelId = asString(record['channel_id']);
|
||||||
|
if (userId === undefined || channelId === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const state: VoiceState = {
|
||||||
|
user_id: userId,
|
||||||
|
channel_id: channelId,
|
||||||
|
guild_id: asString(record['guild_id']) ?? '',
|
||||||
|
self_mute: record['self_mute'] === true,
|
||||||
|
self_deaf: record['self_deaf'] === true,
|
||||||
|
server_mute: record['server_mute'] === true,
|
||||||
|
server_deaf: record['server_deaf'] === true,
|
||||||
|
camera: record['camera'] === true,
|
||||||
|
screen: record['screen'] === true,
|
||||||
|
joined_at: asString(record['joined_at']) ?? '',
|
||||||
|
};
|
||||||
|
const sessionId = asString(record['session_id']);
|
||||||
|
if (sessionId !== undefined) {
|
||||||
|
state.session_id = sessionId;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список голосовых состояний из ответа REST или снапшота READY. */
|
||||||
|
export function parseVoiceStateList(value: unknown): VoiceState[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.map(parseVoiceState).filter((item): item is VoiceState => item !== null)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `VOICE_STATE_UPDATE`: либо элемент `voice_states`, либо `{left: true}`
|
||||||
|
* с идентификаторами сервера, комнаты и участника.
|
||||||
|
*/
|
||||||
|
export function parseVoiceStateEvent(value: unknown): VoiceStateEvent | null {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (record === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (record['left'] === true) {
|
||||||
|
const guildId = asString(record['guild_id']);
|
||||||
|
const userId = asString(record['user_id']);
|
||||||
|
if (guildId === undefined || userId === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { kind: 'left', guildId, userId };
|
||||||
|
}
|
||||||
|
const state = parseVoiceState(value);
|
||||||
|
return state === null ? null : { kind: 'state', state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вход в голосовую комнату: сервер выдаёт токен и записывает состояние. */
|
||||||
|
export function joinVoiceChannel(channelId: string): Promise<VoiceJoinResult> {
|
||||||
|
return request<VoiceJoinResult>(`/channels/${encodeURIComponent(channelId)}/voice/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveVoiceChannel(channelId: string): Promise<{ ok: true }> {
|
||||||
|
return request<{ ok: true }>(`/channels/${encodeURIComponent(channelId)}/voice/leave`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Свои флаги: микрофон, наушники, камера, демонстрация экрана. */
|
||||||
|
export async function updateMyVoiceState(
|
||||||
|
guildId: string,
|
||||||
|
patch: VoiceSelfPatch,
|
||||||
|
): Promise<VoiceState[]> {
|
||||||
|
const payload = await request<{ voice_states: unknown }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/voice-states/@me`,
|
||||||
|
{ method: 'PATCH', body: patch },
|
||||||
|
);
|
||||||
|
return parseVoiceStateList(payload.voice_states);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVoiceStates(
|
||||||
|
guildId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<VoiceState[]> {
|
||||||
|
const payload = await request<{ voice_states: unknown }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/voice-states`,
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return parseVoiceStateList(payload.voice_states);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Серверный мьют и глушение участника: права `MUTE_MEMBERS` / `DEAFEN_MEMBERS`. */
|
||||||
|
export async function moderateVoiceState(
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
patch: VoiceModerationPatch,
|
||||||
|
): Promise<VoiceState[]> {
|
||||||
|
const payload = await request<{ voice_states: unknown }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/voice-states/${encodeURIComponent(userId)}`,
|
||||||
|
{ method: 'PATCH', body: patch },
|
||||||
|
);
|
||||||
|
return parseVoiceStateList(payload.voice_states);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перемещение участника в другую голосовую комнату: право `MOVE_MEMBERS`. */
|
||||||
|
export async function moveVoiceMember(
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<VoiceState[]> {
|
||||||
|
const payload = await request<{ voice_states: unknown }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/voice-states/${encodeURIComponent(userId)}/move`,
|
||||||
|
{ method: 'POST', body: { channel_id: channelId } },
|
||||||
|
);
|
||||||
|
return parseVoiceStateList(payload.voice_states);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { VoiceTrackHandle } from '@/lib/livekit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Аудио удалённого участника: дорожка подключается к своему элементу, а
|
||||||
|
* громкость и глушение применяются к нему же (AGENT.md 7.14).
|
||||||
|
*/
|
||||||
|
export function RemoteAudio({
|
||||||
|
track,
|
||||||
|
volume,
|
||||||
|
muted,
|
||||||
|
}: {
|
||||||
|
track: VoiceTrackHandle;
|
||||||
|
/** Громкость 0–100 (личная настройка участника). */
|
||||||
|
volume: number;
|
||||||
|
/** Локальное глушение: звук выключен целиком. */
|
||||||
|
muted: boolean;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (element === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
track.attach(element);
|
||||||
|
return () => {
|
||||||
|
track.detach(element);
|
||||||
|
};
|
||||||
|
}, [track]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (element === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
element.volume = Math.min(1, Math.max(0, volume / 100));
|
||||||
|
element.muted = muted;
|
||||||
|
}, [volume, muted]);
|
||||||
|
|
||||||
|
return <audio ref={ref} autoPlay playsInline data-testid="voice-remote-audio" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Видео участника: камера или демонстрация экрана. */
|
||||||
|
export function RemoteVideo({
|
||||||
|
track,
|
||||||
|
muted,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
track: VoiceTrackHandle;
|
||||||
|
/** Свой предпросмотр не должен звучать и зацикливаться. */
|
||||||
|
muted: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (element === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
track.attach(element);
|
||||||
|
return () => {
|
||||||
|
track.detach(element);
|
||||||
|
};
|
||||||
|
}, [track]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted={muted}
|
||||||
|
data-testid="voice-video"
|
||||||
|
className={`h-full w-full object-cover ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useCallback, useRef, useState, type DragEvent, type MouseEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { NavLink } from 'react-router';
|
||||||
|
|
||||||
|
import type { Channel } from '@/api/types';
|
||||||
|
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
||||||
|
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
||||||
|
import { Menu, MenuItem } from '@/components/ui/Menu';
|
||||||
|
import { VoiceOccupantRow } from '@/components/voice/VoiceOccupantRow';
|
||||||
|
import { allowVoiceDrop, draggedUserId } from '@/components/voice/voiceDrag';
|
||||||
|
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||||
|
import { useVoiceModeration, useVoiceOccupants } from '@/components/voice/useVoiceOccupants';
|
||||||
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
|
||||||
|
interface VoiceChannelItemProps {
|
||||||
|
guildId: string;
|
||||||
|
channel: Channel;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Голосовая комната в сайдбаре: клик — вход (повторный клик по своей комнате
|
||||||
|
* ничего не делает), двойной клик или меню — «Отключиться». Под комнатой
|
||||||
|
* видно, кто в ней сидит; при праве MOVE_MEMBERS участника можно перетащить
|
||||||
|
* на другую голосовую комнату.
|
||||||
|
*/
|
||||||
|
export function VoiceChannelItem({ guildId, channel, selected }: VoiceChannelItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const voiceEnabled = useVoiceEnabled();
|
||||||
|
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||||
|
const permissions = useVoiceModeration(guildId);
|
||||||
|
const status = useVoiceConnectionStore((state) => state.status);
|
||||||
|
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||||
|
const error = useVoiceConnectionStore((state) => state.error);
|
||||||
|
const refreshing = useVoiceConnectionStore((state) => state.refreshing);
|
||||||
|
const join = useVoiceConnectionStore((state) => state.join);
|
||||||
|
const leave = useVoiceConnectionStore((state) => state.leave);
|
||||||
|
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||||
|
const describeError = useApiErrorMessage();
|
||||||
|
const { unread, mentions } = useChannelUnread(channel.id);
|
||||||
|
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLAnchorElement | null>(null);
|
||||||
|
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||||
|
|
||||||
|
/** Комната, в которой мы сейчас находимся (включая подключение и ошибку). */
|
||||||
|
const here = activeChannelId === channel.id && status !== 'idle';
|
||||||
|
|
||||||
|
const highlighted = !selected && (unread > 0 || mentions > 0);
|
||||||
|
const label =
|
||||||
|
mentions > 0
|
||||||
|
? t('channels.selectMentions', { name: channel.name, count: mentions })
|
||||||
|
: unread > 0
|
||||||
|
? t('channels.selectUnread', { name: channel.name, count: unread })
|
||||||
|
: t('channels.select', { name: channel.name });
|
||||||
|
|
||||||
|
const onDrop = (event: DragEvent<HTMLAnchorElement>): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
const userId = draggedUserId(event);
|
||||||
|
if (userId !== '') {
|
||||||
|
void moveParticipant(userId, channel.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDoubleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||||
|
if (!here) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void leave();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<NavLink
|
||||||
|
ref={anchorRef}
|
||||||
|
to={`/app/${guildId}/${channel.id}`}
|
||||||
|
aria-current={selected ? 'page' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
title={voiceEnabled ? channel.name : t('voice.disabled')}
|
||||||
|
onClick={() => {
|
||||||
|
if (voiceEnabled) {
|
||||||
|
void join(guildId, channel.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setMenuOpen(true);
|
||||||
|
}}
|
||||||
|
onDragOver={permissions.move ? allowVoiceDrop : undefined}
|
||||||
|
onDrop={permissions.move ? onDrop : undefined}
|
||||||
|
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
selected
|
||||||
|
? 'gl-indicator bg-surface-3 text-fg'
|
||||||
|
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||||
|
} ${highlighted ? 'font-semibold text-fg' : ''}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">🔊</span>
|
||||||
|
<span className="truncate">{channel.name}</span>
|
||||||
|
<ChannelBadge channelId={channel.id} selected={selected} />
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{here && status === 'connecting' ? (
|
||||||
|
<p role="status" className="px-2 pl-7 text-xs text-fg-muted">
|
||||||
|
{t('voice.joining')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{here && status === 'reconnecting' ? (
|
||||||
|
<p role="status" className="px-2 pl-7 text-xs text-warning">
|
||||||
|
{t('voice.reconnecting')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{here && refreshing ? (
|
||||||
|
<p role="status" className="px-2 pl-7 text-xs text-fg-muted">
|
||||||
|
{t('voice.refreshing')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{here && status === 'error' ? (
|
||||||
|
<p role="alert" className="px-2 pl-7 text-xs text-danger">
|
||||||
|
{describeError(error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{occupants.length === 0 ? null : (
|
||||||
|
<ul
|
||||||
|
aria-label={t('voice.channelParticipants', { name: channel.name })}
|
||||||
|
className="mb-0.5 flex flex-col gap-0.5 pl-4"
|
||||||
|
>
|
||||||
|
{occupants.map((occupant) => (
|
||||||
|
<VoiceOccupantRow
|
||||||
|
key={occupant.userId}
|
||||||
|
occupant={occupant}
|
||||||
|
guildId={guildId}
|
||||||
|
channelId={channel.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
open={menuOpen}
|
||||||
|
label={t('voice.channelMenu.label', { name: channel.name })}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onClose={closeMenu}
|
||||||
|
className="w-56"
|
||||||
|
>
|
||||||
|
{here ? (
|
||||||
|
<MenuItem
|
||||||
|
icon="⏻"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
closeMenu();
|
||||||
|
void leave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('voice.disconnect')}
|
||||||
|
</MenuItem>
|
||||||
|
) : (
|
||||||
|
<MenuItem
|
||||||
|
icon="🔊"
|
||||||
|
disabled={!voiceEnabled}
|
||||||
|
onClick={() => {
|
||||||
|
closeMenu();
|
||||||
|
void join(guildId, channel.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(voiceEnabled ? 'voice.join' : 'voice.disabled')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { VoiceQuality } from '@/lib/livekit';
|
||||||
|
|
||||||
|
/** Цвет точки качества связи: зелёный — отлично, красный — связи нет. */
|
||||||
|
const qualityClass: Record<VoiceQuality, string> = {
|
||||||
|
excellent: 'bg-success',
|
||||||
|
good: 'bg-success/70',
|
||||||
|
poor: 'bg-warning',
|
||||||
|
lost: 'bg-danger',
|
||||||
|
unknown: 'bg-fg-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Точка качества связи с подписью для скринридера (AGENT.md 7.14). */
|
||||||
|
export function VoiceQualityDot({ quality }: { quality: VoiceQuality | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (quality === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = t(`voice.quality.${quality}`);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
data-testid="voice-quality"
|
||||||
|
data-quality={quality}
|
||||||
|
className={`inline-block h-2 w-2 shrink-0 rounded-full ${qualityClass[quality]}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Иконка состояния участника: перечёркнутая, когда флаг выключен.
|
||||||
|
* Подпись обязательна — по ней состояние читают и тесты, и скринридер.
|
||||||
|
*/
|
||||||
|
export function VoiceFlagIcon({
|
||||||
|
glyph,
|
||||||
|
label,
|
||||||
|
off = false,
|
||||||
|
}: {
|
||||||
|
glyph: string;
|
||||||
|
label: string;
|
||||||
|
off?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={`relative inline-block leading-none ${off ? 'text-danger' : 'text-fg-muted'}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className={off ? 'opacity-60' : ''}>
|
||||||
|
{glyph}
|
||||||
|
</span>
|
||||||
|
{off ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-1/2 top-1/2 h-[1.5px] w-4 -translate-x-1/2 -translate-y-1/2 -rotate-45 rounded-full bg-current"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Флаги участника: микрофон, звук, камера, демонстрация экрана. */
|
||||||
|
export function VoiceFlags({
|
||||||
|
selfMute,
|
||||||
|
selfDeaf,
|
||||||
|
serverMute,
|
||||||
|
serverDeaf,
|
||||||
|
camera,
|
||||||
|
screen,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
selfMute: boolean;
|
||||||
|
selfDeaf: boolean;
|
||||||
|
serverMute: boolean;
|
||||||
|
serverDeaf: boolean;
|
||||||
|
camera: boolean;
|
||||||
|
screen: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const muted = selfMute || serverMute;
|
||||||
|
const deafened = selfDeaf || serverDeaf;
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center gap-1.5 text-xs ${className}`}>
|
||||||
|
<VoiceFlagIcon
|
||||||
|
glyph="🎤"
|
||||||
|
off={muted}
|
||||||
|
label={t(serverMute ? 'voice.serverMuted' : muted ? 'voice.muted' : 'voice.micOn')}
|
||||||
|
/>
|
||||||
|
<VoiceFlagIcon
|
||||||
|
glyph="🎧"
|
||||||
|
off={deafened}
|
||||||
|
label={t(serverDeaf ? 'voice.serverDeafened' : deafened ? 'voice.deafened' : 'voice.soundOn')}
|
||||||
|
/>
|
||||||
|
{camera ? <VoiceFlagIcon glyph="🎥" label={t('voice.cameraOn')} /> : null}
|
||||||
|
{screen ? <VoiceFlagIcon glyph="🖥" label={t('voice.screenOn')} /> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кольцо вокруг аватара, когда участник говорит. */
|
||||||
|
export function speakingRingClass(speaking: boolean): string {
|
||||||
|
return speaking
|
||||||
|
? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1'
|
||||||
|
: 'ring-1 ring-border/60';
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { Channel } from '@/api/types';
|
||||||
|
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||||
|
import { VolumeSlider } from '@/components/voice/VoiceControls';
|
||||||
|
import type { VoiceOccupant } from '@/components/voice/useVoiceOccupants';
|
||||||
|
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||||
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
|
|
||||||
|
export interface VoiceOccupantMenuProps {
|
||||||
|
open: boolean;
|
||||||
|
occupant: VoiceOccupant;
|
||||||
|
anchorRef: RefObject<HTMLElement | null>;
|
||||||
|
onClose: () => void;
|
||||||
|
permissions: VoiceModerationPermissions;
|
||||||
|
/** Куда можно переместить участника (без его текущей комнаты). */
|
||||||
|
moveTargets: Channel[];
|
||||||
|
onMute: (userId: string, muted: boolean) => void;
|
||||||
|
onDeafen: (userId: string, deafened: boolean) => void;
|
||||||
|
onMove: (userId: string, channelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контекстное меню участника: личная громкость и — при наличии прав —
|
||||||
|
* серверный мьют, глушение и перемещение в другую голосовую комнату.
|
||||||
|
*/
|
||||||
|
export function VoiceOccupantMenu({
|
||||||
|
open,
|
||||||
|
occupant,
|
||||||
|
anchorRef,
|
||||||
|
onClose,
|
||||||
|
permissions,
|
||||||
|
moveTargets,
|
||||||
|
onMute,
|
||||||
|
onDeafen,
|
||||||
|
onMove,
|
||||||
|
}: VoiceOccupantMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
|
||||||
|
const setParticipantVolume = useVoiceStore((state) => state.setParticipantVolume);
|
||||||
|
const canModerate =
|
||||||
|
!occupant.isLocal && (permissions.mute || permissions.deafen || permissions.move);
|
||||||
|
|
||||||
|
const run = (action: () => void): void => {
|
||||||
|
onClose();
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
open={open}
|
||||||
|
label={t('voice.menu.label', { name: occupant.name })}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onClose={onClose}
|
||||||
|
className="w-64"
|
||||||
|
>
|
||||||
|
<MenuGroupLabel>{occupant.name}</MenuGroupLabel>
|
||||||
|
{occupant.isLocal ? null : (
|
||||||
|
<VolumeSlider
|
||||||
|
label={t('voice.volume')}
|
||||||
|
value={volume}
|
||||||
|
onChange={(value) => setParticipantVolume(occupant.userId, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canModerate ? <MenuSeparator /> : null}
|
||||||
|
{canModerate ? <MenuGroupLabel>{t('voice.menu.moderation')}</MenuGroupLabel> : null}
|
||||||
|
{permissions.mute && !occupant.isLocal ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={occupant.serverMute ? '🔊' : '🔇'}
|
||||||
|
onClick={() => run(() => onMute(occupant.userId, !occupant.serverMute))}
|
||||||
|
>
|
||||||
|
{t(occupant.serverMute ? 'voice.menu.unmute' : 'voice.menu.mute')}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{permissions.deafen && !occupant.isLocal ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={occupant.serverDeaf ? '🔔' : '🔕'}
|
||||||
|
onClick={() => run(() => onDeafen(occupant.userId, !occupant.serverDeaf))}
|
||||||
|
>
|
||||||
|
{t(occupant.serverDeaf ? 'voice.menu.undeafen' : 'voice.menu.deafen')}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
{permissions.move && !occupant.isLocal && moveTargets.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuGroupLabel>{t('voice.menu.move')}</MenuGroupLabel>
|
||||||
|
{moveTargets.map((target) => (
|
||||||
|
<MenuItem
|
||||||
|
key={target.id}
|
||||||
|
icon="➡"
|
||||||
|
onClick={() => run(() => onMove(occupant.userId, target.id))}
|
||||||
|
>
|
||||||
|
{t('voice.menu.moveTo', { name: target.name })}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback, useRef, useState, type DragEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
|
||||||
|
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||||
|
import {
|
||||||
|
useVoiceChannels,
|
||||||
|
useVoiceModeration,
|
||||||
|
type VoiceOccupant,
|
||||||
|
} from '@/components/voice/useVoiceOccupants';
|
||||||
|
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка участника голосовой комнаты в сайдбаре: аватар с кольцом речи,
|
||||||
|
* имя, флаги и точка качества связи. Клик открывает меню с громкостью и
|
||||||
|
* действиями модератора; при праве MOVE_MEMBERS строку можно перетащить
|
||||||
|
* на другую голосовую комнату.
|
||||||
|
*/
|
||||||
|
export function VoiceOccupantRow({
|
||||||
|
occupant,
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
}: {
|
||||||
|
occupant: VoiceOccupant;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const permissions = useVoiceModeration(guildId);
|
||||||
|
const moveTargets = useVoiceChannels(guildId).filter((channel) => channel.id !== channelId);
|
||||||
|
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||||
|
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||||
|
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const close = useCallback(() => setOpen(false), []);
|
||||||
|
const label = t('voice.menu.label', { name: occupant.name });
|
||||||
|
const canDrag = permissions.move && !occupant.isLocal;
|
||||||
|
|
||||||
|
const onDragStart = (event: DragEvent<HTMLButtonElement>): void => {
|
||||||
|
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center">
|
||||||
|
<button
|
||||||
|
ref={anchorRef}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
draggable={canDrag}
|
||||||
|
onDragStart={canDrag ? onDragStart : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpen((value) => !value);
|
||||||
|
}}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-[var(--radius-sm)] px-2 py-0.5 text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
|
||||||
|
<Avatar
|
||||||
|
name={occupant.name}
|
||||||
|
seed={occupant.userId}
|
||||||
|
fileId={occupant.avatarFileId}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{occupant.name}</span>
|
||||||
|
{occupant.isSpeaking ? <span className="sr-only">{t('voice.speaking')}</span> : null}
|
||||||
|
<VoiceFlags
|
||||||
|
className="shrink-0"
|
||||||
|
selfMute={occupant.selfMute}
|
||||||
|
selfDeaf={occupant.selfDeaf}
|
||||||
|
serverMute={occupant.serverMute}
|
||||||
|
serverDeaf={occupant.serverDeaf}
|
||||||
|
camera={occupant.camera}
|
||||||
|
screen={occupant.screen}
|
||||||
|
/>
|
||||||
|
<VoiceQualityDot quality={occupant.quality} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<VoiceOccupantMenu
|
||||||
|
open={open}
|
||||||
|
occupant={occupant}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onClose={close}
|
||||||
|
permissions={permissions}
|
||||||
|
moveTargets={moveTargets}
|
||||||
|
onMute={(userId, muted) => void setParticipantMuted(userId, muted)}
|
||||||
|
onDeafen={(userId, deafened) => void setParticipantDeafened(userId, deafened)}
|
||||||
|
onMove={(userId, targetId) => void moveParticipant(userId, targetId)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { useCallback, useRef, useState, type DragEvent, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { Channel } from '@/api/types';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Card, Button } from '@/components/ui/primitives';
|
||||||
|
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
||||||
|
import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
|
||||||
|
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||||
|
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||||
|
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
|
||||||
|
import {
|
||||||
|
useVoiceChannels,
|
||||||
|
useVoiceModeration,
|
||||||
|
useVoiceOccupants,
|
||||||
|
type VoiceOccupant,
|
||||||
|
} from '@/components/voice/useVoiceOccupants';
|
||||||
|
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||||
|
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||||
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
|
||||||
|
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
|
||||||
|
function VoiceTile({
|
||||||
|
occupant,
|
||||||
|
permissions,
|
||||||
|
moveTargets,
|
||||||
|
onMute,
|
||||||
|
onDeafen,
|
||||||
|
onMove,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
occupant: VoiceOccupant;
|
||||||
|
permissions: VoiceModerationPermissions;
|
||||||
|
moveTargets: Channel[];
|
||||||
|
onMute: (userId: string, muted: boolean) => void;
|
||||||
|
onDeafen: (userId: string, deafened: boolean) => void;
|
||||||
|
onMove: (userId: string, targetId: string) => void;
|
||||||
|
/** Кнопки управления — только на своей плитке. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
|
||||||
|
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
||||||
|
const media = occupant.participant;
|
||||||
|
const video = media?.screen ?? media?.video;
|
||||||
|
|
||||||
|
const onDragStart = (event: DragEvent<HTMLDivElement>): void => {
|
||||||
|
if (occupant.isLocal || !permissions.move) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`voice-tile-${occupant.userId}`}
|
||||||
|
data-speaking={occupant.isSpeaking}
|
||||||
|
draggable={permissions.move && !occupant.isLocal}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
className={`flex flex-col gap-2 rounded-[var(--radius-lg)] border bg-surface-1 p-3 transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
occupant.isSpeaking ? 'border-success/70' : 'border-border/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-[var(--radius-md)] bg-surface-2">
|
||||||
|
{video === undefined ? (
|
||||||
|
<span className={`rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
|
||||||
|
<Avatar
|
||||||
|
name={occupant.name}
|
||||||
|
seed={occupant.userId}
|
||||||
|
fileId={occupant.avatarFileId}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<RemoteVideo track={video} muted={occupant.isLocal} />
|
||||||
|
)}
|
||||||
|
{media?.audio === undefined ? null : (
|
||||||
|
<RemoteAudio track={media.audio} volume={volume} muted={deafened} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
|
||||||
|
{occupant.name}
|
||||||
|
</span>
|
||||||
|
<VoiceQualityDot quality={occupant.quality} />
|
||||||
|
{occupant.isLocal ? (
|
||||||
|
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={anchorRef}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-label={t('voice.menu.label', { name: occupant.name })}
|
||||||
|
title={t('voice.menu.label', { name: occupant.name })}
|
||||||
|
onClick={() => setMenuOpen((value) => !value)}
|
||||||
|
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⋯</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VoiceFlags
|
||||||
|
selfMute={occupant.selfMute}
|
||||||
|
selfDeaf={occupant.selfDeaf}
|
||||||
|
serverMute={occupant.serverMute}
|
||||||
|
serverDeaf={occupant.serverDeaf}
|
||||||
|
camera={occupant.camera}
|
||||||
|
screen={occupant.screen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{occupant.isSpeaking ? <p className="sr-only">{t('voice.speaking')}</p> : null}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<VoiceOccupantMenu
|
||||||
|
open={menuOpen}
|
||||||
|
occupant={occupant}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
|
permissions={permissions}
|
||||||
|
moveTargets={moveTargets}
|
||||||
|
onMute={onMute}
|
||||||
|
onDeafen={onDeafen}
|
||||||
|
onMove={onMove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопки управления своей сессией: микрофон, звук, камера, экран, выход. */
|
||||||
|
function VoiceControlsRow({
|
||||||
|
micEnabled,
|
||||||
|
deafened,
|
||||||
|
cameraEnabled,
|
||||||
|
screenEnabled,
|
||||||
|
serverMuted,
|
||||||
|
serverDeafened,
|
||||||
|
onMic,
|
||||||
|
onSound,
|
||||||
|
onCamera,
|
||||||
|
onScreen,
|
||||||
|
onLeave,
|
||||||
|
}: {
|
||||||
|
micEnabled: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
cameraEnabled: boolean;
|
||||||
|
screenEnabled: boolean;
|
||||||
|
serverMuted: boolean;
|
||||||
|
serverDeafened: boolean;
|
||||||
|
onMic: () => void;
|
||||||
|
onSound: () => void;
|
||||||
|
onCamera: () => void;
|
||||||
|
onScreen: () => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center gap-1"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('voice.controls')}
|
||||||
|
>
|
||||||
|
<VoiceControlButton
|
||||||
|
glyph="🎤"
|
||||||
|
label={t(micEnabled ? 'voice.mic.off' : 'voice.mic.on')}
|
||||||
|
active={micEnabled}
|
||||||
|
disabled={serverMuted}
|
||||||
|
{...(serverMuted ? { disabledReason: t('voice.serverMuted') } : {})}
|
||||||
|
onClick={onMic}
|
||||||
|
/>
|
||||||
|
<VoiceControlButton
|
||||||
|
glyph="🎧"
|
||||||
|
label={t(deafened ? 'voice.sound.on' : 'voice.sound.off')}
|
||||||
|
active={!deafened}
|
||||||
|
disabled={serverDeafened}
|
||||||
|
{...(serverDeafened ? { disabledReason: t('voice.serverDeafened') } : {})}
|
||||||
|
onClick={onSound}
|
||||||
|
/>
|
||||||
|
<VoiceControlButton
|
||||||
|
glyph="🎥"
|
||||||
|
label={t(cameraEnabled ? 'voice.camera.off' : 'voice.camera.on')}
|
||||||
|
active={cameraEnabled}
|
||||||
|
onClick={onCamera}
|
||||||
|
/>
|
||||||
|
<VoiceControlButton
|
||||||
|
glyph="🖥"
|
||||||
|
label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||||
|
active={screenEnabled}
|
||||||
|
onClick={onScreen}
|
||||||
|
/>
|
||||||
|
<VoiceControlButton
|
||||||
|
glyph="⏻"
|
||||||
|
label={t('voice.disconnect')}
|
||||||
|
active
|
||||||
|
onClick={onLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопка управления голосом: иконка с состоянием и подписью. */
|
||||||
|
function VoiceControlButton({
|
||||||
|
glyph,
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
disabled = false,
|
||||||
|
disabledReason,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
glyph: string;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const title = disabled && disabledReason !== undefined ? disabledReason : label;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={title}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
active ? 'border-border/60 bg-surface-2 text-fg' : 'border-danger/60 bg-danger/10 text-danger'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{glyph}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Панель голосовой комнаты в области контента: крупные плитки участников,
|
||||||
|
* управление своей сессией и модерация при наличии прав (AGENT.md 7.14).
|
||||||
|
*/
|
||||||
|
export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel: Channel }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const voiceEnabled = useVoiceEnabled();
|
||||||
|
const describeError = useApiErrorMessage();
|
||||||
|
useGuildVoiceStates(guildId);
|
||||||
|
|
||||||
|
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||||
|
const permissions = useVoiceModeration(guildId);
|
||||||
|
const moveTargets = useVoiceChannels(guildId).filter((item) => item.id !== channel.id);
|
||||||
|
const status = useVoiceConnectionStore((state) => state.status);
|
||||||
|
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||||
|
const error = useVoiceConnectionStore((state) => state.error);
|
||||||
|
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
|
||||||
|
const cameraEnabled = useVoiceConnectionStore((state) => state.cameraEnabled);
|
||||||
|
const screenEnabled = useVoiceConnectionStore((state) => state.screenEnabled);
|
||||||
|
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
||||||
|
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
|
||||||
|
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
|
||||||
|
const refreshing = useVoiceConnectionStore((state) => state.refreshing);
|
||||||
|
const join = useVoiceConnectionStore((state) => state.join);
|
||||||
|
const leave = useVoiceConnectionStore((state) => state.leave);
|
||||||
|
const setMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
|
||||||
|
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||||
|
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||||
|
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
|
||||||
|
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||||
|
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||||
|
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||||
|
|
||||||
|
const here = activeChannelId === channel.id;
|
||||||
|
const connecting = here && status === 'connecting';
|
||||||
|
const inRoom = here && (status === 'connected' || status === 'reconnecting');
|
||||||
|
const failed = here && status === 'error';
|
||||||
|
|
||||||
|
const onMute = useCallback(
|
||||||
|
(userId: string, muted: boolean) => void setParticipantMuted(userId, muted),
|
||||||
|
[setParticipantMuted],
|
||||||
|
);
|
||||||
|
const onDeafen = useCallback(
|
||||||
|
(userId: string, deafenedNext: boolean) => void setParticipantDeafened(userId, deafenedNext),
|
||||||
|
[setParticipantDeafened],
|
||||||
|
);
|
||||||
|
const onMove = useCallback(
|
||||||
|
(userId: string, targetId: string) => void moveParticipant(userId, targetId),
|
||||||
|
[moveParticipant],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!voiceEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6">
|
||||||
|
<Card className="max-w-md text-center" data-testid="voice-disabled">
|
||||||
|
<h3 className="text-lg font-semibold">{t('voice.title')}</h3>
|
||||||
|
<p className="mt-2 text-sm text-fg-muted">{t('voice.disabled')}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{inRoom ? (
|
||||||
|
<p role="status" className="text-xs text-fg-muted">
|
||||||
|
{t(refreshing ? 'voice.refreshing' : 'voice.connected')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={connecting}
|
||||||
|
onClick={() => void join(guildId, channel.id)}
|
||||||
|
data-testid="voice-join"
|
||||||
|
>
|
||||||
|
{t(connecting ? 'voice.joining' : 'voice.join')}
|
||||||
|
</Button>
|
||||||
|
{connecting ? (
|
||||||
|
<p role="status" className="text-xs text-fg-muted">
|
||||||
|
{t('voice.joining')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{describeError(error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{occupants.length === 0 ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t('voice.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||||
|
{occupants.map((occupant) => (
|
||||||
|
<VoiceTile
|
||||||
|
key={occupant.userId}
|
||||||
|
occupant={occupant}
|
||||||
|
permissions={permissions}
|
||||||
|
moveTargets={moveTargets}
|
||||||
|
onMute={onMute}
|
||||||
|
onDeafen={onDeafen}
|
||||||
|
onMove={onMove}
|
||||||
|
>
|
||||||
|
{occupant.isLocal && inRoom ? (
|
||||||
|
<VoiceControlsRow
|
||||||
|
micEnabled={micEnabled}
|
||||||
|
deafened={deafened}
|
||||||
|
cameraEnabled={cameraEnabled}
|
||||||
|
screenEnabled={screenEnabled}
|
||||||
|
serverMuted={serverMuted}
|
||||||
|
serverDeafened={serverDeafened}
|
||||||
|
onMic={() => void setMicEnabled(!micEnabled)}
|
||||||
|
onSound={() => void setDeafened(!deafened)}
|
||||||
|
onCamera={() => void setCameraEnabled(!cameraEnabled)}
|
||||||
|
onScreen={() => void setScreenShareEnabled(!screenEnabled)}
|
||||||
|
onLeave={() => void leave()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</VoiceTile>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подсказка «вы говорите с выключенным микрофоном» (AGENT.md 7.14).
|
||||||
|
*
|
||||||
|
* Появляется по сигналу SDK не чаще одного раза в 5 секунд и живёт несколько
|
||||||
|
* секунд; показывает её оболочка приложения, чтобы подсказку было видно с
|
||||||
|
* любого экрана, а не только из голосовой комнаты.
|
||||||
|
*/
|
||||||
|
export function VoiceSpeakingNotice() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const visible = useVoiceConnectionStore((state) => state.speakingWhileMuted);
|
||||||
|
const dismiss = useVoiceConnectionStore((state) => state.dismissSpeakingWarning);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="voice-speaking-notice"
|
||||||
|
className="pointer-events-auto fixed bottom-24 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-[var(--radius-md)] border border-warning/60 bg-surface-2 px-3 py-2 text-sm shadow-[var(--shadow-3)]"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">🎤</span>
|
||||||
|
<p>{t('voice.speakingWhileMuted')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.dismiss')}
|
||||||
|
title={t('common.dismiss')}
|
||||||
|
onClick={dismiss}
|
||||||
|
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { fetchVoiceStates, guildVoiceStatesQueryKey } from '@/api/voice';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подстраховка к шлюзу: пока открыт сервер, один раз (и по устареванию)
|
||||||
|
* перечитываем `GET /guilds/{id}/voice-states`.
|
||||||
|
*
|
||||||
|
* Основной источник — READY и `VOICE_STATE_UPDATE`; REST нужен, если событие
|
||||||
|
* потерялось или вкладка уснула. Ошибку не показываем: у голосовых комнат
|
||||||
|
* есть свои состояния подключения.
|
||||||
|
*/
|
||||||
|
export function useGuildVoiceStates(guildId: string | null): void {
|
||||||
|
const setGuildVoiceStates = useSessionStore((state) => state.setGuildVoiceStates);
|
||||||
|
const voice = useQuery({
|
||||||
|
queryKey: guildVoiceStatesQueryKey(guildId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchVoiceStates(guildId ?? '', signal),
|
||||||
|
enabled: guildId !== null,
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (guildId === null || voice.data === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGuildVoiceStates(guildId, voice.data);
|
||||||
|
}, [guildId, voice.data, setGuildVoiceStates]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доступен ли голос на инстансе (`GET /meta`, `features.voice_enabled`).
|
||||||
|
*
|
||||||
|
* Пока мета не загрузилась или недоступна, считаем голос включённым: иначе
|
||||||
|
* сетевая ошибка молча отключала бы голосовые комнаты, а понятную ошибку
|
||||||
|
* всё равно вернёт сервер при подключении.
|
||||||
|
*/
|
||||||
|
export function useVoiceEnabled(): boolean {
|
||||||
|
const meta = useQuery({
|
||||||
|
queryKey: metaQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchMeta(signal),
|
||||||
|
staleTime: 300_000,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
return meta.data?.features.voice_enabled !== false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { Channel } from '@/api/types';
|
||||||
|
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
|
import type { VoiceQuality, VoiceParticipant } from '@/lib/livekit';
|
||||||
|
import { voiceModerationPermissions, type VoiceModerationPermissions } from '@/lib/identity';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useSessionStore, voiceStatesOfChannel } from '@/stores/session';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Участник голосовой комнаты для интерфейса: серверное состояние
|
||||||
|
* (`voice_states`) плюс медиа-данные SDK, если мы сами в этой комнате.
|
||||||
|
*/
|
||||||
|
export interface VoiceOccupant {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
avatarFileId: string | undefined;
|
||||||
|
isLocal: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
selfMute: boolean;
|
||||||
|
selfDeaf: boolean;
|
||||||
|
serverMute: boolean;
|
||||||
|
serverDeaf: boolean;
|
||||||
|
camera: boolean;
|
||||||
|
screen: boolean;
|
||||||
|
/** Качество связи знает только SDK: вне своей комнаты его нет. */
|
||||||
|
quality: VoiceQuality | null;
|
||||||
|
/** Дорожки и флаги LiveKit: только для участников своей комнаты. */
|
||||||
|
participant: VoiceParticipant | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стабильная пустая ссылка: селектор не должен пересоздавать массив. */
|
||||||
|
const NO_PARTICIPANTS: VoiceParticipant[] = [];
|
||||||
|
const NO_OCCUPANTS: VoiceOccupant[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кто в комнате: список строится из `voice_states` (один источник правды),
|
||||||
|
* имена и аватары — из участников сервера, речь и качество — из SDK.
|
||||||
|
*/
|
||||||
|
export function useVoiceOccupants(guildId: string | null, channelId: string | null): VoiceOccupant[] {
|
||||||
|
const voiceStates = useSessionStore((state) => state.voiceStates);
|
||||||
|
const sessionUser = useSessionStore((state) => state.user);
|
||||||
|
const mediaParticipants = useVoiceConnectionStore((state) =>
|
||||||
|
state.channelId === channelId && channelId !== null ? state.participants : NO_PARTICIPANTS,
|
||||||
|
);
|
||||||
|
const connectedHere = useVoiceConnectionStore(
|
||||||
|
(state) => channelId !== null && state.channelId === channelId,
|
||||||
|
);
|
||||||
|
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
|
||||||
|
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
||||||
|
const cameraEnabled = useVoiceConnectionStore((state) => state.cameraEnabled);
|
||||||
|
const screenEnabled = useVoiceConnectionStore((state) => state.screenEnabled);
|
||||||
|
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
|
||||||
|
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
|
||||||
|
|
||||||
|
const states = useMemo(
|
||||||
|
() => (channelId === null ? [] : voiceStatesOfChannel(voiceStates, channelId)),
|
||||||
|
[voiceStates, channelId],
|
||||||
|
);
|
||||||
|
const userIds = useMemo(() => states.map((state) => state.user_id), [states]);
|
||||||
|
const directory = useAuthorDirectory(guildId, userIds);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (channelId === null) {
|
||||||
|
return NO_OCCUPANTS;
|
||||||
|
}
|
||||||
|
const occupants = states.map((state): VoiceOccupant => {
|
||||||
|
const media = mediaParticipants.find((item) => item.userId === state.user_id) ?? null;
|
||||||
|
const author = directory.resolve(state.user_id);
|
||||||
|
return {
|
||||||
|
userId: state.user_id,
|
||||||
|
name: author.name,
|
||||||
|
avatarFileId: author.avatarFileId,
|
||||||
|
isLocal: state.user_id === sessionUser?.id,
|
||||||
|
isSpeaking: media?.isSpeaking === true,
|
||||||
|
selfMute: state.self_mute,
|
||||||
|
selfDeaf: state.self_deaf,
|
||||||
|
serverMute: state.server_mute,
|
||||||
|
serverDeaf: state.server_deaf,
|
||||||
|
camera: state.camera || media?.cameraEnabled === true,
|
||||||
|
screen: state.screen || media?.screenEnabled === true,
|
||||||
|
quality: media?.quality ?? null,
|
||||||
|
participant: media,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Своё состояние приходит и событием, но при подключении может опоздать:
|
||||||
|
// рисуем себя сразу, чтобы не пропадать из собственной комнаты.
|
||||||
|
const me = sessionUser;
|
||||||
|
if (connectedHere && me !== null && !occupants.some((item) => item.isLocal)) {
|
||||||
|
const media = mediaParticipants.find((item) => item.userId === me.id) ?? null;
|
||||||
|
occupants.unshift({
|
||||||
|
userId: me.id,
|
||||||
|
name: me.display_name === '' ? me.username : me.display_name,
|
||||||
|
avatarFileId: me.avatar_file_id,
|
||||||
|
isLocal: true,
|
||||||
|
isSpeaking: media?.isSpeaking === true,
|
||||||
|
selfMute: !micEnabled,
|
||||||
|
selfDeaf: deafened,
|
||||||
|
serverMute: serverMuted,
|
||||||
|
serverDeaf: serverDeafened,
|
||||||
|
camera: cameraEnabled,
|
||||||
|
screen: screenEnabled,
|
||||||
|
quality: media?.quality ?? null,
|
||||||
|
participant: media,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return occupants;
|
||||||
|
}, [
|
||||||
|
channelId,
|
||||||
|
states,
|
||||||
|
mediaParticipants,
|
||||||
|
connectedHere,
|
||||||
|
directory,
|
||||||
|
sessionUser,
|
||||||
|
micEnabled,
|
||||||
|
deafened,
|
||||||
|
cameraEnabled,
|
||||||
|
screenEnabled,
|
||||||
|
serverMuted,
|
||||||
|
serverDeafened,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Права на голосовую модерацию для текущего пользователя. */
|
||||||
|
export function useVoiceModeration(guildId: string | null): VoiceModerationPermissions {
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const guild = useSessionStore((state) =>
|
||||||
|
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||||
|
);
|
||||||
|
const sessionUser = useSessionStore((state) => state.user);
|
||||||
|
const isAdmin =
|
||||||
|
sessionUser?.is_instance_admin === true || currentUser.data?.is_instance_admin === true;
|
||||||
|
if (guild === null) {
|
||||||
|
return { mute: false, deafen: false, move: false };
|
||||||
|
}
|
||||||
|
return voiceModerationPermissions(
|
||||||
|
guild.my_permissions,
|
||||||
|
guild.owner_id,
|
||||||
|
sessionUser?.id ?? currentUser.data?.id,
|
||||||
|
isAdmin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Голосовые комнаты сервера: цель для перемещения участников. */
|
||||||
|
export function useVoiceChannels(guildId: string | null): Channel[] {
|
||||||
|
const guild = useSessionStore((state) =>
|
||||||
|
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||||
|
);
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
(guild?.channels ?? [])
|
||||||
|
.filter((channel) => channel.type === 'voice' && channel.can_view !== false)
|
||||||
|
.sort((left, right) => left.position - right.position),
|
||||||
|
[guild],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { DragEvent } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME-тип перетаскивания участника голосовой комнаты (AGENT.md 7.14):
|
||||||
|
* плитка в области контента или строка в сайдбаре → голосовая комната.
|
||||||
|
*/
|
||||||
|
export const VOICE_DRAG_MIME = 'application/x-glchat-voice-user';
|
||||||
|
|
||||||
|
/** id участника из события перетаскивания (пусто, если тащат что-то другое). */
|
||||||
|
export function draggedUserId(event: DragEvent<HTMLElement>): string {
|
||||||
|
return event.dataTransfer.getData(VOICE_DRAG_MIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разрешает сброс только для перетаскивания участника. */
|
||||||
|
export function allowVoiceDrop(event: DragEvent<HTMLElement>): void {
|
||||||
|
if (event.dataTransfer.types.includes(VOICE_DRAG_MIME)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,10 @@
|
|||||||
},
|
},
|
||||||
"emojis": {
|
"emojis": {
|
||||||
"limit_reached": "The server has reached its emoji limit."
|
"limit_reached": "The server has reached its emoji limit."
|
||||||
}
|
},
|
||||||
|
"voice.disabled": "Voice is not configured on this instance.",
|
||||||
|
"voice.channel_full": "No free slots in this room.",
|
||||||
|
"voice.token_refresh_failed": "Could not renew the voice session. Please join again."
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switchToLight": "Light theme",
|
"switchToLight": "Light theme",
|
||||||
@@ -232,6 +235,66 @@
|
|||||||
"slowmode": "slowmode {{seconds}}s",
|
"slowmode": "slowmode {{seconds}}s",
|
||||||
"leaveHint": "You can leave the server in server settings → “Danger zone”."
|
"leaveHint": "You can leave the server in server settings → “Danger zone”."
|
||||||
},
|
},
|
||||||
|
"voice": {
|
||||||
|
"title": "Voice room",
|
||||||
|
"join": "Join",
|
||||||
|
"joining": "Connecting…",
|
||||||
|
"connected": "Connected",
|
||||||
|
"reconnecting": "Reconnecting…",
|
||||||
|
"refreshing": "Renewing session…",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"disabled": "Voice is not configured on this instance",
|
||||||
|
"empty": "Nobody is in the room yet",
|
||||||
|
"you": "You",
|
||||||
|
"speaking": "Speaking",
|
||||||
|
"muted": "Microphone is off",
|
||||||
|
"soundOn": "Sound is on",
|
||||||
|
"deafened": "Sound is off",
|
||||||
|
"cameraOn": "Camera is on",
|
||||||
|
"screenOn": "Screen sharing",
|
||||||
|
"serverMuted": "Microphone was muted by a moderator",
|
||||||
|
"serverDeafened": "Sound was muted by a moderator",
|
||||||
|
"speakingWhileMuted": "You are speaking while muted",
|
||||||
|
"volume": "Participant volume",
|
||||||
|
"controls": "Voice controls",
|
||||||
|
"channelParticipants": "Participants of “{{name}}”",
|
||||||
|
"mic": {
|
||||||
|
"on": "Unmute microphone",
|
||||||
|
"off": "Mute microphone"
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"on": "Unmute sound",
|
||||||
|
"off": "Mute sound"
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"on": "Turn camera on",
|
||||||
|
"off": "Turn camera off"
|
||||||
|
},
|
||||||
|
"screen": {
|
||||||
|
"on": "Start screen sharing",
|
||||||
|
"off": "Stop screen sharing"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"excellent": "Excellent connection",
|
||||||
|
"good": "Good connection",
|
||||||
|
"poor": "Poor connection",
|
||||||
|
"lost": "Connection lost",
|
||||||
|
"unknown": "Connecting"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"label": "Participant {{name}}",
|
||||||
|
"moderation": "Moderation",
|
||||||
|
"mute": "Server mute",
|
||||||
|
"unmute": "Remove server mute",
|
||||||
|
"deafen": "Server deafen",
|
||||||
|
"undeafen": "Remove server deafen",
|
||||||
|
"move": "Move",
|
||||||
|
"moveTo": "Move to “{{name}}”"
|
||||||
|
},
|
||||||
|
"channelMenu": {
|
||||||
|
"label": "Voice room {{name}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
"rail": "Friends",
|
"rail": "Friends",
|
||||||
"title": "Friends",
|
"title": "Friends",
|
||||||
|
|||||||
@@ -67,7 +67,10 @@
|
|||||||
},
|
},
|
||||||
"emojis": {
|
"emojis": {
|
||||||
"limit_reached": "На сервере достигнут лимит эмодзи."
|
"limit_reached": "На сервере достигнут лимит эмодзи."
|
||||||
}
|
},
|
||||||
|
"voice.disabled": "Голос не настроен на инстансе.",
|
||||||
|
"voice.channel_full": "В комнате нет свободных мест.",
|
||||||
|
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново."
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switchToLight": "Светлая тема",
|
"switchToLight": "Светлая тема",
|
||||||
@@ -232,6 +235,66 @@
|
|||||||
"slowmode": "медленный режим {{seconds}} с",
|
"slowmode": "медленный режим {{seconds}} с",
|
||||||
"leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона»."
|
"leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона»."
|
||||||
},
|
},
|
||||||
|
"voice": {
|
||||||
|
"title": "Голосовая комната",
|
||||||
|
"join": "Подключиться",
|
||||||
|
"joining": "Подключение…",
|
||||||
|
"connected": "Подключено",
|
||||||
|
"reconnecting": "Переподключение…",
|
||||||
|
"refreshing": "Продлеваем сессию…",
|
||||||
|
"disconnect": "Отключиться",
|
||||||
|
"disabled": "Голос не настроен на инстансе",
|
||||||
|
"empty": "В комнате пока никого нет",
|
||||||
|
"you": "Вы",
|
||||||
|
"speaking": "Говорит",
|
||||||
|
"muted": "Микрофон выключен",
|
||||||
|
"soundOn": "Звук включён",
|
||||||
|
"deafened": "Звук выключен",
|
||||||
|
"cameraOn": "Камера включена",
|
||||||
|
"screenOn": "Демонстрация экрана",
|
||||||
|
"serverMuted": "Микрофон выключен модератором",
|
||||||
|
"serverDeafened": "Звук выключен модератором",
|
||||||
|
"speakingWhileMuted": "Вы говорите с выключенным микрофоном",
|
||||||
|
"volume": "Громкость участника",
|
||||||
|
"controls": "Управление голосом",
|
||||||
|
"channelParticipants": "Участники комнаты «{{name}}»",
|
||||||
|
"mic": {
|
||||||
|
"on": "Включить микрофон",
|
||||||
|
"off": "Выключить микрофон"
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"on": "Включить звук",
|
||||||
|
"off": "Выключить звук"
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"on": "Включить камеру",
|
||||||
|
"off": "Выключить камеру"
|
||||||
|
},
|
||||||
|
"screen": {
|
||||||
|
"on": "Начать демонстрацию экрана",
|
||||||
|
"off": "Остановить демонстрацию экрана"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"excellent": "Отличное соединение",
|
||||||
|
"good": "Хорошее соединение",
|
||||||
|
"poor": "Плохое соединение",
|
||||||
|
"lost": "Соединение потеряно",
|
||||||
|
"unknown": "Соединение устанавливается"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"label": "Участник {{name}}",
|
||||||
|
"moderation": "Модерация",
|
||||||
|
"mute": "Выключить микрофон",
|
||||||
|
"unmute": "Включить микрофон",
|
||||||
|
"deafen": "Выключить звук",
|
||||||
|
"undeafen": "Включить звук",
|
||||||
|
"move": "Переместить",
|
||||||
|
"moveTo": "Переместить в «{{name}}»"
|
||||||
|
},
|
||||||
|
"channelMenu": {
|
||||||
|
"label": "Голосовая комната {{name}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
"rail": "Друзья",
|
"rail": "Друзья",
|
||||||
"title": "Друзья",
|
"title": "Друзья",
|
||||||
|
|||||||
+51
-2
@@ -44,6 +44,10 @@ export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
|
|||||||
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
|
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
|
||||||
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
|
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
|
||||||
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
|
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
|
||||||
|
export const PERMISSION_CONNECT_VOICE = 'CONNECT_VOICE';
|
||||||
|
export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS';
|
||||||
|
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
|
||||||
|
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
|
||||||
|
|
||||||
/** Есть ли у пользователя право с указанным именем. */
|
/** Есть ли у пользователя право с указанным именем. */
|
||||||
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
||||||
@@ -99,6 +103,52 @@ export function canAdministerGuild(
|
|||||||
return isInstanceAdmin || canManageGuildSettings(permissions, ownerId, userId);
|
return isInstanceAdmin || canManageGuildSettings(permissions, ownerId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Права модератора голосовых комнат: что доступно пользователю на сервере. */
|
||||||
|
export interface VoiceModerationPermissions {
|
||||||
|
mute: boolean;
|
||||||
|
deafen: boolean;
|
||||||
|
move: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Права на голосовую модерацию (AGENT.md 7.14): серверный мьют, глушение и
|
||||||
|
* перемещение. Администратор инстанса видит все пункты, как и в остальном UI.
|
||||||
|
*/
|
||||||
|
export function voiceModerationPermissions(
|
||||||
|
permissions: readonly string[],
|
||||||
|
ownerId: string,
|
||||||
|
userId: string | undefined,
|
||||||
|
isInstanceAdmin = false,
|
||||||
|
): VoiceModerationPermissions {
|
||||||
|
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||||
|
return { mute: true, deafen: true, move: true };
|
||||||
|
}
|
||||||
|
const administrator = hasPermission(permissions, PERMISSION_ADMINISTRATOR);
|
||||||
|
return {
|
||||||
|
mute: administrator || hasPermission(permissions, PERMISSION_MUTE_MEMBERS),
|
||||||
|
deafen: administrator || hasPermission(permissions, PERMISSION_DEAFEN_MEMBERS),
|
||||||
|
move: administrator || hasPermission(permissions, PERMISSION_MOVE_MEMBERS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Может ли пользователь войти в голосовую комнату. */
|
||||||
|
export function canConnectVoice(
|
||||||
|
permissions: readonly string[],
|
||||||
|
isInstanceAdmin = false,
|
||||||
|
channelCanConnect?: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (channelCanConnect === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isInstanceAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||||
|
hasPermission(permissions, PERMISSION_CONNECT_VOICE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
|
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
|
||||||
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
|
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
|
||||||
@@ -137,8 +187,7 @@ export function canManageEmojis(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Может ли пользователь отправлять сообщения в комнату. */
|
/** Может ли пользователь отправлять сообщения в комнату. */
|
||||||
export function canSendMessages(
|
export function canSendMessages( permissions: readonly string[],
|
||||||
permissions: readonly string[],
|
|
||||||
isInstanceAdmin = false,
|
isInstanceAdmin = false,
|
||||||
channelCanSend?: boolean,
|
channelCanSend?: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { ConnectionQuality, Room, RoomEvent, Track, type Participant } from 'livekit-client';
|
||||||
|
|
||||||
|
import type { VoiceDeviceKind } from '@/stores/voice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над `livekit-client`: единственное место, где клиент знает про SDK.
|
||||||
|
*
|
||||||
|
* Стор и компоненты работают с простыми снимками (`VoiceParticipant`) и
|
||||||
|
* дескрипторами дорожек (`VoiceTrackHandle`), поэтому:
|
||||||
|
* - UI не зависит от версии SDK и умеет рисовать участников без него;
|
||||||
|
* - в тестах SDK подменяется моком `livekit-client`, а логика стора
|
||||||
|
* проверяется настоящая.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Качество связи участника (значения совпадают с `ConnectionQuality` из SDK). */
|
||||||
|
export type VoiceQuality = 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
|
||||||
|
|
||||||
|
/** Дескриптор медиа-дорожки: подключение к элементу и отключение от него. */
|
||||||
|
export interface VoiceTrackHandle {
|
||||||
|
kind: 'audio' | 'video';
|
||||||
|
attach: (element: HTMLMediaElement) => void;
|
||||||
|
detach: (element: HTMLMediaElement) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снимок участника комнаты: всё, что нужно интерфейсу. */
|
||||||
|
export interface VoiceParticipant {
|
||||||
|
/** identity в LiveKit — это id пользователя. */
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
isLocal: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
micEnabled: boolean;
|
||||||
|
cameraEnabled: boolean;
|
||||||
|
screenEnabled: boolean;
|
||||||
|
quality: VoiceQuality;
|
||||||
|
/** Микрофон собеседника (своя дорожка не нужна: себя не слушаем). */
|
||||||
|
audio?: VoiceTrackHandle;
|
||||||
|
/** Камера: своя — для предпросмотра, чужая — для плитки. */
|
||||||
|
video?: VoiceTrackHandle;
|
||||||
|
/** Демонстрация экрана. */
|
||||||
|
screen?: VoiceTrackHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceRoomStatus = 'connected' | 'reconnecting' | 'reconnected' | 'disconnected';
|
||||||
|
|
||||||
|
export interface VoiceRoomCallbacks {
|
||||||
|
/** Изменился состав комнаты или медиа-состояние участников. */
|
||||||
|
onParticipants: (participants: VoiceParticipant[]) => void;
|
||||||
|
onStatus: (status: VoiceRoomStatus) => void;
|
||||||
|
/** SDK сообщает, что свой микрофон говорит, хотя публикация выключена. */
|
||||||
|
onSpeakingWhileMuted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceRoomHandle {
|
||||||
|
connect: (url: string, token: string) => Promise<void>;
|
||||||
|
disconnect: () => Promise<void>;
|
||||||
|
/** Снимает подписки на события SDK: после этого объект больше не нужен. */
|
||||||
|
dispose: () => void;
|
||||||
|
setMicrophoneEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||||
|
participants: () => VoiceParticipant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запасной TTL токена, если `exp` не удалось разобрать (AGENT.md §7.14). */
|
||||||
|
export const DEFAULT_TOKEN_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
function qualityOf(participant: Participant): VoiceQuality {
|
||||||
|
switch (participant.connectionQuality) {
|
||||||
|
case ConnectionQuality.Excellent:
|
||||||
|
return 'excellent';
|
||||||
|
case ConnectionQuality.Good:
|
||||||
|
return 'good';
|
||||||
|
case ConnectionQuality.Poor:
|
||||||
|
return 'poor';
|
||||||
|
case ConnectionQuality.Lost:
|
||||||
|
return 'lost';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOf(
|
||||||
|
track: Track | undefined,
|
||||||
|
kind: 'audio' | 'video',
|
||||||
|
): VoiceTrackHandle | undefined {
|
||||||
|
if (track === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
attach: (element) => {
|
||||||
|
track.attach(element);
|
||||||
|
},
|
||||||
|
detach: (element) => {
|
||||||
|
track.detach(element);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Опубликована ли дорожка и не замьючена ли она. */
|
||||||
|
function isLive(participant: Participant, source: Track.Source): boolean {
|
||||||
|
const publication = participant.getTrackPublication(source);
|
||||||
|
return publication !== undefined && publication.track !== undefined && !publication.isMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotOf(participant: Participant, isLocal: boolean): VoiceParticipant {
|
||||||
|
const microphone = participant.getTrackPublication(Track.Source.Microphone)?.track;
|
||||||
|
const camera = participant.getTrackPublication(Track.Source.Camera)?.track;
|
||||||
|
const screen = participant.getTrackPublication(Track.Source.ScreenShare)?.track;
|
||||||
|
const snapshot: VoiceParticipant = {
|
||||||
|
userId: participant.identity,
|
||||||
|
name: participant.name === undefined || participant.name === '' ? participant.identity : participant.name,
|
||||||
|
isLocal,
|
||||||
|
isSpeaking: participant.isSpeaking,
|
||||||
|
micEnabled: isLive(participant, Track.Source.Microphone),
|
||||||
|
cameraEnabled: isLive(participant, Track.Source.Camera),
|
||||||
|
screenEnabled: isLive(participant, Track.Source.ScreenShare),
|
||||||
|
quality: qualityOf(participant),
|
||||||
|
};
|
||||||
|
// Свой микрофон не слушаем: элемент нужен только для чужих дорожек.
|
||||||
|
const audio = isLocal ? undefined : handleOf(microphone, 'audio');
|
||||||
|
const video = handleOf(camera, 'video');
|
||||||
|
const screenShare = handleOf(screen, 'video');
|
||||||
|
if (audio !== undefined) {
|
||||||
|
snapshot.audio = audio;
|
||||||
|
}
|
||||||
|
if (video !== undefined) {
|
||||||
|
snapshot.video = video;
|
||||||
|
}
|
||||||
|
if (screenShare !== undefined) {
|
||||||
|
snapshot.screen = screenShare;
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotAll(room: Room): VoiceParticipant[] {
|
||||||
|
const participants = [snapshotOf(room.localParticipant, true)];
|
||||||
|
for (const remote of room.remoteParticipants.values()) {
|
||||||
|
participants.push(snapshotOf(remote, false));
|
||||||
|
}
|
||||||
|
return participants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создаёт комнату LiveKit и подписывается на её события. */
|
||||||
|
export function createVoiceRoom(callbacks: VoiceRoomCallbacks): VoiceRoomHandle {
|
||||||
|
// Пустой deviceId в предпочтениях означает «устройство по умолчанию».
|
||||||
|
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||||
|
const refresh = (): void => {
|
||||||
|
callbacks.onParticipants(snapshotAll(room));
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantConnected, refresh);
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, refresh);
|
||||||
|
room.on(RoomEvent.TrackSubscribed, refresh);
|
||||||
|
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
||||||
|
room.on(RoomEvent.TrackPublished, refresh);
|
||||||
|
room.on(RoomEvent.TrackUnpublished, refresh);
|
||||||
|
room.on(RoomEvent.TrackMuted, refresh);
|
||||||
|
room.on(RoomEvent.TrackUnmuted, refresh);
|
||||||
|
room.on(RoomEvent.LocalTrackPublished, refresh);
|
||||||
|
room.on(RoomEvent.LocalTrackUnpublished, refresh);
|
||||||
|
room.on(RoomEvent.ConnectionQualityChanged, refresh);
|
||||||
|
room.on(RoomEvent.ActiveSpeakersChanged, () => {
|
||||||
|
const local = room.localParticipant;
|
||||||
|
if (local.isSpeaking && !local.isMicrophoneEnabled) {
|
||||||
|
callbacks.onSpeakingWhileMuted();
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
room.on(RoomEvent.Reconnecting, () => callbacks.onStatus('reconnecting'));
|
||||||
|
room.on(RoomEvent.Reconnected, () => callbacks.onStatus('reconnected'));
|
||||||
|
room.on(RoomEvent.Disconnected, () => callbacks.onStatus('disconnected'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
async connect(url, token) {
|
||||||
|
await room.connect(url, token);
|
||||||
|
// Автовоспроизведение может быть запрещено до жеста пользователя —
|
||||||
|
// это не ошибка подключения.
|
||||||
|
try {
|
||||||
|
await room.startAudio();
|
||||||
|
} catch {
|
||||||
|
// Звук включится при первом клике пользователя.
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
await room.disconnect();
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
room.removeAllListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
async setMicrophoneEnabled(enabled) {
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(enabled);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
async setCameraEnabled(enabled) {
|
||||||
|
await room.localParticipant.setCameraEnabled(enabled);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
async setScreenShareEnabled(enabled) {
|
||||||
|
await room.localParticipant.setScreenShareEnabled(enabled);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
async switchActiveDevice(kind, deviceId) {
|
||||||
|
await room.switchActiveDevice(kind, deviceId === '' ? 'default' : deviceId);
|
||||||
|
},
|
||||||
|
|
||||||
|
participants() {
|
||||||
|
return snapshotAll(room);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Время истечения токена LiveKit (мс, эпоха) из поля `exp` JWT.
|
||||||
|
* Если разобрать не удалось — считаем, что токен живёт `DEFAULT_TOKEN_TTL_MS`.
|
||||||
|
*/
|
||||||
|
export function tokenExpiresAt(token: string, now: number = Date.now()): number {
|
||||||
|
const expires = readJwtExpiry(token);
|
||||||
|
return expires === null ? now + DEFAULT_TOKEN_TTL_MS : expires * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJwtExpiry(token: string): number | null {
|
||||||
|
const payload = token.split('.')[1];
|
||||||
|
if (payload === undefined || payload === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
|
||||||
|
const decoded: unknown = JSON.parse(globalThis.atob(padded));
|
||||||
|
if (typeof decoded !== 'object' || decoded === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const exp = (decoded as { exp?: unknown }).exp;
|
||||||
|
return typeof exp === 'number' && Number.isFinite(exp) ? exp : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
|
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
|
||||||
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
|
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
|
||||||
export interface MediaDevice {
|
export interface MediaDevice {
|
||||||
@@ -72,6 +73,9 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
|
|||||||
/**
|
/**
|
||||||
* Выбранное устройство нужного вида из стора предпочтений.
|
* Выбранное устройство нужного вида из стора предпочтений.
|
||||||
* Одна обёртка на три вида — чтобы не дублировать селекторы в компонентах.
|
* Одна обёртка на три вида — чтобы не дублировать селекторы в компонентах.
|
||||||
|
*
|
||||||
|
* Смена устройства сразу применяется к активной голосовой сессии
|
||||||
|
* (`switchActiveDevice`), а выбор сохраняется в предпочтениях клиента.
|
||||||
*/
|
*/
|
||||||
export function useDeviceChoice(kind: VoiceDeviceKind): {
|
export function useDeviceChoice(kind: VoiceDeviceKind): {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -87,5 +91,11 @@ export function useDeviceChoice(kind: VoiceDeviceKind): {
|
|||||||
return state.outputDeviceId;
|
return state.outputDeviceId;
|
||||||
});
|
});
|
||||||
const selectDevice = useVoiceStore((state) => state.selectDevice);
|
const selectDevice = useVoiceStore((state) => state.selectDevice);
|
||||||
return { value, select: (deviceId: string) => selectDevice(kind, deviceId) };
|
return {
|
||||||
|
value,
|
||||||
|
select: (deviceId: string) => {
|
||||||
|
selectDevice(kind, deviceId);
|
||||||
|
void useVoiceConnectionStore.getState().switchActiveDevice(kind, deviceId);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router';
|
|||||||
import { fetchGuild, guildQueryKey } from '@/api/guilds';
|
import { fetchGuild, guildQueryKey } from '@/api/guilds';
|
||||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||||
import { GatewayBridge } from '@/components/GatewayBridge';
|
import { GatewayBridge } from '@/components/GatewayBridge';
|
||||||
|
import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice';
|
||||||
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
@@ -193,6 +194,8 @@ export default function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateGuildModal />
|
<CreateGuildModal />
|
||||||
|
{/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */}
|
||||||
|
<VoiceSpeakingNotice />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Field, SelectField } from '@/components/ui/Field';
|
|||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { VoiceChannelItem } from '@/components/voice/VoiceChannelItem';
|
||||||
import { groupChannels } from '@/lib/channels';
|
import { groupChannels } from '@/lib/channels';
|
||||||
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
|
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
@@ -196,11 +197,19 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
<ul className="gl-stagger flex flex-col gap-0.5">
|
<ul className="gl-stagger flex flex-col gap-0.5">
|
||||||
{group.channels.map((channel) => (
|
{group.channels.map((channel) => (
|
||||||
<li key={channel.id}>
|
<li key={channel.id}>
|
||||||
|
{channel.type === 'voice' ? (
|
||||||
|
<VoiceChannelItem
|
||||||
|
guildId={guildId}
|
||||||
|
channel={channel}
|
||||||
|
selected={channel.id === channelId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ChannelLink
|
<ChannelLink
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
selected={channel.id === channelId}
|
selected={channel.id === channelId}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { ChatView } from '@/components/chat/ChatView';
|
|||||||
import { PinsPanel } from '@/components/chat/PinsPanel';
|
import { PinsPanel } from '@/components/chat/PinsPanel';
|
||||||
import { SearchPanel } from '@/components/chat/SearchPanel';
|
import { SearchPanel } from '@/components/chat/SearchPanel';
|
||||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
||||||
import { canManageMessages, canSendMessages } from '@/lib/identity';
|
import { canManageMessages, canSendMessages } from '@/lib/identity';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
|
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
|
||||||
* для голосовых сохраняется заглушка до следующих фаз.
|
* для голосовых — панель голосовой комнаты (AGENT.md 7.14).
|
||||||
*/
|
*/
|
||||||
export default function GuildView() {
|
export default function GuildView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -116,14 +116,7 @@ export default function GuildView() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isVoice ? (
|
{isVoice ? (
|
||||||
<div className="flex flex-1 items-center justify-center p-6">
|
<VoiceRoomPanel guildId={guild?.id ?? ''} channel={channel} />
|
||||||
<Card className="max-w-md text-center">
|
|
||||||
<h3 className="text-lg font-semibold">{t('channels.voicePlaceholder.title')}</h3>
|
|
||||||
<p className="mt-2 text-sm text-fg-muted">
|
|
||||||
{t('channels.voicePlaceholder.body', { name: channel.name })}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SearchPanel
|
<SearchPanel
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useMessagesStore } from '@/stores/messages';
|
|||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
import { useVoiceStore } from '@/stores/voice';
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
import { SettingsModal } from '@/pages/settings/SettingsModal';
|
import { SettingsModal } from '@/pages/settings/SettingsModal';
|
||||||
|
|
||||||
/** Статусы, которые можно выставить из мини-профиля: «не в сети» сервер выводит сам. */
|
/** Статусы, которые можно выставить из мини-профиля: «не в сети» сервер выводит сам. */
|
||||||
@@ -52,6 +53,10 @@ interface PanelControlProps {
|
|||||||
toggleLabel: string;
|
toggleLabel: string;
|
||||||
/** Подпись стрелки и поповера. */
|
/** Подпись стрелки и поповера. */
|
||||||
menuLabel: string;
|
menuLabel: string;
|
||||||
|
/** Заблокировано модератором: переключать нельзя. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Почему кнопка заблокирована (озвучивается вместо подписи). */
|
||||||
|
disabledReason?: string;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenMenu: () => void;
|
onOpenMenu: () => void;
|
||||||
@@ -69,6 +74,8 @@ function PanelControl({
|
|||||||
enabled,
|
enabled,
|
||||||
toggleLabel,
|
toggleLabel,
|
||||||
menuLabel,
|
menuLabel,
|
||||||
|
disabled = false,
|
||||||
|
disabledReason,
|
||||||
onToggle,
|
onToggle,
|
||||||
open,
|
open,
|
||||||
onOpenMenu,
|
onOpenMenu,
|
||||||
@@ -78,6 +85,7 @@ function PanelControl({
|
|||||||
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
|
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
|
||||||
// Группа «стрелка + кнопка»: поповер прижимается к её краю.
|
// Группа «стрелка + кнопка»: поповер прижимается к её краю.
|
||||||
const groupRef: RefObject<HTMLDivElement | null> = useRef(null);
|
const groupRef: RefObject<HTMLDivElement | null> = useRef(null);
|
||||||
|
const label = disabled && disabledReason !== undefined ? disabledReason : toggleLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={groupRef} className="relative flex h-8 w-9 shrink-0 items-stretch gap-0">
|
<div ref={groupRef} className="relative flex h-8 w-9 shrink-0 items-stretch gap-0">
|
||||||
@@ -100,10 +108,11 @@ function PanelControl({
|
|||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={enabled}
|
aria-pressed={enabled}
|
||||||
aria-label={toggleLabel}
|
aria-label={label}
|
||||||
title={toggleLabel}
|
title={label}
|
||||||
|
disabled={disabled}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={`flex min-w-0 flex-1 items-center justify-center rounded-r-[var(--radius-sm)] px-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
|
className={`flex min-w-0 flex-1 items-center justify-center rounded-r-[var(--radius-sm)] px-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
enabled ? 'text-fg' : 'text-danger'
|
enabled ? 'text-fg' : 'text-danger'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -144,9 +153,6 @@ export function UserPanel() {
|
|||||||
const micEnabled = useVoiceStore((state) => state.micEnabled);
|
const micEnabled = useVoiceStore((state) => state.micEnabled);
|
||||||
const cameraEnabled = useVoiceStore((state) => state.cameraEnabled);
|
const cameraEnabled = useVoiceStore((state) => state.cameraEnabled);
|
||||||
const headphonesEnabled = useVoiceStore((state) => state.headphonesEnabled);
|
const headphonesEnabled = useVoiceStore((state) => state.headphonesEnabled);
|
||||||
const toggleMic = useVoiceStore((state) => state.toggleMic);
|
|
||||||
const toggleCamera = useVoiceStore((state) => state.toggleCamera);
|
|
||||||
const toggleHeadphones = useVoiceStore((state) => state.toggleHeadphones);
|
|
||||||
const micVolume = useVoiceStore((state) => state.micVolume);
|
const micVolume = useVoiceStore((state) => state.micVolume);
|
||||||
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
||||||
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
|
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
|
||||||
@@ -155,6 +161,22 @@ export function UserPanel() {
|
|||||||
const camera = useDeviceChoice('videoinput');
|
const camera = useDeviceChoice('videoinput');
|
||||||
const output = useDeviceChoice('audiooutput');
|
const output = useDeviceChoice('audiooutput');
|
||||||
|
|
||||||
|
// Пока не подключены к голосовой комнате, кнопки хранят локальные
|
||||||
|
// предпочтения; в комнате они управляют настоящей сессией LiveKit.
|
||||||
|
const connectionStatus = useVoiceConnectionStore((state) => state.status);
|
||||||
|
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
|
||||||
|
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
|
||||||
|
const connectionMic = useVoiceConnectionStore((state) => state.micEnabled);
|
||||||
|
const connectionCamera = useVoiceConnectionStore((state) => state.cameraEnabled);
|
||||||
|
const connectionDeafened = useVoiceConnectionStore((state) => state.deafened);
|
||||||
|
const setSessionMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
|
||||||
|
const setSessionCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||||
|
const setSessionDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||||
|
const connected = connectionStatus === 'connected' || connectionStatus === 'reconnecting';
|
||||||
|
const micOn = connected ? connectionMic : micEnabled;
|
||||||
|
const cameraOn = connected ? connectionCamera : cameraEnabled;
|
||||||
|
const soundOn = connected ? !connectionDeafened : headphonesEnabled;
|
||||||
|
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
/** Какое устройство сейчас раскрыто в поповере: одновременно только одно. */
|
/** Какое устройство сейчас раскрыто в поповере: одновременно только одно. */
|
||||||
const [openControl, setOpenControl] = useState<'mic' | 'camera' | 'headphones' | null>(null);
|
const [openControl, setOpenControl] = useState<'mic' | 'camera' | 'headphones' | null>(null);
|
||||||
@@ -175,6 +197,8 @@ export function UserPanel() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
|
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
|
||||||
disconnect();
|
disconnect();
|
||||||
|
// Голосовую комнату покидаем без запросов: сессии больше нет.
|
||||||
|
useVoiceConnectionStore.getState().reset();
|
||||||
useSessionStore.getState().reset();
|
useSessionStore.getState().reset();
|
||||||
// Сообщения тоже привязаны к пользователю: чужой истории в памяти быть не должно.
|
// Сообщения тоже привязаны к пользователю: чужой истории в памяти быть не должно.
|
||||||
useMessagesStore.getState().reset();
|
useMessagesStore.getState().reset();
|
||||||
@@ -262,10 +286,12 @@ export function UserPanel() {
|
|||||||
|
|
||||||
<PanelControl
|
<PanelControl
|
||||||
glyph="🎤"
|
glyph="🎤"
|
||||||
enabled={micEnabled}
|
enabled={micOn && !serverMuted}
|
||||||
toggleLabel={t(micEnabled ? 'user.voice.micOff' : 'user.voice.micOn')}
|
toggleLabel={t(micOn ? 'user.voice.micOff' : 'user.voice.micOn')}
|
||||||
menuLabel={t('user.voice.micSettings')}
|
menuLabel={t('user.voice.micSettings')}
|
||||||
onToggle={toggleMic}
|
disabled={serverMuted}
|
||||||
|
{...(serverMuted ? { disabledReason: t('voice.serverMuted') } : {})}
|
||||||
|
onToggle={() => void setSessionMicEnabled(!micOn)}
|
||||||
open={openControl === 'mic'}
|
open={openControl === 'mic'}
|
||||||
onOpenMenu={() => toggleControl('mic')}
|
onOpenMenu={() => toggleControl('mic')}
|
||||||
onCloseMenu={() => setOpenControl(null)}
|
onCloseMenu={() => setOpenControl(null)}
|
||||||
@@ -286,10 +312,10 @@ export function UserPanel() {
|
|||||||
|
|
||||||
<PanelControl
|
<PanelControl
|
||||||
glyph="🎥"
|
glyph="🎥"
|
||||||
enabled={cameraEnabled}
|
enabled={cameraOn}
|
||||||
toggleLabel={t(cameraEnabled ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
|
toggleLabel={t(cameraOn ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
|
||||||
menuLabel={t('user.voice.cameraSettings')}
|
menuLabel={t('user.voice.cameraSettings')}
|
||||||
onToggle={toggleCamera}
|
onToggle={() => void setSessionCameraEnabled(!cameraOn)}
|
||||||
open={openControl === 'camera'}
|
open={openControl === 'camera'}
|
||||||
onOpenMenu={() => toggleControl('camera')}
|
onOpenMenu={() => toggleControl('camera')}
|
||||||
onCloseMenu={() => setOpenControl(null)}
|
onCloseMenu={() => setOpenControl(null)}
|
||||||
@@ -305,10 +331,12 @@ export function UserPanel() {
|
|||||||
|
|
||||||
<PanelControl
|
<PanelControl
|
||||||
glyph="🎧"
|
glyph="🎧"
|
||||||
enabled={headphonesEnabled}
|
enabled={soundOn && !serverDeafened}
|
||||||
toggleLabel={t(headphonesEnabled ? 'user.voice.soundOff' : 'user.voice.soundOn')}
|
toggleLabel={t(soundOn ? 'user.voice.soundOff' : 'user.voice.soundOn')}
|
||||||
menuLabel={t('user.voice.soundSettings')}
|
menuLabel={t('user.voice.soundSettings')}
|
||||||
onToggle={toggleHeadphones}
|
disabled={serverDeafened}
|
||||||
|
{...(serverDeafened ? { disabledReason: t('voice.serverDeafened') } : {})}
|
||||||
|
onToggle={() => void setSessionDeafened(soundOn)}
|
||||||
open={openControl === 'headphones'}
|
open={openControl === 'headphones'}
|
||||||
onOpenMenu={() => toggleControl('headphones')}
|
onOpenMenu={() => toggleControl('headphones')}
|
||||||
onCloseMenu={() => setOpenControl(null)}
|
onCloseMenu={() => setOpenControl(null)}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
import { useFriendsStore } from '@/stores/friends';
|
import { useFriendsStore } from '@/stores/friends';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
import { parseVoiceStateEvent } from '@/api/voice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
||||||
@@ -78,6 +80,8 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
|||||||
useSessionStore.getState().reset();
|
useSessionStore.getState().reset();
|
||||||
// Сессия мертва: лента сообщений тоже больше не наша.
|
// Сессия мертва: лента сообщений тоже больше не наша.
|
||||||
useMessagesStore.getState().reset();
|
useMessagesStore.getState().reset();
|
||||||
|
// Голосовую комнату покидаем без запроса: сессии уже нет.
|
||||||
|
useVoiceConnectionStore.getState().reset();
|
||||||
void invalidateCurrentUser();
|
void invalidateCurrentUser();
|
||||||
},
|
},
|
||||||
onResumeIncomplete: () => {
|
onResumeIncomplete: () => {
|
||||||
@@ -309,6 +313,21 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
return;
|
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:
|
default:
|
||||||
// Неизвестные события игнорируем: контракт может расширяться.
|
// Неизвестные события игнорируем: контракт может расширяться.
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { GuildEmoji } from '@/api/emojis';
|
import type { GuildEmoji } from '@/api/emojis';
|
||||||
import { fetchDirectChannels } from '@/api/friends';
|
import { fetchDirectChannels } from '@/api/friends';
|
||||||
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
||||||
|
import type { VoiceState } from '@/api/voice';
|
||||||
import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
|
import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,12 @@ interface SessionState {
|
|||||||
user: GatewayUser | null;
|
user: GatewayUser | null;
|
||||||
guilds: SessionGuild[];
|
guilds: SessionGuild[];
|
||||||
dmChannels: Channel[];
|
dmChannels: Channel[];
|
||||||
|
/**
|
||||||
|
* Кто в голосовых комнатах: единственный источник правды для интерфейса.
|
||||||
|
* Наполняется снапшотом READY, событиями `VOICE_STATE_UPDATE` и ответами
|
||||||
|
* голосовых ручек (AGENT.md 7.14).
|
||||||
|
*/
|
||||||
|
voiceStates: Record<string, VoiceState[]>;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
selectedGuildId: string | null;
|
selectedGuildId: string | null;
|
||||||
selectedChannelId: string | null;
|
selectedChannelId: string | null;
|
||||||
@@ -45,6 +52,12 @@ interface SessionState {
|
|||||||
removeGuild: (guildId: string) => void;
|
removeGuild: (guildId: string) => void;
|
||||||
setGuildChannels: (guildId: string, channels: Channel[]) => void;
|
setGuildChannels: (guildId: string, channels: Channel[]) => void;
|
||||||
upsertChannel: (guildId: string, channel: 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` шлют его
|
* Набор эмодзи сервера целиком: READY и `GUILD_EMOJIS_UPDATE` шлют его
|
||||||
* одним и тем же списком.
|
* одним и тем же списком.
|
||||||
@@ -78,6 +91,7 @@ interface SessionDefaults {
|
|||||||
user: null;
|
user: null;
|
||||||
guilds: SessionGuild[];
|
guilds: SessionGuild[];
|
||||||
dmChannels: Channel[];
|
dmChannels: Channel[];
|
||||||
|
voiceStates: Record<string, VoiceState[]>;
|
||||||
sessionId: null;
|
sessionId: null;
|
||||||
selectedGuildId: null;
|
selectedGuildId: null;
|
||||||
selectedChannelId: null;
|
selectedChannelId: null;
|
||||||
@@ -88,6 +102,7 @@ const defaults: SessionDefaults = {
|
|||||||
user: null,
|
user: null,
|
||||||
guilds: [],
|
guilds: [],
|
||||||
dmChannels: [],
|
dmChannels: [],
|
||||||
|
voiceStates: {},
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
selectedGuildId: null,
|
selectedGuildId: null,
|
||||||
selectedChannelId: null,
|
selectedChannelId: null,
|
||||||
@@ -139,6 +154,13 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
user: snapshot.user,
|
user: snapshot.user,
|
||||||
guilds,
|
guilds,
|
||||||
dmChannels: snapshot.dm_channels,
|
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,
|
sessionId: snapshot.session_id,
|
||||||
selectedGuildId,
|
selectedGuildId,
|
||||||
selectedChannelId,
|
selectedChannelId,
|
||||||
@@ -228,8 +250,10 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
const selectionLost = state.selectedGuildId === guildId;
|
const selectionLost = state.selectedGuildId === guildId;
|
||||||
// Если удалили выбранный сервер — выбираем первый оставшийся.
|
// Если удалили выбранный сервер — выбираем первый оставшийся.
|
||||||
const nextGuild = selectionLost ? (guilds[0] ?? null) : null;
|
const nextGuild = selectionLost ? (guilds[0] ?? null) : null;
|
||||||
|
const { [guildId]: _removed, ...voiceStates } = state.voiceStates;
|
||||||
set({
|
set({
|
||||||
guilds,
|
guilds,
|
||||||
|
voiceStates,
|
||||||
selectedGuildId: selectionLost ? (nextGuild?.id ?? null) : state.selectedGuildId,
|
selectedGuildId: selectionLost ? (nextGuild?.id ?? null) : state.selectedGuildId,
|
||||||
selectedChannelId: selectionLost
|
selectedChannelId: selectionLost
|
||||||
? nextGuild === null
|
? 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) => {
|
setGuildEmojis: (guildId, emojis) => {
|
||||||
set({
|
set({
|
||||||
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, emojis } : guild)),
|
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 =>
|
return (state: SessionState): SessionGuild | null =>
|
||||||
guildId === null ? null : (state.guilds.find((guild) => guild.id === guildId) ?? 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()`. */
|
/** Виды устройств, которые отдаёт `navigator.mediaDevices.enumerateDevices()`. */
|
||||||
@@ -22,6 +23,8 @@ interface VoicePreferences {
|
|||||||
/** Громкость микрофона и звука, 0–100. */
|
/** Громкость микрофона и звука, 0–100. */
|
||||||
micVolume: number;
|
micVolume: number;
|
||||||
outputVolume: number;
|
outputVolume: number;
|
||||||
|
/** Личная громкость участников голосовой комнаты, 0–100 (AGENT.md 7.14). */
|
||||||
|
participantVolumes: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VoiceState extends VoicePreferences {
|
interface VoiceState extends VoicePreferences {
|
||||||
@@ -34,6 +37,8 @@ interface VoiceState extends VoicePreferences {
|
|||||||
selectDevice: (kind: VoiceDeviceKind, deviceId: string) => void;
|
selectDevice: (kind: VoiceDeviceKind, deviceId: string) => void;
|
||||||
setMicVolume: (value: number) => void;
|
setMicVolume: (value: number) => void;
|
||||||
setOutputVolume: (value: number) => void;
|
setOutputVolume: (value: number) => void;
|
||||||
|
/** Громкость конкретного участника: хранится только на этом устройстве. */
|
||||||
|
setParticipantVolume: (userId: string, value: number) => void;
|
||||||
/** Вернуть исходные предпочтения (используется в тестах). */
|
/** Вернуть исходные предпочтения (используется в тестах). */
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -49,6 +54,7 @@ const defaults: VoicePreferences = {
|
|||||||
outputDeviceId: '',
|
outputDeviceId: '',
|
||||||
micVolume: 100,
|
micVolume: 100,
|
||||||
outputVolume: 100,
|
outputVolume: 100,
|
||||||
|
participantVolumes: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampVolume(value: number): number {
|
function clampVolume(value: number): number {
|
||||||
@@ -70,6 +76,20 @@ function asVolume(value: unknown, fallback: number): number {
|
|||||||
return typeof value === 'number' ? clampVolume(value) : fallback;
|
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, поверх умолчаний. */
|
/** Начальные значения: то, что уже лежит в localStorage, поверх умолчаний. */
|
||||||
function initialPreferences(): VoicePreferences {
|
function initialPreferences(): VoicePreferences {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -98,6 +118,7 @@ function initialPreferences(): VoicePreferences {
|
|||||||
outputDeviceId: asString(stored['outputDeviceId'], defaults.outputDeviceId),
|
outputDeviceId: asString(stored['outputDeviceId'], defaults.outputDeviceId),
|
||||||
micVolume: asVolume(stored['micVolume'], defaults.micVolume),
|
micVolume: asVolume(stored['micVolume'], defaults.micVolume),
|
||||||
outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume),
|
outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume),
|
||||||
|
participantVolumes: asParticipantVolumes(stored['participantVolumes']),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +137,7 @@ function persist(state: VoicePreferences): void {
|
|||||||
outputDeviceId: state.outputDeviceId,
|
outputDeviceId: state.outputDeviceId,
|
||||||
micVolume: state.micVolume,
|
micVolume: state.micVolume,
|
||||||
outputVolume: state.outputVolume,
|
outputVolume: state.outputVolume,
|
||||||
|
participantVolumes: state.participantVolumes,
|
||||||
} satisfies VoicePreferences),
|
} satisfies VoicePreferences),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,6 +176,13 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
|||||||
setMicVolume: (value) => patch({ micVolume: clampVolume(value) }),
|
setMicVolume: (value) => patch({ micVolume: clampVolume(value) }),
|
||||||
setOutputVolume: (value) => patch({ outputVolume: clampVolume(value) }),
|
setOutputVolume: (value) => patch({ outputVolume: clampVolume(value) }),
|
||||||
|
|
||||||
|
setParticipantVolume: (userId, value) => {
|
||||||
|
if (userId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patch({ participantVolumes: { ...get().participantVolumes, [userId]: clampVolume(value) } });
|
||||||
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
set({ ...defaults });
|
set({ ...defaults });
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -163,6 +192,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Предпочтения без действий: то, что уходит в localStorage. */
|
||||||
function preferencesOf(state: VoiceState): VoicePreferences {
|
function preferencesOf(state: VoiceState): VoicePreferences {
|
||||||
return {
|
return {
|
||||||
micEnabled: state.micEnabled,
|
micEnabled: state.micEnabled,
|
||||||
@@ -173,5 +203,6 @@ function preferencesOf(state: VoiceState): VoicePreferences {
|
|||||||
outputDeviceId: state.outputDeviceId,
|
outputDeviceId: state.outputDeviceId,
|
||||||
micVolume: state.micVolume,
|
micVolume: state.micVolume,
|
||||||
outputVolume: state.outputVolume,
|
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