diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02f3f4b..a8534bf 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 23aff1a..5e176ea 100644 --- a/frontend/src/pages/InRoom.tsx +++ b/frontend/src/pages/InRoom.tsx @@ -1,8 +1,20 @@ import React, { useState, useEffect, useRef } from "react"; +import { useParams, useNavigate } from "react-router-dom"; import { io, Socket } from "socket.io-client"; -import { Mic, MicOff, Video, VideoOff, PhoneOff, Users, MessageSquare } from "lucide-react"; +import { + Mic, + MicOff, + Video, + VideoOff, + PhoneOff, + Users, + MessageSquare, +} from "lucide-react"; import { motion } from "framer-motion"; import { HotKeys } from "react-hotkeys"; +import { useConnectionQuality } from "../hooks/useConnectionQuality"; +import ConnectionQualityIndicator from "../components/ConnectionQualityIndicator"; +import { API_ENDPOINTS } from "../lib/apiConfig.js"; const keyMap = { TOGGLE_MIC: "ctrl+m", @@ -10,36 +22,67 @@ const keyMap = { TOGGLE_CHAT: "ctrl+c", }; -const SOCKET_URL = "http://localhost:3000"; +interface ChatMessage { + user: string; + text: string; + time?: string; +} -const InRoom: React.FC<{ roomId: string; userName: string }> = ({ roomId, userName }) => { +const InRoom: React.FC = () => { + const { roomName } = useParams<{ roomName: string }>(); + const navigate = useNavigate(); + const [micOn, setMicOn] = useState(true); const [videoOn, setVideoOn] = useState(true); const [showChat, setShowChat] = useState(false); - const [messages, setMessages] = useState<{ user: string; text: string; time?: string }[]>([]); + const [messages, setMessages] = useState([]); const [chatInput, setChatInput] = useState(""); + const [userName, setUserName] = useState("User"); + const [participantCount, setParticipantCount] = useState(1); const socketRef = useRef(null); const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); const mediaStreamRef = useRef(null); const chatEndRef = useRef(null); + const peerConnectionRef = useRef(null); + const connectionQualityStats = useConnectionQuality({ + localStream: mediaStreamRef.current, + peerConnection: peerConnectionRef.current, + }); + + // Get user name from localStorage or token + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + // Try to get user info from token or make API call + // For now, use a default or extract from token + const storedName = localStorage.getItem("userName"); + if (storedName) { + setUserName(storedName); + } + } + }, []); + + // Initialize Socket.io connection useEffect(() => { - document.title = `${roomId} | PeerCall`; + if (!roomName) return; + + document.title = `${roomName} | PeerCall`; - const socket = io(SOCKET_URL); + const socket = io(API_ENDPOINTS.SOCKET); socketRef.current = socket; - socket.emit("join-room", { roomId, userName }); - console.log("🟢 Joined room:", roomId, "as", userName); + socket.emit("join-room", roomName, userName); + console.log("🟢 Joined room:", roomName, "as", userName); socket.on("chat-history", (history: any[]) => { setMessages( history.map((m) => ({ - roomId: m.roomId, user: m.user, text: m.text, - time: m.timestamp, + time: m.timestamp || m.time, })) ); }); @@ -47,72 +90,81 @@ const InRoom: React.FC<{ roomId: string; userName: string }> = ({ roomId, userNa socket.on("chat-message", (msg: any) => { setMessages((prev) => [ ...prev, - { roomIduser: msg.user, text: msg.text, time: msg.timestamp || new Date().toISOString() }, + { + user: msg.user, + text: msg.text, + time: msg.time || msg.timestamp || new Date().toISOString(), + }, ]); }); - socket.on("user-joined", ({ userName ,roomId}) => { + socket.on("user-joined", ({ userName: joinedUser, roomId }: { userName: string; roomId?: string }) => { setMessages((prev) => [ ...prev, - { user: "System", text: `${userName} joined the room ${roomId}` }, + { user: "System", text: `${joinedUser} joined the room` }, ]); + setParticipantCount((prev) => prev + 1); }); - socket.on("user-left", ({ userName }) => { + socket.on("user-left", ({ userName: leftUser }: { userName: string }) => { setMessages((prev) => [ ...prev, - { user: "System", text: `${userName} left the room` }, + { user: "System", text: `${leftUser} left the room` }, ]); + setParticipantCount((prev) => Math.max(1, prev - 1)); + }); + + socket.on("update-members", (members: any[]) => { + setParticipantCount(members.length || 1); }); return () => { - socket.emit("leave-room", { roomId, userName }); + socket.emit("leave-room", { + roomId: roomName, + userId: localStorage.getItem("token") || "", + userName, + }); socket.disconnect(); }; - }, [roomId, userName]); + }, [roomName, userName]); + // Initialize media stream useEffect(() => { const initMedia = async () => { try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); mediaStreamRef.current = stream; - if (localVideoRef.current) localVideoRef.current.srcObject = stream; - + if (localVideoRef.current) { + localVideoRef.current.srcObject = stream; + } setMicOn(stream.getAudioTracks().some((t) => t.enabled)); setVideoOn(stream.getVideoTracks().some((t) => t.enabled)); } catch (err) { - console.error("Failed to get user media:", err); + console.error("Error accessing camera/mic:", err); + alert("Please allow camera and microphone permissions."); setMicOn(false); setVideoOn(false); } }; + initMedia(); return () => { - mediaStreamRef.current?.getTracks().forEach((t) => t.stop()); + mediaStreamRef.current?.getTracks().forEach((track) => track.stop()); }; }, []); - const sendMessage = (e: React.FormEvent) => { - e.preventDefault(); - if (!chatInput.trim() || !socketRef.current) return; - - const message = { - roomId, - user: userName, - text: chatInput.trim(), - timestamp: new Date().toISOString(), - }; - - socketRef.current.emit("chat-message", message); - setMessages((s) => [...s, message]); - setChatInput(""); - }; - + // Auto-scroll chat to bottom useEffect(() => { - chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); + if (showChat) { + chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } }, [messages, showChat]); + // HotKeys handlers const handlers = { TOGGLE_MIC: (e: KeyboardEvent) => { e.preventDefault(); @@ -148,23 +200,61 @@ const InRoom: React.FC<{ roomId: string; userName: string }> = ({ roomId, userNa setVideoOn(enabled); }; + const handleEndCall = () => { + mediaStreamRef.current?.getTracks().forEach((track) => track.stop()); + socketRef.current?.disconnect(); + navigate("/"); + }; + + const sendMessage = (e: React.FormEvent) => { + e.preventDefault(); + if (!chatInput.trim() || !socketRef.current || !roomName) return; + + const message = { + roomId: roomName, + user: userName, + text: chatInput.trim(), + }; + + socketRef.current.emit("chat-message", message); + setChatInput(""); + }; + + if (!roomName) { + return ( +
+

Invalid room

+
+ ); + } + return ( -
+
{/* Header */} -
-

Room: {roomId}

-
- - {userName} +
+

+ Room: {roomName} +

+
+ +
+ + {participantCount} Participant{participantCount !== 1 ? "s" : ""} +
- {/* Main Section */} + {/* Main Video Area */}
- {/* Video Area */} -
-
+ {/* Video Grid */} +
+ {/* Local Video */} +
- -
+ setChatInput(e.target.value)} placeholder="Type a message..." - className="flex-1 bg-gray-800 rounded-l-md px-3 py-2 text-sm focus:outline-none" + className="flex-1 bg-gray-800 dark:bg-gray-700 rounded-l-md px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" /> -
@@ -238,23 +363,42 @@ const InRoom: React.FC<{ roomId: string; userName: string }> = ({ roomId, userNa )}
- {/* Footer Controls */} -