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(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): void => { if (occupant.isLocal || !permissions.move) { return; } event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId); event.dataTransfer.effectAllowed = 'move'; }; return (
{ // ПКМ по плитке открывает меню участника (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' }`} >
{video === undefined ? ( ) : ( )} {media?.audio === undefined ? null : ( )}
{occupant.name} {occupant.isLocal ? ( <> {/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */} {t('voice.you')} ) : ( )}
{occupant.isSpeaking ?

{t('voice.speaking')}

: null} {children} setMenuOpen(false)} permissions={permissions} moveTargets={moveTargets} onMute={onMute} onDeafen={onDeafen} onMove={onMove} />
); } /** Кнопки управления своей сессией: микрофон, звук, камера, экран, выход. */ 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(null); const qualityAnchorRef = useRef(null); return (
{/* Демонстрация экрана: перед публикацией выбираем разрешение и fps. */} setScreenMenuOpen(false)} anchorRef={screenAnchorRef} placement="bottom" > {t('voice.screen.quality')} {SCREEN_SHARE_PRESET_IDS.map((preset) => ( { setScreenMenuOpen(false); onScreen(preset); }} > {t(`voice.screen.presets.${preset}`)} ))} {/* Качество публикации видео: low/medium/high (`setPublishingQuality`). */} setQualityMenuOpen(false)} anchorRef={qualityAnchorRef} placement="bottom" > {t('voice.publishQuality.label')} {(['low', 'medium', 'high'] as VoicePublishQuality[]).map((quality) => ( { setQualityMenuOpen(false); onVideoQuality(quality); }} > {t(`voice.publishQuality.level.${quality}`)} ))} {soundboard}
); } /** Кнопка управления голосом: иконка с состоянием и подписью. */ 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 ( ); } /** * Панель голосовой комнаты в области контента: крупные плитки участников, * управление своей сессией и модерация при наличии прав (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 (

{t('voice.title')}

{t('voice.disabled')}

); } return (
{inRoom ? (

{t(refreshing ? 'voice.refreshing' : 'voice.connected')}

) : ( <> {connecting ? (

{t('voice.joining')}

) : null} )}
{failed ? (

{describeError(error)}

) : null} {occupants.length === 0 ? (

{t('voice.empty')}

) : (
{occupants.map((occupant) => ( {occupant.isLocal && inRoom ? ( : 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} ))}
)}
); }