├── .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 | 
54 |
55 | 
56 |
57 | 
58 |
59 | 
60 |
61 | 
62 |
63 | 
64 |
65 | 
66 |
67 | 
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 | : }
159 | content={`Turn video ${!isVideoEnabled ? "on" : "off"}`}
160 | />
161 |
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 |
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 |
515 | }
516 |
517 |
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 |
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 |
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 |
--------------------------------------------------------------------------------