diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 5d955b7f..a40c3c41 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -93,12 +93,10 @@ export function AppLayout() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const activeDmCall = useVoiceStore((s) => s.activeDmCall); - const setParticipants = useVoiceStore((s) => s.setParticipants); const { connect: connectVoice, connectDm: connectDmVoice, disconnect: disconnectVoice, - participants: voiceParticipants, isConnected: isVoiceConnected, isConnecting: isVoiceConnecting, connectedChannelId, @@ -107,11 +105,6 @@ export function AppLayout() { // Initialize WebSocket const { isConnected: isWsConnected } = useWebSocket(); - // Sync participants to store - useEffect(() => { - setParticipants(voiceParticipants); - }, [voiceParticipants, setParticipants]); - // Track the last channel we attempted to connect to, to prevent effect loops const lastAttemptedRef = React.useRef(null); diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index 421dd135..1ce593e6 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -26,6 +26,8 @@ export function MainContent() { const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen); const participants = useVoiceStore((s) => s.participants); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); + const connectionError = useVoiceStore((s) => s.connectionError); const showDms = useUIStore((s) => s.showDms); const activeDmCall = useVoiceStore((s) => s.activeDmCall); const outgoingCall = useVoiceStore((s) => s.outgoingCall); @@ -236,8 +238,16 @@ export function MainContent() { {channel.name} - Connected - {participants.length} connected + {connectionError ? ( + Connection Failed + ) : isLiveKitConnected ? ( + <> + Connected + {participants.length} connected + + ) : ( + Connecting... + )}
diff --git a/packages/web/src/components/voice/ConnectionInfoPopover.tsx b/packages/web/src/components/voice/ConnectionInfoPopover.tsx new file mode 100644 index 00000000..bf6e728a --- /dev/null +++ b/packages/web/src/components/voice/ConnectionInfoPopover.tsx @@ -0,0 +1,461 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Track } from 'livekit-client'; +import { getActiveRoom } from '../../hooks/useLiveKit'; + +interface ConnectionInfoPopoverProps { + open: boolean; + onClose: () => void; +} + +interface ConnectionStats { + ping: number | null; + packetLoss: number | null; + jitter: number | null; + audioUp: number | null; + audioDown: number | null; + videoUp: number | null; + videoDown: number | null; + audioCodec: string | null; + videoCodec: string | null; + resolution: string | null; + fps: number | null; + qualityLimitation: string | null; + serverAddress: string | null; + protocol: string | null; + candidateType: string | null; +} + +interface PrevSample { + bytes: number; + timestamp: number; +} + +/** + * Discover all unique RTCPeerConnections from the LiveKit Room engine. + * Different livekit-client versions expose the PC at different internal paths. + * We collect all of them and deduplicate, so the stats loop can process + * outbound-rtp and inbound-rtp reports regardless of transport architecture + * (split publisher/subscriber vs unified-plan single PC). + */ +function discoverPeerConnections(room: any): RTCPeerConnection[] { + const engine = room?.engine; + if (!engine) return []; + + const pcs: RTCPeerConnection[] = []; + const seen = new WeakSet(); + + const tryAdd = (val: any) => { + if (val && typeof val.getStats === 'function' && !seen.has(val)) { + seen.add(val); + pcs.push(val); + } + }; + + // Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc + tryAdd(engine.pcManager?.publisher?.pc); + tryAdd(engine.pcManager?.subscriber?.pc); + // Private backing field fallback + tryAdd(engine.pcManager?.publisher?._pc); + tryAdd(engine.pcManager?.subscriber?._pc); + // Older livekit-client paths + tryAdd(engine.publisher?.pc); + tryAdd(engine.subscriber?.pc); + // Unified-plan single PC + tryAdd(engine.pc); + tryAdd(room.pc); + + return pcs; +} + +/** Determine media kind from a WebRTC stats report (handles both spec and legacy fields). */ +function reportKind(report: any): 'audio' | 'video' | null { + const k = report.kind ?? report.mediaType; + if (k === 'audio' || k === 'video') return k; + return null; +} + +function formatBitrate(bps: number | null): string { + if (bps === null) return '\u2014'; + if (bps < 1000) return `${Math.round(bps)} kbps`; + return `${(bps / 1000).toFixed(bps >= 10000 ? 0 : 1)} Mbps`; +} + +function pingColor(ms: number): string { + if (ms <= 80) return 'text-discord-green'; + if (ms <= 200) return 'text-discord-yellow'; + return 'text-discord-red'; +} + +function lossColor(pct: number): string { + if (pct <= 1) return 'text-discord-green'; + if (pct <= 5) return 'text-discord-yellow'; + return 'text-discord-red'; +} + +function jitterColor(ms: number): string { + if (ms <= 30) return 'text-discord-green'; + if (ms <= 80) return 'text-discord-yellow'; + return 'text-discord-red'; +} + +export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) { + const popoverRef = useRef(null); + const prevSampleRef = useRef>(new Map()); + const [stats, setStats] = useState(null); + + // 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]); + + // Stats polling — only while open + useEffect(() => { + if (!open) { + prevSampleRef.current.clear(); + setStats(null); + return; + } + + const poll = async () => { + const room = getActiveRoom(); + if (!room) { + setStats(null); + return; + } + + const pcs = discoverPeerConnections(room as any); + if (pcs.length === 0) { + setStats(null); + return; + } + + const result: ConnectionStats = { + ping: null, + packetLoss: null, + jitter: null, + audioUp: null, + audioDown: null, + videoUp: null, + videoDown: null, + audioCodec: null, + videoCodec: null, + resolution: null, + fps: null, + qualityLimitation: null, + serverAddress: null, + protocol: null, + candidateType: null, + }; + + const prev = prevSampleRef.current; + const now = performance.now(); + + // Accumulators for inbound aggregate metrics + let totalPacketsReceived = 0; + let totalPacketsLost = 0; + let totalAudioDown = 0; + let hasAudioDown = false; + let totalVideoDown = 0; + let hasVideoDown = false; + + // Track candidate-pair remote ID for Safari fallback (see after forEach) + let selectedCandidatePairRemoteId: string | null = null; + + // Process stats from ALL discovered PeerConnections. + // Report types (outbound-rtp, inbound-rtp, candidate-pair, etc.) are + // self-describing, so we don't need to know which PC they came from. + for (const pc of pcs) { + let reports: RTCStatsReport; + try { + reports = await pc.getStats(); + } catch { + continue; + } + + // Build codec map for this PC's stats (codecId → short name) + const codecMap = new Map(); + reports.forEach((report: any) => { + if (report.type === 'codec') { + codecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? ''); + } + }); + + reports.forEach((report: any) => { + const kind = reportKind(report); + + // ── RTT from candidate-pair ── + if ( + report.type === 'candidate-pair' && + report.state === 'succeeded' + ) { + if (report.currentRoundTripTime != null) { + result.ping = Math.round(report.currentRoundTripTime * 1000); + } + // Safari fallback: collect remote candidate ID for post-loop lookup + if (!result.serverAddress && report.remoteCandidateId) { + selectedCandidatePairRemoteId = report.remoteCandidateId; + } + } + + // ── Outbound (publisher) ── + if (report.type === 'outbound-rtp') { + const key = `out-${report.ssrc}`; + const prevEntry = prev.get(key); + + if (kind === 'audio') { + if (prevEntry) { + const deltaBits = (report.bytesSent - prevEntry.bytes) * 8; + const deltaMs = now - prevEntry.timestamp; + if (deltaMs > 0) result.audioUp = deltaBits / deltaMs; + } + prev.set(key, { bytes: report.bytesSent, timestamp: now }); + + if (report.codecId && codecMap.has(report.codecId)) { + result.audioCodec = codecMap.get(report.codecId)!; + } + } + + if (kind === 'video') { + if (prevEntry) { + const deltaBits = (report.bytesSent - prevEntry.bytes) * 8; + const deltaMs = now - prevEntry.timestamp; + if (deltaMs > 0) { + const bitrate = deltaBits / deltaMs; + // Only accumulate when bitrate > 0 — filters stale outbound-rtp + // reports that Safari/Chrome retain after camera/screenshare stops + if (bitrate > 0) { + result.videoUp = (result.videoUp ?? 0) + bitrate; + } + } + } + prev.set(key, { bytes: report.bytesSent, timestamp: now }); + + if (report.codecId && codecMap.has(report.codecId)) { + result.videoCodec = codecMap.get(report.codecId)!; + } + + // Resolution + FPS — take the active track (non-zero dimensions) + if (report.frameWidth > 0 && report.frameHeight > 0) { + result.resolution = `${report.frameWidth}\u00d7${report.frameHeight}`; + result.fps = Math.round(report.framesPerSecond || 0); + } + + if (report.qualityLimitationReason) { + result.qualityLimitation = report.qualityLimitationReason; + } + } + } + + // ── Inbound (subscriber) ── + if (report.type === 'inbound-rtp') { + const key = `in-${report.ssrc}`; + const prevEntry = prev.get(key); + + if (kind === 'audio') { + if (result.jitter === null && report.jitter != null) { + result.jitter = Math.round(report.jitter * 1000); + } + totalPacketsReceived += report.packetsReceived || 0; + totalPacketsLost += report.packetsLost || 0; + + if (prevEntry) { + const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8; + const deltaMs = now - prevEntry.timestamp; + if (deltaMs > 0) { + totalAudioDown += deltaBits / deltaMs; + hasAudioDown = true; + } + } + prev.set(key, { bytes: report.bytesReceived, timestamp: now }); + } + + if (kind === 'video') { + totalPacketsReceived += report.packetsReceived || 0; + totalPacketsLost += report.packetsLost || 0; + + if (prevEntry) { + const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8; + const deltaMs = now - prevEntry.timestamp; + if (deltaMs > 0) { + const bitrate = deltaBits / deltaMs; + // Only accumulate when bitrate > 0 — filters stale inbound-rtp + if (bitrate > 0) { + totalVideoDown += bitrate; + hasVideoDown = true; + } + } + } + prev.set(key, { bytes: report.bytesReceived, timestamp: now }); + } + } + + // ── Server address from remote-candidate ── + // Safari may leave `address` empty but populate the legacy `ip` field + if (report.type === 'remote-candidate' && !result.serverAddress) { + const addr = report.address || report.ip; + if (addr) { + result.serverAddress = addr; + result.protocol = report.protocol ?? null; + result.candidateType = report.candidateType ?? null; + } + } + }); + + // Safari fallback: look up remote candidate by ID from candidate-pair + if (!result.serverAddress && selectedCandidatePairRemoteId) { + const remoteCandidate = reports.get(selectedCandidatePairRemoteId); + if (remoteCandidate) { + const addr = remoteCandidate.address || remoteCandidate.ip; + if (addr) { + result.serverAddress = addr; + result.protocol = remoteCandidate.protocol ?? null; + result.candidateType = remoteCandidate.candidateType ?? null; + } + } + } + } + + // Safari fallback: resolution from MediaStreamTrack.getSettings() + // Safari omits frameWidth/frameHeight from outbound-rtp stats entirely + if (!result.resolution && result.videoUp !== null && result.videoUp > 0) { + const localPart = room.localParticipant; + for (const pub of localPart.trackPublications.values()) { + if (pub.source === Track.Source.Camera || pub.source === Track.Source.ScreenShare) { + const mt = pub.track?.mediaStreamTrack; + if (mt && mt.readyState === 'live') { + const s = mt.getSettings(); + if (s.width && s.height) { + result.resolution = `${s.width}\u00d7${s.height}`; + result.fps = s.frameRate ? Math.round(s.frameRate) : null; + break; + } + } + } + } + } + + // Finalize aggregated inbound metrics + if (hasAudioDown) result.audioDown = totalAudioDown; + if (hasVideoDown) result.videoDown = totalVideoDown; + + const totalPackets = totalPacketsReceived + totalPacketsLost; + if (totalPackets > 0) { + result.packetLoss = (totalPacketsLost / totalPackets) * 100; + } + + setStats(result); + }; + + poll(); + const interval = setInterval(poll, 1500); + return () => clearInterval(interval); + }, [open]); + + if (!open) return null; + + const room = getActiveRoom(); + const hasVideo = stats && ( + (stats.videoUp !== null && stats.videoUp > 0) || + (stats.videoDown !== null && stats.videoDown > 0) || + stats.resolution !== null + ); + + const Row = ({ label, value, colorClass }: { label: string; value: string; colorClass?: string }) => ( +
+ {label} + {value} +
+ ); + + const Divider = () =>
; + + return ( +
+
+ Connection Info +
+ +
+ {!room ? ( +
Not connected
+ ) : !stats ? ( +
Gathering stats...
+ ) : ( + <> + {/* Network */} + + + + + + + {/* Bitrates */} + + + {hasVideo && ( + <> + + + + )} + + + + {/* Codec + media info */} + + {hasVideo && ( + <> + + + + )} + + + + {/* Server */} + + + + )} +
+
+ ); +} diff --git a/packages/web/src/components/voice/DmCallView.tsx b/packages/web/src/components/voice/DmCallView.tsx index da30fc7d..3ef28101 100644 --- a/packages/web/src/components/voice/DmCallView.tsx +++ b/packages/web/src/components/voice/DmCallView.tsx @@ -29,6 +29,7 @@ export function DmCallView() { const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall); const leaveVoice = useVoiceStore((s) => s.leaveVoice); + const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds); const dmChannels = useServerStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); @@ -164,7 +165,7 @@ export function DmCallView() {
{otherName.charAt(0).toUpperCase()}
- {remoteParticipant?.isSpeaking && ( + {remoteParticipant && speakingParticipantIds.has(remoteParticipant.identity) && (
)}
@@ -203,7 +204,7 @@ export function DmCallView() {
{(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()}
- {localParticipant?.isSpeaking && ( + {localParticipant && speakingParticipantIds.has(localParticipant.identity) && (
)}
diff --git a/packages/web/src/components/voice/GlobalAudioRenderer.tsx b/packages/web/src/components/voice/GlobalAudioRenderer.tsx index aab92b15..82ef5371 100644 --- a/packages/web/src/components/voice/GlobalAudioRenderer.tsx +++ b/packages/web/src/components/voice/GlobalAudioRenderer.tsx @@ -74,9 +74,10 @@ export function GlobalAudioRenderer() { const watchingStreams = useVoiceStore((s) => s.watchingStreams); const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled); const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength); + const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds); // Determine if someone is currently speaking (for stream attenuation) - const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking); + const someoneIsSpeaking = participants.some((p) => !p.isLocal && speakingParticipantIds.has(p.identity)); // Only render audio for remote participants const remoteParticipants = participants.filter((p) => !p.isLocal); diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx index 295902ff..1a2e839a 100644 --- a/packages/web/src/components/voice/PictureInPicture.tsx +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -65,6 +65,7 @@ export function PictureInPicture() { const participants = useVoiceStore((s) => s.participants); const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); const watchingStreams = useVoiceStore((s) => s.watchingStreams); + const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds); const currentChannelId = useChatStore((s) => s.currentChannelId); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const pipCollapsed = useUIStore((s) => s.pipCollapsed); @@ -105,12 +106,12 @@ export function PictureInPicture() { // Fallback participant for avatar (most relevant remote, or first participant) const fallbackParticipant = useMemo(() => { - const speaking = participants.find(p => !p.isLocal && p.isSpeaking); + const speaking = participants.find(p => !p.isLocal && speakingParticipantIds.has(p.identity)); if (speaking) return speaking; const remote = participants.find(p => !p.isLocal); if (remote) return remote; return participants[0] ?? null; - }, [participants]); + }, [participants, speakingParticipantIds]); // Channel name for display const channelName = useMemo(() => { @@ -260,7 +261,7 @@ export function PictureInPicture() { name={displayParticipant.username} size={64} /> - {displayParticipant.isSpeaking && ( + {speakingParticipantIds.has(displayParticipant.identity) && (
)}
@@ -297,7 +298,7 @@ export function PictureInPicture() {
{displayName} - {displayParticipant?.isSpeaking && ( + {displayParticipant && speakingParticipantIds.has(displayParticipant.identity) && (
)}
diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index cae80a8d..a165e727 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -5,6 +5,7 @@ import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; import { AudioManager } from '../../audio/AudioManager'; import { VideoQualityPopover } from './VideoQualityPopover'; +import { ConnectionInfoPopover } from './ConnectionInfoPopover'; /** * VoiceControls renders the voice status + button rows. @@ -20,8 +21,10 @@ export function VoiceControls() { const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled); const connectionError = useVoiceStore((s) => s.connectionError); const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); + const connectionQuality = useVoiceStore((s) => s.connectionQuality); const channels = useServerStore((s) => s.channels); const [showVideoQuality, setShowVideoQuality] = useState(false); + const [showConnectionInfo, setShowConnectionInfo] = useState(false); if (!currentVoiceChannelId) return null; @@ -73,18 +76,34 @@ export function VoiceControls() { ? 'bg-discord-green/20' : 'bg-discord-yellow/20'; + const qualityColor = + connectionQuality === 'excellent' || connectionQuality === 'good' + ? 'text-discord-green' + : connectionQuality === 'poor' + ? 'text-discord-yellow' + : connectionQuality === 'lost' + ? 'text-discord-red' + : statusColor; // 'unknown' falls back to connection-state color + const btnBase = 'flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors'; const btnDefaultStyle = 'bg-[#111214] text-discord-text-muted hover:bg-[#1a1b1e] hover:text-discord-text-secondary'; return ( <> {/* Row 1: Signal icon + status text + disconnect */} -
-
- +
+
+
@@ -96,11 +115,6 @@ export function VoiceControls() {
-
+ + {/* Connection Info Popover */} + setShowConnectionInfo(false)} + />
{/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */} @@ -153,7 +173,10 @@ export function VoiceControls() { {/* Video Quality */}