feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.
- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
участника, упоминание, сообщение, вход/выход из голосовой, свои
микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
комнату с выключенным микрофоном, удержание включает публикацию, отпускание
и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
`useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
расширенный фейковый LiveKit SDK.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { parseChannelPayload } from './gatewayEvents';
|
||||
import { parseGuildSoundList } from './sounds';
|
||||
import { parseVoiceStateList } from './voice';
|
||||
import { describeUnknown } from '@/lib/http';
|
||||
|
||||
import type { GuildEmoji } from './emojis';
|
||||
import type { GuildSound } from './sounds';
|
||||
import type { VoiceState } from './voice';
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
|
||||
@@ -49,6 +51,8 @@ export interface GatewayGuild {
|
||||
roles: Role[];
|
||||
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
||||
emojis: GuildEmoji[];
|
||||
/** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */
|
||||
sounds: GuildSound[];
|
||||
/** Кто из участников в голосовых комнатах (AGENT.md 7.14). */
|
||||
voice_states: VoiceState[];
|
||||
member_ids: string[];
|
||||
@@ -240,6 +244,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
|
||||
channels,
|
||||
roles,
|
||||
emojis: parseGuildEmojiList(record['emojis']),
|
||||
sounds: parseGuildSoundList(record['sounds']),
|
||||
voice_states: parseVoiceStateList(record['voice_states']),
|
||||
member_ids: asStringArray(record['member_ids']),
|
||||
my_role_ids: asStringArray(record['my_role_ids']),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { parseGuildSoundList } from './sounds';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
import type { GatewayUser } from './gateway';
|
||||
import type { GuildEmoji } from './emojis';
|
||||
import type { GuildSound } from './sounds';
|
||||
import type { Message, MessageAttachment, MessageReaction } from './messages';
|
||||
|
||||
/**
|
||||
@@ -58,6 +60,22 @@ export interface GuildEmojisUpdateEvent {
|
||||
emojis: GuildEmoji[];
|
||||
}
|
||||
|
||||
export interface GuildSoundsUpdateEvent {
|
||||
guild_id: string;
|
||||
sounds: GuildSound[];
|
||||
}
|
||||
|
||||
/** Событие `SOUNDBOARD_PLAY` (AGENT.md 7.13). */
|
||||
export interface SoundboardPlayEvent {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
sound_id: string;
|
||||
file_id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
played_at: string;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
@@ -203,6 +221,53 @@ export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent |
|
||||
return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `GUILD_SOUNDS_UPDATE`: набор звуков сервера изменился (AGENT.md 7.13).
|
||||
* Список приходит целиком — тем же форматом, что в снапшоте READY.
|
||||
*/
|
||||
export function parseGuildSoundsUpdate(value: unknown): GuildSoundsUpdateEvent | null {
|
||||
const record = asRecord(value);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
if (record === null || guildId === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { guild_id: guildId, sounds: parseGuildSoundList(record['sounds']) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `SOUNDBOARD_PLAY`: кто-то сыграл звук в голосовой комнате. Событие
|
||||
* приходит только участникам этой комнаты, и каждый клиент играет звук сам.
|
||||
*/
|
||||
export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const guildId = asString(record['guild_id']);
|
||||
const channelId = asString(record['channel_id']);
|
||||
const soundId = asString(record['sound_id']);
|
||||
const fileId = asString(record['file_id']);
|
||||
const userId = asString(record['user_id']);
|
||||
if (
|
||||
guildId === undefined ||
|
||||
channelId === undefined ||
|
||||
soundId === undefined ||
|
||||
fileId === undefined ||
|
||||
userId === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
sound_id: soundId,
|
||||
file_id: fileId,
|
||||
name: asString(record['name']) ?? '',
|
||||
user_id: userId,
|
||||
played_at: asString(record['played_at']) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
|
||||
export function parseChannelPayload(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { request } from './client';
|
||||
|
||||
/**
|
||||
* Саундборд и звуковая палитра сервера (AGENT.md 7.13).
|
||||
*
|
||||
* Контракт один и тот же в трёх местах: `GET /guilds/{id}/sounds`, снапшот
|
||||
* READY (`guilds[].sounds`) и событие шлюза `GUILD_SOUNDS_UPDATE`, поэтому
|
||||
* разбор payload'а общий для всех источников. Сам звук — обычный файл
|
||||
* (`/files/{file_id}`), который клиент скачивает и играет локально.
|
||||
*/
|
||||
|
||||
/** Вид набора: звуки саундборда или звуки интерфейса/событий. */
|
||||
export type SoundKind = 'soundboard' | 'ui';
|
||||
|
||||
/**
|
||||
* События, к которым сервер разрешает привязать UI-звук (AGENT.md 7.13).
|
||||
* Список — контракт: сервер отвергает всё остальное.
|
||||
*/
|
||||
export const SOUND_EVENTS = [
|
||||
'member_join',
|
||||
'member_leave',
|
||||
'mention',
|
||||
'message',
|
||||
'message_send',
|
||||
'voice_join',
|
||||
'voice_leave',
|
||||
'mic_on',
|
||||
'mic_off',
|
||||
'camera_on',
|
||||
'camera_off',
|
||||
'screen_on',
|
||||
'screen_off',
|
||||
'kick',
|
||||
'ban',
|
||||
'mute',
|
||||
'role_grant',
|
||||
'role_revoke',
|
||||
] as const;
|
||||
|
||||
export type SoundEvent = (typeof SOUND_EVENTS)[number];
|
||||
|
||||
/** Звук сервера: элемент `sounds` из READY, REST и `GUILD_SOUNDS_UPDATE`. */
|
||||
export interface GuildSound {
|
||||
id: string;
|
||||
name: string;
|
||||
file_id: string;
|
||||
kind: SoundKind;
|
||||
/** Событие UI-палитры: только у `kind: 'ui'`. */
|
||||
event?: SoundEvent;
|
||||
/** Необязательная эмодзи-иконка для сетки саундборда. */
|
||||
emoji?: string;
|
||||
/** Ссылка на файл (REST); в снапшоте READY её нет. */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Лимиты из контракта: 512 КБ на звук, 30 звуков каждого вида на сервер. */
|
||||
export const SOUND_MAX_BYTES = 512 * 1024;
|
||||
export const SOUND_LIMIT_PER_KIND = 30;
|
||||
|
||||
/** Имя звука: та же проверка, что на сервере. */
|
||||
export const SOUND_NAME_PATTERN = /^[a-zA-Z0-9_]{2,32}$/u;
|
||||
|
||||
/**
|
||||
* Форматы, которые принимает сервер: MP3/OGG/WAV/WebM/M4A. Браузеры
|
||||
* называют одни и те же контейнеры по-разному, поэтому проверяем и MIME,
|
||||
* и расширение файла.
|
||||
*/
|
||||
export const SOUND_CONTENT_TYPES = [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/ogg',
|
||||
'application/ogg',
|
||||
'audio/wav',
|
||||
'audio/wave',
|
||||
'audio/x-wav',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/m4a',
|
||||
'audio/x-m4a',
|
||||
] as const;
|
||||
|
||||
const SOUND_EXTENSIONS = ['.mp3', '.ogg', '.oga', '.wav', '.webm', '.m4a'] as const;
|
||||
|
||||
export const guildSoundsQueryKey = (guildId: string, kind?: SoundKind) =>
|
||||
kind === undefined
|
||||
? (['guilds', guildId, 'sounds'] as const)
|
||||
: (['guilds', guildId, 'sounds', kind] 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 isAllowedSoundType(file: File): boolean {
|
||||
if ((SOUND_CONTENT_TYPES as readonly string[]).includes(file.type)) {
|
||||
return true;
|
||||
}
|
||||
const name = file.name.toLocaleLowerCase();
|
||||
return SOUND_EXTENSIONS.some((extension) => name.endsWith(extension));
|
||||
}
|
||||
|
||||
/** Проходит ли имя клиентскую проверку (та же, что на сервере). */
|
||||
export function isValidSoundName(name: string): boolean {
|
||||
return SOUND_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** Событие из контракта или `undefined` (неизвестные имена отбрасываем). */
|
||||
export function asSoundEvent(value: unknown): SoundEvent | undefined {
|
||||
return typeof value === 'string' && (SOUND_EVENTS as readonly string[]).includes(value)
|
||||
? (value as SoundEvent)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбор одного звука. Поле `kind` может отсутствовать (старые снапшоты) —
|
||||
* считаем такой звук саундбордом, как и сервер по умолчанию.
|
||||
*/
|
||||
export function parseGuildSound(value: unknown): GuildSound | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
const fileId = asString(record['file_id']);
|
||||
if (id === undefined || name === undefined || fileId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const kind = record['kind'] === 'ui' ? 'ui' : 'soundboard';
|
||||
const sound: GuildSound = { id, name, file_id: fileId, kind };
|
||||
const event = asSoundEvent(record['event']);
|
||||
const emoji = asString(record['emoji']);
|
||||
const url = asString(record['url']);
|
||||
if (event !== undefined) {
|
||||
sound.event = event;
|
||||
}
|
||||
if (emoji !== undefined) {
|
||||
sound.emoji = emoji;
|
||||
}
|
||||
if (url !== undefined) {
|
||||
sound.url = url;
|
||||
}
|
||||
return sound;
|
||||
}
|
||||
|
||||
/** Разбор списка звуков: битые записи отбрасываются. */
|
||||
export function parseGuildSoundList(value: unknown): GuildSound[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map(parseGuildSound).filter((item): item is GuildSound => item !== null);
|
||||
}
|
||||
|
||||
/** Список звуков сервера; без `kind` сервер отдаёт оба набора. */
|
||||
export async function fetchGuildSounds(
|
||||
guildId: string,
|
||||
kind?: SoundKind,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GuildSound[]> {
|
||||
const query = kind === undefined ? '' : `?kind=${kind}`;
|
||||
const payload = await request<{ sounds?: unknown }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds${query}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return parseGuildSoundList(payload.sounds);
|
||||
}
|
||||
|
||||
/** Тело загрузки: имя, файл, для палитры — событие, опционально эмодзи. */
|
||||
export interface SoundUploadInput {
|
||||
name: string;
|
||||
file: File;
|
||||
event?: SoundEvent;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/** Загрузка звука: multipart с полями `name`, `file`, `event` и `emoji`. */
|
||||
export async function uploadGuildSound(
|
||||
guildId: string,
|
||||
kind: SoundKind,
|
||||
input: SoundUploadInput,
|
||||
): Promise<GuildSound | null> {
|
||||
const form = new FormData();
|
||||
form.append('name', input.name);
|
||||
form.append('file', input.file);
|
||||
if (input.event !== undefined) {
|
||||
form.append('event', input.event);
|
||||
}
|
||||
if (input.emoji !== undefined && input.emoji !== '') {
|
||||
form.append('emoji', input.emoji);
|
||||
}
|
||||
const payload = await request<{ sound?: unknown }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds?kind=${kind}`,
|
||||
{ method: 'POST', form },
|
||||
);
|
||||
return parseGuildSound(payload.sound);
|
||||
}
|
||||
|
||||
/** Переименование: сервер принимает `name` и необязательный `emoji`. */
|
||||
export async function renameGuildSound(
|
||||
guildId: string,
|
||||
soundId: string,
|
||||
input: { name: string; emoji?: string },
|
||||
): Promise<GuildSound | null> {
|
||||
const payload = await request<{ sound?: unknown }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
|
||||
{ method: 'PATCH', body: input },
|
||||
);
|
||||
return parseGuildSound(payload.sound);
|
||||
}
|
||||
|
||||
export async function deleteGuildSound(guildId: string, soundId: string): Promise<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Просьба сыграть звук в голосовой комнате. Сервер проверит право
|
||||
* `USE_SOUNDBOARD`, присутствие в комнате (`voice.not_connected`) и лимит
|
||||
* 3 звука за 10 секунд (`rate_limited`).
|
||||
*/
|
||||
export async function playSoundboardSound(guildId: string, soundId: string): Promise<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}/play`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
@@ -177,6 +177,13 @@ function SystemMessageBody({ message, authors }: { message: Message; authors: Au
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (event.kind === 'member_leave') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('chat.system.memberLeave', { name: nameOf(event.userId) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (event.kind === 'member_kick') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { useSoundboardStore } from '@/stores/soundboard';
|
||||
|
||||
/**
|
||||
* Всплывающая подпись «{имя} включил звук {name}» (AGENT.md 7.13).
|
||||
*
|
||||
* Живёт в оболочке приложения — рядом с подсказкой о выключенном микрофоне,
|
||||
* поэтому её видно с любого экрана. Гаснет сама: время жизни держит
|
||||
* `stores/soundboard` (3 секунды), компонент только рисует текст.
|
||||
*/
|
||||
export function SoundboardNotice() {
|
||||
const { t } = useTranslation();
|
||||
const notice = useSoundboardStore((state) => state.notice);
|
||||
const clear = useSoundboardStore((state) => state.clearNotice);
|
||||
// Имя того, кто сыграл звук, резолвит общий справочник авторов.
|
||||
const authors = useAuthorDirectory(
|
||||
notice?.guildId ?? null,
|
||||
notice === null ? [] : [notice.userId],
|
||||
);
|
||||
|
||||
if (notice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="soundboard-notice"
|
||||
className="pointer-events-auto fixed bottom-40 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2 text-sm shadow-[var(--shadow-3)]"
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
<p>
|
||||
{t('voice.soundboard.playedBy', {
|
||||
name: authors.resolve(notice.userId).name,
|
||||
sound: notice.soundName,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.dismiss')}
|
||||
title={t('common.dismiss')}
|
||||
onClick={clear}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||
import { Card, Button } from '@/components/ui/primitives';
|
||||
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
||||
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
|
||||
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
||||
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
||||
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
|
||||
import {
|
||||
useCanUseSoundboard,
|
||||
useVoiceChannels,
|
||||
useVoiceModeration,
|
||||
useVoiceOccupants,
|
||||
@@ -18,8 +22,9 @@ import {
|
||||
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||
import { speakingRingClass } from '@/components/voice/voiceStyles';
|
||||
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||
import { SCREEN_SHARE_PRESET_IDS, type ScreenSharePresetId } from '@/lib/livekit';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { useVoiceStore, type VoicePublishQuality } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
|
||||
@@ -105,7 +110,11 @@ function VoiceTile({
|
||||
</span>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
{occupant.isLocal ? (
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
<>
|
||||
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
||||
<VoiceStatsButton />
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={anchorRef}
|
||||
@@ -157,10 +166,14 @@ function VoiceControlsRow({
|
||||
screenEnabled,
|
||||
serverMuted,
|
||||
serverDeafened,
|
||||
screenPreset,
|
||||
videoQuality,
|
||||
soundboard,
|
||||
onMic,
|
||||
onSound,
|
||||
onCamera,
|
||||
onScreen,
|
||||
onVideoQuality,
|
||||
onLeave,
|
||||
}: {
|
||||
micEnabled: boolean;
|
||||
@@ -169,13 +182,26 @@ function VoiceControlsRow({
|
||||
screenEnabled: boolean;
|
||||
serverMuted: boolean;
|
||||
serverDeafened: boolean;
|
||||
/** Выбранный пресет шаринга: показывается в меню демонстрации экрана. */
|
||||
screenPreset: ScreenSharePresetId;
|
||||
/** Качество публикации видео. */
|
||||
videoQuality: VoicePublishQuality;
|
||||
/** Кнопка саундборда: рисуется, когда есть право `USE_SOUNDBOARD`. */
|
||||
soundboard: ReactNode;
|
||||
onMic: () => void;
|
||||
onSound: () => void;
|
||||
onCamera: () => void;
|
||||
onScreen: () => void;
|
||||
/** `null` — остановить демонстрацию, иначе — начать с этим пресетом. */
|
||||
onScreen: (preset: ScreenSharePresetId | null) => void;
|
||||
onVideoQuality: (quality: VoicePublishQuality) => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [screenMenuOpen, setScreenMenuOpen] = useState(false);
|
||||
const [qualityMenuOpen, setQualityMenuOpen] = useState(false);
|
||||
const screenAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const qualityAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
@@ -204,12 +230,94 @@ function VoiceControlsRow({
|
||||
active={cameraEnabled}
|
||||
onClick={onCamera}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="🖥"
|
||||
label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
active={screenEnabled}
|
||||
onClick={onScreen}
|
||||
/>
|
||||
{/* Демонстрация экрана: перед публикацией выбираем разрешение и fps. */}
|
||||
<button
|
||||
ref={screenAnchorRef}
|
||||
type="button"
|
||||
aria-pressed={screenEnabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={screenMenuOpen}
|
||||
aria-label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
title={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
data-testid="voice-screen-button"
|
||||
onClick={() => {
|
||||
if (screenEnabled) {
|
||||
onScreen(null);
|
||||
return;
|
||||
}
|
||||
setScreenMenuOpen((value) => !value);
|
||||
}}
|
||||
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 ${
|
||||
screenEnabled
|
||||
? 'border-border/60 bg-surface-2 text-fg'
|
||||
: 'border-danger/60 bg-danger/10 text-danger'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">🖥</span>
|
||||
</button>
|
||||
<Menu
|
||||
open={screenMenuOpen}
|
||||
label={t('voice.screen.quality')}
|
||||
onClose={() => setScreenMenuOpen(false)}
|
||||
anchorRef={screenAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.screen.quality')}</MenuGroupLabel>
|
||||
{SCREEN_SHARE_PRESET_IDS.map((preset) => (
|
||||
<MenuItem
|
||||
key={preset}
|
||||
role="menuitemradio"
|
||||
selected={screenPreset === preset}
|
||||
data-testid={`voice-screen-preset-${preset}`}
|
||||
onClick={() => {
|
||||
setScreenMenuOpen(false);
|
||||
onScreen(preset);
|
||||
}}
|
||||
>
|
||||
{t(`voice.screen.presets.${preset}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* Качество публикации видео: low/medium/high (`setPublishingQuality`). */}
|
||||
<button
|
||||
ref={qualityAnchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={qualityMenuOpen}
|
||||
aria-label={t('voice.publishQuality.label')}
|
||||
title={t('voice.publishQuality.label')}
|
||||
data-testid="voice-quality-button"
|
||||
onClick={() => setQualityMenuOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1.5 text-xs leading-none text-fg transition-colors duration-[var(--duration-fast)]"
|
||||
>
|
||||
{t(`voice.publishQuality.short.${videoQuality}`)}
|
||||
</button>
|
||||
<Menu
|
||||
open={qualityMenuOpen}
|
||||
label={t('voice.publishQuality.label')}
|
||||
onClose={() => setQualityMenuOpen(false)}
|
||||
anchorRef={qualityAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.publishQuality.label')}</MenuGroupLabel>
|
||||
{(['low', 'medium', 'high'] as VoicePublishQuality[]).map((quality) => (
|
||||
<MenuItem
|
||||
key={quality}
|
||||
role="menuitemradio"
|
||||
selected={videoQuality === quality}
|
||||
data-testid={`voice-publish-quality-${quality}`}
|
||||
onClick={() => {
|
||||
setQualityMenuOpen(false);
|
||||
onVideoQuality(quality);
|
||||
}}
|
||||
>
|
||||
{t(`voice.publishQuality.level.${quality}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{soundboard}
|
||||
<VoiceControlButton glyph="⏻" label={t('voice.disconnect')} active onClick={onLeave} />
|
||||
</div>
|
||||
);
|
||||
@@ -263,7 +371,11 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
|
||||
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||
const permissions = useVoiceModeration(guildId);
|
||||
const canUseSoundboard = useCanUseSoundboard(guildId);
|
||||
const moveTargets = useVoiceChannels(guildId).filter((item) => item.id !== channel.id);
|
||||
const screenPreset = useVoiceStore((state) => state.screenSharePreset);
|
||||
const setScreenSharePreset = useVoiceStore((state) => state.setScreenSharePreset);
|
||||
const videoQuality = useVoiceStore((state) => state.videoQuality);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||
const error = useVoiceConnectionStore((state) => state.error);
|
||||
@@ -280,6 +392,7 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
|
||||
const setVideoQuality = useVoiceConnectionStore((state) => state.setVideoQuality);
|
||||
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||
@@ -367,10 +480,24 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
screenEnabled={screenEnabled}
|
||||
serverMuted={serverMuted}
|
||||
serverDeafened={serverDeafened}
|
||||
screenPreset={screenPreset}
|
||||
videoQuality={videoQuality}
|
||||
soundboard={
|
||||
canUseSoundboard ? <VoiceSoundboard guildId={guildId} disabled={false} /> : null
|
||||
}
|
||||
onMic={() => void setMicEnabled(!micEnabled)}
|
||||
onSound={() => void setDeafened(!deafened)}
|
||||
onCamera={() => void setCameraEnabled(!cameraEnabled)}
|
||||
onScreen={() => void setScreenShareEnabled(!screenEnabled)}
|
||||
onScreen={(preset) => {
|
||||
if (preset === null) {
|
||||
void setScreenShareEnabled(false);
|
||||
return;
|
||||
}
|
||||
// Пресет сохраняем до публикации: стор читает его при старте.
|
||||
setScreenSharePreset(preset);
|
||||
void setScreenShareEnabled(true);
|
||||
}}
|
||||
onVideoQuality={(quality) => void setVideoQuality(quality)}
|
||||
onLeave={() => void leave()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { playSoundboardSound, type GuildSound } from '@/api/sounds';
|
||||
import { apiErrorCode } from '@/api/client';
|
||||
import { retryAfterMs } from '@/api/messages';
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
import { useGuildSounds } from '@/components/voice/useGuildSounds';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useSoundboardStore } from '@/stores/soundboard';
|
||||
|
||||
/** Сколько миллисекунд в секунде: обратный отсчёт лимита (429). */
|
||||
const SECOND_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Попап «Саундборд» в панели голосовой комнаты (AGENT.md 7.13).
|
||||
*
|
||||
* Звуки берутся из снапшота сервера (READY + `GUILD_SOUNDS_UPDATE`). Нажатие
|
||||
* уходит на сервер (`POST .../play`), а звук играет локально сразу: событие
|
||||
* `SOUNDBOARD_PLAY` придёт и остальным участникам комнаты. Лимит сервера —
|
||||
* 3 звука за 10 секунд: на 429 показываем обратный отсчёт, на
|
||||
* `voice.not_connected` — подсказку «сначала войдите в комнату».
|
||||
*/
|
||||
export function VoiceSoundboard({ guildId, disabled }: { guildId: string; disabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const describeError = useApiErrorMessage();
|
||||
const sounds = useGuildSounds(guildId, 'soundboard');
|
||||
const myId = useSessionStore((state) => state.user?.id ?? '');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
/** Активный лимит: `id` отличает новый отсчёт от предыдущего. */
|
||||
const [limit, setLimit] = useState<{ id: number; seconds: number } | null>(null);
|
||||
const limitSeq = useRef(0);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const limitId = limit?.id ?? null;
|
||||
|
||||
// Обратный отсчёт лимита: пока он идёт, кнопки проигрывания выключены.
|
||||
useEffect(() => {
|
||||
if (limitId === null) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setLimit((current) => {
|
||||
if (current === null || current.id !== limitId) {
|
||||
return current;
|
||||
}
|
||||
return current.seconds <= 1 ? null : { ...current, seconds: current.seconds - 1 };
|
||||
});
|
||||
}, SECOND_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [limitId]);
|
||||
|
||||
const secondsLeft = limit?.seconds ?? 0;
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const visible =
|
||||
needle === ''
|
||||
? sounds
|
||||
: sounds.filter((sound) => sound.name.toLocaleLowerCase().includes(needle));
|
||||
const notConnected = apiErrorCode(error) === 'voice.not_connected';
|
||||
|
||||
const play = async (sound: GuildSound): Promise<void> => {
|
||||
if (pending !== null || secondsLeft > 0) {
|
||||
return;
|
||||
}
|
||||
setPending(sound.id);
|
||||
setError(null);
|
||||
try {
|
||||
await playSoundboardSound(guildId, sound.id);
|
||||
// Свой звук играем сразу: событие шлюза придёт позже и будет распознано
|
||||
// как эхо (сопоставление по sound_id + user_id + времени).
|
||||
useSoundboardStore.getState().playMySound(guildId, sound, myId);
|
||||
} catch (caught) {
|
||||
setError(caught);
|
||||
const wait = retryAfterMs(caught);
|
||||
if (wait !== null) {
|
||||
limitSeq.current += 1;
|
||||
setLimit({ id: limitSeq.current, seconds: Math.max(1, Math.ceil(wait / SECOND_MS)) });
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t('voice.soundboard.open')}
|
||||
title={t('voice.soundboard.open')}
|
||||
disabled={disabled}
|
||||
data-testid="voice-soundboard-button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-1.5 text-base leading-none text-fg transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
label={t('voice.soundboard.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
className="w-72"
|
||||
>
|
||||
<div data-testid="voice-soundboard-panel" className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('voice.soundboard.searchLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label={t('voice.soundboard.searchLabel')}
|
||||
placeholder={t('voice.soundboard.searchPlaceholder')}
|
||||
data-testid="voice-soundboard-search"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{secondsLeft > 0 ? (
|
||||
<p role="status" data-testid="voice-soundboard-limit" className="text-xs text-warning">
|
||||
{t('voice.soundboard.rateLimited', { seconds: secondsLeft })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{notConnected ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-warning">
|
||||
{t('voice.soundboard.notConnected')}
|
||||
</p>
|
||||
) : error !== null ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-danger">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sounds.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.empty')}</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.noResults')}</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-1">
|
||||
{visible.map((sound) => (
|
||||
<li key={sound.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`voice-soundboard-sound-${sound.id}`}
|
||||
aria-label={t('voice.soundboard.playLabel', { name: sound.name })}
|
||||
title={sound.name}
|
||||
disabled={pending !== null || secondsLeft > 0}
|
||||
onClick={() => void play(sound)}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">{sound.emoji ?? '🎵'}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{sound.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
import { StatRow } from '@/components/ui/StatRow';
|
||||
import type { VoiceStats } from '@/lib/livekit';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Период опроса статистики: раз в секунду, пока попап открыт. */
|
||||
export const STATS_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
/** Прочерк вместо значения, которого SDK не отдал. */
|
||||
function value(text: string | number | null): string {
|
||||
return text === null ? '—' : String(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Попап «Статистика» у своей плитки (AGENT.md 7.14): фактические разрешение,
|
||||
* fps, кодек и битрейт публикации плюс качество канала (RTT, потери, jitter).
|
||||
*
|
||||
* Опрос идёт только пока попап открыт: закрытие останавливает таймер, и SDK
|
||||
* больше не дёргается. Значения приходят из `getRTCStatsReport()`; чего нет —
|
||||
* показываем прочерком.
|
||||
*/
|
||||
export function VoiceStatsButton() {
|
||||
const { t } = useTranslation();
|
||||
const fetchStats = useVoiceConnectionStore((state) => state.fetchStats);
|
||||
// Вне активной сессии статистики нет: кнопку не показываем.
|
||||
const connected = useVoiceConnectionStore((state) => state.status === 'connected');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stats, setStats] = useState<VoiceStats | null>(null);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async (): Promise<void> => {
|
||||
const next = await fetchStats();
|
||||
if (!cancelled) {
|
||||
setStats(next);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => {
|
||||
void poll();
|
||||
}, STATS_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [open, fetchStats]);
|
||||
|
||||
const resolution =
|
||||
stats?.width === null || stats?.width === undefined || stats.height === null
|
||||
? null
|
||||
: `${stats.width}×${stats.height}`;
|
||||
|
||||
if (!connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t('voice.stats.open')}
|
||||
title={t('voice.stats.open')}
|
||||
data-testid="voice-stats-button"
|
||||
onClick={() => setOpen((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>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
label={t('voice.stats.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
className="w-72"
|
||||
>
|
||||
<div data-testid="voice-stats-panel">
|
||||
<h3 className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('voice.stats.title')}
|
||||
</h3>
|
||||
<dl className="flex flex-col text-sm">
|
||||
<StatRow label={t('voice.stats.resolution')} value={value(resolution)} />
|
||||
<StatRow
|
||||
label={t('voice.stats.frameRate')}
|
||||
value={
|
||||
stats?.frameRate === null || stats?.frameRate === undefined
|
||||
? '—'
|
||||
: `${stats.frameRate} fps`
|
||||
}
|
||||
/>
|
||||
<StatRow label={t('voice.stats.codec')} value={value(stats?.codec ?? null)} />
|
||||
<StatRow
|
||||
label={t('voice.stats.bitrate')}
|
||||
value={
|
||||
stats?.bitrateKbps === null || stats?.bitrateKbps === undefined
|
||||
? '—'
|
||||
: `${stats.bitrateKbps} ${t('voice.stats.kbps')}`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.rtt')}
|
||||
value={
|
||||
stats?.roundTripMs === null || stats?.roundTripMs === undefined
|
||||
? '—'
|
||||
: `${stats.roundTripMs} ${t('voice.stats.ms')}`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.packetLoss')}
|
||||
value={
|
||||
stats?.packetLossPercent === null || stats?.packetLossPercent === undefined
|
||||
? '—'
|
||||
: `${stats.packetLossPercent}%`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.jitter')}
|
||||
value={
|
||||
stats?.jitterMs === null || stats?.jitterMs === undefined
|
||||
? '—'
|
||||
: `${stats.jitterMs} ${t('voice.stats.ms')}`
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { GuildSound, SoundKind } from '@/api/sounds';
|
||||
import { useSessionStore, guildSoundsOf } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Звуки сервера из снапшота (READY и `GUILD_SOUNDS_UPDATE`, AGENT.md 7.13).
|
||||
*
|
||||
* Один источник данных для саундборда и звуковой палитры: и попап панели
|
||||
* комнаты, и обработчики событий читают один и тот же список. Логика разбора
|
||||
* payload'а живёт в `api/sounds`, здесь — только доступ к снапшоту.
|
||||
*/
|
||||
|
||||
/** Пустой список — стабильная ссылка: селектор не должен перерисовывать зря. */
|
||||
const NO_SOUNDS: GuildSound[] = [];
|
||||
|
||||
/** Звуки сервера: без `kind` — оба набора (саундборд и палитра). */
|
||||
export function useGuildSounds(guildId: string | null, kind?: SoundKind): GuildSound[] {
|
||||
const sounds = useSessionStore(
|
||||
useCallback(
|
||||
(state) => (guildId === null ? NO_SOUNDS : guildSoundsOf(state.guilds, guildId)),
|
||||
[guildId],
|
||||
),
|
||||
);
|
||||
return useMemo(
|
||||
() => (kind === undefined ? sounds : sounds.filter((sound) => sound.kind === kind)),
|
||||
[sounds, kind],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { useVoiceStore, type PushToTalkKey } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Push-to-talk (AGENT.md 7.14): пока режим включён, микрофон выключен, а
|
||||
* удержание клавиши его включает.
|
||||
*
|
||||
* Клавишу слушаем на уровне документа, но только когда окно в фокусе и фокус
|
||||
* не в поле ввода: иначе пробел в сообщении «зажигал» бы микрофон. Нажатие без
|
||||
* повторов (`event.repeat` игнорируем), модификаторы браузера (Ctrl/Alt/Meta
|
||||
* вместе с обычной клавишей) не считаются PTT, а пробел на кнопке остаётся
|
||||
* кликом. Серверный мьют приоритетнее: при нём микрофон не включается.
|
||||
*/
|
||||
|
||||
/** Соответствие «левая клавиша → правая»: AltRight тоже считается Alt. */
|
||||
const MIRRORED_KEYS: Partial<Record<PushToTalkKey, string>> = {
|
||||
AltLeft: 'AltRight',
|
||||
ControlLeft: 'ControlRight',
|
||||
};
|
||||
|
||||
/** Совпадает ли нажатие с выбранной клавишей push-to-talk. */
|
||||
export function matchesPushToTalk(key: PushToTalkKey, code: string): boolean {
|
||||
return code === key || code === MIRRORED_KEYS[key];
|
||||
}
|
||||
|
||||
/** Идёт ли ввод текста: поля ввода, textarea, select и contenteditable. */
|
||||
export function isTextEntryTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return true;
|
||||
}
|
||||
return target.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент, на котором пробел нажимает кнопку: активная кнопка/ссылка. Для них
|
||||
* PTT не перехватывает пробел, чтобы не ломать управление с клавиатуры.
|
||||
*/
|
||||
function isButtonLike(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.tagName === 'A' ||
|
||||
target.getAttribute('role') === 'button' ||
|
||||
target.getAttribute('role') === 'menuitem'
|
||||
);
|
||||
}
|
||||
|
||||
/** Нажат ли модификатор, который не является самой клавишей PTT. */
|
||||
function hasForeignModifier(event: KeyboardEvent, key: PushToTalkKey): boolean {
|
||||
const ctrl = event.ctrlKey && key !== 'ControlLeft';
|
||||
const alt = event.altKey && key !== 'AltLeft';
|
||||
return ctrl || alt || event.metaKey;
|
||||
}
|
||||
|
||||
export function usePushToTalk(): void {
|
||||
const mode = useVoiceStore((state) => state.micActivation);
|
||||
const key = useVoiceStore((state) => state.pttKey);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
|
||||
// Возврат к голосовой активации: микрофон снова по сохранённому
|
||||
// предпочтению (в режиме PTT его выключает сам push-to-talk).
|
||||
useEffect(() => {
|
||||
if (mode === 'push_to_talk' || status !== 'connected') {
|
||||
return;
|
||||
}
|
||||
const connection = useVoiceConnectionStore.getState();
|
||||
const preference = useVoiceStore.getState().micEnabled;
|
||||
if (connection.micEnabled !== preference) {
|
||||
void connection.setMicEnabled(preference);
|
||||
}
|
||||
}, [mode, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'push_to_talk') {
|
||||
return;
|
||||
}
|
||||
if (status !== 'connected' && status !== 'reconnecting') {
|
||||
return;
|
||||
}
|
||||
// Вход в комнату в режиме PTT: микрофон ждёт нажатия клавиши.
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(false);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (!matchesPushToTalk(key, event.code) || event.repeat) {
|
||||
return;
|
||||
}
|
||||
if (!document.hasFocus() || hasForeignModifier(event, key)) {
|
||||
return;
|
||||
}
|
||||
const target = event.target ?? document.activeElement;
|
||||
if (isTextEntryTarget(target)) {
|
||||
return;
|
||||
}
|
||||
// Пробел на кнопке — это клик, а не push-to-talk.
|
||||
if (key === 'Space' && isButtonLike(target)) {
|
||||
return;
|
||||
}
|
||||
// Пробел не должен прокручивать страницу, пока идёт разговор.
|
||||
event.preventDefault();
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(true);
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent): void => {
|
||||
if (!matchesPushToTalk(key, event.code)) {
|
||||
return;
|
||||
}
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(false);
|
||||
};
|
||||
|
||||
/** Потеря фокуса окна отпускает клавишу: иначе микрофон остался бы включён. */
|
||||
const release = (): void => {
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(false);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', release);
|
||||
document.addEventListener('visibilitychange', release);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', release);
|
||||
document.removeEventListener('visibilitychange', release);
|
||||
};
|
||||
}, [mode, key, status]);
|
||||
}
|
||||
@@ -3,7 +3,11 @@ 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 {
|
||||
canUseSoundboard,
|
||||
voiceModerationPermissions,
|
||||
type VoiceModerationPermissions,
|
||||
} from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore, voiceStatesOfChannel } from '@/stores/session';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
@@ -146,6 +150,25 @@ export function useVoiceModeration(guildId: string | null): VoiceModerationPermi
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь играть звуки саундборда на этом сервере (7.13):
|
||||
* право `USE_SOUNDBOARD`. Наличие голосовой сессии проверяет вызывающий код —
|
||||
* сервер всё равно ответит `voice.not_connected`, если мы не в комнате.
|
||||
*/
|
||||
export function useCanUseSoundboard(guildId: string | null): boolean {
|
||||
const currentUser = useCurrentUser();
|
||||
const guild = useSessionStore((state) =>
|
||||
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||
);
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
if (guild === null) {
|
||||
return false;
|
||||
}
|
||||
const isAdmin =
|
||||
sessionUser?.is_instance_admin === true || currentUser.data?.is_instance_admin === true;
|
||||
return canUseSoundboard(guild.my_permissions, isAdmin);
|
||||
}
|
||||
|
||||
/** Голосовые комнаты сервера: цель для перемещения участников. */
|
||||
export function useVoiceChannels(guildId: string | null): Channel[] {
|
||||
const guild = useSessionStore((state) =>
|
||||
|
||||
@@ -70,7 +70,17 @@
|
||||
},
|
||||
"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."
|
||||
"voice.token_refresh_failed": "Could not renew the voice session. Please join again.",
|
||||
"sound": {
|
||||
"invalid_type": "MP3, OGG, WAV, WebM and M4A are supported.",
|
||||
"invalid_name": "Sound name: 2–32 characters, latin letters, digits and «_».",
|
||||
"name_taken": "A sound with this name already exists."
|
||||
},
|
||||
"sounds": {
|
||||
"limit_reached": "The limit of sounds of this kind is reached.",
|
||||
"event_required": "Choose an event for a palette sound."
|
||||
},
|
||||
"voice.not_connected": "Join a voice channel first."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Light theme",
|
||||
@@ -268,7 +278,14 @@
|
||||
},
|
||||
"screen": {
|
||||
"on": "Start screen sharing",
|
||||
"off": "Stop screen sharing"
|
||||
"off": "Stop screen sharing",
|
||||
"quality": "Screen share quality",
|
||||
"presets": {
|
||||
"1080p60": "1080p · 60 fps",
|
||||
"1080p30": "1080p · 30 fps",
|
||||
"720p30": "720p · 30 fps",
|
||||
"480p30": "480p · 30 fps"
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"excellent": "Excellent connection",
|
||||
@@ -289,6 +306,44 @@
|
||||
},
|
||||
"channelMenu": {
|
||||
"label": "Voice room {{name}}"
|
||||
},
|
||||
"soundboard": {
|
||||
"title": "Soundboard",
|
||||
"open": "Open the soundboard",
|
||||
"searchLabel": "Search sounds",
|
||||
"searchPlaceholder": "Sound name",
|
||||
"empty": "This server has no soundboard sounds yet.",
|
||||
"noResults": "Nothing found.",
|
||||
"playLabel": "Play the sound {{name}}",
|
||||
"rateLimited": "Too fast: try again in {{seconds}} s.",
|
||||
"notConnected": "Join a voice channel first.",
|
||||
"playedBy": "{{name}} played «{{sound}}»"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistics",
|
||||
"open": "Open connection statistics",
|
||||
"resolution": "Resolution",
|
||||
"frameRate": "Frame rate",
|
||||
"codec": "Codec",
|
||||
"bitrate": "Bitrate",
|
||||
"kbps": "kbps",
|
||||
"ms": "ms",
|
||||
"rtt": "Round-trip time",
|
||||
"packetLoss": "Packet loss",
|
||||
"jitter": "Jitter"
|
||||
},
|
||||
"publishQuality": {
|
||||
"label": "Video quality",
|
||||
"short": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"level": {
|
||||
"low": "Low quality",
|
||||
"medium": "Medium quality",
|
||||
"high": "High quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"friends": {
|
||||
@@ -490,7 +545,8 @@
|
||||
"memberJoin": "{{name}} joined the server",
|
||||
"memberKick": "{{name}} left the server (kicked)",
|
||||
"channelCreated": "Room #{{name}} created",
|
||||
"voiceChannelCreated": "Voice room {{name}} created"
|
||||
"voiceChannelCreated": "Voice room {{name}} created",
|
||||
"memberLeave": "{{name}} left the server"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -761,6 +817,59 @@
|
||||
"fileSelected": "{{name}} · {{size}}",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…"
|
||||
},
|
||||
"sounds": {
|
||||
"title": "Sounds",
|
||||
"description": "Server soundboard and sound palette: up to {{limit}} sounds of each kind, 512 KB each.",
|
||||
"counter": "{{count}} of {{limit}}",
|
||||
"empty": "No sounds of this kind yet.",
|
||||
"uploadTitle": "Upload a sound",
|
||||
"uploadHint": "Drop a file below or choose it: MP3, OGG, WAV, WebM or M4A up to {{limit}}.",
|
||||
"name": "Sound name",
|
||||
"namePlaceholder": "for example, airhorn",
|
||||
"nameHint": "2–32 characters: latin letters, digits and «_».",
|
||||
"emoji": "Emoji",
|
||||
"emojiPlaceholder": "for example, 📣",
|
||||
"emojiHint": "Optional icon for the soundboard grid.",
|
||||
"event": "Event",
|
||||
"dropHint": "Drop the file here",
|
||||
"file": "Sound file",
|
||||
"chooseFile": "Choose a file",
|
||||
"fileSelected": "{{name}} · {{size}}",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"rename": "Rename",
|
||||
"renameLabel": "Rename the sound {{name}}",
|
||||
"renameTitle": "Rename the sound",
|
||||
"delete": "Delete",
|
||||
"deleteLabel": "Delete the sound {{name}}",
|
||||
"deleteConfirmTitle": "Delete the sound?",
|
||||
"deleteConfirmBody": "The sound {{name}} will be deleted permanently.",
|
||||
"deleteConfirm": "Delete",
|
||||
"kinds": {
|
||||
"soundboard": "Soundboard",
|
||||
"ui": "Sound palette"
|
||||
},
|
||||
"events": {
|
||||
"member_join": "Member joined",
|
||||
"member_leave": "Member left",
|
||||
"mention": "Mention",
|
||||
"message": "New message",
|
||||
"message_send": "Message sent",
|
||||
"voice_join": "Joined a voice channel",
|
||||
"voice_leave": "Left a voice channel",
|
||||
"mic_on": "Microphone on",
|
||||
"mic_off": "Microphone off",
|
||||
"camera_on": "Camera on",
|
||||
"camera_off": "Camera off",
|
||||
"screen_on": "Screen share started",
|
||||
"screen_off": "Screen share stopped",
|
||||
"kick": "Member kicked",
|
||||
"ban": "Member banned",
|
||||
"mute": "Member muted",
|
||||
"role_grant": "Role granted",
|
||||
"role_revoke": "Role revoked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navLabel": "Settings sections",
|
||||
@@ -777,7 +886,27 @@
|
||||
"output": "Sound",
|
||||
"micVolume": "Microphone volume",
|
||||
"outputVolume": "Sound volume",
|
||||
"stub": "Changes apply immediately and are stored on this device."
|
||||
"stub": "Changes apply immediately and are stored on this device.",
|
||||
"micMode": "Microphone activation",
|
||||
"micModes": {
|
||||
"voice": "Voice activity",
|
||||
"push_to_talk": "Push-to-talk"
|
||||
},
|
||||
"pttKey": "Key",
|
||||
"pttHint": "Hold the key to talk. Works while the window is focused and the cursor is not in a text field.",
|
||||
"pttKeys": {
|
||||
"space": "Space",
|
||||
"v": "V",
|
||||
"b": "B",
|
||||
"n": "N",
|
||||
"alt": "Alt",
|
||||
"ctrl": "Ctrl"
|
||||
},
|
||||
"sounds": "Sounds",
|
||||
"eventSounds": "Server event sounds",
|
||||
"eventSoundsHint": "Play the server palette sounds: member joins, mentions and other events.",
|
||||
"soundboard": "Soundboard",
|
||||
"soundboardHint": "Play soundboard sounds in a voice channel."
|
||||
},
|
||||
"language": {
|
||||
"title": "Language & region",
|
||||
|
||||
@@ -70,7 +70,17 @@
|
||||
},
|
||||
"voice.disabled": "Голос не настроен на инстансе.",
|
||||
"voice.channel_full": "В комнате нет свободных мест.",
|
||||
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново."
|
||||
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.",
|
||||
"sound": {
|
||||
"invalid_type": "Поддерживаются MP3, OGG, WAV, WebM и M4A.",
|
||||
"invalid_name": "Имя звука: 2–32 символа, латиница, цифры и «_».",
|
||||
"name_taken": "Звук с таким именем уже есть."
|
||||
},
|
||||
"sounds": {
|
||||
"limit_reached": "Достигнут лимит звуков этого вида.",
|
||||
"event_required": "Для звука палитры выберите событие."
|
||||
},
|
||||
"voice.not_connected": "Сначала войдите в голосовую комнату."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Светлая тема",
|
||||
@@ -268,7 +278,14 @@
|
||||
},
|
||||
"screen": {
|
||||
"on": "Начать демонстрацию экрана",
|
||||
"off": "Остановить демонстрацию экрана"
|
||||
"off": "Остановить демонстрацию экрана",
|
||||
"quality": "Качество демонстрации",
|
||||
"presets": {
|
||||
"1080p60": "1080p · 60 fps",
|
||||
"1080p30": "1080p · 30 fps",
|
||||
"720p30": "720p · 30 fps",
|
||||
"480p30": "480p · 30 fps"
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"excellent": "Отличное соединение",
|
||||
@@ -289,6 +306,44 @@
|
||||
},
|
||||
"channelMenu": {
|
||||
"label": "Голосовая комната {{name}}"
|
||||
},
|
||||
"soundboard": {
|
||||
"title": "Саундборд",
|
||||
"open": "Открыть саундборд",
|
||||
"searchLabel": "Поиск звука",
|
||||
"searchPlaceholder": "Название звука",
|
||||
"empty": "На сервере пока нет звуков саундборда.",
|
||||
"noResults": "Ничего не найдено.",
|
||||
"playLabel": "Проиграть звук {{name}}",
|
||||
"rateLimited": "Слишком часто: повторите через {{seconds}} с.",
|
||||
"notConnected": "Сначала войдите в голосовую комнату.",
|
||||
"playedBy": "{{name}} включил звук «{{sound}}»"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Статистика",
|
||||
"open": "Открыть статистику соединения",
|
||||
"resolution": "Разрешение",
|
||||
"frameRate": "Частота кадров",
|
||||
"codec": "Кодек",
|
||||
"bitrate": "Битрейт",
|
||||
"kbps": "кбит/с",
|
||||
"ms": "мс",
|
||||
"rtt": "Задержка (RTT)",
|
||||
"packetLoss": "Потери пакетов",
|
||||
"jitter": "Джиттер"
|
||||
},
|
||||
"publishQuality": {
|
||||
"label": "Качество видео",
|
||||
"short": {
|
||||
"low": "Низкое",
|
||||
"medium": "Среднее",
|
||||
"high": "Высокое"
|
||||
},
|
||||
"level": {
|
||||
"low": "Низкое качество",
|
||||
"medium": "Среднее качество",
|
||||
"high": "Высокое качество"
|
||||
}
|
||||
}
|
||||
},
|
||||
"friends": {
|
||||
@@ -490,7 +545,8 @@
|
||||
"memberJoin": "{{name}} присоединился к серверу",
|
||||
"memberKick": "{{name}} покинул сервер (исключён)",
|
||||
"channelCreated": "Комната #{{name}} создана",
|
||||
"voiceChannelCreated": "Голосовая комната {{name}} создана"
|
||||
"voiceChannelCreated": "Голосовая комната {{name}} создана",
|
||||
"memberLeave": "{{name}} покинул сервер"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -761,6 +817,59 @@
|
||||
"fileSelected": "{{name}} · {{size}}",
|
||||
"upload": "Загрузить",
|
||||
"uploading": "Загружаем…"
|
||||
},
|
||||
"sounds": {
|
||||
"title": "Звуки",
|
||||
"description": "Саундборд и звуковая палитра сервера: до {{limit}} звуков каждого вида, каждый до 512 КБ.",
|
||||
"counter": "{{count}} из {{limit}}",
|
||||
"empty": "Звуков этого вида пока нет.",
|
||||
"uploadTitle": "Загрузить звук",
|
||||
"uploadHint": "Перетащите файл в область ниже или выберите его: MP3, OGG, WAV, WebM или M4A до {{limit}}.",
|
||||
"name": "Имя звука",
|
||||
"namePlaceholder": "например, airhorn",
|
||||
"nameHint": "2–32 символа: латиница, цифры и «_».",
|
||||
"emoji": "Эмодзи",
|
||||
"emojiPlaceholder": "например, 📣",
|
||||
"emojiHint": "Необязательная иконка для сетки саундборда.",
|
||||
"event": "Событие",
|
||||
"dropHint": "Перетащите файл сюда",
|
||||
"file": "Файл звука",
|
||||
"chooseFile": "Выбрать файл",
|
||||
"fileSelected": "{{name}} · {{size}}",
|
||||
"upload": "Загрузить",
|
||||
"uploading": "Загружаем…",
|
||||
"rename": "Переименовать",
|
||||
"renameLabel": "Переименовать звук {{name}}",
|
||||
"renameTitle": "Переименовать звук",
|
||||
"delete": "Удалить",
|
||||
"deleteLabel": "Удалить звук {{name}}",
|
||||
"deleteConfirmTitle": "Удалить звук?",
|
||||
"deleteConfirmBody": "Звук {{name}} будет удалён без возможности восстановления.",
|
||||
"deleteConfirm": "Удалить",
|
||||
"kinds": {
|
||||
"soundboard": "Саундборд",
|
||||
"ui": "Звуковая палитра"
|
||||
},
|
||||
"events": {
|
||||
"member_join": "Участник присоединился",
|
||||
"member_leave": "Участник вышел",
|
||||
"mention": "Упоминание",
|
||||
"message": "Новое сообщение",
|
||||
"message_send": "Отправка сообщения",
|
||||
"voice_join": "Вход в голосовую комнату",
|
||||
"voice_leave": "Выход из голосовой комнаты",
|
||||
"mic_on": "Микрофон включён",
|
||||
"mic_off": "Микрофон выключен",
|
||||
"camera_on": "Камера включена",
|
||||
"camera_off": "Камера выключена",
|
||||
"screen_on": "Демонстрация начата",
|
||||
"screen_off": "Демонстрация остановлена",
|
||||
"kick": "Исключение участника",
|
||||
"ban": "Бан участника",
|
||||
"mute": "Мьют участника",
|
||||
"role_grant": "Выдача роли",
|
||||
"role_revoke": "Снятие роли"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navLabel": "Пункты настроек",
|
||||
@@ -777,7 +886,27 @@
|
||||
"output": "Звук",
|
||||
"micVolume": "Громкость микрофона",
|
||||
"outputVolume": "Громкость звука",
|
||||
"stub": "Изменения применяются сразу и сохраняются на этом устройстве."
|
||||
"stub": "Изменения применяются сразу и сохраняются на этом устройстве.",
|
||||
"micMode": "Активация микрофона",
|
||||
"micModes": {
|
||||
"voice": "Голосовая активация",
|
||||
"push_to_talk": "Push-to-talk"
|
||||
},
|
||||
"pttKey": "Клавиша",
|
||||
"pttHint": "Удерживайте клавишу, чтобы говорить. Работает, когда окно в фокусе и курсор не в поле ввода.",
|
||||
"pttKeys": {
|
||||
"space": "Пробел",
|
||||
"v": "V",
|
||||
"b": "B",
|
||||
"n": "N",
|
||||
"alt": "Alt",
|
||||
"ctrl": "Ctrl"
|
||||
},
|
||||
"sounds": "Звуки",
|
||||
"eventSounds": "Звуки событий сервера",
|
||||
"eventSoundsHint": "Проигрывать звуки палитры сервера: вход участника, упоминания и другие события.",
|
||||
"soundboard": "Саундборд",
|
||||
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате."
|
||||
},
|
||||
"language": {
|
||||
"title": "Язык и регион",
|
||||
|
||||
@@ -41,6 +41,8 @@ export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
|
||||
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
|
||||
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
|
||||
export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
|
||||
export const PERMISSION_MANAGE_SOUNDS = 'MANAGE_SOUNDS';
|
||||
export const PERMISSION_USE_SOUNDBOARD = 'USE_SOUNDBOARD';
|
||||
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
|
||||
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
|
||||
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
|
||||
@@ -186,6 +188,40 @@ export function canManageEmojis(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь управлять звуками сервера (AGENT.md 7.13): владелец,
|
||||
* ADMINISTRATOR, MANAGE_SOUNDS или администратор инстанса.
|
||||
*/
|
||||
export function canManageSounds(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
isInstanceAdmin = false,
|
||||
): boolean {
|
||||
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_MANAGE_SOUNDS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь играть звуки саундборда (AGENT.md 7.13): право
|
||||
* `USE_SOUNDBOARD`. Присутствие в голосовой комнате проверяется отдельно —
|
||||
* сервер отвечает `voice.not_connected`, если мы не в комнате.
|
||||
*/
|
||||
export function canUseSoundboard(permissions: readonly string[], isInstanceAdmin = false): boolean {
|
||||
if (isInstanceAdmin) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_USE_SOUNDBOARD)
|
||||
);
|
||||
}
|
||||
|
||||
/** Может ли пользователь отправлять сообщения в комнату. */
|
||||
export function canSendMessages(
|
||||
permissions: readonly string[],
|
||||
|
||||
+232
-5
@@ -1,6 +1,6 @@
|
||||
import type { Participant, Room, Track } from 'livekit-client';
|
||||
import type { LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
|
||||
|
||||
import type { VoiceDeviceKind } from '@/stores/voice';
|
||||
import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Обёртка над `livekit-client`: единственное место, где клиент знает про SDK.
|
||||
@@ -46,6 +46,60 @@ export interface VoiceParticipant {
|
||||
|
||||
export type VoiceRoomStatus = 'connected' | 'reconnecting' | 'reconnected' | 'disconnected';
|
||||
|
||||
/** Пресеты демонстрации экрана: разрешение и fps выбирает пользователь. */
|
||||
export type ScreenSharePresetId = '1080p60' | '1080p30' | '720p30' | '480p30';
|
||||
|
||||
/** Разрешение и fps, которые уходят в `ScreenShareCaptureOptions`. */
|
||||
export const SCREEN_SHARE_PRESETS: Record<
|
||||
ScreenSharePresetId,
|
||||
{ width: number; height: number; frameRate: number }
|
||||
> = {
|
||||
'1080p60': { width: 1920, height: 1080, frameRate: 60 },
|
||||
'1080p30': { width: 1920, height: 1080, frameRate: 30 },
|
||||
'720p30': { width: 1280, height: 720, frameRate: 30 },
|
||||
'480p30': { width: 854, height: 480, frameRate: 30 },
|
||||
};
|
||||
|
||||
/** Список пресетов в порядке показа: от лучшего к экономному. */
|
||||
export const SCREEN_SHARE_PRESET_IDS: ScreenSharePresetId[] = [
|
||||
'1080p60',
|
||||
'1080p30',
|
||||
'720p30',
|
||||
'480p30',
|
||||
];
|
||||
|
||||
/**
|
||||
* Статистика своей публикации (AGENT.md 7.14): фактические разрешение, fps,
|
||||
* кодек и битрейт, а также качество канала. `null` означает «SDK поле не
|
||||
* отдал» — интерфейс показывает прочерк.
|
||||
*/
|
||||
export interface VoiceStats {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
frameRate: number | null;
|
||||
codec: string | null;
|
||||
/** Битрейт отправки в килобитах в секунду (по приросту `bytesSent`). */
|
||||
bitrateKbps: number | null;
|
||||
roundTripMs: number | null;
|
||||
jitterMs: number | null;
|
||||
packetsLost: number | null;
|
||||
/** Доля потерянных пакетов, 0–100. */
|
||||
packetLossPercent: number | null;
|
||||
}
|
||||
|
||||
/** Пустая статистика: SDK ещё не отдал отчёт. */
|
||||
export const EMPTY_VOICE_STATS: VoiceStats = {
|
||||
width: null,
|
||||
height: null,
|
||||
frameRate: null,
|
||||
codec: null,
|
||||
bitrateKbps: null,
|
||||
roundTripMs: null,
|
||||
jitterMs: null,
|
||||
packetsLost: null,
|
||||
packetLossPercent: null,
|
||||
};
|
||||
|
||||
export interface VoiceRoomCallbacks {
|
||||
/** Изменился состав комнаты или медиа-состояние участников. */
|
||||
onParticipants: (participants: VoiceParticipant[]) => void;
|
||||
@@ -61,7 +115,12 @@ export interface VoiceRoomHandle {
|
||||
dispose: () => void;
|
||||
setMicrophoneEnabled: (enabled: boolean) => Promise<void>;
|
||||
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||
/** Демонстрация экрана: пресет задаёт разрешение и fps захвата. */
|
||||
setScreenShareEnabled: (enabled: boolean, preset?: ScreenSharePresetId) => Promise<void>;
|
||||
/** Качество публикации своей камеры (и шаринга, если он идёт). */
|
||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||
/** Статистика своей публикации для попапа «Статистика». */
|
||||
fetchStats: () => Promise<VoiceStats>;
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
participants: () => VoiceParticipant[];
|
||||
}
|
||||
@@ -152,6 +211,140 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
return participants;
|
||||
};
|
||||
|
||||
/**
|
||||
* Своя видеодорожка для смены качества публикации и статистики: сначала
|
||||
* активная (камера, затем демонстрация экрана), иначе — выключенная.
|
||||
* `setPublishingQuality` есть только у `LocalVideoTrack`, поэтому проверяем
|
||||
* метод, а не тип.
|
||||
*/
|
||||
const videoPublication = (): LocalVideoTrack | undefined => {
|
||||
const local = room.localParticipant;
|
||||
let fallback: LocalVideoTrack | undefined;
|
||||
for (const source of [Track.Source.Camera, Track.Source.ScreenShare]) {
|
||||
const publication = local.getTrackPublication(source);
|
||||
const track = publication?.track;
|
||||
if (publication === undefined || track === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!publication.isMuted) {
|
||||
return track as LocalVideoTrack;
|
||||
}
|
||||
fallback = fallback ?? (track as LocalVideoTrack);
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** Предыдущий замер `bytesSent`: битрейт считается по приросту. */
|
||||
let previousSample: { bytes: number; at: number } | null = null;
|
||||
|
||||
/**
|
||||
* Статистика своей публикации. Источники — те же, что в `chrome://webrtc`:
|
||||
* `outbound-rtp` (разрешение, fps, кодек, отправленные байты),
|
||||
* `remote-inbound-rtp` (RTT, jitter, потери) и `candidate-pair` (RTT).
|
||||
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
|
||||
*/
|
||||
const collectStats = async (): Promise<VoiceStats> => {
|
||||
const track = videoPublication();
|
||||
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
|
||||
previousSample = null;
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
}
|
||||
let report: RTCStatsReport | undefined;
|
||||
try {
|
||||
report = await track.getRTCStatsReport();
|
||||
} catch {
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
}
|
||||
if (report === undefined) {
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
}
|
||||
|
||||
const entries: Record<string, unknown>[] = [];
|
||||
report.forEach((entry: unknown) => {
|
||||
if (typeof entry === 'object' && entry !== null) {
|
||||
entries.push(entry as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
const codecs = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry['type'] === 'codec' && typeof entry['id'] === 'string') {
|
||||
const mime = entry['mimeType'];
|
||||
codecs.set(entry['id'], typeof mime === 'string' ? mime : '');
|
||||
}
|
||||
}
|
||||
|
||||
const outbound = entries.find(
|
||||
(entry) =>
|
||||
entry['type'] === 'outbound-rtp' &&
|
||||
(entry['kind'] === 'video' || entry['mediaType'] === 'video'),
|
||||
);
|
||||
const remoteInbound = entries.find(
|
||||
(entry) => entry['type'] === 'remote-inbound-rtp' && entry['kind'] !== 'audio',
|
||||
);
|
||||
const candidatePair = entries.find(
|
||||
(entry) => entry['type'] === 'candidate-pair' && entry['state'] === 'succeeded',
|
||||
);
|
||||
|
||||
const stats: VoiceStats = { ...EMPTY_VOICE_STATS };
|
||||
const frameWidth = num(outbound?.['frameWidth']);
|
||||
const frameHeight = num(outbound?.['frameHeight']);
|
||||
const framesPerSecond = num(outbound?.['framesPerSecond']);
|
||||
if (frameWidth !== null) {
|
||||
stats.width = Math.round(frameWidth);
|
||||
}
|
||||
if (frameHeight !== null) {
|
||||
stats.height = Math.round(frameHeight);
|
||||
}
|
||||
if (framesPerSecond !== null) {
|
||||
stats.frameRate = Math.round(framesPerSecond);
|
||||
}
|
||||
const codecId = outbound?.['codecId'];
|
||||
const mimeType = typeof codecId === 'string' ? codecs.get(codecId) : undefined;
|
||||
if (mimeType !== undefined && mimeType !== '') {
|
||||
// «video/VP8» → «VP8»: в интерфейсе нужен короткий кодек.
|
||||
stats.codec = mimeType.split('/')[1]?.toLocaleUpperCase() ?? mimeType;
|
||||
}
|
||||
|
||||
// Битрейт: разница `bytesSent` между замерами, делённая на интервал.
|
||||
const bytesSent = num(outbound?.['bytesSent']);
|
||||
const timestamp = num(outbound?.['timestamp']) ?? Date.now();
|
||||
if (bytesSent !== null) {
|
||||
if (previousSample !== null) {
|
||||
const elapsedMs = Math.max(1, timestamp - previousSample.at);
|
||||
const deltaBytes = Math.max(0, bytesSent - previousSample.bytes);
|
||||
stats.bitrateKbps = Math.round((deltaBytes * 8) / elapsedMs);
|
||||
}
|
||||
previousSample = { bytes: bytesSent, at: timestamp };
|
||||
} else {
|
||||
previousSample = null;
|
||||
}
|
||||
|
||||
const roundTrip =
|
||||
num(remoteInbound?.['roundTripTime']) ?? num(candidatePair?.['currentRoundTripTime']);
|
||||
if (roundTrip !== null) {
|
||||
stats.roundTripMs = Math.round(roundTrip * 1000);
|
||||
}
|
||||
const jitter = num(remoteInbound?.['jitter']);
|
||||
if (jitter !== null) {
|
||||
stats.jitterMs = Math.round(jitter * 1000);
|
||||
}
|
||||
const packetsLost = num(remoteInbound?.['packetsLost']);
|
||||
const packetsSent = num(outbound?.['packetsSent']);
|
||||
if (packetsLost !== null) {
|
||||
stats.packetsLost = Math.max(0, Math.round(packetsLost));
|
||||
if (packetsSent !== null && packetsSent + packetsLost > 0) {
|
||||
stats.packetLossPercent =
|
||||
Math.round(((packetsLost / (packetsSent + packetsLost)) * 100 + Number.EPSILON) * 10) /
|
||||
10;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
const refresh = (): void => {
|
||||
callbacks.onParticipants(snapshotAll(room));
|
||||
@@ -210,11 +403,45 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setScreenShareEnabled(enabled) {
|
||||
await room.localParticipant.setScreenShareEnabled(enabled);
|
||||
async setScreenShareEnabled(enabled, preset) {
|
||||
if (!enabled) {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
// Разрешение и fps выбирает пользователь: они уходят в захват экрана.
|
||||
const resolution = SCREEN_SHARE_PRESETS[preset ?? '1080p30'];
|
||||
await room.localParticipant.setScreenShareEnabled(true, {
|
||||
resolution: {
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
frameRate: resolution.frameRate,
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setVideoQuality(quality) {
|
||||
const track = videoPublication();
|
||||
// `setPublishingQuality` есть только у локальной видеодорожки: если
|
||||
// публикации нет, менять нечего — предпочтение применится при старте.
|
||||
if (track === undefined || typeof track.setPublishingQuality !== 'function') {
|
||||
return;
|
||||
}
|
||||
const { VideoQuality } = await import('livekit-client');
|
||||
const levels: Record<VoicePublishQuality, (typeof VideoQuality)[keyof typeof VideoQuality]> =
|
||||
{
|
||||
low: VideoQuality.LOW,
|
||||
medium: VideoQuality.MEDIUM,
|
||||
high: VideoQuality.HIGH,
|
||||
};
|
||||
track.setPublishingQuality(levels[quality]);
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
return collectStats();
|
||||
},
|
||||
|
||||
async switchActiveDevice(kind, deviceId) {
|
||||
await room.switchActiveDevice(kind, deviceId === '' ? 'default' : deviceId);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Локальное воспроизведение звуков саундборда и звуковой палитры
|
||||
* (AGENT.md 7.13): сервер только рассылает событие, а файл каждый клиент
|
||||
* скачивает сам (`/files/{file_id}`, тот же origin) и играет локально.
|
||||
*
|
||||
* Движок держит кэш object URL (повторное нажатие не качает файл заново) и
|
||||
* список играющих элементов, чтобы звук можно было остановить: свой звук не
|
||||
* накладывается сам на себя, разные звуки звучат одновременно, а при выходе
|
||||
* из комнаты всё глохнет.
|
||||
*
|
||||
* Громкость берётся из общей громкости приложения (`stores/voice`).
|
||||
*/
|
||||
|
||||
/** Сколько звуков может звучать одновременно: защита от «стены» звука. */
|
||||
export const SOUNDBOARD_MAX_CONCURRENT = 8;
|
||||
|
||||
/** Кэш ссылок на файлы: `file_id` → object URL (или прямой адрес). */
|
||||
const sources = new Map<string, Promise<string>>();
|
||||
/** Играющие элементы по `file_id`: их останавливает `stopSoundFile`. */
|
||||
const playing = new Map<string, Set<HTMLAudioElement>>();
|
||||
/** Порядок запусков: самый старый звук глушится при переполнении. */
|
||||
const order: HTMLAudioElement[] = [];
|
||||
|
||||
/**
|
||||
* Общая громкость приложения в долях единицы (0–1). «Звук выключен» (в том
|
||||
* числе серверный дефен) глушит и саундборд, и звуки событий: 0 означает
|
||||
* «не играть вовсе».
|
||||
*/
|
||||
export function soundVolume(): number {
|
||||
const { outputVolume, headphonesEnabled } = useVoiceStore.getState();
|
||||
if (!headphonesEnabled) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, outputVolume / 100));
|
||||
}
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ссылка на файл звука: сначала пробуем скачать его в blob (тогда браузер
|
||||
* играет из памяти и файл не запрашивается дважды), при неудаче — играем по
|
||||
* прямому адресу `/files/{id}`. Ошибка скачивания не должна ломать звук.
|
||||
*/
|
||||
function resolveSource(fileId: string): Promise<string> {
|
||||
const cached = sources.get(fileId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const url = fileContentUrl(fileId);
|
||||
const pending = (async (): Promise<string> => {
|
||||
try {
|
||||
const response = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
return url;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return url;
|
||||
}
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
sources.set(fileId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function track(fileId: string, element: HTMLAudioElement): void {
|
||||
const set = playing.get(fileId) ?? new Set<HTMLAudioElement>();
|
||||
set.add(element);
|
||||
playing.set(fileId, set);
|
||||
order.push(element);
|
||||
}
|
||||
|
||||
function untrack(fileId: string, element: HTMLAudioElement): void {
|
||||
const set = playing.get(fileId);
|
||||
if (set !== undefined) {
|
||||
set.delete(element);
|
||||
if (set.size === 0) {
|
||||
playing.delete(fileId);
|
||||
}
|
||||
}
|
||||
const index = order.indexOf(element);
|
||||
if (index >= 0) {
|
||||
order.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Сколько звуков играет прямо сейчас. */
|
||||
export function playingCount(): number {
|
||||
return order.length;
|
||||
}
|
||||
|
||||
/** Останавливает все экземпляры одного звука (например, перед повтором). */
|
||||
export function stopSoundFile(fileId: string): void {
|
||||
const set = playing.get(fileId);
|
||||
if (set === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const element of [...set]) {
|
||||
stopElement(element);
|
||||
untrack(fileId, element);
|
||||
}
|
||||
}
|
||||
|
||||
/** Останавливает всё: используется при выходе из комнаты и в тестах. */
|
||||
export function stopAllSounds(): void {
|
||||
for (const [fileId, set] of [...playing]) {
|
||||
for (const element of [...set]) {
|
||||
stopElement(element);
|
||||
untrack(fileId, element);
|
||||
}
|
||||
}
|
||||
order.length = 0;
|
||||
}
|
||||
|
||||
function stopElement(element: HTMLAudioElement): void {
|
||||
try {
|
||||
element.pause();
|
||||
element.currentTime = 0;
|
||||
} catch {
|
||||
// Элемент мог не начать играть — это не ошибка.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проигрывает звук. Возвращает `false`, если звук играть не нужно или не
|
||||
* удалось (звук выключен, автовоспроизведение запрещено, формат не
|
||||
* поддержан): вызывающий код не должен показывать ошибку — звук не критичен
|
||||
* для интерфейса.
|
||||
*/
|
||||
export async function playSoundFile(fileId: string, volume = soundVolume()): Promise<boolean> {
|
||||
if (fileId === '' || volume <= 0) {
|
||||
return false;
|
||||
}
|
||||
// Тот же звук не накладывается сам на себя: повтор начинается заново.
|
||||
stopSoundFile(fileId);
|
||||
while (order.length >= SOUNDBOARD_MAX_CONCURRENT) {
|
||||
const oldest = order[0];
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
for (const [key, set] of playing) {
|
||||
if (set.has(oldest)) {
|
||||
untrack(key, oldest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
stopElement(oldest);
|
||||
}
|
||||
|
||||
let element: HTMLAudioElement;
|
||||
try {
|
||||
element = new Audio(await resolveSource(fileId));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
element.volume = clampVolume(volume);
|
||||
track(fileId, element);
|
||||
// Конец звука освобождает место для следующего; в jsdom события нет.
|
||||
element.addEventListener?.('ended', () => untrack(fileId, element));
|
||||
try {
|
||||
await element.play();
|
||||
return true;
|
||||
} catch {
|
||||
untrack(fileId, element);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Запуск без ожидания: обработчики событий не должны ждать звука. */
|
||||
export function playSoundFileInBackground(fileId: string, volume?: number): void {
|
||||
void playSoundFile(fileId, volume).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Полная очистка движка: кэш ссылок, играющие элементы (тесты и выход). */
|
||||
export function resetSoundboardEngine(): void {
|
||||
stopAllSounds();
|
||||
for (const pending of sources.values()) {
|
||||
void pending
|
||||
.then((url) => {
|
||||
if (url.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
sources.clear();
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
/** Событие системного сообщения. */
|
||||
export type SystemMessageEvent =
|
||||
| { kind: 'member_join'; userId: string }
|
||||
| { kind: 'member_leave'; userId: string }
|
||||
| { kind: 'member_kick'; userId: string }
|
||||
| { kind: 'channel_created'; name: string; channelType: string };
|
||||
|
||||
@@ -33,7 +34,7 @@ export function parseSystemMessage(content: string): SystemMessageEvent | null {
|
||||
const record = asRecord(payload);
|
||||
const event = record?.['event'];
|
||||
const userId = record?.['user_id'];
|
||||
if (event === 'member_join' || event === 'member_kick') {
|
||||
if (event === 'member_join' || event === 'member_leave' || event === 'member_kick') {
|
||||
return typeof userId === 'string' && userId !== '' ? { kind: event, userId } : null;
|
||||
}
|
||||
if (event === 'channel_created') {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { fetchGuild, guildQueryKey } from '@/api/guilds';
|
||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { GatewayBridge } from '@/components/GatewayBridge';
|
||||
import { SoundboardNotice } from '@/components/voice/SoundboardNotice';
|
||||
import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice';
|
||||
import { usePushToTalk } from '@/components/voice/usePushToTalk';
|
||||
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
@@ -39,6 +41,10 @@ export default function AppLayout() {
|
||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
|
||||
|
||||
// Push-to-talk слушает клавишу на уровне документа: хук живёт в оболочке,
|
||||
// чтобы работать на любом экране, пока открыта голосовая сессия.
|
||||
usePushToTalk();
|
||||
|
||||
const segments = location.pathname.split('/').filter((segment) => segment !== '');
|
||||
const isFriends = segments[1] === 'friends';
|
||||
const guildId = isFriends || segments[1] === undefined ? null : decodeURIComponent(segments[1]);
|
||||
@@ -196,6 +202,8 @@ export default function AppLayout() {
|
||||
<CreateGuildModal />
|
||||
{/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */}
|
||||
<VoiceSpeakingNotice />
|
||||
{/* Подпись «{имя} включил звук {name}» из события SOUNDBOARD_PLAY (7.13). */}
|
||||
<SoundboardNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canAdministerGuild, canManageEmojis } from '@/lib/identity';
|
||||
import { canAdministerGuild, canManageEmojis, canManageSounds } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
@@ -16,10 +16,11 @@ import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||
import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection';
|
||||
|
||||
/**
|
||||
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
||||
* общее (имя и описание), участники, роли, эмодзи и «Опасная зона».
|
||||
* общее (имя и описание), участники, роли, эмодзи, звуки и «Опасная зона».
|
||||
*
|
||||
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
|
||||
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
|
||||
@@ -150,6 +151,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
|
||||
const canManageSoundSet = canManageSounds(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
currentUser.data?.id,
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
@@ -163,6 +172,9 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
{canManageEmojiSet ? (
|
||||
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
|
||||
) : null}
|
||||
{canManageSoundSet ? (
|
||||
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
|
||||
) : null}
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
SOUND_EVENTS,
|
||||
SOUND_LIMIT_PER_KIND,
|
||||
SOUND_MAX_BYTES,
|
||||
deleteGuildSound,
|
||||
fetchGuildSounds,
|
||||
guildSoundsQueryKey,
|
||||
isAllowedSoundType,
|
||||
isValidSoundName,
|
||||
renameGuildSound,
|
||||
uploadGuildSound,
|
||||
type GuildSound,
|
||||
type SoundEvent,
|
||||
type SoundKind,
|
||||
} from '@/api/sounds';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Badge, Button } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
interface GuildSoundsSectionProps {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная ошибка проверки: текст показываем сразу, на сервер не ходим.
|
||||
* Ключ перевода — как у серверных ошибок (`errors.<code>`).
|
||||
*/
|
||||
type LocalError =
|
||||
| { kind: 'file.too_large' }
|
||||
| { kind: 'sound.invalid_type' }
|
||||
| { kind: 'sound.invalid_name' }
|
||||
| { kind: 'sounds.event_required' };
|
||||
|
||||
/** Названия событий палитры: подписи берём из локалей. */
|
||||
function eventLabelKey(event: SoundEvent): string {
|
||||
return `settings.servers.sounds.events.${event}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Звуки»: саундборд и звуковая палитра сервера (AGENT.md 7.13, право
|
||||
* MANAGE_SOUNDS). Список читается по REST, а снапшот обновляется точечно —
|
||||
* и загрузкой, и событием шлюза `GUILD_SOUNDS_UPDATE`.
|
||||
*/
|
||||
export function GuildSoundsSection({ guildId }: GuildSoundsSectionProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [kind, setKind] = useState<SoundKind>('soundboard');
|
||||
const [name, setName] = useState('');
|
||||
const [emoji, setEmoji] = useState('');
|
||||
const [event, setEvent] = useState<SoundEvent>('member_join');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [localError, setLocalError] = useState<LocalError | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [renaming, setRenaming] = useState<GuildSound | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [removing, setRemoving] = useState<GuildSound | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: guildSoundsQueryKey(guildId),
|
||||
queryFn: ({ signal }) => fetchGuildSounds(guildId, undefined, signal),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const sounds = list.data ?? [];
|
||||
const soundboard = sounds.filter((sound) => sound.kind === 'soundboard');
|
||||
const palette = sounds.filter((sound) => sound.kind === 'ui');
|
||||
const current = kind === 'soundboard' ? soundboard : palette;
|
||||
const full = current.length >= SOUND_LIMIT_PER_KIND;
|
||||
|
||||
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
|
||||
const refresh = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) });
|
||||
};
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: (input: {
|
||||
name: string;
|
||||
file: File;
|
||||
kind: SoundKind;
|
||||
event?: SoundEvent;
|
||||
emoji?: string;
|
||||
}) => uploadGuildSound(guildId, input.kind, input),
|
||||
onSuccess: (sound) => {
|
||||
if (sound !== null) {
|
||||
useSessionStore.getState().upsertGuildSound(guildId, sound);
|
||||
}
|
||||
setName('');
|
||||
setEmoji('');
|
||||
setFile(null);
|
||||
setLocalError(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: (input: { soundId: string; name: string; emoji?: string }) =>
|
||||
renameGuildSound(guildId, input.soundId, {
|
||||
name: input.name,
|
||||
...(input.emoji === undefined || input.emoji === '' ? {} : { emoji: input.emoji }),
|
||||
}),
|
||||
onSuccess: (sound) => {
|
||||
if (sound !== null) {
|
||||
useSessionStore.getState().upsertGuildSound(guildId, sound);
|
||||
}
|
||||
setRenaming(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (soundId: string) => deleteGuildSound(guildId, soundId),
|
||||
onSuccess: (_result, soundId) => {
|
||||
useSessionStore.getState().removeGuildSound(guildId, soundId);
|
||||
setRemoving(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
/** Проверки на клиенте: формат, размер и имя — до запроса на сервер. */
|
||||
const pickFile = (next: File | undefined): void => {
|
||||
if (next === undefined) {
|
||||
return;
|
||||
}
|
||||
upload.reset();
|
||||
if (!isAllowedSoundType(next)) {
|
||||
setFile(null);
|
||||
setLocalError({ kind: 'sound.invalid_type' });
|
||||
return;
|
||||
}
|
||||
if (next.size > SOUND_MAX_BYTES) {
|
||||
setFile(null);
|
||||
setLocalError({ kind: 'file.too_large' });
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setFile(next);
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (file === null) {
|
||||
setLocalError({ kind: 'sound.invalid_type' });
|
||||
return;
|
||||
}
|
||||
if (!isValidSoundName(name)) {
|
||||
setLocalError({ kind: 'sound.invalid_name' });
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
upload.mutate({
|
||||
name,
|
||||
file,
|
||||
kind,
|
||||
...(kind === 'ui' ? { event } : {}),
|
||||
...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }),
|
||||
});
|
||||
};
|
||||
|
||||
const onDrop = (event_: DragEvent<HTMLDivElement>): void => {
|
||||
event_.preventDefault();
|
||||
setDragging(false);
|
||||
pickFile(event_.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
/** Строка списка: имя, событие, эмодзи, переименование и удаление. */
|
||||
const renderRow = (sound: GuildSound) => (
|
||||
<li
|
||||
key={sound.id}
|
||||
data-testid={`guild-sound-${sound.id}`}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||
>
|
||||
<span aria-hidden="true" className="w-6 shrink-0 text-center">
|
||||
{sound.emoji ?? '🎵'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{sound.name}</span>
|
||||
{sound.event === undefined ? null : <Badge>{t(eventLabelKey(sound.event))}</Badge>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={t('settings.servers.sounds.renameLabel', { name: sound.name })}
|
||||
onClick={() => {
|
||||
rename.reset();
|
||||
setRenameValue(sound.name);
|
||||
setRenaming(sound);
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.sounds.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
aria-label={t('settings.servers.sounds.deleteLabel', { name: sound.name })}
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setRemoving(sound);
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.sounds.delete')}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.servers.sounds.title')}
|
||||
description={t('settings.servers.sounds.description', { limit: SOUND_LIMIT_PER_KIND })}
|
||||
>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{(['soundboard', 'ui'] as SoundKind[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={kind === item}
|
||||
data-testid={`guild-sounds-tab-${item}`}
|
||||
onClick={() => setKind(item)}
|
||||
className={`rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
kind === item
|
||||
? 'bg-surface-3 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
{/* Счётчик вида прямо в переключателе: «Саундборд · 2 из 30». */}
|
||||
{t(`settings.servers.sounds.kinds.${item}`)}
|
||||
{' · '}
|
||||
{t('settings.servers.sounds.counter', {
|
||||
count: item === 'soundboard' ? soundboard.length : palette.length,
|
||||
limit: SOUND_LIMIT_PER_KIND,
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-fg-muted" data-testid="guild-sounds-counter">
|
||||
{t('settings.servers.sounds.counter', {
|
||||
count: current.length,
|
||||
limit: SOUND_LIMIT_PER_KIND,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{list.isPending ? (
|
||||
<SkeletonLines className="mt-4" rows={3} />
|
||||
) : list.isError ? (
|
||||
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
|
||||
) : current.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.sounds.empty')}</p>
|
||||
) : (
|
||||
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-sounds">
|
||||
{current.map(renderRow)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4">
|
||||
<h3 className="text-sm font-semibold">{t('settings.servers.sounds.uploadTitle')}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('settings.servers.sounds.uploadHint', {
|
||||
limit: formatBytes(SOUND_MAX_BYTES, i18n.language),
|
||||
})}
|
||||
</p>
|
||||
{full ? <p className="text-xs text-warning">{t('errors.sounds.limit_reached')}</p> : null}
|
||||
|
||||
<Field
|
||||
label={t('settings.servers.sounds.name')}
|
||||
name="sound_name"
|
||||
value={name}
|
||||
maxLength={32}
|
||||
placeholder={t('settings.servers.sounds.namePlaceholder')}
|
||||
hint={t('settings.servers.sounds.nameHint')}
|
||||
onChange={(event_) => setName(event_.target.value)}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t('settings.servers.sounds.emoji')}
|
||||
name="sound_emoji"
|
||||
value={emoji}
|
||||
maxLength={8}
|
||||
placeholder={t('settings.servers.sounds.emojiPlaceholder')}
|
||||
hint={t('settings.servers.sounds.emojiHint')}
|
||||
onChange={(event_) => setEmoji(event_.target.value)}
|
||||
/>
|
||||
|
||||
{kind !== 'ui' ? null : (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('settings.servers.sounds.event')}
|
||||
</span>
|
||||
<select
|
||||
value={event}
|
||||
aria-label={t('settings.servers.sounds.event')}
|
||||
data-testid="guild-sounds-event"
|
||||
onChange={(event_) => setEvent(event_.target.value as SoundEvent)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{SOUND_EVENTS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{t(eventLabelKey(item))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={(event_) => {
|
||||
event_.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-dashed px-3 py-2 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
dragging ? 'border-accent bg-accent/10' : 'border-border/60 bg-surface-2/40'
|
||||
}`}
|
||||
data-testid="sound-dropzone"
|
||||
>
|
||||
<span className="text-fg-muted">{t('settings.servers.sounds.dropHint')}</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,.mp3,.ogg,.wav,.webm,.m4a"
|
||||
aria-label={t('settings.servers.sounds.file')}
|
||||
onChange={(event_) => {
|
||||
pickFile(event_.target.files?.[0]);
|
||||
event_.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" onClick={() => fileInputRef.current?.click()}>
|
||||
{t('settings.servers.sounds.chooseFile')}
|
||||
</Button>
|
||||
{file === null ? null : (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('settings.servers.sounds.fileSelected', {
|
||||
name: file.name,
|
||||
size: formatBytes(file.size, i18n.language),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{localError === null ? null : (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{t(`errors.${localError.kind}`)}
|
||||
</p>
|
||||
)}
|
||||
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={upload.isPending || file === null || name.trim() === '' || full}
|
||||
onClick={submit}
|
||||
>
|
||||
{t(
|
||||
upload.isPending
|
||||
? 'settings.servers.sounds.uploading'
|
||||
: 'settings.servers.sounds.upload',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={renaming !== null}
|
||||
title={t('settings.servers.sounds.renameTitle')}
|
||||
onClose={() => setRenaming(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRenaming(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={rename.isPending || !isValidSoundName(renameValue.trim())}
|
||||
onClick={() => {
|
||||
if (renaming !== null) {
|
||||
rename.mutate({ soundId: renaming.id, name: renameValue.trim() });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(rename.isPending ? 'common.saving' : 'common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
label={t('settings.servers.sounds.name')}
|
||||
name="sound_rename"
|
||||
value={renameValue}
|
||||
maxLength={32}
|
||||
hint={t('settings.servers.sounds.nameHint')}
|
||||
onChange={(event_) => setRenameValue(event_.target.value)}
|
||||
/>
|
||||
{rename.isError ? <ErrorNotice className="mt-3" error={rename.error} /> : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={removing !== null}
|
||||
title={t('settings.servers.sounds.deleteConfirmTitle')}
|
||||
onClose={() => setRemoving(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRemoving(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (removing !== null) {
|
||||
remove.mutate(removing.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.sounds.deleteConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('settings.servers.sounds.deleteConfirmBody', { name: removing?.name ?? '' })}
|
||||
</p>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls';
|
||||
import { PUSH_TO_TALK_KEYS, useVoiceStore, type PushToTalkKey } from '@/stores/voice';
|
||||
import { useDeviceChoice } from '@/lib/useMediaDevices';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
/**
|
||||
* «Аудио-видео»: выбор устройств ввода/вывода и громкости.
|
||||
* Подписи клавиш push-to-talk: `code` не зависит от раскладки, поэтому
|
||||
* хранится код, а показывается человекочитаемое имя.
|
||||
*/
|
||||
const PTT_KEY_LABELS: Record<PushToTalkKey, string> = {
|
||||
Space: 'settings.voice.pttKeys.space',
|
||||
KeyV: 'settings.voice.pttKeys.v',
|
||||
KeyB: 'settings.voice.pttKeys.b',
|
||||
KeyN: 'settings.voice.pttKeys.n',
|
||||
AltLeft: 'settings.voice.pttKeys.alt',
|
||||
ControlLeft: 'settings.voice.pttKeys.ctrl',
|
||||
};
|
||||
|
||||
/**
|
||||
* «Аудио-видео»: устройства ввода/вывода, громкости, режим активации
|
||||
* микрофона (голосовая активация или push-to-talk) и переключатели звуков
|
||||
* сервера (AGENT.md 7.13/7.14).
|
||||
*
|
||||
* Значения хранятся локально (zustand + localStorage) и применяются на лету:
|
||||
* выбранное устройство сразу переключается в активной голосовой сессии.
|
||||
@@ -20,6 +35,14 @@ export function VoiceSettingsSection() {
|
||||
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
||||
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
|
||||
const setOutputVolume = useVoiceStore((state) => state.setOutputVolume);
|
||||
const micActivation = useVoiceStore((state) => state.micActivation);
|
||||
const pttKey = useVoiceStore((state) => state.pttKey);
|
||||
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
||||
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
||||
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
||||
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
||||
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
||||
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -58,6 +81,74 @@ export function VoiceSettingsSection() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Режим активации микрофона: голосовая активация или push-to-talk. */}
|
||||
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
||||
<legend className="text-sm font-semibold">{t('settings.voice.micMode')}</legend>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{(['voice', 'push_to_talk'] as const).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="mic_activation"
|
||||
value={mode}
|
||||
checked={micActivation === mode}
|
||||
aria-label={t(`settings.voice.micModes.${mode}`)}
|
||||
onChange={() => setMicActivation(mode)}
|
||||
/>
|
||||
<span>{t(`settings.voice.micModes.${mode}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{micActivation === 'push_to_talk' ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-fg-muted">{t('settings.voice.pttKey')}</span>
|
||||
<select
|
||||
value={pttKey}
|
||||
aria-label={t('settings.voice.pttKey')}
|
||||
data-testid="ptt-key-select"
|
||||
onChange={(event) => setPttKey(event.target.value as PushToTalkKey)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
|
||||
>
|
||||
{PUSH_TO_TALK_KEYS.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{t(PTT_KEY_LABELS[key])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t('settings.voice.pttHint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
{/* Звуки сервера: палитра событий и саундборд (AGENT.md 7.13). */}
|
||||
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
||||
<legend className="text-sm font-semibold">{t('settings.voice.sounds')}</legend>
|
||||
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventSoundsEnabled}
|
||||
aria-label={t('settings.voice.eventSounds')}
|
||||
onChange={(event) => setEventSoundsEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.voice.eventSounds')}</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.eventSoundsHint')}</p>
|
||||
<label className="mt-3 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundboardEnabled}
|
||||
aria-label={t('settings.voice.soundboard')}
|
||||
onChange={(event) => setSoundboardEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.voice.soundboard')}</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
|
||||
</fieldset>
|
||||
|
||||
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parseDmChannelEvent,
|
||||
parseGuildEmojisUpdate,
|
||||
parseGuildId,
|
||||
parseGuildSoundsUpdate,
|
||||
parseGuildUpdate,
|
||||
parseMessageDelete,
|
||||
parseMessagePayload,
|
||||
@@ -24,14 +25,19 @@ import {
|
||||
parseReadStateUpdate,
|
||||
parseRelationshipEvent,
|
||||
parseRoleEvent,
|
||||
parseSoundboardPlay,
|
||||
parseTypingStart,
|
||||
parseUserUpdate,
|
||||
} from '@/api/gatewayEvents';
|
||||
import { guildSoundsQueryKey } from '@/api/sounds';
|
||||
import { parseSystemMessage } from '@/lib/systemMessage';
|
||||
import { useFriendsStore } from '@/stores/friends';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useSessionStore, type SessionGuild } from '@/stores/session';
|
||||
import { playEventSound, useSoundboardStore } from '@/stores/soundboard';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { parseVoiceStateEvent } from '@/api/voice';
|
||||
import type { Message } from '@/api/messages';
|
||||
|
||||
/**
|
||||
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
||||
@@ -207,6 +213,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
messages.applyMessageCreate(message);
|
||||
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
|
||||
session.touchDmChannel(message.channel_id, message.id, message.created_at);
|
||||
playMessageSound(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -297,6 +304,23 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
session.patchDmPresence(presence.user_id, presence.visible_status ?? presence.status);
|
||||
return;
|
||||
}
|
||||
case 'GUILD_SOUNDS_UPDATE': {
|
||||
// Набор звуков сервера изменился целиком: снапшот и REST-кэш (7.13).
|
||||
const update = parseGuildSoundsUpdate(event.d);
|
||||
if (update !== null) {
|
||||
session.setGuildSounds(update.guild_id, update.sounds);
|
||||
void invalidateSoundQueries(update.guild_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'SOUNDBOARD_PLAY': {
|
||||
// Событие приходит только участникам комнаты: играем звук локально.
|
||||
const played = parseSoundboardPlay(event.d);
|
||||
if (played !== null) {
|
||||
useSoundboardStore.getState().applyGatewayPlay(played);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'RELATIONSHIP_UPDATE': {
|
||||
// Связь появилась или пропала — перечитываем список целиком.
|
||||
const relationship = parseRelationshipEvent(event.d);
|
||||
@@ -319,10 +343,30 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
if (voice === null) {
|
||||
return;
|
||||
}
|
||||
const me = session.user?.id ?? null;
|
||||
const myChannel = useVoiceConnectionStore.getState().channelId;
|
||||
if (voice.kind === 'left') {
|
||||
// До удаления записи узнаём, из какой комнаты ушёл участник.
|
||||
const leftChannel =
|
||||
(session.voiceStates[voice.guildId] ?? []).find((item) => item.user_id === voice.userId)
|
||||
?.channel_id ?? null;
|
||||
session.applyVoiceLeave(voice.guildId, voice.userId);
|
||||
if (voice.userId !== me && leftChannel !== null && leftChannel === myChannel) {
|
||||
playEventSound(voice.guildId, 'voice_leave');
|
||||
}
|
||||
} else {
|
||||
// Участник мог быть в другой комнате — тогда это перемещение в нашу.
|
||||
const previous = (session.voiceStates[voice.state.guild_id] ?? []).find(
|
||||
(item) => item.user_id === voice.state.user_id,
|
||||
);
|
||||
session.applyVoiceState(voice.state);
|
||||
if (
|
||||
voice.state.user_id !== me &&
|
||||
voice.state.channel_id === myChannel &&
|
||||
previous?.channel_id !== myChannel
|
||||
) {
|
||||
playEventSound(voice.state.guild_id, 'voice_join');
|
||||
}
|
||||
}
|
||||
// server_mute/server_deaf и перемещение себя применяем к своей сессии.
|
||||
useVoiceConnectionStore.getState().syncServerState();
|
||||
@@ -334,6 +378,45 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Звук события UI-палитры для нового сообщения (AGENT.md 7.13): системные
|
||||
* сообщения — это вход/выход участника, остальные — упоминание или обычное
|
||||
* сообщение. Свои сообщения звуком не сопровождаются.
|
||||
*/
|
||||
function playMessageSound(message: Message): void {
|
||||
const session = useSessionStore.getState();
|
||||
const guildId = guildIdOfChannel(session.guilds, message.channel_id);
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
if (message.type === 'system') {
|
||||
const event = parseSystemMessage(message.content);
|
||||
if (event?.kind === 'member_join') {
|
||||
playEventSound(guildId, 'member_join');
|
||||
return;
|
||||
}
|
||||
if (event?.kind === 'member_leave' || event?.kind === 'member_kick') {
|
||||
playEventSound(guildId, 'member_leave');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const me = session.user?.id ?? null;
|
||||
if (me !== null && message.author_id === me) {
|
||||
return;
|
||||
}
|
||||
playEventSound(guildId, me !== null && message.mentions.includes(me) ? 'mention' : 'message');
|
||||
}
|
||||
|
||||
/** Сервер, которому принадлежит комната: в личной беседе его нет. */
|
||||
function guildIdOfChannel(guilds: SessionGuild[], channelId: string): string | null {
|
||||
for (const guild of guilds) {
|
||||
if (guild.channels.some((channel) => channel.id === channelId)) {
|
||||
return guild.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Сервер появился (создан или найден): забираем сводку из REST. */
|
||||
async function addGuildFromRest(guildId: string): Promise<void> {
|
||||
const [{ getQueryClient }, users] = await Promise.all([
|
||||
@@ -392,6 +475,12 @@ async function invalidateEmojiQueries(guildId: string): Promise<void> {
|
||||
await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
|
||||
}
|
||||
|
||||
/** Инвалидация списка звуков сервера после `GUILD_SOUNDS_UPDATE` (7.13). */
|
||||
async function invalidateSoundQueries(guildId: string): Promise<void> {
|
||||
const { getQueryClient } = await import('@/lib/queryClient');
|
||||
await getQueryClient().invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) });
|
||||
}
|
||||
|
||||
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
|
||||
function syncUrlWithSelection(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
||||
import type { GuildEmoji } from '@/api/emojis';
|
||||
import { fetchDirectChannels } from '@/api/friends';
|
||||
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
||||
import type { GuildSound, SoundKind } from '@/api/sounds';
|
||||
import type { VoiceState } from '@/api/voice';
|
||||
import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
|
||||
|
||||
@@ -17,6 +18,8 @@ export interface SessionGuild extends GuildSummary {
|
||||
channels: Channel[];
|
||||
/** Кастомные эмодзи сервера: READY и `GUILD_EMOJIS_UPDATE` (AGENT.md 7.12). */
|
||||
emojis: GuildEmoji[];
|
||||
/** Саундборд и звуковая палитра: READY и `GUILD_SOUNDS_UPDATE` (7.13). */
|
||||
sounds: GuildSound[];
|
||||
my_nickname?: string;
|
||||
}
|
||||
|
||||
@@ -67,6 +70,15 @@ interface SessionState {
|
||||
upsertGuildEmoji: (guildId: string, emoji: GuildEmoji) => void;
|
||||
/** Эмодзи удалён: убираем его из снапшота (реакции остаются как есть). */
|
||||
removeGuildEmoji: (guildId: string, emojiId: string) => void;
|
||||
/**
|
||||
* Набор звуков сервера целиком: READY и `GUILD_SOUNDS_UPDATE` шлют его
|
||||
* одним и тем же списком (AGENT.md 7.13).
|
||||
*/
|
||||
setGuildSounds: (guildId: string, sounds: GuildSound[]) => void;
|
||||
/** Звук загружен или переименован: заменяем запись по id. */
|
||||
upsertGuildSound: (guildId: string, sound: GuildSound) => void;
|
||||
/** Звук удалён: убираем его из снапшота. */
|
||||
removeGuildSound: (guildId: string, soundId: string) => void;
|
||||
/** Личные беседы: список целиком (READY или `GET /users/@me/channels`). */
|
||||
setDmChannels: (channels: Channel[]) => void;
|
||||
upsertDmChannel: (channel: Channel) => void;
|
||||
@@ -120,6 +132,7 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild {
|
||||
my_permissions: guild.my_permissions ?? [],
|
||||
channels: guild.channels,
|
||||
emojis: guild.emojis,
|
||||
sounds: guild.sounds,
|
||||
};
|
||||
if (guild.icon_file_id !== undefined) {
|
||||
summary.icon_file_id = guild.icon_file_id;
|
||||
@@ -211,19 +224,20 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
const guilds = get().guilds;
|
||||
const index = guilds.findIndex((item) => item.id === guild.id);
|
||||
if (index === -1) {
|
||||
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [] }] });
|
||||
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [], sounds: [] }] });
|
||||
return;
|
||||
}
|
||||
const existing = guilds[index];
|
||||
if (existing === undefined) {
|
||||
return;
|
||||
}
|
||||
// Сводка из REST не знает ни комнат, ни эмодзи — накопленное не теряем.
|
||||
// Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем.
|
||||
const merged: SessionGuild = {
|
||||
...existing,
|
||||
...guild,
|
||||
channels: existing.channels,
|
||||
emojis: existing.emojis,
|
||||
sounds: existing.sounds,
|
||||
};
|
||||
const next = [...guilds];
|
||||
next[index] = merged;
|
||||
@@ -352,6 +366,39 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
setGuildSounds: (guildId, sounds) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, sounds } : guild)),
|
||||
});
|
||||
},
|
||||
|
||||
upsertGuildSound: (guildId, sound) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) => {
|
||||
if (guild.id !== guildId) {
|
||||
return guild;
|
||||
}
|
||||
const index = guild.sounds.findIndex((item) => item.id === sound.id);
|
||||
if (index === -1) {
|
||||
return { ...guild, sounds: [...guild.sounds, sound] };
|
||||
}
|
||||
const sounds = [...guild.sounds];
|
||||
sounds[index] = sound;
|
||||
return { ...guild, sounds };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
removeGuildSound: (guildId, soundId) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) =>
|
||||
guild.id === guildId
|
||||
? { ...guild, sounds: guild.sounds.filter((sound) => sound.id !== soundId) }
|
||||
: guild,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
setDmChannels: (channels) => {
|
||||
set({ dmChannels: channels });
|
||||
},
|
||||
@@ -479,3 +526,30 @@ export function myVoiceState(
|
||||
}
|
||||
return (voiceStates[guildId] ?? []).find((state) => state.user_id === userId) ?? null;
|
||||
}
|
||||
|
||||
/** Пустой список — стабильная ссылка: селекторы не должны перерисовывать зря. */
|
||||
const NO_SOUNDS: GuildSound[] = [];
|
||||
|
||||
/**
|
||||
* Звуки сервера из снапшота (READY и `GUILD_SOUNDS_UPDATE`). Без `kind`
|
||||
* возвращаются оба набора: саундборд и звуковая палитра.
|
||||
*/
|
||||
export function guildSoundsOf(
|
||||
guilds: readonly SessionGuild[],
|
||||
guildId: string | null,
|
||||
kind?: SoundKind,
|
||||
): GuildSound[] {
|
||||
if (guildId === null) {
|
||||
return NO_SOUNDS;
|
||||
}
|
||||
const guild = guilds.find((item) => item.id === guildId);
|
||||
if (guild === undefined) {
|
||||
return NO_SOUNDS;
|
||||
}
|
||||
return kind === undefined ? guild.sounds : guild.sounds.filter((sound) => sound.kind === kind);
|
||||
}
|
||||
|
||||
/** Готовый селектор для `useSessionStore`: звуки сервера одного вида. */
|
||||
export function selectGuildSounds(guildId: string | null, kind?: SoundKind) {
|
||||
return (state: SessionState): GuildSound[] => guildSoundsOf(state.guilds, guildId, kind);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { GuildSound, SoundEvent } from '@/api/sounds';
|
||||
import { playSoundFileInBackground, stopAllSounds } from '@/lib/soundboard';
|
||||
import { guildSoundsOf, useSessionStore } from '@/stores/session';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import type { SoundboardPlayEvent } from '@/api/gatewayEvents';
|
||||
|
||||
/**
|
||||
* Саундборд и звуковая палитра (AGENT.md 7.13).
|
||||
*
|
||||
* Сервер не транслирует звук: он присылает событие `SOUNDBOARD_PLAY`, а каждый
|
||||
* клиент скачивает файл и играет его локально. Здесь живёт то, что нельзя
|
||||
* размазать по компонентам: подпись «кто включил звук» и защита от двойного
|
||||
* проигрывания — своё нажатие звучит сразу (оптимистично), а пришедшее следом
|
||||
* событие шлюза уже не играет второй раз.
|
||||
*/
|
||||
|
||||
/** Окно, в котором событие шлюза считается эхом своего нажатия. */
|
||||
export const SOUNDBOARD_DEDUP_WINDOW_MS = 3_000;
|
||||
/** Сколько держится подпись «{имя} включил звук {name}». */
|
||||
export const SOUNDBOARD_NOTICE_MS = 3_000;
|
||||
/** Сколько последних проигрываний помним для сопоставления. */
|
||||
const RECENT_LIMIT = 20;
|
||||
|
||||
export interface SoundboardNotice {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
soundName: string;
|
||||
}
|
||||
|
||||
interface RecentPlay {
|
||||
soundId: string;
|
||||
userId: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface SoundboardState {
|
||||
/** Подпись о последнем сыгранном звуке (гаснет сама через 3 секунды). */
|
||||
notice: SoundboardNotice | null;
|
||||
/** Недавние проигрывания: сопоставление оптимистичного звука с событием. */
|
||||
recent: RecentPlay[];
|
||||
/** Своё нажатие «Проиграть»: звук играет сразу, не дожидаясь шлюза. */
|
||||
playMySound: (guildId: string, sound: GuildSound, userId: string) => void;
|
||||
/** Событие `SOUNDBOARD_PLAY`: играем чужой звук (свой уже сыграл). */
|
||||
applyGatewayPlay: (event: SoundboardPlayEvent) => void;
|
||||
clearNotice: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
let noticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearNoticeTimer(): void {
|
||||
if (noticeTimer !== null) {
|
||||
clearTimeout(noticeTimer);
|
||||
noticeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const useSoundboardStore = create<SoundboardState>((set, get) => {
|
||||
/** Общая часть: звук играет, подпись появляется, проигрывание запоминается. */
|
||||
const play = (guildId: string, userId: string, soundId: string, fileId: string, name: string) => {
|
||||
playSoundFileInBackground(fileId);
|
||||
const recent = [
|
||||
{ soundId, userId, at: Date.now() },
|
||||
...get().recent.filter((item) => !(item.soundId === soundId && item.userId === userId)),
|
||||
].slice(0, RECENT_LIMIT);
|
||||
clearNoticeTimer();
|
||||
set({ recent, notice: { guildId, userId, soundName: name } });
|
||||
noticeTimer = setTimeout(() => {
|
||||
noticeTimer = null;
|
||||
set({ notice: null });
|
||||
}, SOUNDBOARD_NOTICE_MS);
|
||||
};
|
||||
|
||||
return {
|
||||
notice: null,
|
||||
recent: [],
|
||||
|
||||
playMySound: (guildId, sound, userId) => {
|
||||
if (!useVoiceStore.getState().soundboardEnabled) {
|
||||
return;
|
||||
}
|
||||
play(guildId, userId, sound.id, sound.file_id, sound.name);
|
||||
},
|
||||
|
||||
applyGatewayPlay: (event) => {
|
||||
if (!useVoiceStore.getState().soundboardEnabled) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const mine = get().recent.some(
|
||||
(item) =>
|
||||
item.soundId === event.sound_id &&
|
||||
item.userId === event.user_id &&
|
||||
now - item.at <= SOUNDBOARD_DEDUP_WINDOW_MS,
|
||||
);
|
||||
if (mine) {
|
||||
// Свой звук уже сыграл оптимистично — второй раз не играем.
|
||||
return;
|
||||
}
|
||||
const known = guildSoundsOf(useSessionStore.getState().guilds, event.guild_id).find(
|
||||
(sound) => sound.id === event.sound_id,
|
||||
);
|
||||
const name = event.name !== '' ? event.name : (known?.name ?? '');
|
||||
play(event.guild_id, event.user_id, event.sound_id, event.file_id, name);
|
||||
},
|
||||
|
||||
clearNotice: () => {
|
||||
clearNoticeTimer();
|
||||
set({ notice: null });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
clearNoticeTimer();
|
||||
stopAllSounds();
|
||||
set({ notice: null, recent: [] });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** Звук сервера, привязанный к событию UI-палитры (или `null`). */
|
||||
export function findEventSound(guildId: string | null, event: SoundEvent): GuildSound | null {
|
||||
if (guildId === null) {
|
||||
return null;
|
||||
}
|
||||
const sounds = guildSoundsOf(useSessionStore.getState().guilds, guildId, 'ui');
|
||||
return sounds.find((sound) => sound.event === event) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проигрывает звук события сервера, если он задан и звуки событий включены.
|
||||
* Вызывается из диспетчера шлюза и из голосовой сессии: молчание — норма,
|
||||
* когда звук не настроен или пользователь его выключил.
|
||||
*/
|
||||
export function playEventSound(guildId: string | null, event: SoundEvent): void {
|
||||
if (guildId === null || !useVoiceStore.getState().eventSoundsEnabled) {
|
||||
return;
|
||||
}
|
||||
const sound = findEventSound(guildId, event);
|
||||
if (sound === null) {
|
||||
return;
|
||||
}
|
||||
playSoundFileInBackground(sound.file_id);
|
||||
}
|
||||
|
||||
/** Полная остановка звуков: выход из комнаты, сброс сессии (тесты). */
|
||||
export function stopSoundboardPlayback(): void {
|
||||
stopAllSounds();
|
||||
}
|
||||
+92
-2
@@ -1,8 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { ScreenSharePresetId } from '@/lib/livekit';
|
||||
|
||||
/**
|
||||
* Локальные настройки «голосовой» панели: состояния кнопок микрофона, камеры и
|
||||
* наушников, выбранные устройства и громкости.
|
||||
* наушников, выбранные устройства, громкости, режим активации микрофона
|
||||
* (push-to-talk) и качество публикации (AGENT.md 7.13/7.14).
|
||||
*
|
||||
* Это только предпочтения клиента: они хранятся в localStorage и не уходят на
|
||||
* сервер. Реальная медиа-сессия живёт в `stores/voiceConnection.ts` и читает
|
||||
@@ -12,7 +15,30 @@ import { create } from 'zustand';
|
||||
/** Виды устройств, которые отдаёт `navigator.mediaDevices.enumerateDevices()`. */
|
||||
export type VoiceDeviceKind = 'audioinput' | 'videoinput' | 'audiooutput';
|
||||
|
||||
interface VoicePreferences {
|
||||
/** Как включается микрофон: голосовая активация или удержание клавиши. */
|
||||
export type MicActivationMode = 'voice' | 'push_to_talk';
|
||||
|
||||
/** Качество публикации видео (`VideoQuality` из LiveKit). */
|
||||
export type VoicePublishQuality = 'low' | 'medium' | 'high';
|
||||
|
||||
/**
|
||||
* Клавиши push-to-talk. `code` не зависит от раскладки — так клавиша работает
|
||||
* одинаково и в русской, и в английской. Для Alt/Ctrl берём левые варианты,
|
||||
* но правая клавиша тоже считается нажатием (см. `matchesPushToTalk`).
|
||||
*/
|
||||
export const PUSH_TO_TALK_KEYS = [
|
||||
'Space',
|
||||
'KeyV',
|
||||
'KeyB',
|
||||
'KeyN',
|
||||
'AltLeft',
|
||||
'ControlLeft',
|
||||
] as const;
|
||||
|
||||
export type PushToTalkKey = (typeof PUSH_TO_TALK_KEYS)[number];
|
||||
|
||||
/** Предпочтения клиента: то, что уходит в localStorage. */
|
||||
export interface VoicePreferences {
|
||||
micEnabled: boolean;
|
||||
cameraEnabled: boolean;
|
||||
headphonesEnabled: boolean;
|
||||
@@ -25,6 +51,18 @@ interface VoicePreferences {
|
||||
outputVolume: number;
|
||||
/** Личная громкость участников голосовой комнаты, 0–100 (AGENT.md 7.14). */
|
||||
participantVolumes: Record<string, number>;
|
||||
/** Режим активации микрофона (AGENT.md 7.14: push-to-talk). */
|
||||
micActivation: MicActivationMode;
|
||||
/** Клавиша push-to-talk. */
|
||||
pttKey: PushToTalkKey;
|
||||
/** Звуки событий сервера (UI-палитра) включены (AGENT.md 7.13). */
|
||||
eventSoundsEnabled: boolean;
|
||||
/** Саундборд включён. */
|
||||
soundboardEnabled: boolean;
|
||||
/** Качество своей публикации видео. */
|
||||
videoQuality: VoicePublishQuality;
|
||||
/** Пресет разрешения и fps для демонстрации экрана. */
|
||||
screenSharePreset: ScreenSharePresetId;
|
||||
}
|
||||
|
||||
interface VoiceState extends VoicePreferences {
|
||||
@@ -39,6 +77,12 @@ interface VoiceState extends VoicePreferences {
|
||||
setOutputVolume: (value: number) => void;
|
||||
/** Громкость конкретного участника: хранится только на этом устройстве. */
|
||||
setParticipantVolume: (userId: string, value: number) => void;
|
||||
setMicActivation: (mode: MicActivationMode) => void;
|
||||
setPttKey: (key: PushToTalkKey) => void;
|
||||
setEventSoundsEnabled: (enabled: boolean) => void;
|
||||
setSoundboardEnabled: (enabled: boolean) => void;
|
||||
setVideoQuality: (quality: VoicePublishQuality) => void;
|
||||
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
|
||||
/** Вернуть исходные предпочтения (используется в тестах). */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -55,6 +99,12 @@ const defaults: VoicePreferences = {
|
||||
micVolume: 100,
|
||||
outputVolume: 100,
|
||||
participantVolumes: {},
|
||||
micActivation: 'voice',
|
||||
pttKey: 'Space',
|
||||
eventSoundsEnabled: true,
|
||||
soundboardEnabled: true,
|
||||
videoQuality: 'high',
|
||||
screenSharePreset: '1080p30',
|
||||
};
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
@@ -90,6 +140,17 @@ function asParticipantVolumes(value: unknown): Record<string, number> {
|
||||
return volumes;
|
||||
}
|
||||
|
||||
/** Значение из фиксированного списка: чужие строки отбрасываем. */
|
||||
function asChoice<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
const MIC_ACTIVATION_MODES: readonly MicActivationMode[] = ['voice', 'push_to_talk'];
|
||||
const PUBLISH_QUALITIES: readonly VoicePublishQuality[] = ['low', 'medium', 'high'];
|
||||
const SCREEN_PRESETS: readonly ScreenSharePresetId[] = ['1080p60', '1080p30', '720p30', '480p30'];
|
||||
|
||||
/** Начальные значения: то, что уже лежит в localStorage, поверх умолчаний. */
|
||||
function initialPreferences(): VoicePreferences {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -119,6 +180,16 @@ function initialPreferences(): VoicePreferences {
|
||||
micVolume: asVolume(stored['micVolume'], defaults.micVolume),
|
||||
outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume),
|
||||
participantVolumes: asParticipantVolumes(stored['participantVolumes']),
|
||||
micActivation: asChoice(stored['micActivation'], MIC_ACTIVATION_MODES, defaults.micActivation),
|
||||
pttKey: asChoice(stored['pttKey'], PUSH_TO_TALK_KEYS, defaults.pttKey),
|
||||
eventSoundsEnabled: asBoolean(stored['eventSoundsEnabled'], defaults.eventSoundsEnabled),
|
||||
soundboardEnabled: asBoolean(stored['soundboardEnabled'], defaults.soundboardEnabled),
|
||||
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
|
||||
screenSharePreset: asChoice(
|
||||
stored['screenSharePreset'],
|
||||
SCREEN_PRESETS,
|
||||
defaults.screenSharePreset,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,6 +209,12 @@ function persist(state: VoicePreferences): void {
|
||||
micVolume: state.micVolume,
|
||||
outputVolume: state.outputVolume,
|
||||
participantVolumes: state.participantVolumes,
|
||||
micActivation: state.micActivation,
|
||||
pttKey: state.pttKey,
|
||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||
soundboardEnabled: state.soundboardEnabled,
|
||||
videoQuality: state.videoQuality,
|
||||
screenSharePreset: state.screenSharePreset,
|
||||
} satisfies VoicePreferences),
|
||||
);
|
||||
}
|
||||
@@ -183,6 +260,13 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
||||
patch({ participantVolumes: { ...get().participantVolumes, [userId]: clampVolume(value) } });
|
||||
},
|
||||
|
||||
setMicActivation: (mode) => patch({ micActivation: mode }),
|
||||
setPttKey: (key) => patch({ pttKey: key }),
|
||||
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
|
||||
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
|
||||
setVideoQuality: (quality) => patch({ videoQuality: quality }),
|
||||
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
|
||||
|
||||
reset: () => {
|
||||
set({ ...defaults });
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -204,5 +288,11 @@ function preferencesOf(state: VoiceState): VoicePreferences {
|
||||
micVolume: state.micVolume,
|
||||
outputVolume: state.outputVolume,
|
||||
participantVolumes: state.participantVolumes,
|
||||
micActivation: state.micActivation,
|
||||
pttKey: state.pttKey,
|
||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||
soundboardEnabled: state.soundboardEnabled,
|
||||
videoQuality: state.videoQuality,
|
||||
screenSharePreset: state.screenSharePreset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
tokenExpiresAt,
|
||||
type VoiceParticipant,
|
||||
type VoiceRoomHandle,
|
||||
type VoiceStats,
|
||||
} from '@/lib/livekit';
|
||||
import { myVoiceState, useSessionStore } from '@/stores/session';
|
||||
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
|
||||
import { playEventSound, stopSoundboardPlayback } from '@/stores/soundboard';
|
||||
import { useVoiceStore, type VoiceDeviceKind, type VoicePublishQuality } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Голосовая сессия LiveKit: подключение к комнате, публикация своих дорожек,
|
||||
@@ -69,9 +71,18 @@ export interface VoiceConnectionState {
|
||||
join: (guildId: string, channelId: string) => Promise<void>;
|
||||
leave: () => Promise<void>;
|
||||
setMicEnabled: (enabled: boolean) => Promise<void>;
|
||||
/**
|
||||
* Push-to-talk: включает микрофон на время удержания клавиши, не меняя
|
||||
* сохранённое предпочтение (AGENT.md 7.14). Серверный мьют приоритетнее.
|
||||
*/
|
||||
setPushToTalkActive: (active: boolean) => void;
|
||||
setDeafened: (enabled: boolean) => Promise<void>;
|
||||
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||
/** Качество публикации своего видео: `low` | `medium` | `high`. */
|
||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||
/** Статистика своей публикации для попапа «Статистика» (раз в секунду). */
|
||||
fetchStats: () => Promise<VoiceStats | null>;
|
||||
/** Смена устройства на лету: применяется к активной дорожке (AGENT.md 7.14). */
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
|
||||
@@ -162,6 +173,15 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
};
|
||||
|
||||
/** Звук события сервера играет только из активной комнаты (AGENT.md 7.13). */
|
||||
const playSound = (event: Parameters<typeof playEventSound>[1]): void => {
|
||||
const { guildId, status } = get();
|
||||
if (guildId === null || status !== 'connected') {
|
||||
return;
|
||||
}
|
||||
playEventSound(guildId, event);
|
||||
};
|
||||
|
||||
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
|
||||
const closeRoom = async (): Promise<void> => {
|
||||
const handle = room;
|
||||
@@ -184,6 +204,8 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
clearTokenTimer();
|
||||
clearWarningTimer();
|
||||
await closeRoom();
|
||||
// Звуки саундборда не должны доигрывать после выхода из комнаты.
|
||||
stopSoundboardPlayback();
|
||||
const channel = joinedChannelId;
|
||||
joinedChannelId = null;
|
||||
if (channel !== null) {
|
||||
@@ -323,6 +345,8 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
await get().leave();
|
||||
}
|
||||
const preferences = useVoiceStore.getState();
|
||||
// В режиме push-to-talk микрофон до нажатия клавиши выключен.
|
||||
const pushToTalk = preferences.micActivation === 'push_to_talk';
|
||||
const mine = (generation += 1);
|
||||
set({
|
||||
status: 'connecting',
|
||||
@@ -330,7 +354,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
channelId,
|
||||
error: null,
|
||||
participants: [],
|
||||
micEnabled: preferences.micEnabled,
|
||||
micEnabled: preferences.micEnabled && !pushToTalk,
|
||||
deafened: !preferences.headphonesEnabled,
|
||||
cameraEnabled: preferences.cameraEnabled,
|
||||
screenEnabled: false,
|
||||
@@ -386,8 +410,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
return;
|
||||
}
|
||||
await handle.setMicrophoneEnabled(preferences.micEnabled);
|
||||
await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk);
|
||||
await handle.setCameraEnabled(preferences.cameraEnabled);
|
||||
// Качество публикации — из предпочтений клиента.
|
||||
await handle.setVideoQuality(preferences.videoQuality);
|
||||
set({ status: 'connected', participants: handle.participants() });
|
||||
get().syncServerState();
|
||||
} catch (error) {
|
||||
@@ -418,6 +444,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
const guildId = get().guildId;
|
||||
const me = useSessionStore.getState().user?.id ?? null;
|
||||
await closeRoom();
|
||||
stopSoundboardPlayback();
|
||||
const channel = joinedChannelId;
|
||||
joinedChannelId = null;
|
||||
if (guildId !== null && me !== null) {
|
||||
@@ -440,9 +467,27 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
useVoiceStore.getState().setMicEnabled(enabled);
|
||||
await applyMic(enabled);
|
||||
playSound(enabled ? 'mic_on' : 'mic_off');
|
||||
await patchSelf({ self_mute: !enabled });
|
||||
},
|
||||
|
||||
setPushToTalkActive: (active) => {
|
||||
const { status, serverMuted, micEnabled } = get();
|
||||
if (status !== 'connected' && status !== 'reconnecting') {
|
||||
return;
|
||||
}
|
||||
// Серверный мьют приоритетнее: модератор выключил микрофон — молчим.
|
||||
if (active && serverMuted) {
|
||||
return;
|
||||
}
|
||||
if (micEnabled === active) {
|
||||
return;
|
||||
}
|
||||
// Звуки палитры на нажатие клавиши не играем: это была бы пулемётная очередь.
|
||||
void applyMic(active);
|
||||
void patchSelf({ self_mute: !active });
|
||||
},
|
||||
|
||||
setDeafened: async (enabled) => {
|
||||
useVoiceStore.getState().setHeadphonesEnabled(!enabled);
|
||||
set({ deafened: enabled });
|
||||
@@ -461,6 +506,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
set({ error });
|
||||
}
|
||||
}
|
||||
playSound(enabled ? 'camera_on' : 'camera_off');
|
||||
await patchSelf({ camera: enabled });
|
||||
},
|
||||
|
||||
@@ -469,15 +515,43 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
const handle = room;
|
||||
if (handle !== null) {
|
||||
try {
|
||||
await handle.setScreenShareEnabled(enabled);
|
||||
// Разрешение и fps берём из выбранного пресета (AGENT.md 7.14).
|
||||
await handle.setScreenShareEnabled(enabled, useVoiceStore.getState().screenSharePreset);
|
||||
set({ participants: handle.participants() });
|
||||
} catch (error) {
|
||||
set({ error });
|
||||
}
|
||||
}
|
||||
playSound(enabled ? 'screen_on' : 'screen_off');
|
||||
await patchSelf({ screen: enabled });
|
||||
},
|
||||
|
||||
setVideoQuality: async (quality) => {
|
||||
useVoiceStore.getState().setVideoQuality(quality);
|
||||
const handle = room;
|
||||
if (handle === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handle.setVideoQuality(quality);
|
||||
} catch (error) {
|
||||
set({ error });
|
||||
}
|
||||
},
|
||||
|
||||
fetchStats: async () => {
|
||||
const handle = room;
|
||||
if (handle === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await handle.fetchStats();
|
||||
} catch {
|
||||
// Отчёт WebRTC может быть недоступен — попап покажет прочерки.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setVolume: (userId, volume) => {
|
||||
useVoiceStore.getState().setParticipantVolume(userId, volume);
|
||||
},
|
||||
@@ -535,8 +609,13 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
const serverMuted = state.server_mute;
|
||||
const serverDeafened = state.server_deaf;
|
||||
if (serverMuted !== get().serverMuted || serverDeafened !== get().serverDeafened) {
|
||||
const wasServerMuted = get().serverMuted;
|
||||
if (serverMuted !== wasServerMuted || serverDeafened !== get().serverDeafened) {
|
||||
set({ serverMuted, serverDeafened });
|
||||
// Модератор только что выключил нам микрофон — звук события «mute».
|
||||
if (serverMuted && !wasServerMuted) {
|
||||
playSound('mute');
|
||||
}
|
||||
}
|
||||
// Модератор выключил микрофон — гасим публикацию, предпочтение не трогаем.
|
||||
if (serverMuted && micEnabled) {
|
||||
@@ -560,6 +639,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
generation += 1;
|
||||
clearTokenTimer();
|
||||
clearWarningTimer();
|
||||
stopSoundboardPlayback();
|
||||
const handle = room;
|
||||
room = null;
|
||||
joinedChannelId = null;
|
||||
|
||||
Reference in New Issue
Block a user