feat(web): второй ряд дашборда и пинг участников в голосовой комнате
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер инстанса» (лимиты профиля) и «Сервер целиком» (вся машина); - голосовая комната: рядом с качеством связи видно пинг участника («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit (`glchat.latency`, lossy), поэтому пинг виден для каждого участника; - статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был недоступен (отчёт читался только с видео); - в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»: выключенный не опрашивает SDK и не публикует своё значение; - тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника, публикация по служебной теме, выключенный пинг не дёргает SDK.
This commit is contained in:
@@ -14,6 +14,18 @@ export interface InstanceMetrics {
|
||||
percent: number;
|
||||
source: 'cgroup' | 'host' | '';
|
||||
};
|
||||
/** Вся машина: второй ряд дашборда. */
|
||||
host_cpu: {
|
||||
percent: number;
|
||||
cores: number;
|
||||
source: 'cgroup' | 'host' | '';
|
||||
};
|
||||
host_memory: {
|
||||
used_bytes: number;
|
||||
total_bytes: number;
|
||||
percent: number;
|
||||
source: 'cgroup' | 'host' | '';
|
||||
};
|
||||
disk: {
|
||||
used_bytes: number;
|
||||
total_bytes: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { InstanceMetrics } from '@/api/metrics';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { Gauge } from '@/components/ui/Gauge';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
@@ -10,16 +11,15 @@ interface InstanceDashboardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Дашборд инстанса: полукруглые индикаторы процессора и памяти, ниже — два
|
||||
* блока про диск (занято / всего) и вес базы данных. Значения обновляются раз
|
||||
* в секунду, подписи у блоков диска показываются только при наведении.
|
||||
* Дашборд инстанса: два ряда полукруглых индикаторов (контейнер и вся машина),
|
||||
* ниже — два блока про диск (занято / всего) и вес базы данных. Значения
|
||||
* обновляются раз в секунду, подписи у блоков диска показываются только при
|
||||
* наведении, а у рядов подпись стоит сверху.
|
||||
*/
|
||||
export function InstanceDashboard({ state }: InstanceDashboardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||
const metrics = state.metrics;
|
||||
// До первого ответа показываем прочерк, а не нули: ноль — это тоже значение.
|
||||
const pending = metrics === undefined ? '—' : undefined;
|
||||
|
||||
return (
|
||||
<Card data-testid="instance-dashboard">
|
||||
@@ -30,33 +30,20 @@ export function InstanceDashboard({ state }: InstanceDashboardProps) {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-around gap-6">
|
||||
<Gauge
|
||||
testId="gauge-cpu"
|
||||
value={metrics?.cpu.percent ?? 0}
|
||||
label={t('settings.instance.dashboard.cpu')}
|
||||
display={pending ?? formatPercent(metrics?.cpu.percent ?? 0, locale)}
|
||||
caption={
|
||||
metrics === undefined
|
||||
? undefined
|
||||
: t('settings.instance.dashboard.cpuCaption', {
|
||||
count: Math.round(metrics.cpu.cores),
|
||||
})
|
||||
}
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<GaugeRow
|
||||
testId="row-container"
|
||||
title={t('settings.instance.dashboard.containerRow')}
|
||||
hint={t('settings.instance.dashboard.containerHint')}
|
||||
cpu={metrics?.cpu}
|
||||
memory={metrics?.memory}
|
||||
/>
|
||||
<Gauge
|
||||
testId="gauge-memory"
|
||||
value={metrics?.memory.percent ?? 0}
|
||||
label={t('settings.instance.dashboard.memory')}
|
||||
display={pending ?? formatPercent(metrics?.memory.percent ?? 0, locale)}
|
||||
caption={
|
||||
metrics === undefined
|
||||
? undefined
|
||||
: `${formatBytes(metrics.memory.used_bytes, locale)} / ${formatBytes(
|
||||
metrics.memory.total_bytes,
|
||||
locale,
|
||||
)}`
|
||||
}
|
||||
<GaugeRow
|
||||
testId="row-host"
|
||||
title={t('settings.instance.dashboard.hostRow')}
|
||||
hint={t('settings.instance.dashboard.hostHint')}
|
||||
cpu={metrics?.host_cpu}
|
||||
memory={metrics?.host_memory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +78,58 @@ export function InstanceDashboard({ state }: InstanceDashboardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface GaugeRowProps {
|
||||
testId: string;
|
||||
/** Подпись ряда: к чему относятся индикаторы (контейнер или вся машина). */
|
||||
title: string;
|
||||
hint: string;
|
||||
cpu: InstanceMetrics['cpu'] | undefined;
|
||||
memory: InstanceMetrics['memory'] | undefined;
|
||||
}
|
||||
|
||||
/** GaugeRow — ряд из двух полукругов с подписью, к чему он относится. */
|
||||
function GaugeRow({ testId, title, hint, cpu, memory }: GaugeRowProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||
const pending = cpu === undefined && memory === undefined ? '—' : undefined;
|
||||
|
||||
return (
|
||||
<section data-testid={testId}>
|
||||
<header className="flex items-baseline justify-between gap-2">
|
||||
<h4 className="text-sm font-medium">{title}</h4>
|
||||
<span className="text-xs text-fg-muted">{hint}</span>
|
||||
</header>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-around gap-6">
|
||||
<Gauge
|
||||
testId={`${testId}-cpu`}
|
||||
value={cpu?.percent ?? 0}
|
||||
label={t('settings.instance.dashboard.cpu')}
|
||||
display={pending ?? formatPercent(cpu?.percent ?? 0, locale)}
|
||||
caption={
|
||||
cpu === undefined
|
||||
? undefined
|
||||
: t('settings.instance.dashboard.cpuCaption', { count: Math.round(cpu.cores) })
|
||||
}
|
||||
/>
|
||||
<Gauge
|
||||
testId={`${testId}-memory`}
|
||||
value={memory?.percent ?? 0}
|
||||
label={t('settings.instance.dashboard.memory')}
|
||||
display={pending ?? formatPercent(memory?.percent ?? 0, locale)}
|
||||
caption={
|
||||
memory === undefined
|
||||
? undefined
|
||||
: `${formatBytes(memory.used_bytes, locale)} / ${formatBytes(
|
||||
memory.total_bytes,
|
||||
locale,
|
||||
)}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiskStatProps {
|
||||
value: string;
|
||||
title: string;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { qualityLabel } from '@/components/voice/voiceQualityLabel';
|
||||
|
||||
import type { VoiceQuality } from '@/lib/livekit';
|
||||
|
||||
/** Цвет точки качества связи: зелёный — отлично, красный — связи нет. */
|
||||
@@ -11,13 +13,22 @@ const qualityClass: Record<VoiceQuality, string> = {
|
||||
unknown: 'bg-fg-muted',
|
||||
};
|
||||
|
||||
/** Точка качества связи с подписью для скринридера (AGENT.md 7.14). */
|
||||
export function VoiceQualityDot({ quality }: { quality: VoiceQuality | null }) {
|
||||
/**
|
||||
* Точка качества связи с подписью для скринридера (AGENT.md 7.14).
|
||||
* В тултип попадает и пинг участника — он обновляется раз в секунду.
|
||||
*/
|
||||
export function VoiceQualityDot({
|
||||
quality,
|
||||
latencyMs,
|
||||
}: {
|
||||
quality: VoiceQuality | null;
|
||||
latencyMs?: number | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (quality === null) {
|
||||
return null;
|
||||
}
|
||||
const label = t(`voice.quality.${quality}`);
|
||||
const label = qualityLabel(t, quality, latencyMs);
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
@@ -25,11 +36,40 @@ export function VoiceQualityDot({ quality }: { quality: VoiceQuality | null }) {
|
||||
title={label}
|
||||
data-testid="voice-quality"
|
||||
data-quality={quality}
|
||||
data-latency={latencyMs ?? ''}
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${qualityClass[quality]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Точка качества с видимой подписью: «Отличное соединение, 42 мс» в плитке
|
||||
* участника. Пинг обновляется раз в секунду вместе с состоянием связи.
|
||||
*/
|
||||
export function VoiceQualityBadge({
|
||||
quality,
|
||||
latencyMs,
|
||||
}: {
|
||||
quality: VoiceQuality | null;
|
||||
latencyMs?: number | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (quality === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-fg-muted"
|
||||
data-testid="voice-quality-label"
|
||||
data-latency={latencyMs ?? ''}
|
||||
title={t('voice.latencyHint')}
|
||||
>
|
||||
<VoiceQualityDot quality={quality} latencyMs={latencyMs} />
|
||||
<span className="tabular-nums">{qualityLabel(t, quality, latencyMs)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Иконка состояния участника: перечёркнутая, когда флаг выключен.
|
||||
* Подпись обязательна — по ней состояние читают и тесты, и скринридер.
|
||||
|
||||
@@ -31,6 +31,8 @@ export function VoiceOccupantRow({
|
||||
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);
|
||||
@@ -87,7 +89,7 @@ export function VoiceOccupantRow({
|
||||
camera={occupant.camera}
|
||||
screen={occupant.screen}
|
||||
/>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
<VoiceQualityDot quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
|
||||
</button>
|
||||
|
||||
<VoiceOccupantMenu
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { VoiceFlags, VoiceQualityBadge } from '@/components/voice/VoiceIndicators';
|
||||
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
||||
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
||||
@@ -50,6 +50,8 @@ function VoiceTile({
|
||||
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);
|
||||
@@ -108,7 +110,7 @@ function VoiceTile({
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
|
||||
{occupant.name}
|
||||
</span>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
<VoiceQualityBadge quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
|
||||
{occupant.isLocal ? (
|
||||
<>
|
||||
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { VoiceQuality } from '@/lib/livekit';
|
||||
|
||||
/**
|
||||
* qualityLabel собирает подпись качества связи с пингом участника:
|
||||
* «Отличное соединение, 42 мс». Пинг неизвестен — остаётся только качество.
|
||||
*/
|
||||
export function qualityLabel(
|
||||
t: TFunction,
|
||||
quality: VoiceQuality,
|
||||
latencyMs: number | undefined,
|
||||
): string {
|
||||
const qualityText = t(`voice.quality.${quality}`);
|
||||
if (latencyMs === undefined) {
|
||||
return qualityText;
|
||||
}
|
||||
return `${qualityText}, ${t('voice.latency', { ms: latencyMs })}`;
|
||||
}
|
||||
@@ -344,7 +344,10 @@
|
||||
"medium": "Medium quality",
|
||||
"high": "High quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"latency": "{{ms}} ms",
|
||||
"latencyHint": "Participant ping to the instance",
|
||||
"latencyUnknown": "ping unknown"
|
||||
},
|
||||
"friends": {
|
||||
"rail": "Friends",
|
||||
@@ -740,7 +743,11 @@
|
||||
"cpuCaption_other": "{{count}} cores",
|
||||
"memory": "Memory",
|
||||
"diskHint": "Disk: used / total. {{free}} free",
|
||||
"databaseHint": "Database: main file and WAL ({{wal}})"
|
||||
"databaseHint": "Database: main file and WAL ({{wal}})",
|
||||
"containerRow": "Instance container",
|
||||
"containerHint": "Profile limits",
|
||||
"hostRow": "Whole server",
|
||||
"hostHint": "Entire machine"
|
||||
},
|
||||
"health": {
|
||||
"title": "Instance healthcheck",
|
||||
@@ -947,7 +954,10 @@
|
||||
"eventSounds": "Server event sounds",
|
||||
"eventSoundsHint": "Play the server palette sounds: member joins, mentions and other events.",
|
||||
"soundboard": "Soundboard",
|
||||
"soundboardHint": "Play soundboard sounds in a voice channel."
|
||||
"soundboardHint": "Play soundboard sounds in a voice channel.",
|
||||
"latency": "Participant ping",
|
||||
"latencySharing": "Show ping in the voice room",
|
||||
"latencySharingHint": "Participant latency to the instance updates every second; your own ping is shared with the room."
|
||||
},
|
||||
"language": {
|
||||
"title": "Language & region",
|
||||
|
||||
@@ -344,7 +344,10 @@
|
||||
"medium": "Среднее качество",
|
||||
"high": "Высокое качество"
|
||||
}
|
||||
}
|
||||
},
|
||||
"latency": "{{ms}} мс",
|
||||
"latencyHint": "Пинг участника до инстанса",
|
||||
"latencyUnknown": "пинг неизвестен"
|
||||
},
|
||||
"friends": {
|
||||
"rail": "Друзья",
|
||||
@@ -740,7 +743,11 @@
|
||||
"cpuCaption_other": "{{count}} ядра",
|
||||
"memory": "Память",
|
||||
"diskHint": "Диск: занято / всего. Свободно {{free}}",
|
||||
"databaseHint": "База данных: файл и журнал WAL ({{wal}})"
|
||||
"databaseHint": "База данных: файл и журнал WAL ({{wal}})",
|
||||
"containerRow": "Контейнер инстанса",
|
||||
"containerHint": "Лимиты профиля",
|
||||
"hostRow": "Сервер целиком",
|
||||
"hostHint": "Вся машина"
|
||||
},
|
||||
"health": {
|
||||
"title": "Healthcheck инстанса",
|
||||
@@ -947,7 +954,10 @@
|
||||
"eventSounds": "Звуки событий сервера",
|
||||
"eventSoundsHint": "Проигрывать звуки палитры сервера: вход участника, упоминания и другие события.",
|
||||
"soundboard": "Саундборд",
|
||||
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате."
|
||||
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате.",
|
||||
"latency": "Пинг участников",
|
||||
"latencySharing": "Показывать пинг в голосовой комнате",
|
||||
"latencySharingHint": "Задержка участников до инстанса обновляется раз в секунду; свой пинг публикуется в комнате."
|
||||
},
|
||||
"language": {
|
||||
"title": "Язык и регион",
|
||||
|
||||
+105
-3
@@ -1,4 +1,4 @@
|
||||
import type { LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
|
||||
import type { LocalAudioTrack, LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
|
||||
|
||||
import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice';
|
||||
|
||||
@@ -106,8 +106,20 @@ export interface VoiceRoomCallbacks {
|
||||
onStatus: (status: VoiceRoomStatus) => void;
|
||||
/** SDK сообщает, что свой микрофон говорит, хотя публикация выключена. */
|
||||
onSpeakingWhileMuted: () => void;
|
||||
/**
|
||||
* Пришёл (или измерился) пинг участника: `identity` — идентификатор
|
||||
* пользователя, `ms` — задержка до инстанса. `null` означает, что участник
|
||||
* отключился и значение нужно забыть.
|
||||
*/
|
||||
onLatency?: (identity: string, ms: number | null) => void;
|
||||
}
|
||||
|
||||
/** Топик служебных сообщений с пингом: чужие data-пакеты игнорируем. */
|
||||
export const LATENCY_TOPIC = 'glchat.latency';
|
||||
|
||||
/** Как часто участник публикует свой пинг. */
|
||||
export const LATENCY_INTERVAL_MS = 1_000;
|
||||
|
||||
export interface VoiceRoomHandle {
|
||||
connect: (url: string, token: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
@@ -121,6 +133,12 @@ export interface VoiceRoomHandle {
|
||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||
/** Статистика своей публикации для попапа «Статистика». */
|
||||
fetchStats: () => Promise<VoiceStats>;
|
||||
/**
|
||||
* Запускает публикацию своего пинга раз в секунду и отдаёт функцию остановки.
|
||||
* Значение уходит всем участникам комнаты по data-каналу, поэтому пинг
|
||||
* каждого виден остальным (AGENT.md 7.14).
|
||||
*/
|
||||
startLatencyReporting: (shouldReport?: () => boolean) => () => void;
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
participants: () => VoiceParticipant[];
|
||||
}
|
||||
@@ -234,6 +252,68 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* startLatencyReporting измеряет свой RTT до инстанса раз в секунду,
|
||||
* отдаёт его локально и публикует в комнату. Ошибки глушим: телеметрия не
|
||||
* должна ломать голосовую сессию.
|
||||
*/
|
||||
const startLatencyReporting = (shouldReport?: () => boolean): (() => void) => {
|
||||
let stopped = false;
|
||||
const tick = async (): Promise<void> => {
|
||||
// Выключенный пинг не только не публикуется, но и не опрашивает SDK.
|
||||
if (stopped || shouldReport?.() === false) {
|
||||
return;
|
||||
}
|
||||
const identity = room.localParticipant.identity;
|
||||
let latency: number | null;
|
||||
try {
|
||||
latency = (await collectStats()).roundTripMs;
|
||||
} catch {
|
||||
// Отчёт WebRTC может быть недоступен — просто пропускаем такт.
|
||||
latency = null;
|
||||
}
|
||||
if (stopped || latency === null) {
|
||||
return;
|
||||
}
|
||||
callbacks.onLatency?.(identity, latency);
|
||||
const payload = new TextEncoder().encode(JSON.stringify({ ms: latency }));
|
||||
void room.localParticipant
|
||||
.publishData(payload, { reliable: false, topic: LATENCY_TOPIC })
|
||||
.catch(() => undefined);
|
||||
};
|
||||
void tick();
|
||||
const timer = setInterval(() => {
|
||||
void tick();
|
||||
}, LATENCY_INTERVAL_MS);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
};
|
||||
|
||||
/** Своя дорожка микрофона: нужна для статистики в аудио-сессии. */
|
||||
const microphoneTrack = (): LocalAudioTrack | undefined => {
|
||||
const track = room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track;
|
||||
return track as LocalAudioTrack | undefined;
|
||||
};
|
||||
|
||||
/** parseLatency разбирает полезную нагрузку с пингом участника. */
|
||||
function parseLatency(payload: Uint8Array): number | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(new TextDecoder().decode(payload));
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return null;
|
||||
}
|
||||
const value = (parsed as { ms?: unknown }).ms;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 60_000) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
@@ -248,7 +328,9 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
|
||||
*/
|
||||
const collectStats = async (): Promise<VoiceStats> => {
|
||||
const track = videoPublication();
|
||||
// Качество канала (RTT, jitter, потери) есть и в аудио-сессии, поэтому
|
||||
// отчёт берём с любой своей дорожки: видео, иначе микрофон.
|
||||
const track = videoPublication() ?? microphoneTrack();
|
||||
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
|
||||
previousSample = null;
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
@@ -350,8 +432,26 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
callbacks.onParticipants(snapshotAll(room));
|
||||
};
|
||||
|
||||
// Пинг участника: своё значение измеряем по WebRTC-статистике и рассылаем
|
||||
// по data-каналу, чужие значения принимаем и отдаём в колбэк.
|
||||
room.on(
|
||||
RoomEvent.DataReceived,
|
||||
(payload: Uint8Array, participant?: { identity: string }, _kind?: unknown, topic?: string) => {
|
||||
if (topic !== LATENCY_TOPIC || participant === undefined) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseLatency(payload);
|
||||
if (parsed !== null) {
|
||||
callbacks.onLatency?.(participant.identity, parsed);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
room.on(RoomEvent.ParticipantConnected, refresh);
|
||||
room.on(RoomEvent.ParticipantDisconnected, refresh);
|
||||
room.on(RoomEvent.ParticipantDisconnected, (participant: Participant) => {
|
||||
callbacks.onLatency?.(participant.identity, null);
|
||||
refresh();
|
||||
});
|
||||
room.on(RoomEvent.TrackSubscribed, refresh);
|
||||
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
||||
room.on(RoomEvent.TrackPublished, refresh);
|
||||
@@ -389,6 +489,8 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
await room.disconnect();
|
||||
},
|
||||
|
||||
startLatencyReporting,
|
||||
|
||||
dispose() {
|
||||
room.removeAllListeners();
|
||||
},
|
||||
|
||||
@@ -39,9 +39,11 @@ export function VoiceSettingsSection() {
|
||||
const pttKey = useVoiceStore((state) => state.pttKey);
|
||||
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
||||
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
||||
const latencySharingEnabled = useVoiceStore((state) => state.latencySharingEnabled);
|
||||
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
||||
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
||||
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
||||
const setLatencySharingEnabled = useVoiceStore((state) => state.setLatencySharingEnabled);
|
||||
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
||||
|
||||
return (
|
||||
@@ -149,6 +151,21 @@ export function VoiceSettingsSection() {
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
|
||||
</fieldset>
|
||||
|
||||
{/* Пинг участников: задержка в комнате и публикация своей (AGENT.md 7.14). */}
|
||||
<fieldset className="mt-5 border-t border-border/40 pt-4">
|
||||
<legend className="text-sm font-semibold">{t('settings.voice.latency')}</legend>
|
||||
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={latencySharingEnabled}
|
||||
aria-label={t('settings.voice.latencySharing')}
|
||||
onChange={(event) => setLatencySharingEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.voice.latencySharing')}</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.latencySharingHint')}</p>
|
||||
</fieldset>
|
||||
|
||||
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface VoicePreferences {
|
||||
eventSoundsEnabled: boolean;
|
||||
/** Саундборд включён. */
|
||||
soundboardEnabled: boolean;
|
||||
/**
|
||||
* Пинг участников: показывать задержку в комнате и делиться своей
|
||||
* (AGENT.md 7.14). Выключено — SDK не опрашивается вовсе.
|
||||
*/
|
||||
latencySharingEnabled: boolean;
|
||||
/** Качество своей публикации видео. */
|
||||
videoQuality: VoicePublishQuality;
|
||||
/** Пресет разрешения и fps для демонстрации экрана. */
|
||||
@@ -81,6 +86,7 @@ interface VoiceState extends VoicePreferences {
|
||||
setPttKey: (key: PushToTalkKey) => void;
|
||||
setEventSoundsEnabled: (enabled: boolean) => void;
|
||||
setSoundboardEnabled: (enabled: boolean) => void;
|
||||
setLatencySharingEnabled: (enabled: boolean) => void;
|
||||
setVideoQuality: (quality: VoicePublishQuality) => void;
|
||||
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
|
||||
/** Вернуть исходные предпочтения (используется в тестах). */
|
||||
@@ -103,6 +109,7 @@ const defaults: VoicePreferences = {
|
||||
pttKey: 'Space',
|
||||
eventSoundsEnabled: true,
|
||||
soundboardEnabled: true,
|
||||
latencySharingEnabled: true,
|
||||
videoQuality: 'high',
|
||||
screenSharePreset: '1080p30',
|
||||
};
|
||||
@@ -184,6 +191,10 @@ function initialPreferences(): VoicePreferences {
|
||||
pttKey: asChoice(stored['pttKey'], PUSH_TO_TALK_KEYS, defaults.pttKey),
|
||||
eventSoundsEnabled: asBoolean(stored['eventSoundsEnabled'], defaults.eventSoundsEnabled),
|
||||
soundboardEnabled: asBoolean(stored['soundboardEnabled'], defaults.soundboardEnabled),
|
||||
latencySharingEnabled: asBoolean(
|
||||
stored['latencySharingEnabled'],
|
||||
defaults.latencySharingEnabled,
|
||||
),
|
||||
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
|
||||
screenSharePreset: asChoice(
|
||||
stored['screenSharePreset'],
|
||||
@@ -213,6 +224,7 @@ function persist(state: VoicePreferences): void {
|
||||
pttKey: state.pttKey,
|
||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||
soundboardEnabled: state.soundboardEnabled,
|
||||
latencySharingEnabled: state.latencySharingEnabled,
|
||||
videoQuality: state.videoQuality,
|
||||
screenSharePreset: state.screenSharePreset,
|
||||
} satisfies VoicePreferences),
|
||||
@@ -264,6 +276,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
||||
setPttKey: (key) => patch({ pttKey: key }),
|
||||
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
|
||||
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
|
||||
setLatencySharingEnabled: (enabled) => patch({ latencySharingEnabled: enabled }),
|
||||
setVideoQuality: (quality) => patch({ videoQuality: quality }),
|
||||
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
|
||||
|
||||
@@ -292,6 +305,7 @@ function preferencesOf(state: VoiceState): VoicePreferences {
|
||||
pttKey: state.pttKey,
|
||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||
soundboardEnabled: state.soundboardEnabled,
|
||||
latencySharingEnabled: state.latencySharingEnabled,
|
||||
videoQuality: state.videoQuality,
|
||||
screenSharePreset: state.screenSharePreset,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface VoiceConnectionState {
|
||||
channelId: string | null;
|
||||
/** Участники комнаты глазами SDK: речь, качество, дорожки. */
|
||||
participants: VoiceParticipant[];
|
||||
/**
|
||||
* Пинг участников до инстанса, мс: своё значение измеряем по статистике
|
||||
* WebRTC, чужие приходят по data-каналу (AGENT.md 7.14).
|
||||
*/
|
||||
latencies: Record<string, number>;
|
||||
/** Опубликован ли свой микрофон. */
|
||||
micEnabled: boolean;
|
||||
/** Локальное глушение: чужой звук не слышен. */
|
||||
@@ -105,6 +110,7 @@ const defaults = {
|
||||
guildId: null,
|
||||
channelId: null,
|
||||
participants: [] as VoiceParticipant[],
|
||||
latencies: {} as Record<string, number>,
|
||||
micEnabled: true,
|
||||
deafened: false,
|
||||
cameraEnabled: false,
|
||||
@@ -118,6 +124,8 @@ const defaults = {
|
||||
|
||||
/** Активная комната SDK. Живёт вне стора: подписки и таймеры не сериализуются. */
|
||||
let room: VoiceRoomHandle | null = null;
|
||||
/** Остановка публикации пинга: живёт вместе с активной комнатой. */
|
||||
let stopLatencyReporting: (() => void) | null = null;
|
||||
/** Канал, о котором уже знает сервер: только для него шлём PATCH и leave. */
|
||||
let joinedChannelId: string | null = null;
|
||||
/** Номер подключения: ответы устаревших операций не должны менять состояние. */
|
||||
@@ -184,6 +192,9 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
|
||||
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
|
||||
const closeRoom = async (): Promise<void> => {
|
||||
stopLatencyReporting?.();
|
||||
stopLatencyReporting = null;
|
||||
set({ latencies: {} });
|
||||
const handle = room;
|
||||
room = null;
|
||||
if (handle === null) {
|
||||
@@ -396,6 +407,20 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
void finishSession();
|
||||
},
|
||||
onSpeakingWhileMuted: warnSpeakingWhileMuted,
|
||||
onLatency: (identity, latency) => {
|
||||
if (mine !== generation) {
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
const latencies = { ...state.latencies };
|
||||
if (latency === null) {
|
||||
delete latencies[identity];
|
||||
} else {
|
||||
latencies[identity] = latency;
|
||||
}
|
||||
return { latencies };
|
||||
});
|
||||
},
|
||||
});
|
||||
if (mine !== generation) {
|
||||
handle.dispose();
|
||||
@@ -410,6 +435,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Пинг публикуется раз в секунду и виден всем участникам комнаты.
|
||||
stopLatencyReporting = handle.startLatencyReporting(
|
||||
() => useVoiceStore.getState().latencySharingEnabled,
|
||||
);
|
||||
await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk);
|
||||
await handle.setCameraEnabled(preferences.cameraEnabled);
|
||||
// Качество публикации — из предпочтений клиента.
|
||||
|
||||
Reference in New Issue
Block a user