3fe634c9bc
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер инстанса» (лимиты профиля) и «Сервер целиком» (вся машина); - голосовая комната: рядом с качеством связи видно пинг участника («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit (`glchat.latency`, lossy), поэтому пинг виден для каждого участника; - статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был недоступен (отчёт читался только с видео); - в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»: выключенный не опрашивает SDK и не публикует своё значение; - тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника, публикация по служебной теме, выключенный пинг не дёргает SDK.
109 lines
4.5 KiB
TypeScript
109 lines
4.5 KiB
TypeScript
import { useCallback, useRef, useState, type DragEvent } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { Avatar } from '@/components/ui/Avatar';
|
|
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
|
|
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
|
import {
|
|
useVoiceChannels,
|
|
useVoiceModeration,
|
|
type VoiceOccupant,
|
|
} from '@/components/voice/useVoiceOccupants';
|
|
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
|
import { speakingRingClass } from '@/components/voice/voiceStyles';
|
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
|
|
|
/**
|
|
* Строка участника голосовой комнаты в сайдбаре: аватар с кольцом речи,
|
|
* имя, флаги и точка качества связи. Клик открывает меню с громкостью и
|
|
* действиями модератора; при праве MOVE_MEMBERS строку можно перетащить
|
|
* на другую голосовую комнату.
|
|
*/
|
|
export function VoiceOccupantRow({
|
|
occupant,
|
|
guildId,
|
|
channelId,
|
|
}: {
|
|
occupant: VoiceOccupant;
|
|
guildId: string;
|
|
channelId: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const permissions = useVoiceModeration(guildId);
|
|
const moveTargets = useVoiceChannels(guildId).filter((channel) => channel.id !== channelId);
|
|
// Подписка именно на карту пингов: селектор-функция не вызывает перерисовку.
|
|
const latencies = useVoiceConnectionStore((state) => state.latencies);
|
|
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
|
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
|
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
|
const close = useCallback(() => setOpen(false), []);
|
|
const label = t('voice.menu.label', { name: occupant.name });
|
|
const canDrag = permissions.move && !occupant.isLocal;
|
|
|
|
const onDragStart = (event: DragEvent<HTMLButtonElement>): void => {
|
|
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
};
|
|
|
|
return (
|
|
<li className="flex items-center">
|
|
<button
|
|
ref={anchorRef}
|
|
type="button"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
aria-label={label}
|
|
title={label}
|
|
draggable={canDrag}
|
|
onDragStart={canDrag ? onDragStart : undefined}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setOpen((value) => !value);
|
|
}}
|
|
onContextMenu={(event) => {
|
|
// ПКМ — привычный жест для меню участника (AGENT.md 7.14).
|
|
event.preventDefault();
|
|
setOpen(true);
|
|
}}
|
|
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-[var(--radius-sm)] px-2 py-0.5 text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<span className={`shrink-0 rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
|
|
<Avatar
|
|
name={occupant.name}
|
|
seed={occupant.userId}
|
|
fileId={occupant.avatarFileId}
|
|
size="sm"
|
|
/>
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{occupant.name}</span>
|
|
{occupant.isSpeaking ? <span className="sr-only">{t('voice.speaking')}</span> : null}
|
|
<VoiceFlags
|
|
className="shrink-0"
|
|
selfMute={occupant.selfMute}
|
|
selfDeaf={occupant.selfDeaf}
|
|
serverMute={occupant.serverMute}
|
|
serverDeaf={occupant.serverDeaf}
|
|
camera={occupant.camera}
|
|
screen={occupant.screen}
|
|
/>
|
|
<VoiceQualityDot quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
|
|
</button>
|
|
|
|
<VoiceOccupantMenu
|
|
open={open}
|
|
occupant={occupant}
|
|
anchorRef={anchorRef}
|
|
onClose={close}
|
|
permissions={permissions}
|
|
moveTargets={moveTargets}
|
|
onMute={(userId, muted) => void setParticipantMuted(guildId, userId, muted)}
|
|
onDeafen={(userId, deafened) => void setParticipantDeafened(guildId, userId, deafened)}
|
|
onMove={(userId, targetId) => void moveParticipant(guildId, userId, targetId)}
|
|
/>
|
|
</li>
|
|
);
|
|
}
|