Files
glchat/web/src/components/voice/VoiceRoomPanel.tsx
T
grendervill cc16c18b13 fix(web): пинг участника показывается только при наведении
Постоянная подпись в плитке занимала место и была не нужна: задержка теперь
живёт в подсказке индикатора связи («Отличное соединение, 46 мс — задержка до
голосового сервера»), а плитка осталась компактной. Тесты и e2e проверяют
`title`/`aria-label` индикатора вместо видимого текста.
2026-09-20 22:30:24 +03:00

513 lines
20 KiB
TypeScript

import { useCallback, useRef, useState, type DragEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { Channel } from '@/api/types';
import { Avatar } from '@/components/ui/Avatar';
import { 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,
type VoiceOccupant,
} from '@/components/voice/useVoiceOccupants';
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, type VoicePublishQuality } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
function VoiceTile({
occupant,
permissions,
moveTargets,
onMute,
onDeafen,
onMove,
children,
}: {
occupant: VoiceOccupant;
permissions: VoiceModerationPermissions;
moveTargets: Channel[];
onMute: (userId: string, muted: boolean) => void;
onDeafen: (userId: string, deafened: boolean) => void;
onMove: (userId: string, targetId: string) => void;
/** Кнопки управления — только на своей плитке. */
children?: ReactNode;
}) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement | null>(null);
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
// Подписка именно на карту пингов: селектор-функция не вызывает перерисовку.
const latencies = useVoiceConnectionStore((state) => state.latencies);
// Общая громкость звука умножается на личную громкость участника.
const outputVolume = useVoiceStore((state) => state.outputVolume);
const deafened = useVoiceConnectionStore((state) => state.deafened);
const media = occupant.participant;
const video = media?.screen ?? media?.video;
const onDragStart = (event: DragEvent<HTMLDivElement>): void => {
if (occupant.isLocal || !permissions.move) {
return;
}
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
event.dataTransfer.effectAllowed = 'move';
};
return (
<div
data-testid={`voice-tile-${occupant.userId}`}
data-speaking={occupant.isSpeaking}
draggable={permissions.move && !occupant.isLocal}
onDragStart={onDragStart}
onContextMenu={(event) => {
// ПКМ по плитке открывает меню участника (AGENT.md 7.14).
if (occupant.isLocal) {
return;
}
event.preventDefault();
setMenuOpen(true);
}}
className={`flex flex-col gap-2 rounded-[var(--radius-lg)] border bg-surface-1 p-3 transition-colors duration-[var(--duration-fast)] ${
occupant.isSpeaking ? 'border-success/70' : 'border-border/60'
}`}
>
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-[var(--radius-md)] bg-surface-2">
{video === undefined ? (
<span className={`rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
<Avatar
name={occupant.name}
seed={occupant.userId}
fileId={occupant.avatarFileId}
size="lg"
/>
</span>
) : (
<RemoteVideo track={video} muted={occupant.isLocal} />
)}
{media?.audio === undefined ? null : (
<RemoteAudio
track={media.audio}
volume={(volume * outputVolume) / 100}
muted={deafened}
/>
)}
</div>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
{occupant.name}
</span>
<VoiceQualityDot quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
{occupant.isLocal ? (
<>
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
<VoiceStatsButton />
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
</>
) : (
<button
ref={anchorRef}
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t('voice.menu.label', { name: occupant.name })}
title={t('voice.menu.label', { name: occupant.name })}
onClick={() => setMenuOpen((value) => !value)}
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
>
<span aria-hidden="true">⋯</span>
</button>
)}
</div>
<VoiceFlags
selfMute={occupant.selfMute}
selfDeaf={occupant.selfDeaf}
serverMute={occupant.serverMute}
serverDeaf={occupant.serverDeaf}
camera={occupant.camera}
screen={occupant.screen}
/>
{occupant.isSpeaking ? <p className="sr-only">{t('voice.speaking')}</p> : null}
{children}
<VoiceOccupantMenu
open={menuOpen}
occupant={occupant}
anchorRef={anchorRef}
onClose={() => setMenuOpen(false)}
permissions={permissions}
moveTargets={moveTargets}
onMute={onMute}
onDeafen={onDeafen}
onMove={onMove}
/>
</div>
);
}
/** Кнопки управления своей сессией: микрофон, звук, камера, экран, выход. */
function VoiceControlsRow({
micEnabled,
deafened,
cameraEnabled,
screenEnabled,
serverMuted,
serverDeafened,
screenPreset,
videoQuality,
soundboard,
onMic,
onSound,
onCamera,
onScreen,
onVideoQuality,
onLeave,
}: {
micEnabled: boolean;
deafened: boolean;
cameraEnabled: boolean;
screenEnabled: boolean;
serverMuted: boolean;
serverDeafened: boolean;
/** Выбранный пресет шаринга: показывается в меню демонстрации экрана. */
screenPreset: ScreenSharePresetId;
/** Качество публикации видео. */
videoQuality: VoicePublishQuality;
/** Кнопка саундборда: рисуется, когда есть право `USE_SOUNDBOARD`. */
soundboard: ReactNode;
onMic: () => void;
onSound: () => void;
onCamera: () => 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"
role="group"
aria-label={t('voice.controls')}
>
<VoiceControlButton
glyph="🎤"
label={t(micEnabled ? 'voice.mic.off' : 'voice.mic.on')}
active={micEnabled}
disabled={serverMuted}
{...(serverMuted ? { disabledReason: t('voice.serverMuted') } : {})}
onClick={onMic}
/>
<VoiceControlButton
glyph="🎧"
label={t(deafened ? 'voice.sound.on' : 'voice.sound.off')}
active={!deafened}
disabled={serverDeafened}
{...(serverDeafened ? { disabledReason: t('voice.serverDeafened') } : {})}
onClick={onSound}
/>
<VoiceControlButton
glyph="🎥"
label={t(cameraEnabled ? 'voice.camera.off' : 'voice.camera.on')}
active={cameraEnabled}
onClick={onCamera}
/>
{/* Демонстрация экрана: перед публикацией выбираем разрешение и 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>
);
}
/** Кнопка управления голосом: иконка с состоянием и подписью. */
function VoiceControlButton({
glyph,
label,
active,
disabled = false,
disabledReason,
onClick,
}: {
glyph: string;
label: string;
active: boolean;
disabled?: boolean;
disabledReason?: string;
onClick: () => void;
}) {
const title = disabled && disabledReason !== undefined ? disabledReason : label;
return (
<button
type="button"
aria-pressed={active}
aria-label={title}
title={title}
disabled={disabled}
onClick={onClick}
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${
active
? 'border-border/60 bg-surface-2 text-fg'
: 'border-danger/60 bg-danger/10 text-danger'
}`}
>
<span aria-hidden="true">{glyph}</span>
</button>
);
}
/**
* Панель голосовой комнаты в области контента: крупные плитки участников,
* управление своей сессией и модерация при наличии прав (AGENT.md 7.14).
*/
export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel: Channel }) {
const { t } = useTranslation();
const voiceEnabled = useVoiceEnabled();
const describeError = useApiErrorMessage();
useGuildVoiceStates(guildId);
const occupants = useVoiceOccupants(guildId, channel.id);
const permissions = useVoiceModeration(guildId);
const 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);
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
const cameraEnabled = useVoiceConnectionStore((state) => state.cameraEnabled);
const screenEnabled = useVoiceConnectionStore((state) => state.screenEnabled);
const deafened = useVoiceConnectionStore((state) => state.deafened);
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
const refreshing = useVoiceConnectionStore((state) => state.refreshing);
const join = useVoiceConnectionStore((state) => state.join);
const leave = useVoiceConnectionStore((state) => state.leave);
const setMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
const setVideoQuality = useVoiceConnectionStore((state) => state.setVideoQuality);
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
const here = activeChannelId === channel.id;
const connecting = here && status === 'connecting';
const inRoom = here && (status === 'connected' || status === 'reconnecting');
const failed = here && status === 'error';
const onMute = useCallback(
(userId: string, muted: boolean) => void setParticipantMuted(guildId, userId, muted),
[guildId, setParticipantMuted],
);
const onDeafen = useCallback(
(userId: string, deafenedNext: boolean) =>
void setParticipantDeafened(guildId, userId, deafenedNext),
[guildId, setParticipantDeafened],
);
const onMove = useCallback(
(userId: string, targetId: string) => void moveParticipant(guildId, userId, targetId),
[guildId, moveParticipant],
);
if (!voiceEnabled) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<Card className="max-w-md text-center" data-testid="voice-disabled">
<h3 className="text-lg font-semibold">{t('voice.title')}</h3>
<p className="mt-2 text-sm text-fg-muted">{t('voice.disabled')}</p>
</Card>
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
<div className="flex flex-wrap items-center gap-2">
{inRoom ? (
<p role="status" className="text-xs text-fg-muted">
{t(refreshing ? 'voice.refreshing' : 'voice.connected')}
</p>
) : (
<>
<Button
disabled={connecting}
onClick={() => void join(guildId, channel.id)}
data-testid="voice-join"
>
{t(connecting ? 'voice.joining' : 'voice.join')}
</Button>
{connecting ? (
<p role="status" className="text-xs text-fg-muted">
{t('voice.joining')}
</p>
) : null}
</>
)}
</div>
{failed ? (
<p role="alert" className="text-sm text-danger">
{describeError(error)}
</p>
) : null}
{occupants.length === 0 ? (
<p className="text-sm text-fg-muted">{t('voice.empty')}</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
{occupants.map((occupant) => (
<VoiceTile
key={occupant.userId}
occupant={occupant}
permissions={permissions}
moveTargets={moveTargets}
onMute={onMute}
onDeafen={onDeafen}
onMove={onMove}
>
{occupant.isLocal && inRoom ? (
<VoiceControlsRow
micEnabled={micEnabled}
deafened={deafened}
cameraEnabled={cameraEnabled}
screenEnabled={screenEnabled}
serverMuted={serverMuted}
serverDeafened={serverDeafened}
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={(preset) => {
if (preset === null) {
void setScreenShareEnabled(false);
return;
}
// Пресет сохраняем до публикации: стор читает его при старте.
setScreenSharePreset(preset);
void setScreenShareEnabled(true);
}}
onVideoQuality={(quality) => void setVideoQuality(quality)}
onLeave={() => void leave()}
/>
) : null}
</VoiceTile>
))}
</div>
)}
</div>
);
}