Skip to content

Commit c3f2f39

Browse files
committed
feat: connection quality indicator
1 parent 20234b5 commit c3f2f39

File tree

4 files changed

+477
-9
lines changed

4 files changed

+477
-9
lines changed

frontend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import React, { useState } from "react";
2+
import { WifiOff, SignalLow, SignalMedium, SignalHigh, Info } from "lucide-react";
3+
import { motion, AnimatePresence } from "framer-motion";
4+
import { ConnectionQuality, ConnectionQualityStats } from "../hooks/useConnectionQuality";
5+
6+
interface ConnectionQualityIndicatorProps {
7+
quality: ConnectionQuality;
8+
stats?: ConnectionQualityStats;
9+
showLabel?: boolean;
10+
className?: string;
11+
compact?: boolean;
12+
}
13+
14+
const ConnectionQualityIndicator: React.FC<ConnectionQualityIndicatorProps> = ({
15+
quality,
16+
stats,
17+
showLabel = false,
18+
className = "",
19+
compact = false,
20+
}) => {
21+
const [showTooltip, setShowTooltip] = useState(false);
22+
23+
const getQualityConfig = () => {
24+
switch (quality) {
25+
case "excellent": //4 bars
26+
return {
27+
icon: SignalHigh,
28+
barColor: "bg-green-500",
29+
inactiveColor: "bg-gray-700/40",
30+
textColor: "text-green-500",
31+
label: "Excellent",
32+
bars: [true, true, true, true],
33+
};
34+
case "good": // 3 bars
35+
return {
36+
icon: SignalHigh,
37+
barColor: "bg-green-400",
38+
inactiveColor: "bg-gray-700/40",
39+
textColor: "text-green-400",
40+
label: "Good",
41+
bars: [true, true, true, false],
42+
};
43+
case "fair": // 2 bars
44+
return {
45+
icon: SignalMedium,
46+
barColor: "bg-yellow-500",
47+
inactiveColor: "bg-gray-700/40",
48+
textColor: "text-yellow-500",
49+
label: "Fair",
50+
bars: [true, true, false, false],
51+
};
52+
case "poor": // 1 bar
53+
return {
54+
icon: SignalLow,
55+
barColor: "bg-red-500",
56+
inactiveColor: "bg-gray-700/40",
57+
textColor: "text-red-500",
58+
label: "Poor",
59+
bars: [true, false, false, false],
60+
};
61+
default: // 0 bars
62+
return {
63+
icon: WifiOff,
64+
barColor: "bg-gray-500",
65+
inactiveColor: "bg-gray-700/40",
66+
textColor: "text-gray-400",
67+
label: "Unknown",
68+
bars: [false, false, false, false],
69+
};
70+
}
71+
};
72+
73+
const config = getQualityConfig();
74+
const Icon = config.icon;
75+
const barHeights = [6, 9, 12, 15];
76+
77+
const formatMetric = (value?: number, unit: string = "") => {
78+
if (value === undefined) return "N/A";
79+
if (value < 1) return `${value.toFixed(2)}${unit}`;
80+
return `${Math.round(value)}${unit}`;
81+
};
82+
83+
const tooltipContent = stats && (
84+
<div className="text-xs space-y-1">
85+
<div className="font-semibold text-white mb-2">Connection Quality</div>
86+
{stats.rtt !== undefined && (
87+
<div className="flex justify-between gap-4 text-gray-300">
88+
<span>Latency:</span>
89+
<span className="font-medium">{formatMetric(stats.rtt, "ms")}</span>
90+
</div>
91+
)}
92+
{stats.packetLoss !== undefined && (
93+
<div className="flex justify-between gap-4 text-gray-300">
94+
<span>Packet Loss:</span>
95+
<span className="font-medium">{formatMetric(stats.packetLoss, "%")}</span>
96+
</div>
97+
)}
98+
{stats.jitter !== undefined && (
99+
<div className="flex justify-between gap-4 text-gray-300">
100+
<span>Jitter:</span>
101+
<span className="font-medium">{formatMetric(stats.jitter, "ms")}</span>
102+
</div>
103+
)}
104+
{stats.bandwidth !== undefined && (
105+
<div className="flex justify-between gap-4 text-gray-300">
106+
<span>Bandwidth:</span>
107+
<span className="font-medium">{formatMetric(stats.bandwidth, "kbps")}</span>
108+
</div>
109+
)}
110+
{stats.videoResolution && (
111+
<div className="flex justify-between gap-4 text-gray-300">
112+
<span>Resolution:</span>
113+
<span className="font-medium">
114+
{stats.videoResolution.width}x{stats.videoResolution.height}
115+
</span>
116+
</div>
117+
)}
118+
{stats.videoFrameRate !== undefined && (
119+
<div className="flex justify-between gap-4 text-gray-300">
120+
<span>Frame Rate:</span>
121+
<span className="font-medium">{formatMetric(stats.videoFrameRate, "fps")}</span>
122+
</div>
123+
)}
124+
</div>
125+
);
126+
127+
if (compact) {
128+
return (
129+
<div
130+
className={`relative ${className}`}
131+
onMouseEnter={() => setShowTooltip(true)}
132+
onMouseLeave={() => setShowTooltip(false)}
133+
>
134+
<div className="flex items-end gap-0.5 bg-black/60 backdrop-blur-sm px-2 py-1.5 rounded-md border border-white/10">
135+
{quality === "unknown" ? (
136+
<Icon className={`w-3.5 h-3.5 ${config.textColor}`} />
137+
) : (
138+
<div className="flex items-end gap-0.5">
139+
{config.bars.map((active, index) => (
140+
<div
141+
key={index}
142+
className={`w-1 rounded-sm transition-all duration-200 ${
143+
active ? config.barColor : config.inactiveColor
144+
}`}
145+
style={{
146+
height: `${barHeights[index]}px`,
147+
}}
148+
/>
149+
))}
150+
</div>
151+
)}
152+
</div>
153+
<AnimatePresence>
154+
{showTooltip && tooltipContent && (
155+
<motion.div
156+
initial={{ opacity: 0, y: 5 }}
157+
animate={{ opacity: 1, y: 0 }}
158+
exit={{ opacity: 0, y: 5 }}
159+
transition={{ duration: 0.15 }}
160+
className="absolute bottom-full right-0 mb-2 bg-gray-900/95 backdrop-blur-md px-3 py-2 rounded-lg border border-gray-700 shadow-xl min-w-[180px] z-50"
161+
>
162+
{tooltipContent}
163+
</motion.div>
164+
)}
165+
</AnimatePresence>
166+
</div>
167+
);
168+
}
169+
170+
return (
171+
<div
172+
className={`relative flex items-center gap-2 ${className}`}
173+
onMouseEnter={() => setShowTooltip(true)}
174+
onMouseLeave={() => setShowTooltip(false)}
175+
>
176+
<div className="flex items-center gap-2 bg-gray-900/80 backdrop-blur-sm px-3 py-1.5 rounded-lg border border-gray-800/50">
177+
{/*signal bars*/}
178+
<div className="flex items-end gap-0.5">
179+
{quality === "unknown" ? (
180+
<Icon className={`w-4 h-4 ${config.textColor}`} />
181+
) : (
182+
<div className="flex items-end gap-0.5">
183+
{config.bars.map((active, index) => (
184+
<div
185+
key={index}
186+
className={`w-1.5 rounded-sm transition-all duration-200 ${
187+
active ? config.barColor : config.inactiveColor
188+
}`}
189+
style={{
190+
height: `${barHeights[index]}px`,
191+
}}
192+
/>
193+
))}
194+
</div>
195+
)}
196+
</div>
197+
198+
{showLabel && (
199+
<span className={`text-xs font-medium ${config.textColor}`}>{config.label}</span>
200+
)}
201+
202+
{stats && (stats.rtt !== undefined || stats.packetLoss !== undefined) && (
203+
<Info className="w-3 h-3 text-gray-500" />
204+
)}
205+
</div>
206+
<AnimatePresence>
207+
{showTooltip && tooltipContent && (
208+
<motion.div
209+
initial={{ opacity: 0, y: 5 }}
210+
animate={{ opacity: 1, y: 0 }}
211+
exit={{ opacity: 0, y: 5 }}
212+
transition={{ duration: 0.15 }}
213+
className="absolute top-full right-0 mt-2 bg-gray-900/95 backdrop-blur-md px-3 py-2.5 rounded-lg border border-gray-700 shadow-xl min-w-[200px] z-50"
214+
>
215+
{tooltipContent}
216+
</motion.div>
217+
)}
218+
</AnimatePresence>
219+
</div>
220+
);
221+
};
222+
223+
export default ConnectionQualityIndicator;
224+

0 commit comments

Comments
 (0)