├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── jsconfig.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── next.svg └── vercel.svg ├── src ├── components │ └── ui │ │ ├── CallingUi.jsx │ │ ├── ThemeProvider.jsx │ │ ├── TooltipUniversal.jsx │ │ ├── alert.jsx │ │ ├── button.jsx │ │ ├── card.jsx │ │ ├── checkbox.jsx │ │ ├── dialog.jsx │ │ ├── input-otp.jsx │ │ ├── input.jsx │ │ ├── label.jsx │ │ ├── sheet.jsx │ │ ├── toast.jsx │ │ ├── toaster.jsx │ │ └── tooltip.jsx ├── helpers.js ├── hooks │ └── use-toast.js ├── lib │ └── utils.js ├── pages │ ├── [room].jsx │ ├── _app.js │ ├── _document.js │ ├── api │ │ └── hello.js │ └── index.js └── styles │ └── globals.css └── tailwind.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | /src/architecture-doc.md 39 | src/pages/TODO.md 40 | /src/EC2 41 | /architecture.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Video Calling Application 2 | 3 | A secure, privacy-focused video calling application built with Next.js and WebRTC, featuring real-time chat and room management capabilities. with self hosted coturn on ec2. 4 | 5 | ## Features ✨ 6 | 7 | ### Room Management 8 | - Create public or password-protected rooms 9 | - Share room URLs with participants 10 | - Set maximum participants per room 11 | - Mesh architecture for peer-to-peer connections 12 | - TODO: Implement SFU for better scalability 13 | 14 | ### Video & Audio Features 15 | - Toggle audio and video streams anytime 16 | - High-quality real-time video communication 17 | - Instant notifications when participants leave 18 | - Client-side only data processing - we never access your streams 19 | 20 | ### Chat Functionality 21 | - Real-time chat alongside video calls 22 | - No message storage - complete privacy 23 | - Instant message delivery 24 | 25 | ### Privacy & Security 26 | - Client-side only data processing 27 | - No storage of participant information 28 | - No interception or access to audio/video streams 29 | - No chat message persistence 30 | - Optional password protection for rooms 31 | 32 | ### Tech stack 33 | - Next JS 34 | - shad CN 35 | - tailwind 36 | - raw web RTC API's 37 | - socket-io 38 | - flask as a simple signalling server and for socket 39 | - EC2 40 | - self hosted coturn 41 | 42 | ### UI/UX 43 | - Dark mode support using shadcn 44 | - Responsive design 45 | - Intuitive controls 46 | - Toast notifications for important events 47 | 48 | --- 49 | 50 | 51 | ## UI Preview 📸 52 | 53 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293841/i1_oqf8ms.png) 54 | 55 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293841/i2_hyd59l.png) 56 | 57 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293841/i3_gupwfx.png) 58 | 59 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293842/i4_dldzic.png) 60 | 61 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293842/i5_jbueqa.png) 62 | 63 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293843/i6_ktbmom.png) 64 | 65 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293843/i8_yybmmh.png) 66 | 67 | ![](https://res.cloudinary.com/dbm0lqxsayooletsgognnnasdnasdn/image/upload/v1741293843/i7_ewe58i.png) 68 | 69 | --- 70 | 71 | We take your privacy seriously: 72 | - No storage of personal information 73 | - No recording or interception of audio/video streams 74 | - No persistence of chat messages 75 | - All communication is peer-to-peer 76 | - No server-side processing of media streams 77 | 78 | 79 | 80 | ## TODO 81 | - the app follows a mesh architecture for connecting the calls which is decent for 3-4 people but does not scale so well and puts extreme load on the users device for <4 people in a call, so implement SFU 82 | 83 | - add screen sharing 84 | 85 | ## note 86 | coturn helps with NAT, and we are able to by pass most NATs and ISP's! even the super strict ones in India. if you find the call not working on any specific ISP or behind a very specific nat of FW feel free to drop a message 87 | 88 | # Backend repo, OSS 89 | - a simple flask app used as a signalling server to connect candidates and transmit basic info about ICE 90 | [Backend repo](https://github.com/Govind783/flask-signalling-server-vc) 91 | 92 | # Turn server config and details 93 | - learn more the turn config, hosting coturn and traversing restrective NAT's 94 | [Indepth guide](https://govindbuilds.com/blogs) 95 | 96 | Do star the Repo 🌟 97 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": false, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: false, 4 | env: { 5 | TURN_USER_NAME: process.env.TURN_USER_NAME, 6 | TURN_PASSWORD: process.env.TURN_PASSWORD, 7 | }, 8 | }; 9 | 10 | export default nextConfig; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-webrtc", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-checkbox": "^1.1.3", 13 | "@radix-ui/react-dialog": "^1.1.4", 14 | "@radix-ui/react-label": "^2.1.1", 15 | "@radix-ui/react-slot": "^1.1.1", 16 | "@radix-ui/react-toast": "^1.2.4", 17 | "@radix-ui/react-tooltip": "^1.1.6", 18 | "@upstash/redis": "^1.34.3", 19 | "axios": "^1.7.9", 20 | "class-variance-authority": "^0.7.1", 21 | "clsx": "^2.1.1", 22 | "copy-to-clipboard": "^3.3.3", 23 | "crypto-js": "^4.2.0", 24 | "input-otp": "^1.4.2", 25 | "lucide-react": "^0.471.1", 26 | "next": "14.1.0", 27 | "next-themes": "^0.4.4", 28 | "react": "^18", 29 | "react-dom": "^18", 30 | "socket.io": "^4.8.1", 31 | "socket.io-client": "^4.8.1", 32 | "tailwind-merge": "^2.6.0", 33 | "tailwindcss-animate": "^1.0.7", 34 | "twilio": "^5.4.3", 35 | "uuid": "^11.0.5" 36 | }, 37 | "devDependencies": { 38 | "autoprefixer": "^10.0.1", 39 | "eslint": "^8", 40 | "eslint-config-next": "14.1.0", 41 | "postcss": "^8", 42 | "tailwindcss": "^3.3.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Govind783/next-JS-webrtc-coturn/e3dd961f12ec801f74c7984fab04e9dd8a1357ed/public/favicon.ico -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ui/CallingUi.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect, useRef, useState } from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; 4 | 5 | import { Mic, MicOff, Video, VideoOff, PhoneOff, MessageSquare, Users } from "lucide-react"; 6 | import UniversalTooltip from "./TooltipUniversal"; 7 | import { toast } from "@/hooks/use-toast"; 8 | 9 | const VideoCallScreen = memo( 10 | ({ local_video, participantsInCall, setParticipantsInCall, nameofUser, socket, roomID }) => { 11 | const videoRefs = useRef({}); 12 | 13 | useEffect(() => { 14 | participantsInCall.forEach((participant) => { 15 | const videoElement = videoRefs.current[participant.name]; 16 | if (!videoElement) return; 17 | 18 | const streamToUse = participant.name === nameofUser ? local_video : participant.stream; 19 | 20 | if (streamToUse && videoElement.srcObject !== streamToUse) { 21 | console.log(`Setting ${participant.name === nameofUser ? "local" : "remote"} stream for ${participant.name}`); 22 | videoElement.srcObject = streamToUse; 23 | } 24 | 25 | videoElement.volume = 0.7; 26 | }); 27 | }, [participantsInCall, local_video, nameofUser]); 28 | 29 | const [isVideoEnabled, setIsVideoEnabled] = useState(true); 30 | const [isAudioEnabled, setIsAudioEnabled] = useState(true); 31 | const toggleVideo = () => { 32 | if (local_video) { 33 | const videoTrack = local_video.getVideoTracks()[0]; 34 | if (videoTrack) { 35 | videoTrack.enabled = !videoTrack.enabled; 36 | setIsVideoEnabled(videoTrack.enabled); 37 | setParticipantsInCall((prev) => 38 | prev.map((p) => (p.name === nameofUser ? { ...p, videoOn: videoTrack.enabled } : p)) 39 | ); 40 | 41 | socket.emit("mediaStateChange", { 42 | roomID, 43 | userName: nameofUser, 44 | enabled: videoTrack.enabled, 45 | mediaType: "video", 46 | }); 47 | } 48 | } 49 | }; 50 | 51 | const toggleAudio = () => { 52 | if (local_video) { 53 | const audioTrack = local_video.getAudioTracks()[0]; 54 | if (audioTrack) { 55 | audioTrack.enabled = !audioTrack.enabled; 56 | setIsAudioEnabled(audioTrack.enabled); 57 | setParticipantsInCall((prev) => 58 | prev.map((p) => (p.name === nameofUser ? { ...p, micOn: audioTrack.enabled } : p)) 59 | ); 60 | 61 | socket.emit("mediaStateChange", { 62 | roomID, 63 | userName: nameofUser, 64 | enabled: audioTrack.enabled, 65 | mediaType: "audio", 66 | }); 67 | } 68 | } 69 | }; 70 | 71 | useEffect(() => { 72 | socket.on("userDisconnected", ({ name }) => { 73 | setParticipantsInCall((prev) => { 74 | const prevState = [...prev]; 75 | console.log(prevState); 76 | 77 | return prevState.filter((i) => i.name !== name); 78 | }); 79 | toast({ 80 | title: `${name} left the call`, 81 | }); 82 | }); 83 | 84 | socket.on("mediaStateChanged", ({ userName, enabled, mediaType }) => { 85 | setParticipantsInCall((prev) => { 86 | const updated = prev.map((p) => 87 | p.name.trim().toLowerCase().replace(/ /g, "_") === userName.trim().toLowerCase().replace(/ /g, "_") 88 | ? { 89 | ...p, 90 | [mediaType === "video" ? "videoOn" : "micOn"]: Boolean( 91 | enabled === true || enabled === "true" || enabled === "True" 92 | ), 93 | } 94 | : p 95 | ); 96 | return updated; 97 | }); 98 | }); 99 | 100 | return () => { 101 | socket.off("userDisconnected"); 102 | socket.off("mediaStateChanged"); 103 | }; 104 | }, []); 105 | 106 | const handleLeaveCall = () => { 107 | socket?.disconnect(); 108 | window.location.href = "/"; 109 | }; 110 | 111 | return ( 112 |
113 |
114 |
115 |
116 | {participantsInCall.map((participant, index) => ( 117 |
118 |
119 |
135 |
136 | {participant.name} {participant.name === nameofUser ? "(You)" : ""} 137 |
138 |
139 | ))} 140 |
141 |
142 | 143 |
144 | 147 | 148 | 149 | } 150 | content={"Leave call"} 151 | /> 152 | 153 |
157 | :
162 |
166 | : } 168 | content={`Turn Audio ${!isAudioEnabled ? "on" : "off"}`} 169 | /> 170 |
171 | 172 | 173 |
174 |
175 |
176 | ); 177 | } 178 | ); 179 | VideoCallScreen.displayName = "VideoCallScreen"; 180 | 181 | export default VideoCallScreen; 182 | 183 | const ChatComponentForVC = memo(({ nameofUser, socket, roomID }) => { 184 | const [messages, setMessages] = useState([]); 185 | const [newMessage, setNewMessage] = useState(""); 186 | const messagesEndRef = useRef(null); 187 | 188 | const sendMessage = () => { 189 | if (newMessage.trim()) { 190 | const newMSG_Object = { user: nameofUser, message: newMessage }; 191 | socket.emit("newMessageFromSender", { newMSG_Object, roomID }); 192 | setMessages([...messages, newMSG_Object]); 193 | setNewMessage(""); 194 | } 195 | }; 196 | 197 | useEffect(() => { 198 | socket.on("newMessageFromReciever", (newMessage) => { 199 | setMessages((prev) => [...prev, newMessage]); 200 | }); 201 | 202 | return () => { 203 | socket.off("newMessageFromReciever"); 204 | }; 205 | }, []); 206 | 207 | useEffect(() => { 208 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 209 | }, [messages.length]); 210 | 211 | return ( 212 | 213 | 214 | 217 | 218 | 219 | } 220 | content="Send Message" 221 | /> 222 | 223 | 224 |
225 | 226 | Chat 227 | 228 |
229 | {messages.map((msg, index) => ( 230 |
231 |
236 |

{msg.message}

237 | {/*
238 | - {msg.user} 239 |
*/} 240 |
241 |
242 | ))} 243 |
244 |
245 |
246 |
247 | setNewMessage(e.target.value)} 252 | onKeyDown={(e) => e.key === "Enter" && sendMessage()} 253 | className="!border-gray-500 bg-black text-gray-100 flex md:h-10 h-9 w-full md:placeholder:text-sm placeholder:text-xs rounded-md border px-3 py-2 lg:text-base text-sm" 254 | /> 255 | {/* */} 261 |
262 |
263 |
264 | 265 | 266 | ); 267 | }); 268 | 269 | ChatComponentForVC.displayName = "chat-comp"; 270 | -------------------------------------------------------------------------------- /src/components/ui/ThemeProvider.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ThemeProvider as NextThemesProvider } from "next-themes" 3 | 4 | export function ThemeProvider({ 5 | children, 6 | ...props 7 | }) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ui/TooltipUniversal.jsx: -------------------------------------------------------------------------------- 1 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; 2 | 3 | import React, { memo } from "react"; 4 | 5 | const UniversalTooltip = memo(({ trigger, content }) => { 6 | return ( 7 | 8 | 9 | {trigger} 10 | {content} 11 | 12 | 13 | ); 14 | }); 15 | UniversalTooltip.displayName = "tooltip-component"; 16 | 17 | export default UniversalTooltip; 18 | -------------------------------------------------------------------------------- /src/components/ui/alert.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border border-neutral-200 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-neutral-950 dark:border-neutral-800 dark:[&>svg]:text-neutral-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50", 12 | destructive: 13 | "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef(({ className, variant, ...props }, ref) => ( 23 |
28 | )) 29 | Alert.displayName = "Alert" 30 | 31 | const AlertTitle = React.forwardRef(({ className, ...props }, ref) => ( 32 |
36 | )) 37 | AlertTitle.displayName = "AlertTitle" 38 | 39 | const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( 40 |
44 | )) 45 | AlertDescription.displayName = "AlertDescription" 46 | 47 | export { Alert, AlertTitle, AlertDescription } 48 | -------------------------------------------------------------------------------- /src/components/ui/button.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90", 13 | destructive: 14 | "bg-red-500 text-neutral-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90", 15 | outline: 16 | "border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-600 dark:bg-neutral-950 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-50", 17 | secondary: 18 | "bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80", 19 | ghost: "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", 20 | link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { 37 | const Comp = asChild ? Slot : "button" 38 | return ( 39 | () 43 | ); 44 | }) 45 | Button.displayName = "Button" 46 | 47 | export { Button, buttonVariants } 48 | -------------------------------------------------------------------------------- /src/components/ui/card.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef(({ className, ...props }, ref) => ( 6 |
13 | )) 14 | Card.displayName = "Card" 15 | 16 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( 17 |
21 | )) 22 | CardHeader.displayName = "CardHeader" 23 | 24 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( 25 |
29 | )) 30 | CardTitle.displayName = "CardTitle" 31 | 32 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( 33 |
37 | )) 38 | CardDescription.displayName = "CardDescription" 39 | 40 | const CardContent = React.forwardRef(({ className, ...props }, ref) => ( 41 |
42 | )) 43 | CardContent.displayName = "CardContent" 44 | 45 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( 46 |
50 | )) 51 | CardFooter.displayName = "CardFooter" 52 | 53 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 54 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { Check } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef(({ className, ...props }, ref) => ( 8 | 15 | 16 | 17 | 18 | 19 | )) 20 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 21 | 22 | export { Checkbox } 23 | -------------------------------------------------------------------------------- /src/components/ui/dialog.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { X } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( 16 | 23 | )) 24 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 25 | 26 | const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( 27 | 28 | 29 | 36 | {children} 37 | 39 | 40 | Close 41 | 42 | 43 | 44 | )) 45 | DialogContent.displayName = DialogPrimitive.Content.displayName 46 | 47 | const DialogHeader = ({ 48 | className, 49 | ...props 50 | }) => ( 51 |
54 | ) 55 | DialogHeader.displayName = "DialogHeader" 56 | 57 | const DialogFooter = ({ 58 | className, 59 | ...props 60 | }) => ( 61 |
64 | ) 65 | DialogFooter.displayName = "DialogFooter" 66 | 67 | const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( 68 | 72 | )) 73 | DialogTitle.displayName = DialogPrimitive.Title.displayName 74 | 75 | const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( 76 | 80 | )) 81 | DialogDescription.displayName = DialogPrimitive.Description.displayName 82 | 83 | export { 84 | Dialog, 85 | DialogPortal, 86 | DialogOverlay, 87 | DialogClose, 88 | DialogTrigger, 89 | DialogContent, 90 | DialogHeader, 91 | DialogFooter, 92 | DialogTitle, 93 | DialogDescription, 94 | } 95 | -------------------------------------------------------------------------------- /src/components/ui/input-otp.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { OTPInput, OTPInputContext } from "input-otp" 3 | import { Dot } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => ( 8 | 13 | )) 14 | InputOTP.displayName = "InputOTP" 15 | 16 | const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => ( 17 |
18 | )) 19 | InputOTPGroup.displayName = "InputOTPGroup" 20 | 21 | const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => { 22 | const inputOTPContext = React.useContext(OTPInputContext) 23 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] 24 | 25 | return ( 26 | (
34 | {char} 35 | {hasFakeCaret && ( 36 |
38 |
40 |
41 | )} 42 |
) 43 | ); 44 | }) 45 | InputOTPSlot.displayName = "InputOTPSlot" 46 | 47 | const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => ( 48 |
49 | 50 |
51 | )) 52 | InputOTPSeparator.displayName = "InputOTPSeparator" 53 | 54 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } 55 | -------------------------------------------------------------------------------- /src/components/ui/input.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef(({ className, type, ...props }, ref) => { 6 | return ( 7 | () 15 | ); 16 | }) 17 | Input.displayName = "Input" 18 | 19 | export { Input } -------------------------------------------------------------------------------- /src/components/ui/label.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef(({ className, ...props }, ref) => ( 12 | 13 | )) 14 | Label.displayName = LabelPrimitive.Root.displayName 15 | 16 | export { Label } 17 | -------------------------------------------------------------------------------- /src/components/ui/sheet.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SheetPrimitive from "@radix-ui/react-dialog" 3 | import { cva } from "class-variance-authority"; 4 | import { X } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Sheet = SheetPrimitive.Root 9 | 10 | const SheetTrigger = SheetPrimitive.Trigger 11 | 12 | const SheetClose = SheetPrimitive.Close 13 | 14 | const SheetPortal = SheetPrimitive.Portal 15 | 16 | const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => ( 17 | 24 | )) 25 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 26 | 27 | const sheetVariants = cva( 28 | "fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-neutral-950", 29 | { 30 | variants: { 31 | side: { 32 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 33 | bottom: 34 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 35 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 36 | right: 37 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 38 | }, 39 | }, 40 | defaultVariants: { 41 | side: "right", 42 | }, 43 | } 44 | ) 45 | 46 | const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => ( 47 | 48 | 49 | 50 | {children} 51 | 53 | 54 | Close 55 | 56 | 57 | 58 | )) 59 | SheetContent.displayName = SheetPrimitive.Content.displayName 60 | 61 | const SheetHeader = ({ 62 | className, 63 | ...props 64 | }) => ( 65 |
68 | ) 69 | SheetHeader.displayName = "SheetHeader" 70 | 71 | const SheetFooter = ({ 72 | className, 73 | ...props 74 | }) => ( 75 |
78 | ) 79 | SheetFooter.displayName = "SheetFooter" 80 | 81 | const SheetTitle = React.forwardRef(({ className, ...props }, ref) => ( 82 | 86 | )) 87 | SheetTitle.displayName = SheetPrimitive.Title.displayName 88 | 89 | const SheetDescription = React.forwardRef(({ className, ...props }, ref) => ( 90 | 94 | )) 95 | SheetDescription.displayName = SheetPrimitive.Description.displayName 96 | 97 | export { 98 | Sheet, 99 | SheetPortal, 100 | SheetOverlay, 101 | SheetTrigger, 102 | SheetClose, 103 | SheetContent, 104 | SheetHeader, 105 | SheetFooter, 106 | SheetTitle, 107 | SheetDescription, 108 | } 109 | -------------------------------------------------------------------------------- /src/components/ui/toast.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ToastPrimitives from "@radix-ui/react-toast" 3 | import { cva } from "class-variance-authority"; 4 | import { X } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ToastProvider = ToastPrimitives.Provider 9 | 10 | const ToastViewport = React.forwardRef(({ className, ...props }, ref) => ( 11 | 18 | )) 19 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 20 | 21 | const toastVariants = cva( 22 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-neutral-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-gray-500", 23 | { 24 | variants: { 25 | variant: { 26 | default: "border bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50", 27 | destructive: 28 | "destructive group border-red-500 bg-red-500 text-neutral-50 dark:border-red-900 dark:bg-red-900 dark:text-neutral-50", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | }, 34 | } 35 | ) 36 | 37 | const Toast = React.forwardRef(({ className, variant, ...props }, ref) => { 38 | return ( 39 | () 43 | ); 44 | }) 45 | Toast.displayName = ToastPrimitives.Root.displayName 46 | 47 | const ToastAction = React.forwardRef(({ className, ...props }, ref) => ( 48 | 55 | )) 56 | ToastAction.displayName = ToastPrimitives.Action.displayName 57 | 58 | const ToastClose = React.forwardRef(({ className, ...props }, ref) => ( 59 | 67 | 68 | 69 | )) 70 | ToastClose.displayName = ToastPrimitives.Close.displayName 71 | 72 | const ToastTitle = React.forwardRef(({ className, ...props }, ref) => ( 73 | 74 | )) 75 | ToastTitle.displayName = ToastPrimitives.Title.displayName 76 | 77 | const ToastDescription = React.forwardRef(({ className, ...props }, ref) => ( 78 | 79 | )) 80 | ToastDescription.displayName = ToastPrimitives.Description.displayName 81 | 82 | export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction }; 83 | -------------------------------------------------------------------------------- /src/components/ui/toaster.jsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/hooks/use-toast" 2 | import { 3 | Toast, 4 | ToastClose, 5 | ToastDescription, 6 | ToastProvider, 7 | ToastTitle, 8 | ToastViewport, 9 | } from "@/components/ui/toast" 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast() 13 | 14 | return ( 15 | ( 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | ( 19 |
20 | {title && {title}} 21 | {description && ( 22 | {description} 23 | )} 24 |
25 | {action} 26 | 27 |
) 28 | ); 29 | })} 30 | 31 |
) 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( 15 | 23 | )) 24 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 25 | 26 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 27 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | export async function dekryptTurnUserAndPass(encryptedValue, decryptionKeyForCreds) { 2 | try { 3 | const keyBuffer = new TextEncoder().encode(decryptionKeyForCreds); 4 | const keyHashBuffer = await crypto.subtle.digest("SHA-256", keyBuffer); 5 | const keyHash = new Uint8Array(keyHashBuffer); 6 | 7 | const encryptedBytes = base64ToBytes(encryptedValue); 8 | 9 | const decryptedBytes = new Uint8Array(encryptedBytes.length); 10 | for (let i = 0; i < encryptedBytes.length; i++) { 11 | decryptedBytes[i] = encryptedBytes[i] ^ keyHash[i % keyHash.length]; 12 | } 13 | return new TextDecoder().decode(decryptedBytes); 14 | } catch (error) { 15 | console.error("Decryption failed:", error); 16 | return null; 17 | } 18 | } 19 | 20 | function base64ToBytes(base64) { 21 | const binary = atob(base64); 22 | const bytes = new Uint8Array(binary.length); 23 | for (let i = 0; i < binary.length; i++) { 24 | bytes[i] = binary.charCodeAt(i); 25 | } 26 | return bytes; 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/use-toast.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | // Inspired by react-hot-toast library 3 | import * as React from "react" 4 | 5 | const TOAST_LIMIT = 1 6 | const TOAST_REMOVE_DELAY = 1000000 7 | 8 | const actionTypes = { 9 | ADD_TOAST: "ADD_TOAST", 10 | UPDATE_TOAST: "UPDATE_TOAST", 11 | DISMISS_TOAST: "DISMISS_TOAST", 12 | REMOVE_TOAST: "REMOVE_TOAST" 13 | } 14 | 15 | let count = 0 16 | 17 | function genId() { 18 | count = (count + 1) % Number.MAX_SAFE_INTEGER 19 | return count.toString(); 20 | } 21 | 22 | const toastTimeouts = new Map() 23 | 24 | const addToRemoveQueue = (toastId) => { 25 | if (toastTimeouts.has(toastId)) { 26 | return 27 | } 28 | 29 | const timeout = setTimeout(() => { 30 | toastTimeouts.delete(toastId) 31 | dispatch({ 32 | type: "REMOVE_TOAST", 33 | toastId: toastId, 34 | }) 35 | }, TOAST_REMOVE_DELAY) 36 | 37 | toastTimeouts.set(toastId, timeout) 38 | } 39 | 40 | export const reducer = (state, action) => { 41 | switch (action.type) { 42 | case "ADD_TOAST": 43 | return { 44 | ...state, 45 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 46 | }; 47 | 48 | case "UPDATE_TOAST": 49 | return { 50 | ...state, 51 | toasts: state.toasts.map((t) => 52 | t.id === action.toast.id ? { ...t, ...action.toast } : t), 53 | }; 54 | 55 | case "DISMISS_TOAST": { 56 | const { toastId } = action 57 | 58 | // ! Side effects ! - This could be extracted into a dismissToast() action, 59 | // but I'll keep it here for simplicity 60 | if (toastId) { 61 | addToRemoveQueue(toastId) 62 | } else { 63 | state.toasts.forEach((toast) => { 64 | addToRemoveQueue(toast.id) 65 | }) 66 | } 67 | 68 | return { 69 | ...state, 70 | toasts: state.toasts.map((t) => 71 | t.id === toastId || toastId === undefined 72 | ? { 73 | ...t, 74 | open: false, 75 | } 76 | : t), 77 | }; 78 | } 79 | case "REMOVE_TOAST": 80 | if (action.toastId === undefined) { 81 | return { 82 | ...state, 83 | toasts: [], 84 | } 85 | } 86 | return { 87 | ...state, 88 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 89 | }; 90 | } 91 | } 92 | 93 | const listeners = [] 94 | 95 | let memoryState = { toasts: [] } 96 | 97 | function dispatch(action) { 98 | memoryState = reducer(memoryState, action) 99 | listeners.forEach((listener) => { 100 | listener(memoryState) 101 | }) 102 | } 103 | 104 | function toast({ 105 | ...props 106 | }) { 107 | const id = genId() 108 | 109 | const update = (props) => 110 | dispatch({ 111 | type: "UPDATE_TOAST", 112 | toast: { ...props, id }, 113 | }) 114 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 115 | 116 | dispatch({ 117 | type: "ADD_TOAST", 118 | toast: { 119 | ...props, 120 | id, 121 | open: true, 122 | onOpenChange: (open) => { 123 | if (!open) dismiss() 124 | }, 125 | }, 126 | }) 127 | 128 | return { 129 | id: id, 130 | dismiss, 131 | update, 132 | } 133 | } 134 | 135 | function useToast() { 136 | const [state, setState] = React.useState(memoryState) 137 | 138 | React.useEffect(() => { 139 | listeners.push(setState) 140 | return () => { 141 | const index = listeners.indexOf(setState) 142 | if (index > -1) { 143 | listeners.splice(index, 1) 144 | } 145 | }; 146 | }, [state]) 147 | 148 | return { 149 | ...state, 150 | toast, 151 | dismiss: (toastId) => dispatch({ type: "DISMISS_TOAST", toastId }), 152 | }; 153 | } 154 | 155 | export { useToast, toast } 156 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/[room].jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useRef, memo, useEffect } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Input } from "@/components/ui/input"; 6 | import { toast } from "@/hooks/use-toast"; 7 | import { Alert, AlertTitle } from "@/components/ui/alert"; 8 | import { Camera, Loader2, Lock, RefreshCw, Settings } from "lucide-react"; 9 | import CryptoJS from "crypto-js"; 10 | import { useRouter } from "next/router"; 11 | import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; 12 | import { 13 | Dialog, 14 | DialogContent, 15 | DialogDescription, 16 | DialogHeader, 17 | DialogTitle, 18 | DialogTrigger, 19 | } from "@/components/ui/dialog"; 20 | import { io } from "socket.io-client"; 21 | import VideoCallScreen from "@/components/ui/CallingUi"; 22 | import axios from "axios"; 23 | import { dekryptTurnUserAndPass } from "@/helpers"; 24 | 25 | const SIGNING_KEY = "8ieFzC7WvOzu1MW"; 26 | const backendDomain = "https://api-vc2.govindbuilds.com"; 27 | const decryptToken = (encryptedToken) => { 28 | try { 29 | const bytes = CryptoJS.AES.decrypt(decodeURIComponent(encryptedToken), SIGNING_KEY); 30 | const decryptedData = bytes.toString(CryptoJS.enc.Utf8); 31 | 32 | return decryptedData; 33 | } catch (error) { 34 | console.error("Invalid token or decryption failed:", error); 35 | return null; 36 | } 37 | }; 38 | 39 | const peerConfiguration = { 40 | iceServers: [ 41 | { urls: "stun:stun.l.google.com:19302" }, 42 | { urls: "stun:stun1.l.google.com:19302" }, 43 | { urls: "stun:stun2.l.google.com:19302" }, 44 | { urls: "stun:stun3.l.google.com:19302" }, 45 | { urls: "stun:stun4.l.google.com:19302" }, 46 | { 47 | urls: "stun:api-vc2.govindbuilds.com:3478", 48 | }, 49 | { 50 | urls: "turn:api-vc2.govindbuilds.com:3478?transport=tcp", 51 | username: null, 52 | credential: null, 53 | }, 54 | { 55 | urls: "turns:api-vc2.govindbuilds.com:5349", 56 | username: null, 57 | credential: null, 58 | }, 59 | ], 60 | }; 61 | 62 | export default function IndividualMeetingRoom() { 63 | const router = useRouter(); 64 | const [stream, setStream] = useState(null); 65 | const [permissionDenied, setPermissionDenied] = useState(false); 66 | const [userName, setUserName] = useState(""); 67 | const [protectionStatus, setProtectionStatus] = useState({ 68 | hasPassword: false, // false or the OTP 69 | }); 70 | const [inputOTP, setInputOTP] = useState(""); 71 | const [roomID, setRoomID] = useState(); 72 | const [isInCall_OR_ON_PreCallUI, setIsInCall_OR_ON_PreCallUI] = useState(false); 73 | const [dekryptionFailed, setDekrypttionFailed] = useState(false); 74 | const [loadingForJoiningCall, setLoadingForJoiningCall] = useState(false); 75 | const [participantsInCall, setParticipantsInCall] = useState([]); 76 | const socketRef = useRef(); 77 | const dekryptionKeyForCreds = useRef(null); 78 | const [secretMessage, setSecretMessage] = useState( 79 | "37zaSMVwMJgCunp0CZKnL19yTAUfPooUKw9DM8pYVFuPn5wZ12wzlxiwLnsK+d4HWg==" 80 | ); 81 | const remoteVideoRef = useRef(); 82 | const local_videoRef = useRef(null); 83 | const localUserNameRef = useRef(null); 84 | 85 | const peerConnectionRef = useRef(); 86 | const peerConnectionsRef = useRef({}); 87 | const pendingIceCandidates = useRef({}); 88 | 89 | const requestMediaPermissions = async () => { 90 | try { 91 | const mediaStream = await navigator.mediaDevices.getUserMedia({ 92 | video: true, 93 | audio: { 94 | echoCancellation: true, 95 | noiseSuppression: true, 96 | autoGainControl: true, 97 | }, 98 | }); 99 | setStream(mediaStream); 100 | 101 | if (local_videoRef.current) { 102 | local_videoRef.current.srcObject = mediaStream; 103 | } 104 | setPermissionDenied(false); 105 | } catch (error) { 106 | console.error("Error accessing media devices:", error); 107 | setPermissionDenied(true); 108 | } 109 | }; 110 | 111 | useEffect(() => { 112 | if (socketRef.current) { 113 | socketRef.current.on("getDekryptionKeyAndMessage", ({ message, credsKey }) => { 114 | dekryptionKeyForCreds.current = credsKey; 115 | }); 116 | socketRef.current.on("receiveOffer", async ({ offer, senderName, remoteUsersName }) => { 117 | await handleIncomingOffer({ offer, senderName, remoteUsersName }); 118 | }); 119 | 120 | socketRef.current.on("receiveAnswer", async ({ answer, senderName }) => { 121 | // 'senderName' is the remote user who created this answer 122 | const pc = peerConnectionsRef.current[senderName]; 123 | if (!pc) return; 124 | 125 | await pc.setRemoteDescription(new RTCSessionDescription(answer)); 126 | await processPendingCandidates(senderName); 127 | setIsInCall_OR_ON_PreCallUI(true); 128 | }); 129 | 130 | socketRef.current.on("receiveIceCandidate", async ({ candidate, senderName }) => { 131 | const pc = peerConnectionsRef.current[senderName]; 132 | if (!pc) { 133 | // if the peer connection doesn't exist yet, store the candidate for later 134 | if (!pendingIceCandidates.current[senderName]) { 135 | pendingIceCandidates.current[senderName] = []; 136 | } 137 | pendingIceCandidates.current[senderName].push(candidate); 138 | } else { 139 | await pc.addIceCandidate(new RTCIceCandidate(candidate)); 140 | } 141 | }); 142 | } 143 | }, [socketRef.current]); 144 | 145 | useEffect(() => { 146 | if (router.query.room) setRoomID(router.query.room); 147 | }, [router.query]); 148 | 149 | useEffect(() => { 150 | if (roomID) { 151 | socketRef.current = io(backendDomain); 152 | 153 | return () => { 154 | socketRef.current?.disconnect(); 155 | if (stream) { 156 | stream.getTracks().forEach((track) => { 157 | track.stop(); 158 | }); 159 | } 160 | }; 161 | } 162 | }, [roomID]); 163 | 164 | const requestTurnCreds = async () => { 165 | try { 166 | const turnCreds = await axios.post(`${backendDomain}/get-creds`, { 167 | domain: window.location.hostname, 168 | message: secretMessage, 169 | }); 170 | return turnCreds.data; 171 | } catch (e) { 172 | toast({ 173 | title: "Failed to load valid credentials", 174 | }); 175 | setLoadingForJoiningCall(false); 176 | return null; 177 | } 178 | }; 179 | 180 | const pingBackendToStartCallProcessAndTransferCreds = () => { 181 | return new Promise((resolve) => { 182 | socketRef.current.emit("request_call_credentials"); 183 | resolve({ success: true }); 184 | }); 185 | }; 186 | 187 | const handleJoin = async () => { 188 | if (!userName.trim()) { 189 | toast({ 190 | title: "Please enter your name to join", 191 | }); 192 | return; 193 | } 194 | 195 | if (protectionStatus.hasPassword) { 196 | if (protectionStatus.hasPassword !== inputOTP) { 197 | toast({ 198 | title: "Wrong password, please try again", 199 | }); 200 | return; 201 | } 202 | } 203 | 204 | if (!stream) { 205 | toast({ 206 | title: "Please grant mic and camera access to join the call", 207 | }); 208 | try { 209 | await requestMediaPermissions(); 210 | } catch (e) { 211 | toast({ 212 | title: "Permission for mic and vidoe not given", 213 | }); 214 | return; 215 | } 216 | } 217 | setLoadingForJoiningCall(true); 218 | 219 | const gotCredsKeyAndMessage = await pingBackendToStartCallProcessAndTransferCreds(); 220 | 221 | if (!gotCredsKeyAndMessage?.success) { 222 | setLoadingForJoiningCall(false); 223 | return toast({ 224 | title: "Failed to communicate with turn server", 225 | }); 226 | } 227 | const turnServerCreds = await requestTurnCreds(); 228 | 229 | if (!(turnServerCreds.userName && turnServerCreds.password)) { 230 | return toast({ 231 | title: "Failed to communicate with turn server", 232 | }); 233 | } 234 | 235 | try { 236 | const dekryptedUserName = await dekryptTurnUserAndPass(turnServerCreds.userName, dekryptionKeyForCreds.current); 237 | const dekryptedPassword = await dekryptTurnUserAndPass(turnServerCreds.password, dekryptionKeyForCreds.current); 238 | if (!(dekryptedUserName || dekryptedPassword)) { 239 | return toast({ 240 | title: "Failed to communicate with turn server", 241 | }); 242 | } 243 | 244 | peerConfiguration.iceServers.forEach((server) => { 245 | if (server.username === null) server.username = dekryptedUserName; 246 | if (server.credential === null) server.credential = dekryptedPassword; 247 | }); 248 | } catch (e) { 249 | toast({ 250 | title: "Failed to dekrypt credientials", 251 | }); 252 | setLoadingForJoiningCall(false); 253 | return; 254 | } 255 | const trimmedUserName = userName.trim().toLowerCase().replace(/ /g, "_"); 256 | 257 | localUserNameRef.current = trimmedUserName; 258 | socketRef.current.emit( 259 | "basicInfoOFClientOnConnect", 260 | { 261 | roomID, 262 | name: trimmedUserName, 263 | }, 264 | async (serverACK) => { 265 | if (serverACK.sameName) { 266 | toast({ 267 | title: `A person named '${serverACK.existingName}' is already in the room. Please join with a different name.`, 268 | }); 269 | setLoadingForJoiningCall(false); 270 | return; 271 | } 272 | if (serverACK.isFirstInTheCall && !serverACK.roomFull) { 273 | setParticipantsInCall((prev) => [...prev, { name: trimmedUserName, videoOn: true, micOn: true, stream }]); 274 | setIsInCall_OR_ON_PreCallUI(true); 275 | } else if (!serverACK.isFirstInTheCall && !serverACK.roomFull) { 276 | setParticipantsInCall((prev) => [...prev, { name: trimmedUserName, videoOn: true, micOn: true, stream }]); 277 | if (serverACK.existingUsers && serverACK.existingUsers.length > 0) { 278 | for (const remoteUser of serverACK.existingUsers) { 279 | await createAndSendOffer(remoteUser); 280 | } 281 | } 282 | } else if (serverACK.roomFull) { 283 | toast({ 284 | title: "This room is already full", 285 | }); 286 | setLoadingForJoiningCall(false); 287 | return; 288 | } 289 | } 290 | ); 291 | }; 292 | 293 | const createAndSendOffer = async (targetName) => { 294 | if (!peerConnectionsRef.current[targetName]) { 295 | peerConnectionsRef.current[targetName] = createPeerConnection(targetName); 296 | } 297 | const pc = peerConnectionsRef.current[targetName]; 298 | 299 | try { 300 | const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }); 301 | await pc.setLocalDescription(offer); 302 | 303 | // one use at a time 304 | socketRef.current.emit("sendOffer", { 305 | offer, 306 | roomID, 307 | senderName: userName.trim().toLowerCase().replace(/ /g, "_"), 308 | targetName, 309 | }); 310 | } catch (err) { 311 | // console.error("Error in createAndSendOffer:", err); 312 | toast({ 313 | title: "Error in creating and sending offer", 314 | }); 315 | } 316 | }; 317 | 318 | useEffect(() => { 319 | if (router.isReady) { 320 | if (router.query && router.query?.token) { 321 | const enkryptedToken = router.query?.token; 322 | if (enkryptedToken) { 323 | const dekryptedToken = decryptToken(enkryptedToken); 324 | // possibe value has_password--1256 ( 1256 being the OTP ) || does_not_have_password 325 | if (dekryptedToken) { 326 | if (dekryptedToken.startsWith("has_password")) { 327 | const OTP = dekryptedToken.split("--")[1]; 328 | setProtectionStatus({ 329 | hasPassword: OTP || false, 330 | }); 331 | } 332 | } else { 333 | setDekrypttionFailed(true); 334 | } 335 | } 336 | } else { 337 | setDekrypttionFailed(true); 338 | // toast({ 339 | // title: "Token not found" 340 | // }) 341 | return; 342 | } 343 | } 344 | }, [router.query]); 345 | 346 | const createPeerConnection = (remoteUserName) => { 347 | const pc = new RTCPeerConnection(peerConfiguration); 348 | 349 | if (stream) { 350 | stream.getTracks().forEach((track) => { 351 | pc.addTrack(track, stream); 352 | }); 353 | } 354 | 355 | pc.ontrack = (event) => { 356 | const [incomingStream] = event.streams; 357 | 358 | setParticipantsInCall((prevList) => { 359 | const existing = prevList.find((p) => p.name === remoteUserName); 360 | if (!existing) { 361 | return [ 362 | ...prevList, 363 | { 364 | name: remoteUserName, 365 | videoOn: true, 366 | micOn: true, 367 | stream: incomingStream, 368 | }, 369 | ]; 370 | } 371 | // If they're already in, update the stream 372 | return prevList.map((p) => (p.name === remoteUserName ? { ...p, stream: incomingStream } : p)); 373 | }); 374 | }; 375 | 376 | pc.onicecandidate = (event) => { 377 | if (event.candidate) { 378 | // const candidate = event.candidate; 379 | // console.log(`ICE candidate (${remoteUserName}):`, { 380 | // type: candidate.type, 381 | // protocol: candidate.protocol, 382 | // address: candidate.address, 383 | // port: candidate.port, 384 | // relatedAddress: candidate.relatedAddress, 385 | // relatedPort: candidate.relatedPort, 386 | // }); 387 | 388 | socketRef.current?.emit("sendIceCandidateToSignalingServer", { 389 | iceCandidate: event.candidate, 390 | roomID, 391 | senderName: localUserNameRef.current, 392 | targetName: remoteUserName, 393 | }); 394 | } else { 395 | console.log( 396 | " this has to be NULLL the last packet after successful or failed ice transfer ⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️", 397 | event.candidate 398 | ); 399 | } 400 | }; 401 | 402 | // pc.onicegatheringstatechange = () => { 403 | // console.log("ICE gathering state:", pc.iceGatheringState); 404 | // }; 405 | 406 | pc.onconnectionstatechange = () => { 407 | console.log("Connection state:", pc.connectionState); 408 | // if (pc.connectionState === 'failed') { 409 | // console.log('Connection failed details:', { 410 | // iceState: pc.iceConnectionState, 411 | // signalingState: pc.signalingState, 412 | // connectionState: pc.connectionState 413 | // }); 414 | // } 415 | }; 416 | 417 | pc.oniceconnectionstatechange = () => { 418 | console.log("ICE connection state:", pc.iceConnectionState); 419 | }; 420 | 421 | // pc.addEventListener("selectedcandidatepairchange", (event) => { 422 | // const currentPair = pc.getSelectedCandidatePair(); 423 | // console.log("Selected candidate pair:", currentPair); 424 | // }); 425 | return pc; 426 | }; 427 | 428 | const handleIncomingOffer = async ({ offer, senderName, remoteUsersName }) => { 429 | if (!stream) { 430 | await requestMediaPermissions(); 431 | } 432 | 433 | if (!peerConnectionsRef.current[senderName]) { 434 | peerConnectionsRef.current[senderName] = createPeerConnection(senderName); 435 | } 436 | const pc = peerConnectionsRef.current[senderName]; 437 | 438 | try { 439 | await pc.setRemoteDescription(new RTCSessionDescription(offer)); 440 | await processPendingCandidates(senderName); 441 | 442 | // create ANSWER 443 | const answer = await pc.createAnswer({ 444 | offerToReceiveAudio: true, 445 | offerToReceiveVideo: true, 446 | }); 447 | await pc.setLocalDescription(answer); 448 | 449 | socketRef.current?.emit("sendAnswer", { 450 | answer, 451 | roomID, 452 | senderName: remoteUsersName, // your local name 453 | receiverName: senderName, // the original offerer/rmote dude 454 | }); 455 | 456 | setIsInCall_OR_ON_PreCallUI(true); 457 | } catch (err) { 458 | console.error("Error in handleIncomingOffer:", err); 459 | toast({ 460 | title: "failed to get WEB-RTC offer", 461 | }); 462 | return; 463 | } 464 | }; 465 | 466 | const processPendingCandidates = async (senderName) => { 467 | if (pendingIceCandidates.current[senderName]) { 468 | for (const candidate of pendingIceCandidates.current[senderName]) { 469 | await peerConnectionsRef.current[senderName].addIceCandidate(new RTCIceCandidate(candidate)); 470 | } 471 | pendingIceCandidates.current[senderName] = []; 472 | } 473 | }; 474 | 475 | return ( 476 |
477 | {isInCall_OR_ON_PreCallUI ? ( 478 | 486 | ) : ( 487 |
488 |
489 | {/* Left Side */} 490 |
491 | {!stream && !permissionDenied && ( 492 |
493 |

Grant mic and camera access

494 |

495 | We do not access your audio or video streams all data stays on your device. 496 |

497 | 500 |
501 | )} 502 | 503 | {permissionDenied && } 504 | 505 | { 506 |
518 | 519 | {stream && ( 520 |
521 |

Ready to join

522 | { 526 | setUserName(e.target.value); 527 | }} 528 | className="!border-gray-700 text-white border" 529 | /> 530 | {protectionStatus.hasPassword && ( 531 |
532 | setInputOTP(e)} className="w-full !mt-4" maxLength={4}> 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 |

541 | this meeting is protected, Please enter the OTP to join 542 |

543 |
544 | )} 545 | 548 |
549 | )} 550 |
551 |
552 | )} 553 | 554 | 555 |
556 | ); 557 | } 558 | 559 | const PermissionDenied = memo(() => { 560 | return ( 561 |
562 | 563 |
564 | 565 | Camera and Mic Access Required 566 |
567 |
568 | 569 |
570 |

Follow these steps to grant access:

571 | 572 |
573 |
574 | 575 |
576 |

Click the lock icon

577 |

578 | Located in your browser's address bar 579 |

580 |
581 |
582 | 583 |
584 | 585 |
586 |

Open Site Settings

587 |

Find Camera and Microphone permissions

588 |
589 |
590 | 591 |
592 | 593 |
594 |

Allow access

595 |

Enable both camera and microphone

596 |
597 |
598 | 599 |
600 | 601 |
602 |

Refresh the page

603 |

Try your action again

604 |
605 |
606 |
607 | 608 | 612 |
613 |
614 | ); 615 | }); 616 | PermissionDenied.displayName = "PermissionDenied"; 617 | 618 | const DecryptionFailedModal = ({ open, setOpen }) => { 619 | return ( 620 | 621 | 622 | 623 | 624 | The Decryption has failed 625 | 626 |

627 | You cannot join this meeting now with a failed token. Please ask the host to share a new link. 628 |

629 |
630 |
631 |
632 |
633 | ); 634 | }; 635 | -------------------------------------------------------------------------------- /src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import { Toaster } from "@/components/ui/toaster"; 3 | import { ThemeProvider } from "@/components/ui/ThemeProvider"; 4 | 5 | export default function App({ Component, pageProps }) { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/_document.js: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default function handler(req, res) { 4 | res.status(200).json({ name: "John Doe" }); 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import React, { useEffect, useRef, useState } from "react"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | import CryptoJS from "crypto-js"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { Label } from "@/components/ui/label"; 8 | import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; 9 | import { Code2, Copy, Github, LockIcon, UnlockIcon, User } from "lucide-react"; 10 | import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"; 11 | import { Checkbox } from "@/components/ui/checkbox"; 12 | import copy from "copy-to-clipboard"; 13 | import { toast } from "@/hooks/use-toast"; 14 | import Link from "next/link"; 15 | 16 | const inter = Inter({ subsets: ["latin"] }); 17 | 18 | const SIGNING_KEY = "8ieFzC7WvOzu1MW"; 19 | // vercel test 20 | export default function Home() { 21 | const [roomTitle, setRoomTitle] = useState(""); 22 | const [isPasswordProtected, setIsPasswordProtected] = useState(false); 23 | const [password, setPassword] = useState(""); 24 | const [generatedLink, setGeneratedLink] = useState(""); 25 | const [otp, setOtp] = useState(""); 26 | const inputRef = useRef(); 27 | 28 | useEffect(() => { 29 | inputRef.current?.focus(); 30 | }, []); 31 | 32 | const generateRoom = () => { 33 | if (!roomTitle) { 34 | toast({ 35 | title: "Please enter a room name", 36 | }); 37 | return; 38 | } 39 | 40 | if (otp.length < 4 && isPasswordProtected) { 41 | toast({ 42 | title: "Please enter a 4 digit OTP", 43 | }); 44 | return; 45 | } 46 | const roomId = uuidv4(); 47 | const protectionStatus = isPasswordProtected ? `has_password--${otp}` : "does_not_have_password"; 48 | 49 | const encryptedToken = CryptoJS.AES.encrypt(protectionStatus, SIGNING_KEY).toString(); 50 | const link = `${window.location.href}${roomId}?token=${encodeURIComponent(encryptedToken)}`; 51 | setGeneratedLink(link); 52 | }; 53 | 54 | return ( 55 |
56 |
57 | 58 | 59 | Create a Meeting Room 60 | 61 | 62 |
63 | 64 | setRoomTitle(e.target.value)} 73 | className="text-gray-100 flex h-10 w-full bg-black rounded-3xl border border-gray-500 px-3 pl-5 py-2 text-base ring-offset-white focus-visible:ring-neutral-300 focus-visible:ring-offset-2 focus-visible:outline-none" 74 | /> 75 |
76 |
77 | 82 |
83 | 84 | {isPasswordProtected ? : } 85 |
86 |
87 | 88 | {isPasswordProtected && ( 89 |
90 | setOtp(e)} className="w-full !mt-4" maxLength={4}> 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 |
99 | )} 100 | 103 | {generatedLink && ( 104 |
105 |

{generatedLink}

106 | { 109 | copy(generatedLink); 110 | toast({ 111 | title: "Copied successfully!", 112 | }); 113 | }} 114 | /> 115 |
116 | )} 117 |
118 | 119 |
120 |
121 | All data stays on your device. There's no server involved. we do "NOT" access your 122 |
  • video streams
  • 123 |
  • audio streams
  • 124 |
  • store any messages
  • 125 |
  • or intercept any web rtc offers
  • 126 |
  • 127 | The code is{" "} 128 | 133 | open source 134 | 135 |
  • 136 | Feel free to inspect the network tab :) 137 |
    138 | 139 |
    140 | 146 | 152 |
    153 |
    154 |
    155 |
    156 |
    157 |
    158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | 29 | @layer utilities { 30 | .text-balance { 31 | text-wrap: balance; 32 | } 33 | } 34 | 35 | @layer base { 36 | :root { 37 | --radius: 0.5rem; 38 | } 39 | } 40 | 41 | 42 | input:focus { 43 | outline: none; 44 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))' 14 | }, 15 | borderRadius: { 16 | lg: 'var(--radius)', 17 | md: 'calc(var(--radius) - 2px)', 18 | sm: 'calc(var(--radius) - 4px)' 19 | }, 20 | colors: {} 21 | } 22 | }, 23 | plugins: [require("tailwindcss-animate")], 24 | }; 25 | --------------------------------------------------------------------------------