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
@@ -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) =>