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:
@@ -177,6 +177,13 @@ function SystemMessageBody({ message, authors }: { message: Message; authors: Au
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (event.kind === 'member_leave') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('chat.system.memberLeave', { name: nameOf(event.userId) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (event.kind === 'member_kick') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { useSoundboardStore } from '@/stores/soundboard';
|
||||
|
||||
/**
|
||||
* Всплывающая подпись «{имя} включил звук {name}» (AGENT.md 7.13).
|
||||
*
|
||||
* Живёт в оболочке приложения — рядом с подсказкой о выключенном микрофоне,
|
||||
* поэтому её видно с любого экрана. Гаснет сама: время жизни держит
|
||||
* `stores/soundboard` (3 секунды), компонент только рисует текст.
|
||||
*/
|
||||
export function SoundboardNotice() {
|
||||
const { t } = useTranslation();
|
||||
const notice = useSoundboardStore((state) => state.notice);
|
||||
const clear = useSoundboardStore((state) => state.clearNotice);
|
||||
// Имя того, кто сыграл звук, резолвит общий справочник авторов.
|
||||
const authors = useAuthorDirectory(
|
||||
notice?.guildId ?? null,
|
||||
notice === null ? [] : [notice.userId],
|
||||
);
|
||||
|
||||
if (notice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="soundboard-notice"
|
||||
className="pointer-events-auto fixed bottom-40 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2 text-sm shadow-[var(--shadow-3)]"
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
<p>
|
||||
{t('voice.soundboard.playedBy', {
|
||||
name: authors.resolve(notice.userId).name,
|
||||
sound: notice.soundName,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.dismiss')}
|
||||
title={t('common.dismiss')}
|
||||
onClick={clear}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||
import { Card, Button } from '@/components/ui/primitives';
|
||||
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
||||
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
|
||||
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
||||
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
||||
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
|
||||
import {
|
||||
useCanUseSoundboard,
|
||||
useVoiceChannels,
|
||||
useVoiceModeration,
|
||||
useVoiceOccupants,
|
||||
@@ -18,8 +22,9 @@ import {
|
||||
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||
import { speakingRingClass } from '@/components/voice/voiceStyles';
|
||||
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||
import { SCREEN_SHARE_PRESET_IDS, type ScreenSharePresetId } from '@/lib/livekit';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { useVoiceStore, type VoicePublishQuality } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
|
||||
@@ -105,7 +110,11 @@ function VoiceTile({
|
||||
</span>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
{occupant.isLocal ? (
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
<>
|
||||
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
||||
<VoiceStatsButton />
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={anchorRef}
|
||||
@@ -157,10 +166,14 @@ function VoiceControlsRow({
|
||||
screenEnabled,
|
||||
serverMuted,
|
||||
serverDeafened,
|
||||
screenPreset,
|
||||
videoQuality,
|
||||
soundboard,
|
||||
onMic,
|
||||
onSound,
|
||||
onCamera,
|
||||
onScreen,
|
||||
onVideoQuality,
|
||||
onLeave,
|
||||
}: {
|
||||
micEnabled: boolean;
|
||||
@@ -169,13 +182,26 @@ function VoiceControlsRow({
|
||||
screenEnabled: boolean;
|
||||
serverMuted: boolean;
|
||||
serverDeafened: boolean;
|
||||
/** Выбранный пресет шаринга: показывается в меню демонстрации экрана. */
|
||||
screenPreset: ScreenSharePresetId;
|
||||
/** Качество публикации видео. */
|
||||
videoQuality: VoicePublishQuality;
|
||||
/** Кнопка саундборда: рисуется, когда есть право `USE_SOUNDBOARD`. */
|
||||
soundboard: ReactNode;
|
||||
onMic: () => void;
|
||||
onSound: () => void;
|
||||
onCamera: () => void;
|
||||
onScreen: () => void;
|
||||
/** `null` — остановить демонстрацию, иначе — начать с этим пресетом. */
|
||||
onScreen: (preset: ScreenSharePresetId | null) => void;
|
||||
onVideoQuality: (quality: VoicePublishQuality) => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [screenMenuOpen, setScreenMenuOpen] = useState(false);
|
||||
const [qualityMenuOpen, setQualityMenuOpen] = useState(false);
|
||||
const screenAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const qualityAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
@@ -204,12 +230,94 @@ function VoiceControlsRow({
|
||||
active={cameraEnabled}
|
||||
onClick={onCamera}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="🖥"
|
||||
label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
active={screenEnabled}
|
||||
onClick={onScreen}
|
||||
/>
|
||||
{/* Демонстрация экрана: перед публикацией выбираем разрешение и fps. */}
|
||||
<button
|
||||
ref={screenAnchorRef}
|
||||
type="button"
|
||||
aria-pressed={screenEnabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={screenMenuOpen}
|
||||
aria-label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
title={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
data-testid="voice-screen-button"
|
||||
onClick={() => {
|
||||
if (screenEnabled) {
|
||||
onScreen(null);
|
||||
return;
|
||||
}
|
||||
setScreenMenuOpen((value) => !value);
|
||||
}}
|
||||
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 ${
|
||||
screenEnabled
|
||||
? 'border-border/60 bg-surface-2 text-fg'
|
||||
: 'border-danger/60 bg-danger/10 text-danger'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">🖥</span>
|
||||
</button>
|
||||
<Menu
|
||||
open={screenMenuOpen}
|
||||
label={t('voice.screen.quality')}
|
||||
onClose={() => setScreenMenuOpen(false)}
|
||||
anchorRef={screenAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.screen.quality')}</MenuGroupLabel>
|
||||
{SCREEN_SHARE_PRESET_IDS.map((preset) => (
|
||||
<MenuItem
|
||||
key={preset}
|
||||
role="menuitemradio"
|
||||
selected={screenPreset === preset}
|
||||
data-testid={`voice-screen-preset-${preset}`}
|
||||
onClick={() => {
|
||||
setScreenMenuOpen(false);
|
||||
onScreen(preset);
|
||||
}}
|
||||
>
|
||||
{t(`voice.screen.presets.${preset}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* Качество публикации видео: low/medium/high (`setPublishingQuality`). */}
|
||||
<button
|
||||
ref={qualityAnchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={qualityMenuOpen}
|
||||
aria-label={t('voice.publishQuality.label')}
|
||||
title={t('voice.publishQuality.label')}
|
||||
data-testid="voice-quality-button"
|
||||
onClick={() => setQualityMenuOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1.5 text-xs leading-none text-fg transition-colors duration-[var(--duration-fast)]"
|
||||
>
|
||||
{t(`voice.publishQuality.short.${videoQuality}`)}
|
||||
</button>
|
||||
<Menu
|
||||
open={qualityMenuOpen}
|
||||
label={t('voice.publishQuality.label')}
|
||||
onClose={() => setQualityMenuOpen(false)}
|
||||
anchorRef={qualityAnchorRef}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuGroupLabel>{t('voice.publishQuality.label')}</MenuGroupLabel>
|
||||
{(['low', 'medium', 'high'] as VoicePublishQuality[]).map((quality) => (
|
||||
<MenuItem
|
||||
key={quality}
|
||||
role="menuitemradio"
|
||||
selected={videoQuality === quality}
|
||||
data-testid={`voice-publish-quality-${quality}`}
|
||||
onClick={() => {
|
||||
setQualityMenuOpen(false);
|
||||
onVideoQuality(quality);
|
||||
}}
|
||||
>
|
||||
{t(`voice.publishQuality.level.${quality}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{soundboard}
|
||||
<VoiceControlButton glyph="⏻" label={t('voice.disconnect')} active onClick={onLeave} />
|
||||
</div>
|
||||
);
|
||||
@@ -263,7 +371,11 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
|
||||
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||
const permissions = useVoiceModeration(guildId);
|
||||
const canUseSoundboard = useCanUseSoundboard(guildId);
|
||||
const moveTargets = useVoiceChannels(guildId).filter((item) => item.id !== channel.id);
|
||||
const screenPreset = useVoiceStore((state) => state.screenSharePreset);
|
||||
const setScreenSharePreset = useVoiceStore((state) => state.setScreenSharePreset);
|
||||
const videoQuality = useVoiceStore((state) => state.videoQuality);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||
const error = useVoiceConnectionStore((state) => state.error);
|
||||
@@ -280,6 +392,7 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
|
||||
const setVideoQuality = useVoiceConnectionStore((state) => state.setVideoQuality);
|
||||
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||
@@ -367,10 +480,24 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
|
||||
screenEnabled={screenEnabled}
|
||||
serverMuted={serverMuted}
|
||||
serverDeafened={serverDeafened}
|
||||
screenPreset={screenPreset}
|
||||
videoQuality={videoQuality}
|
||||
soundboard={
|
||||
canUseSoundboard ? <VoiceSoundboard guildId={guildId} disabled={false} /> : null
|
||||
}
|
||||
onMic={() => void setMicEnabled(!micEnabled)}
|
||||
onSound={() => void setDeafened(!deafened)}
|
||||
onCamera={() => void setCameraEnabled(!cameraEnabled)}
|
||||
onScreen={() => void setScreenShareEnabled(!screenEnabled)}
|
||||
onScreen={(preset) => {
|
||||
if (preset === null) {
|
||||
void setScreenShareEnabled(false);
|
||||
return;
|
||||
}
|
||||
// Пресет сохраняем до публикации: стор читает его при старте.
|
||||
setScreenSharePreset(preset);
|
||||
void setScreenShareEnabled(true);
|
||||
}}
|
||||
onVideoQuality={(quality) => void setVideoQuality(quality)}
|
||||
onLeave={() => void leave()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { playSoundboardSound, type GuildSound } from '@/api/sounds';
|
||||
import { apiErrorCode } from '@/api/client';
|
||||
import { retryAfterMs } from '@/api/messages';
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
import { useGuildSounds } from '@/components/voice/useGuildSounds';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useSoundboardStore } from '@/stores/soundboard';
|
||||
|
||||
/** Сколько миллисекунд в секунде: обратный отсчёт лимита (429). */
|
||||
const SECOND_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Попап «Саундборд» в панели голосовой комнаты (AGENT.md 7.13).
|
||||
*
|
||||
* Звуки берутся из снапшота сервера (READY + `GUILD_SOUNDS_UPDATE`). Нажатие
|
||||
* уходит на сервер (`POST .../play`), а звук играет локально сразу: событие
|
||||
* `SOUNDBOARD_PLAY` придёт и остальным участникам комнаты. Лимит сервера —
|
||||
* 3 звука за 10 секунд: на 429 показываем обратный отсчёт, на
|
||||
* `voice.not_connected` — подсказку «сначала войдите в комнату».
|
||||
*/
|
||||
export function VoiceSoundboard({ guildId, disabled }: { guildId: string; disabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const describeError = useApiErrorMessage();
|
||||
const sounds = useGuildSounds(guildId, 'soundboard');
|
||||
const myId = useSessionStore((state) => state.user?.id ?? '');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
/** Активный лимит: `id` отличает новый отсчёт от предыдущего. */
|
||||
const [limit, setLimit] = useState<{ id: number; seconds: number } | null>(null);
|
||||
const limitSeq = useRef(0);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const limitId = limit?.id ?? null;
|
||||
|
||||
// Обратный отсчёт лимита: пока он идёт, кнопки проигрывания выключены.
|
||||
useEffect(() => {
|
||||
if (limitId === null) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setLimit((current) => {
|
||||
if (current === null || current.id !== limitId) {
|
||||
return current;
|
||||
}
|
||||
return current.seconds <= 1 ? null : { ...current, seconds: current.seconds - 1 };
|
||||
});
|
||||
}, SECOND_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [limitId]);
|
||||
|
||||
const secondsLeft = limit?.seconds ?? 0;
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const visible =
|
||||
needle === ''
|
||||
? sounds
|
||||
: sounds.filter((sound) => sound.name.toLocaleLowerCase().includes(needle));
|
||||
const notConnected = apiErrorCode(error) === 'voice.not_connected';
|
||||
|
||||
const play = async (sound: GuildSound): Promise<void> => {
|
||||
if (pending !== null || secondsLeft > 0) {
|
||||
return;
|
||||
}
|
||||
setPending(sound.id);
|
||||
setError(null);
|
||||
try {
|
||||
await playSoundboardSound(guildId, sound.id);
|
||||
// Свой звук играем сразу: событие шлюза придёт позже и будет распознано
|
||||
// как эхо (сопоставление по sound_id + user_id + времени).
|
||||
useSoundboardStore.getState().playMySound(guildId, sound, myId);
|
||||
} catch (caught) {
|
||||
setError(caught);
|
||||
const wait = retryAfterMs(caught);
|
||||
if (wait !== null) {
|
||||
limitSeq.current += 1;
|
||||
setLimit({ id: limitSeq.current, seconds: Math.max(1, Math.ceil(wait / SECOND_MS)) });
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t('voice.soundboard.open')}
|
||||
title={t('voice.soundboard.open')}
|
||||
disabled={disabled}
|
||||
data-testid="voice-soundboard-button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-1.5 text-base leading-none text-fg transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
label={t('voice.soundboard.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
className="w-72"
|
||||
>
|
||||
<div data-testid="voice-soundboard-panel" className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('voice.soundboard.searchLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label={t('voice.soundboard.searchLabel')}
|
||||
placeholder={t('voice.soundboard.searchPlaceholder')}
|
||||
data-testid="voice-soundboard-search"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{secondsLeft > 0 ? (
|
||||
<p role="status" data-testid="voice-soundboard-limit" className="text-xs text-warning">
|
||||
{t('voice.soundboard.rateLimited', { seconds: secondsLeft })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{notConnected ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-warning">
|
||||
{t('voice.soundboard.notConnected')}
|
||||
</p>
|
||||
) : error !== null ? (
|
||||
<p role="alert" data-testid="voice-soundboard-error" className="text-xs text-danger">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sounds.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.empty')}</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('voice.soundboard.noResults')}</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-1">
|
||||
{visible.map((sound) => (
|
||||
<li key={sound.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`voice-soundboard-sound-${sound.id}`}
|
||||
aria-label={t('voice.soundboard.playLabel', { name: sound.name })}
|
||||
title={sound.name}
|
||||
disabled={pending !== null || secondsLeft > 0}
|
||||
onClick={() => void play(sound)}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span aria-hidden="true">{sound.emoji ?? '🎵'}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{sound.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
import { StatRow } from '@/components/ui/StatRow';
|
||||
import type { VoiceStats } from '@/lib/livekit';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Период опроса статистики: раз в секунду, пока попап открыт. */
|
||||
export const STATS_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
/** Прочерк вместо значения, которого SDK не отдал. */
|
||||
function value(text: string | number | null): string {
|
||||
return text === null ? '—' : String(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Попап «Статистика» у своей плитки (AGENT.md 7.14): фактические разрешение,
|
||||
* fps, кодек и битрейт публикации плюс качество канала (RTT, потери, jitter).
|
||||
*
|
||||
* Опрос идёт только пока попап открыт: закрытие останавливает таймер, и SDK
|
||||
* больше не дёргается. Значения приходят из `getRTCStatsReport()`; чего нет —
|
||||
* показываем прочерком.
|
||||
*/
|
||||
export function VoiceStatsButton() {
|
||||
const { t } = useTranslation();
|
||||
const fetchStats = useVoiceConnectionStore((state) => state.fetchStats);
|
||||
// Вне активной сессии статистики нет: кнопку не показываем.
|
||||
const connected = useVoiceConnectionStore((state) => state.status === 'connected');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stats, setStats] = useState<VoiceStats | null>(null);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async (): Promise<void> => {
|
||||
const next = await fetchStats();
|
||||
if (!cancelled) {
|
||||
setStats(next);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => {
|
||||
void poll();
|
||||
}, STATS_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [open, fetchStats]);
|
||||
|
||||
const resolution =
|
||||
stats?.width === null || stats?.width === undefined || stats.height === null
|
||||
? null
|
||||
: `${stats.width}×${stats.height}`;
|
||||
|
||||
if (!connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t('voice.stats.open')}
|
||||
title={t('voice.stats.open')}
|
||||
data-testid="voice-stats-button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span aria-hidden="true">📊</span>
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
label={t('voice.stats.title')}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
className="w-72"
|
||||
>
|
||||
<div data-testid="voice-stats-panel">
|
||||
<h3 className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">
|
||||
{t('voice.stats.title')}
|
||||
</h3>
|
||||
<dl className="flex flex-col text-sm">
|
||||
<StatRow label={t('voice.stats.resolution')} value={value(resolution)} />
|
||||
<StatRow
|
||||
label={t('voice.stats.frameRate')}
|
||||
value={
|
||||
stats?.frameRate === null || stats?.frameRate === undefined
|
||||
? '—'
|
||||
: `${stats.frameRate} fps`
|
||||
}
|
||||
/>
|
||||
<StatRow label={t('voice.stats.codec')} value={value(stats?.codec ?? null)} />
|
||||
<StatRow
|
||||
label={t('voice.stats.bitrate')}
|
||||
value={
|
||||
stats?.bitrateKbps === null || stats?.bitrateKbps === undefined
|
||||
? '—'
|
||||
: `${stats.bitrateKbps} ${t('voice.stats.kbps')}`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.rtt')}
|
||||
value={
|
||||
stats?.roundTripMs === null || stats?.roundTripMs === undefined
|
||||
? '—'
|
||||
: `${stats.roundTripMs} ${t('voice.stats.ms')}`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.packetLoss')}
|
||||
value={
|
||||
stats?.packetLossPercent === null || stats?.packetLossPercent === undefined
|
||||
? '—'
|
||||
: `${stats.packetLossPercent}%`
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('voice.stats.jitter')}
|
||||
value={
|
||||
stats?.jitterMs === null || stats?.jitterMs === undefined
|
||||
? '—'
|
||||
: `${stats.jitterMs} ${t('voice.stats.ms')}`
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { GuildSound, SoundKind } from '@/api/sounds';
|
||||
import { useSessionStore, guildSoundsOf } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Звуки сервера из снапшота (READY и `GUILD_SOUNDS_UPDATE`, AGENT.md 7.13).
|
||||
*
|
||||
* Один источник данных для саундборда и звуковой палитры: и попап панели
|
||||
* комнаты, и обработчики событий читают один и тот же список. Логика разбора
|
||||
* payload'а живёт в `api/sounds`, здесь — только доступ к снапшоту.
|
||||
*/
|
||||
|
||||
/** Пустой список — стабильная ссылка: селектор не должен перерисовывать зря. */
|
||||
const NO_SOUNDS: GuildSound[] = [];
|
||||
|
||||
/** Звуки сервера: без `kind` — оба набора (саундборд и палитра). */
|
||||
export function useGuildSounds(guildId: string | null, kind?: SoundKind): GuildSound[] {
|
||||
const sounds = useSessionStore(
|
||||
useCallback(
|
||||
(state) => (guildId === null ? NO_SOUNDS : guildSoundsOf(state.guilds, guildId)),
|
||||
[guildId],
|
||||
),
|
||||
);
|
||||
return useMemo(
|
||||
() => (kind === undefined ? sounds : sounds.filter((sound) => sound.kind === kind)),
|
||||
[sounds, kind],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { useVoiceStore, type PushToTalkKey } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Push-to-talk (AGENT.md 7.14): пока режим включён, микрофон выключен, а
|
||||
* удержание клавиши его включает.
|
||||
*
|
||||
* Клавишу слушаем на уровне документа, но только когда окно в фокусе и фокус
|
||||
* не в поле ввода: иначе пробел в сообщении «зажигал» бы микрофон. Нажатие без
|
||||
* повторов (`event.repeat` игнорируем), модификаторы браузера (Ctrl/Alt/Meta
|
||||
* вместе с обычной клавишей) не считаются PTT, а пробел на кнопке остаётся
|
||||
* кликом. Серверный мьют приоритетнее: при нём микрофон не включается.
|
||||
*/
|
||||
|
||||
/** Соответствие «левая клавиша → правая»: AltRight тоже считается Alt. */
|
||||
const MIRRORED_KEYS: Partial<Record<PushToTalkKey, string>> = {
|
||||
AltLeft: 'AltRight',
|
||||
ControlLeft: 'ControlRight',
|
||||
};
|
||||
|
||||
/** Совпадает ли нажатие с выбранной клавишей push-to-talk. */
|
||||
export function matchesPushToTalk(key: PushToTalkKey, code: string): boolean {
|
||||
return code === key || code === MIRRORED_KEYS[key];
|
||||
}
|
||||
|
||||
/** Идёт ли ввод текста: поля ввода, textarea, select и contenteditable. */
|
||||
export function isTextEntryTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return true;
|
||||
}
|
||||
return target.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент, на котором пробел нажимает кнопку: активная кнопка/ссылка. Для них
|
||||
* PTT не перехватывает пробел, чтобы не ломать управление с клавиатуры.
|
||||
*/
|
||||
function isButtonLike(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.tagName === 'A' ||
|
||||
target.getAttribute('role') === 'button' ||
|
||||
target.getAttribute('role') === 'menuitem'
|
||||
);
|
||||
}
|
||||
|
||||
/** Нажат ли модификатор, который не является самой клавишей PTT. */
|
||||
function hasForeignModifier(event: KeyboardEvent, key: PushToTalkKey): boolean {
|
||||
const ctrl = event.ctrlKey && key !== 'ControlLeft';
|
||||
const alt = event.altKey && key !== 'AltLeft';
|
||||
return ctrl || alt || event.metaKey;
|
||||
}
|
||||
|
||||
export function usePushToTalk(): void {
|
||||
const mode = useVoiceStore((state) => state.micActivation);
|
||||
const key = useVoiceStore((state) => state.pttKey);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
|
||||
// Возврат к голосовой активации: микрофон снова по сохранённому
|
||||
// предпочтению (в режиме PTT его выключает сам push-to-talk).
|
||||
useEffect(() => {
|
||||
if (mode === 'push_to_talk' || status !== 'connected') {
|
||||
return;
|
||||
}
|
||||
const connection = useVoiceConnectionStore.getState();
|
||||
const preference = useVoiceStore.getState().micEnabled;
|
||||
if (connection.micEnabled !== preference) {
|
||||
void connection.setMicEnabled(preference);
|
||||
}
|
||||
}, [mode, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'push_to_talk') {
|
||||
return;
|
||||
}
|
||||
if (status !== 'connected' && status !== 'reconnecting') {
|
||||
return;
|
||||
}
|
||||
// Вход в комнату в режиме PTT: микрофон ждёт нажатия клавиши.
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(false);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (!matchesPushToTalk(key, event.code) || event.repeat) {
|
||||
return;
|
||||
}
|
||||
if (!document.hasFocus() || hasForeignModifier(event, key)) {
|
||||
return;
|
||||
}
|
||||
const target = event.target ?? document.activeElement;
|
||||
if (isTextEntryTarget(target)) {
|
||||
return;
|
||||
}
|
||||
// Пробел на кнопке — это клик, а не push-to-talk.
|
||||
if (key === 'Space' && isButtonLike(target)) {
|
||||
return;
|
||||
}
|
||||
// Пробел не должен прокручивать страницу, пока идёт разговор.
|
||||
event.preventDefault();
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(true);
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent): void => {
|
||||
if (!matchesPushToTalk(key, event.code)) {
|
||||
return;
|
||||
}
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(false);
|
||||
};
|
||||
|
||||
/** Потеря фокуса окна отпускает клавишу: иначе микрофон остался бы включён. */
|
||||
const release = (): void => {
|
||||
useVoiceConnectionStore.getState().setPushToTalkActive(false);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', release);
|
||||
document.addEventListener('visibilitychange', release);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', release);
|
||||
document.removeEventListener('visibilitychange', release);
|
||||
};
|
||||
}, [mode, key, status]);
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { useMemo } from 'react';
|
||||
import type { Channel } from '@/api/types';
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import type { VoiceQuality, VoiceParticipant } from '@/lib/livekit';
|
||||
import { voiceModerationPermissions, type VoiceModerationPermissions } from '@/lib/identity';
|
||||
import {
|
||||
canUseSoundboard,
|
||||
voiceModerationPermissions,
|
||||
type VoiceModerationPermissions,
|
||||
} from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore, voiceStatesOfChannel } from '@/stores/session';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
@@ -146,6 +150,25 @@ export function useVoiceModeration(guildId: string | null): VoiceModerationPermi
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь играть звуки саундборда на этом сервере (7.13):
|
||||
* право `USE_SOUNDBOARD`. Наличие голосовой сессии проверяет вызывающий код —
|
||||
* сервер всё равно ответит `voice.not_connected`, если мы не в комнате.
|
||||
*/
|
||||
export function useCanUseSoundboard(guildId: string | null): boolean {
|
||||
const currentUser = useCurrentUser();
|
||||
const guild = useSessionStore((state) =>
|
||||
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||
);
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
if (guild === null) {
|
||||
return false;
|
||||
}
|
||||
const isAdmin =
|
||||
sessionUser?.is_instance_admin === true || currentUser.data?.is_instance_admin === true;
|
||||
return canUseSoundboard(guild.my_permissions, isAdmin);
|
||||
}
|
||||
|
||||
/** Голосовые комнаты сервера: цель для перемещения участников. */
|
||||
export function useVoiceChannels(guildId: string | null): Channel[] {
|
||||
const guild = useSessionStore((state) =>
|
||||
|
||||
Reference in New Issue
Block a user