From c3f2f392f17e9284aa590c2c41357e628a36e563 Mon Sep 17 00:00:00 2001 From: indar suthar Date: Sun, 9 Nov 2025 14:44:12 +0530 Subject: [PATCH] feat: connection quality indicator --- frontend/package-lock.json | 10 + .../components/ConnectionQualityIndicator.tsx | 224 ++++++++++++++++++ frontend/src/hooks/useConnectionQuality.ts | 200 ++++++++++++++++ frontend/src/pages/InRoom.tsx | 52 +++- 4 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/ConnectionQualityIndicator.tsx create mode 100644 frontend/src/hooks/useConnectionQuality.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 80249e7..a1ef682 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -161,6 +161,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1387,6 +1388,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1470,6 +1472,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3021,6 +3024,7 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3322,6 +3326,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5686,6 +5691,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5898,6 +5904,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5907,6 +5914,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6978,6 +6986,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7282,6 +7291,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/components/ConnectionQualityIndicator.tsx b/frontend/src/components/ConnectionQualityIndicator.tsx new file mode 100644 index 0000000..f61a255 --- /dev/null +++ b/frontend/src/components/ConnectionQualityIndicator.tsx @@ -0,0 +1,224 @@ +import React, { useState } from "react"; +import { WifiOff, SignalLow, SignalMedium, SignalHigh, Info } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { ConnectionQuality, ConnectionQualityStats } from "../hooks/useConnectionQuality"; + +interface ConnectionQualityIndicatorProps { + quality: ConnectionQuality; + stats?: ConnectionQualityStats; + showLabel?: boolean; + className?: string; + compact?: boolean; +} + +const ConnectionQualityIndicator: React.FC = ({ + quality, + stats, + showLabel = false, + className = "", + compact = false, +}) => { + const [showTooltip, setShowTooltip] = useState(false); + + const getQualityConfig = () => { + switch (quality) { + case "excellent": //4 bars + return { + icon: SignalHigh, + barColor: "bg-green-500", + inactiveColor: "bg-gray-700/40", + textColor: "text-green-500", + label: "Excellent", + bars: [true, true, true, true], + }; + case "good": // 3 bars + return { + icon: SignalHigh, + barColor: "bg-green-400", + inactiveColor: "bg-gray-700/40", + textColor: "text-green-400", + label: "Good", + bars: [true, true, true, false], + }; + case "fair": // 2 bars + return { + icon: SignalMedium, + barColor: "bg-yellow-500", + inactiveColor: "bg-gray-700/40", + textColor: "text-yellow-500", + label: "Fair", + bars: [true, true, false, false], + }; + case "poor": // 1 bar + return { + icon: SignalLow, + barColor: "bg-red-500", + inactiveColor: "bg-gray-700/40", + textColor: "text-red-500", + label: "Poor", + bars: [true, false, false, false], + }; + default: // 0 bars + return { + icon: WifiOff, + barColor: "bg-gray-500", + inactiveColor: "bg-gray-700/40", + textColor: "text-gray-400", + label: "Unknown", + bars: [false, false, false, false], + }; + } + }; + + const config = getQualityConfig(); + const Icon = config.icon; + const barHeights = [6, 9, 12, 15]; + + const formatMetric = (value?: number, unit: string = "") => { + if (value === undefined) return "N/A"; + if (value < 1) return `${value.toFixed(2)}${unit}`; + return `${Math.round(value)}${unit}`; + }; + + const tooltipContent = stats && ( +
+
Connection Quality
+ {stats.rtt !== undefined && ( +
+ Latency: + {formatMetric(stats.rtt, "ms")} +
+ )} + {stats.packetLoss !== undefined && ( +
+ Packet Loss: + {formatMetric(stats.packetLoss, "%")} +
+ )} + {stats.jitter !== undefined && ( +
+ Jitter: + {formatMetric(stats.jitter, "ms")} +
+ )} + {stats.bandwidth !== undefined && ( +
+ Bandwidth: + {formatMetric(stats.bandwidth, "kbps")} +
+ )} + {stats.videoResolution && ( +
+ Resolution: + + {stats.videoResolution.width}x{stats.videoResolution.height} + +
+ )} + {stats.videoFrameRate !== undefined && ( +
+ Frame Rate: + {formatMetric(stats.videoFrameRate, "fps")} +
+ )} +
+ ); + + if (compact) { + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
+ {quality === "unknown" ? ( + + ) : ( +
+ {config.bars.map((active, index) => ( +
+ ))} +
+ )} +
+ + {showTooltip && tooltipContent && ( + + {tooltipContent} + + )} + +
+ ); + } + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
+ {/*signal bars*/} +
+ {quality === "unknown" ? ( + + ) : ( +
+ {config.bars.map((active, index) => ( +
+ ))} +
+ )} +
+ + {showLabel && ( + {config.label} + )} + + {stats && (stats.rtt !== undefined || stats.packetLoss !== undefined) && ( + + )} +
+ + {showTooltip && tooltipContent && ( + + {tooltipContent} + + )} + +
+ ); +}; + +export default ConnectionQualityIndicator; + diff --git a/frontend/src/hooks/useConnectionQuality.ts b/frontend/src/hooks/useConnectionQuality.ts new file mode 100644 index 0000000..6cddffb --- /dev/null +++ b/frontend/src/hooks/useConnectionQuality.ts @@ -0,0 +1,200 @@ +import { useState, useEffect, useRef } from "react"; + +export type ConnectionQuality = "excellent" | "good" | "fair" | "poor" | "unknown"; + +export interface ConnectionQualityStats { + quality: ConnectionQuality; + videoResolution?: { width: number; height: number }; + videoFrameRate?: number; + audioLevel?: number; + packetLoss?: number; + jitter?: number; + rtt?: number; //Round-trip-time in ms + bandwidth?: number; //bandwidth in kbps +} + +interface UseConnectionQualityOptions { + localStream?: MediaStream | null; + peerConnection?: RTCPeerConnection | null; + updateInterval?: number; +} + +export const useConnectionQuality = ({ + localStream, + peerConnection, + updateInterval = 2000, +}: UseConnectionQualityOptions): ConnectionQualityStats => { + const [stats, setStats] = useState({ + quality: "unknown", + }); + const intervalRef = useRef(null); + + useEffect(() => { + if (!localStream && !peerConnection) { + setStats({ quality: "unknown" }); + return; + } + + const updateStats = async () => { + try { + const newStats: ConnectionQualityStats = { + quality: "unknown", + }; + + if (localStream) { + const videoTrack = localStream.getVideoTracks()[0]; + const audioTrack = localStream.getAudioTracks()[0]; + + if (videoTrack) { + const settings = videoTrack.getSettings(); + newStats.videoResolution = { + width: settings.width || 0, + height: settings.height || 0, + }; + newStats.videoFrameRate = settings.frameRate || 0; + } + + if (audioTrack) { + try { + const trackStats = await audioTrack.getStats(); + trackStats.forEach((report) => { + if (report.type === "media-source" && "audioLevel" in report) { + newStats.audioLevel = (report as any).audioLevel; + } + }); + } catch (e) { + // not avlble + } + } + } + //network quality + if (peerConnection) { + try { + const pcStats = await peerConnection.getStats(); + let totalPacketsLost = 0; + let totalPackets = 0; + let totalJitter = 0; + let jitterCount = 0; + let totalRtt = 0; + let rttCount = 0; + let availableBandwidth = 0; + + pcStats.forEach((report) => { + if (report.type === "inbound-rtp" && report.mediaType === "video") { + const inboundReport = report as any; + if (inboundReport.packetsLost !== undefined) { + totalPacketsLost += inboundReport.packetsLost; + } + if (inboundReport.packetsReceived !== undefined) { + totalPackets += inboundReport.packetsReceived; + } + if (inboundReport.jitter !== undefined) { + totalJitter += inboundReport.jitter; + jitterCount++; + } + } + if (report.type === "outbound-rtp" && report.mediaType === "video") { + const outboundReport = report as any; + if (outboundReport.packetsLost !== undefined) { + totalPacketsLost += outboundReport.packetsLost; + } + if (outboundReport.packetsSent !== undefined) { + totalPackets += outboundReport.packetsSent; + } + } + if (report.type === "candidate-pair" && report.state === "succeeded") { + const candidatePair = report as any; + if (candidatePair.currentRoundTripTime !== undefined) { + totalRtt += candidatePair.currentRoundTripTime * 1000; + rttCount++; + } + if (candidatePair.availableOutgoingBitrate !== undefined) { + availableBandwidth = Math.max( + availableBandwidth, + candidatePair.availableOutgoingBitrate / 1000 + ); + } + } + }); + + if (totalPackets > 0) { + newStats.packetLoss = (totalPacketsLost / totalPackets) * 100; + } + if (jitterCount > 0) { + newStats.jitter = totalJitter / jitterCount; + } + if (rttCount > 0) { + newStats.rtt = totalRtt / rttCount; + } + if (availableBandwidth > 0) { + newStats.bandwidth = availableBandwidth; + } + } catch (e) { + console.warn("Failed to get peer connection stats:", e); + } + } + newStats.quality = calculateQuality(newStats); + + setStats(newStats); + } catch (error) { + console.error("Error updating connection quality stats:", error); + } + }; + + updateStats(); + + intervalRef.current = window.setInterval(updateStats, updateInterval); + + return () => { + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [localStream, peerConnection, updateInterval]); + + return stats; +}; + +export function calculateQuality(stats: Partial): ConnectionQuality { + if (stats.packetLoss !== undefined || stats.rtt !== undefined || stats.jitter !== undefined) { + const packetLoss = stats.packetLoss || 0; + const rtt = stats.rtt || 0; + const jitter = stats.jitter || 0; + + if (packetLoss < 1 && rtt < 100 && jitter < 20) { + return "excellent"; + } + if (packetLoss < 3 && rtt < 200 && jitter < 50) { + return "good"; + } + if (packetLoss < 5 && rtt < 300 && jitter < 100) { + return "fair"; + } + return "poor"; + } + + if (stats.videoResolution && stats.videoFrameRate !== undefined) { + const { width, height } = stats.videoResolution; + const pixels = width * height; + const frameRate = stats.videoFrameRate; + + //Excellent: 720p+ at 30fps+ + if (pixels >= 1280 * 720 && frameRate >= 30) { + return "excellent"; + } + //Good: 480p+ at 25fps+ + if (pixels >= 640 * 480 && frameRate >= 25) { + return "good"; + } + //Fair: 360p+ at 20fps+ + if (pixels >= 640 * 360 && frameRate >= 20) { + return "fair"; + } + //Poor: anything lower + return "poor"; + } + + return "unknown"; +} + diff --git a/frontend/src/pages/InRoom.tsx b/frontend/src/pages/InRoom.tsx index 83325ad..7661d97 100644 --- a/frontend/src/pages/InRoom.tsx +++ b/frontend/src/pages/InRoom.tsx @@ -9,6 +9,8 @@ import { MessageSquare, } from "lucide-react"; import { motion } from "framer-motion"; +import { useConnectionQuality } from "../hooks/useConnectionQuality"; +import ConnectionQualityIndicator from "../components/ConnectionQualityIndicator"; const InRoom: React.FC<{ roomName: string }> = ({ roomName }) => { const [micOn, setMicOn] = useState(true); @@ -18,6 +20,11 @@ const InRoom: React.FC<{ roomName: string }> = ({ roomName }) => { const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const [localStream, setLocalStream] = useState(null); + const peerConnectionRef = useRef(null); + const connectionQualityStats = useConnectionQuality({ + localStream, + peerConnection: peerConnectionRef.current, + }); // 🔹 STEP 1: Initialize camera and mic on join useEffect(() => { @@ -74,11 +81,20 @@ const InRoom: React.FC<{ roomName: string }> = ({ roomName }) => { return (
{/* Header */} -
-

Room: {roomName}

-
- - 2 Participants +
+

+ Room: {roomName} +

+
+ +
+ + 2 Participants +
@@ -87,7 +103,7 @@ const InRoom: React.FC<{ roomName: string }> = ({ roomName }) => { {/* Video Grid */}
{/* Local Video */} -
+