Files
backspace/packages/web/src/components/voice/ConnectionInfoPopover.tsx
T
Jannis Braun a54dd9084a feat: show encoder implementation in Connection Info, VP9 recommended hint
- Connection Info now shows encoder impl (e.g. libvpx, OpenH264,
  ExternalEncoder) on send-side video tracks alongside quality
  limitation reason
- Amber warning "VP9 is recommended" when H.264 is selected
2026-03-24 01:06:19 +01:00

217 lines
7.7 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
interface ConnectionInfoPopoverProps {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
}
function formatBitrate(kbps: number): string {
if (kbps < 1000) return `${Math.round(kbps)} kbps`;
return `${(kbps / 1000).toFixed(kbps >= 10000 ? 0 : 1)} Mbps`;
}
function pingColor(ms: number): string {
if (ms <= 80) return 'text-status-online';
if (ms <= 200) return 'text-status-idle';
return 'text-txt-danger';
}
function lossColor(pct: number): string {
if (pct <= 1) return 'text-status-online';
if (pct <= 5) return 'text-status-idle';
return 'text-txt-danger';
}
function jitterColor(ms: number): string {
if (ms <= 30) return 'text-status-online';
if (ms <= 80) return 'text-status-idle';
return 'text-txt-danger';
}
function sourceLabel(source: string): string {
switch (source) {
case 'microphone': return 'Microphone';
case 'camera': return 'Camera';
case 'screen_share': return 'Screen';
case 'screen_share_audio': return 'Screen Audio';
default: return 'Unknown';
}
}
function trackLabel(direction: 'send' | 'recv', source: string, participantName: string | null): string {
const arrow = direction === 'send' ? '\u2191' : '\u2193';
if (direction === 'send') {
return `${sourceLabel(source)} ${arrow}`;
}
const name = participantName ?? 'Remote';
return `${name} ${sourceLabel(source)} ${arrow}`;
}
const Row = ({ label, value, colorClass }: { label: string; value: string; colorClass?: string }) => (
<div className="flex items-center justify-between py-[3px]">
<span className="text-[12px] text-txt-tertiary">{label}</span>
<span className={`text-[12px] font-medium ${colorClass ?? 'text-txt-secondary'}`}>{value}</span>
</div>
);
const Divider = () => <div className="border-t border-border-soft my-1" />;
const SectionHeader = ({ title }: { title: string }) => (
<div className="text-[10px] font-bold text-txt-tertiary uppercase tracking-wider pt-1 pb-[2px]">
{title}
</div>
);
function AudioTrackRow({ track }: { track: AudioTrackStat }) {
const label = trackLabel(track.direction, track.source, track.participantName);
return (
<div className="flex items-center justify-between py-[3px]">
<span className="text-[12px] text-txt-tertiary truncate mr-2">{label}</span>
<span className="text-[12px] font-medium text-txt-secondary whitespace-nowrap">
{formatBitrate(track.bitrate)}
{track.codec && <span className="text-txt-tertiary ml-2">{track.codec}</span>}
</span>
</div>
);
}
function VideoTrackRow({ track }: { track: VideoTrackStat }) {
const label = trackLabel(track.direction, track.source, track.participantName);
const resolution = (track.width && track.height) ? `${track.width}\u00d7${track.height}` : null;
return (
<div className="py-[3px]">
<div className="flex items-center justify-between">
<span className="text-[12px] text-txt-tertiary truncate mr-2">{label}</span>
<span className="text-[12px] font-medium text-txt-secondary whitespace-nowrap">
{formatBitrate(track.bitrate)}
{track.codec && <span className="text-txt-tertiary ml-2">{track.codec}</span>}
</span>
</div>
{(resolution || track.fps !== null || track.qualityLimitation || track.simulcastLayer || track.encoderImpl) && (
<div className="flex items-center justify-between pl-3">
<span className="text-[11px] text-txt-tertiary">
{resolution && `${resolution}`}
{track.fps !== null && ` @${track.fps}`}
</span>
<span className="text-[11px] text-txt-tertiary">
{track.direction === 'send' && (() => {
const parts: string[] = [];
if (track.encoderImpl) parts.push(track.encoderImpl);
if (track.qualityLimitation && track.qualityLimitation !== 'none') parts.push(track.qualityLimitation);
return parts.join(' · ') || null;
})()}
{track.direction === 'recv' && track.simulcastLayer
? track.simulcastLayer
: ''}
</span>
</div>
)}
</div>
);
}
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const stats = useTrackStats(open);
const { style } = useFloatingPosition(anchorRef, popoverRef, {
placement: 'top',
offset: 12,
enabled: open,
});
// Click-outside to close
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open, onClose]);
if (!open) return null;
const room = getActiveRoom();
return createPortal(
<div
ref={popoverRef}
style={style}
className="w-[300px] glass rounded-lg overflow-hidden"
>
<div className="px-3 py-2 border-b border-border-hard">
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
</div>
<div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
{!room ? (
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
) : !stats ? (
<div className="text-[12px] text-txt-tertiary py-2 text-center">Gathering stats...</div>
) : (
<>
{/* Network */}
<SectionHeader title="Network" />
<Row
label="Ping"
value={stats.network.ping !== null ? `${stats.network.ping} ms` : '\u2014'}
colorClass={stats.network.ping !== null ? pingColor(stats.network.ping) : undefined}
/>
<Row
label="Packet Loss"
value={stats.network.packetLoss !== null ? `${stats.network.packetLoss.toFixed(1)}%` : '\u2014'}
colorClass={stats.network.packetLoss !== null ? lossColor(stats.network.packetLoss) : undefined}
/>
<Row
label="Jitter"
value={stats.network.jitter !== null ? `${stats.network.jitter} ms` : '\u2014'}
colorClass={stats.network.jitter !== null ? jitterColor(stats.network.jitter) : undefined}
/>
<Row label="Server" value={stats.network.serverAddress ?? '\u2014'} />
<Row
label="Protocol"
value={
stats.network.protocol
? `${stats.network.protocol}${stats.network.candidateType ? ` (${stats.network.candidateType})` : ''}`
: '\u2014'
}
/>
{/* Audio Tracks */}
{stats.audioTracks.length > 0 && (
<>
<Divider />
<SectionHeader title="Audio" />
{stats.audioTracks.map((t) => (
<AudioTrackRow key={t.key} track={t} />
))}
</>
)}
{/* Video Tracks */}
{stats.videoTracks.length > 0 && (
<>
<Divider />
<SectionHeader title="Video" />
{stats.videoTracks.map((t) => (
<VideoTrackRow key={t.key} track={t} />
))}
</>
)}
</>
)}
</div>
</div>,
document.body,
);
}