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:
2026-09-20 19:30:43 +03:00
parent 2538e07ec3
commit 619bd91df4
40 changed files with 4269 additions and 48 deletions
+5
View File
@@ -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']),
+65
View File
@@ -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);
+232
View File
@@ -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' },
);
}
+7
View File
@@ -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>
);
}
+137 -10
View File
@@ -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],
);
}
+134
View File
@@ -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]);
}
+24 -1
View File
@@ -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) =>
+133 -4
View File
@@ -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",
+133 -4
View File
@@ -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": "Язык и регион",
+36
View File
@@ -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
View File
@@ -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);
},
+198
View File
@@ -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();
}
+2 -1
View File
@@ -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') {
+8
View File
@@ -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>
);
}
+14 -2
View File
@@ -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>
);
+90 -1
View File
@@ -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') {
+76 -2
View File
@@ -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);
}
+150
View File
@@ -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
View File
@@ -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,
};
}
+85 -5
View File
@@ -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;