Welcome to the final part of our Google Meet clone tutorial series! In this part, we'll implement video conferencing, screen sharing, and real-time chat functionality using WebRTC and Socket.io.
For reference purposes, here's the outline of this blog series:
- Part 1: Setting Up the Backend and Frontend with Strapi 5 and Next.js..
- Part 2: Real-Time Features, Video Integration, and Screen Sharing.
Prerequisites
Before starting, ensure you have:
- Completed Part 1 this tutorial series.
- Basic understanding of Web Real-Time Communication (WebRTC) concepts.
- You have the Strapi backend created in Part 1 running on your computer.
Installing Dependencies
To continue from where we left off in Part 1, let's add the required WebRTC and Socket.io packages by running the command below in your frontend project folder:
npm install socket.io-client @types/webrtc simple-peer @types/simple-peer
Here is what we installed inside our frontend project:
- socket.io-client: Realtime application framework client
- @types/webrtc: TypeScript definitions for webrtc.
- simple-peer: The mobile web app connects groups of up to four people in a peer-to-peer WebRTC audio and video call so that they can mutually prove unique personhood.
- @types/simple-peer: TypeScript definitions for simple-peer.
For the Strapi backend, install the Socket.io package:
cd ../google-meet-clone-backend
npm install socket.io
Setting Up WebSocket Server
First, create a socket.ts
file in the config
folder and add the code below:
1export default ({ env }) => ({
2 enabled: true,
3 config: {
4 port: env.int("SOCKET_PORT", 1337),
5 cors: {
6 origin: env("SOCKET_CORS_ORIGIN", "*"),
7 methods: ["GET", "POST"],
8 },
9 },
10});
Then, create a new folder named socket
in the api
directory for the socket API. In the api/socket
directory, create a new folder named services
and a socket.ts
file in the services
folder.
Create Event Listeners for Real-time Peer-to-Peer Connection in Strapi
Add the code snippets below in the api/socket/services/socket.ts
file to set and initialize a socket connection, and create all the event listeners we need to communicate with our Next.js client for real-time Peer to Peer connection:
1import { Core } from "@strapi/strapi";
2
3interface MeetingParticipant {
4 socketId: string;
5 username: string;
6}
7
8interface Meeting {
9 participants: Map<string, MeetingParticipant>;
10 lastActivity: number;
11}
12
13export default ({ strapi }: { strapi: Core.Strapi }) => {
14 // Store active meetings and their participants
15 const activeMeetings = new Map<string, Meeting>();
16
17 // Cleanup inactive meetings periodically
18 const cleanupInterval = setInterval(
19 () => {
20 const now = Date.now();
21 activeMeetings.forEach((meeting, meetingId) => {
22 if (now - meeting.lastActivity > 1000 * 60 * 60) {
23 // 1 hour timeout
24 activeMeetings.delete(meetingId);
25 }
26 });
27 },
28 1000 * 60 * 15
29 );
30
31 return {
32 initialize() {
33 strapi.eventHub.on("socket.ready", async () => {
34 const io = (strapi as any).io;
35 if (!io) {
36 strapi.log.error("Socket.IO is not initialized");
37 return;
38 }
39
40 io.on("connection", (socket: any) => {
41 const { meetingId, userId } = socket.handshake.query;
42 strapi.log.info(
43 `Client connected - Socket: ${socket.id}, User: ${userId}, Meeting: ${meetingId}`
44 );
45
46 // Initialize meeting if it doesn't exist
47 if (!activeMeetings.has(meetingId)) {
48 activeMeetings.set(meetingId, {
49 participants: new Map(),
50 lastActivity: Date.now(),
51 });
52 }
53
54 socket.on("join-meeting", async ({ meetingId, userId }) => {
55 try {
56 // Get user data with username
57 const user = await strapi
58 .query("plugin::users-permissions.user")
59 .findOne({
60 where: { id: userId },
61 select: ["id", "username"],
62 });
63
64 strapi.log.info(`User ${userId} joining meeting ${meetingId}`);
65
66 const meeting = activeMeetings.get(meetingId);
67 if (!meeting) return;
68
69 // Add participant to meeting with both ID and username
70 meeting.participants.set(userId.toString(), {
71 socketId: socket.id,
72 username: user.username,
73 });
74 meeting.lastActivity = Date.now();
75
76 // Join socket room
77 socket.join(meetingId);
78
79 // Get current participants with their usernames
80 const currentParticipants = Array.from(
81 meeting.participants.entries()
82 )
83 .filter(([id]) => id !== userId.toString())
84 .map(([id, data]) => ({
85 userId: id,
86 username: data.username,
87 }));
88
89 // Send current participants to the joining user
90 socket.emit("participants-list", currentParticipants);
91
92 // Notify others about the new participant
93 socket.to(meetingId).emit("user-joined", {
94 userId: userId.toString(),
95 username: user.username,
96 });
97
98 strapi.log.info(
99 `Current participants in meeting ${meetingId}:`,
100 Array.from(meeting.participants.entries()).map(
101 ([id, data]) => ({
102 id,
103 username: data.username,
104 })
105 )
106 );
107 } catch (error) {
108 strapi.log.error("Error in join-meeting:", error);
109 }
110 });
111
112 socket.on("chat-message", ({ message, meetingId }) => {
113 socket.to(meetingId).emit("chat-message", message);
114 });
115
116 const meeting = activeMeetings.get(meetingId);
117 if (!meeting) return;
118
119 socket.on("signal", ({ to, from, signal }) => {
120 console.log(
121 `Forwarding ${signal.type} signal from ${from} to ${to}`
122 );
123 const targetSocket = meeting.participants.get(
124 to.toString()
125 )?.socketId;
126 if (targetSocket) {
127 io.to(targetSocket).emit("signal", {
128 signal,
129 userId: from.toString(),
130 });
131 } else {
132 console.log(`No socket found for user ${to}`);
133 }
134 });
135 const handleDisconnect = () => {
136 const meeting = activeMeetings.get(meetingId);
137 if (!meeting) return;
138
139 // Find and remove the disconnected user
140 const disconnectedUserId = Array.from(
141 meeting.participants.entries()
142 ).find(([_, socketId]) => socketId === socket.id)?.[0];
143
144 if (disconnectedUserId) {
145 meeting.participants.delete(disconnectedUserId);
146 meeting.lastActivity = Date.now();
147
148 // Notify others about the user leaving
149 socket.to(meetingId).emit("user-left", {
150 userId: disconnectedUserId,
151 });
152
153 strapi.log.info(
154 `User ${disconnectedUserId} left meeting ${meetingId}`
155 );
156 strapi.log.info(
157 `Remaining participants:`,
158 Array.from(meeting.participants.keys())
159 );
160
161 // Clean up empty meetings
162 if (meeting.participants.size === 0) {
163 activeMeetings.delete(meetingId);
164 strapi.log.info(
165 `Meeting ${meetingId} closed - no participants remaining`
166 );
167 }
168 }
169 };
170
171 socket.on("disconnect", handleDisconnect);
172 socket.on("leave-meeting", handleDisconnect);
173 });
174
175 strapi.log.info("Conference socket service initialized successfully");
176 });
177 },
178
179 destroy() {
180 clearInterval(cleanupInterval);
181 },
182 };
183};
Initialize Socket.io Server with Strapi
Then update your src/index.ts
file to initialize the Socket.IO server, set up event listeners for user updates and creations, and integrate the socket service with Strapi:
1import { Core } from "@strapi/strapi";
2import { Server as SocketServer } from "socket.io";
3
4interface SocketConfig {
5 cors: {
6 origin: string | string[];
7 methods: string[];
8 };
9}
10
11export default {
12 register({ strapi }: { strapi: Core.Strapi }) {
13 const socketConfig = strapi.config.get("socket.config") as SocketConfig;
14
15 if (!socketConfig) {
16 strapi.log.error("Invalid Socket.IO configuration");
17 return;
18 }
19
20 strapi.server.httpServer.on("listening", () => {
21 const io = new SocketServer(strapi.server.httpServer, {
22 cors: socketConfig.cors,
23 });
24
25 (strapi as any).io = io;
26 strapi.eventHub.emit("socket.ready");
27 });
28 },
29
30 bootstrap({ strapi }: { strapi: Core.Strapi }) {
31 const socketService = strapi.service("api::socket.socket") as {
32 initialize: () => void;
33 };
34 if (socketService && typeof socketService.initialize === "function") {
35 socketService.initialize();
36 } else {
37 strapi.log.error("Socket service or initialize method not found");
38 }
39 },
40};
Implementing Video Meeting Page
With our socket connection and events created, let's create a real-time video meeting page to handle video conference rooms to allow users to have video meetings.
Create Page for Video Conference Room
Create a new page for the video conference room in src/app/meetings/[id]/page.tsx
file and add the code snippets below:
1'use client'
2
3import { useEffect, useRef, useState } from "react"
4import { useParams } from "next/navigation"
5import SimplePeer from "simple-peer"
6import { io, Socket } from "socket.io-client"
7import { getCookie } from "cookies-next"
8import { User } from "@/types"
9
10interface ExtendedSimplePeer extends SimplePeer.Instance {
11 _pc: RTCPeerConnection
12}
13
14interface Peer {
15 peer: SimplePeer.Instance
16 userId: string
17 stream?: MediaStream
18}
19
20export default function ConferenceRoom() {
21 const params = useParams()
22 const [peers, setPeers] = useState<Peer[]>([])
23 const [stream, setStream] = useState<MediaStream | null>(null)
24 const socketRef = useRef<Socket>()
25 const userVideo = useRef<HTMLVideoElement>(null)
26 const peersRef = useRef<Peer[]>([])
27 const [user, setUser] = useState<User | null>(null)
28 const [isConnected, setIsConnected] = useState(false)
29 const [screenStream, setScreenStream] = useState<MediaStream | null>(null)
30 const [isScreenSharing, setIsScreenSharing] = useState(false)
31
32 useEffect(() => {
33 try {
34 const cookieValue = getCookie("auth-storage")
35 if (cookieValue) {
36 const parsedAuthState = JSON.parse(String(cookieValue))
37 setUser(parsedAuthState.state.user)
38 }
39 } catch (error) {
40 console.error("Error parsing auth cookie:", error)
41 }
42 }, [])
43
44 useEffect(() => {
45 if (!user?.id || !params.id) return
46
47 const cleanupPeers = () => {
48 peersRef.current.forEach((peer) => {
49 if (peer.peer) {
50 peer.peer.destroy()
51 }
52 })
53 peersRef.current = []
54 setPeers([])
55 }
56
57 cleanupPeers()
58
59 socketRef.current = io(process.env.NEXT_PUBLIC_STRAPI_URL || "", {
60 query: { meetingId: params.id, userId: user.id },
61 transports: ["websocket"],
62 reconnection: true,
63 reconnectionAttempts: 5,
64 })
65
66 socketRef.current.on("connect", () => {
67 setIsConnected(true)
68 console.log("Socket connected:", socketRef.current?.id)
69 })
70
71 socketRef.current.on("disconnect", () => {
72 setIsConnected(false)
73 console.log("Socket disconnected")
74 })
75
76 navigator.mediaDevices
77 .getUserMedia({ video: true, audio: true })
78 .then((stream) => {
79 setStream(stream)
80 if (userVideo.current) {
81 userVideo.current.srcObject = stream
82 }
83
84 socketRef.current?.emit("join-meeting", {
85 userId: user.id,
86 meetingId: params.id,
87 })
88
89 socketRef.current?.on("signal", ({ userId, signal }) => {
90 console.log("Received signal from:", userId, "Signal type:", signal.type)
91 let peer = peersRef.current.find((p) => p.userId === userId)
92
93 if (!peer && stream) {
94 console.log("Creating new peer for signal from:", userId)
95 const newPeer = createPeer(userId, stream, false)
96 peer = { peer: newPeer, userId }
97 peersRef.current.push(peer)
98 setPeers([...peersRef.current])
99 }
100
101 if (peer) {
102 try {
103 peer.peer.signal(signal)
104 } catch (err) {
105 console.error("Error processing signal:", err)
106 }
107 }
108 })
109
110 socketRef.current?.on("participants-list", (participants) => {
111 console.log("Received participants list:", participants)
112
113 cleanupPeers()
114 setPeers([...peersRef.current])
115 })
116
117 socketRef.current?.on("user-joined", ({ userId, username }) => {
118 console.log("New user joined:", userId)
119 if (userId !== user?.id.toString()) {
120
121 if (stream && !peersRef.current.find((p) => p.userId === userId)) {
122 console.log("Creating non-initiator peer for new user:", userId)
123 const peer = createPeer(userId, stream, false)
124 peersRef.current.push({ peer, userId })
125 setPeers([...peersRef.current])
126 }
127 }
128 })
129
130 socketRef.current?.on("user-left", ({ userId }) => {
131 console.log("User left:", userId)
132 const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
133 if (peerIndex !== -1) {
134 peersRef.current[peerIndex].peer.destroy()
135 peersRef.current.splice(peerIndex, 1)
136 setPeers([...peersRef.current])
137 }
138 })
139 })
140 .catch((error) => {
141 console.error("Error accessing media devices:", error)
142 })
143
144 return () => {
145
146 if (socketRef.current) {
147 socketRef.current.emit("leave-meeting", {
148 userId: user?.id,
149 meetingId: params.id,
150 })
151
152 socketRef.current.off("participants-list")
153 socketRef.current.off("user-joined")
154 socketRef.current.off("user-left")
155 socketRef.current.off("signal")
156 socketRef.current.disconnect()
157 }
158
159 if (stream) {
160 stream.getTracks().forEach((track) => track.stop())
161 }
162
163 cleanupPeers()
164 }
165 }, [user?.id, params.id])
166
167 useEffect(() => {
168 if (!socketRef.current) return
169
170 socketRef.current.on("media-state-change", ({ userId, type, enabled }) => {
171 })
172
173 return () => {
174 socketRef.current?.off("media-state-change")
175 }
176 }, [socketRef.current])
177
178 function createPeer(userId: string, stream: MediaStream, initiator: boolean): SimplePeer.Instance {
179 console.log(`Creating peer connection - initiator: ${initiator}, userId: ${userId}`)
180
181 const peer = new SimplePeer({
182 initiator,
183 trickle: false,
184 stream,
185 config: {
186 iceServers: [
187 { urls: "stun:stun.l.google.com:19302" },
188 { urls: "stun:global.stun.twilio.com:3478" },
189 ],
190 },
191 })
192
193 peer.on("signal", (signal) => {
194 console.log(`Sending signal to ${userId}, type: ${signal.type}`)
195 socketRef.current?.emit("signal", {
196 signal,
197 to: userId,
198 from: user?.id,
199 })
200 })
201
202 peer.on("connect", () => {
203 console.log(`Peer connection established with ${userId}`)
204 })
205
206 peer.on("stream", (incomingStream) => {
207 console.log(`Received stream from ${userId}, tracks:`, incomingStream.getTracks())
208 const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
209 if (peerIndex !== -1) {
210 peersRef.current[peerIndex].stream = incomingStream
211 setPeers([...peersRef.current])
212 }
213 })
214
215 peer.on("error", (err) => {
216 console.error(`Peer error with ${userId}:`, err)
217 const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
218 if (peerIndex !== -1) {
219 peersRef.current[peerIndex].peer.destroy()
220 peersRef.current.splice(peerIndex, 1)
221 setPeers([...peersRef.current])
222 }
223 })
224
225 peer.on("close", () => {
226 console.log(`Peer connection closed with ${userId}`)
227 })
228
229 return peer
230 }
231
232 return (
233 <div className="flex flex-col gap-4 p-4">
234 <div className="text-sm text-gray-500">
235 {isConnected ? "Connected to server" : "Disconnected from server"}
236 </div>
237 {/* <ParticipantList /> */}
238 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
239 <div className="relative">
240 <video
241 ref={userVideo}
242 autoPlay
243 muted
244 playsInline
245 className="w-full rounded-lg bg-gray-900"
246 />
247 <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
248 You
249 </div>
250 </div>
251 {peers.map(({ peer, userId, stream }) => (
252 <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
253 ))}
254 </div>
255 </div>
256 )
257}
258
259function PeerVideo({ peer, userId, stream }: { peer: SimplePeer.Instance; userId: string; stream?: MediaStream }) {
260 const ref = useRef<HTMLVideoElement>(null)
261
262 useEffect(() => {
263 if (stream && ref.current) {
264 ref.current.srcObject = stream
265 }
266
267 const handleStream = (incomingStream: MediaStream) => {
268 if (ref.current) {
269 ref.current.srcObject = incomingStream
270 }
271 }
272
273 peer.on("stream", handleStream)
274
275 return () => {
276 if (ref.current) {
277 ref.current.srcObject = null
278 }
279 peer.off("stream", handleStream)
280 }
281 }, [peer, stream])
282
283 return (
284 <div className="relative">
285 <video ref={ref} autoPlay playsInline className="w-full rounded-lg bg-gray-900" />
286 <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
287 </div>
288 </div>
289 )
290}
Here is what the code above does:
- The above code handles the WebRTC and Socket.IO peer-to-peer video chat. It uses SimplePeer to handle WebRTC connections, maintaining a peers state array, and
peersRef
to track all connected users. - The component initializes by getting the user's video and audio stream using
getUserMedia
, then sets up socket connections with events likejoin-meeting
,signal
,user-joined
, anduser-left
to handle real-time communication. - The
createPeer
function is the backbone, creating new peer connections with ice servers for NAT traversal while handling various peer events likesignal
,connect
,stream
, anderror
. - The video streams are displayed using the
userVideo
ref for the local user and a separatePeerVideo
component for remote participants, which manages individual video elements and their streams. It usessocket.current
for maintaining the WebSocket connection and handles cleanup usinguseEffect's
return function, ensuring all peer connections are properly destroyed and media streams are stopped when the component unmounts.
Implementing Screen Sharing
To allow users to share their screen while on the call, let's add screen-sharing functionality to the conference room:
1//...
2import { ScreenShare, StopScreenShare } from 'lucide-react';
3
4interface ExtendedSimplePeer extends SimplePeer.Instance {
5 _pc: RTCPeerConnection;
6}
7
8//...
9export default function ConferenceRoom() {
10 //...
11 const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
12 const [isScreenSharing, setIsScreenSharing] = useState(false);
13
14
15
16 const toggleScreenShare = async () => {
17 if (!isScreenSharing) {
18 try {
19 const screen = await navigator.mediaDevices.getDisplayMedia({
20 video: true,
21 audio: false,
22 });
23
24 // Handle when the user clicks the "Stop sharing" button in the browser
25 screen.getVideoTracks()[0].addEventListener("ended", () => {
26 stopScreenSharing();
27 });
28
29 setScreenStream(screen);
30 setIsScreenSharing(true);
31
32 // Replace video track for all peers
33 peersRef.current.forEach(({ peer }) => {
34 const videoTrack = screen.getVideoTracks()[0];
35 const extendedPeer = peer as ExtendedSimplePeer;
36 const sender = extendedPeer._pc
37 .getSenders()
38 .find((s) => s.track?.kind === "video");
39
40 if (sender) {
41 sender.replaceTrack(videoTrack);
42 }
43 });
44
45 // Replace local video
46 if (userVideo.current) {
47 userVideo.current.srcObject = screen;
48 }
49 } catch (error) {
50 console.error("Error sharing screen:", error);
51 }
52 } else {
53 stopScreenSharing();
54 }
55 };
56
57 const stopScreenSharing = () => {
58 if (screenStream) {
59 screenStream.getTracks().forEach((track) => track.stop());
60 setScreenStream(null);
61 setIsScreenSharing(false);
62
63 // Revert to camera video for all peers
64 if (stream) {
65 peersRef.current.forEach(({ peer }) => {
66 const videoTrack = stream.getVideoTracks()[0];
67 const extendedPeer = peer as ExtendedSimplePeer;
68 const sender = extendedPeer._pc
69 .getSenders()
70 .find((s) => s.track?.kind === "video");
71
72 if (sender) {
73 sender.replaceTrack(videoTrack);
74 }
75 });
76
77 // Revert local video
78 if (userVideo.current) {
79 userVideo.current.srcObject = stream;
80 }
81 }
82 }
83 };
84
85 //...
86
87 return (
88 <div className="flex flex-col gap-4 p-4">
89 <div className="text-sm text-gray-500">
90 {isConnected ? "Connected to server" : "Disconnected from server"}
91 </div>
92 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
93 <div className="relative">
94 <video
95 ref={userVideo}
96 autoPlay
97 muted
98 playsInline
99 className="w-full rounded-lg bg-gray-900"
100 />
101 <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
102 You
103 </div>
104 </div>
105 {peers.map(({ peer, userId, stream }) => (
106 <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
107 ))}
108 </div>
109 {/* added this button to handle the start screen and stop sharing. */}
110
111 <button
112 onClick={toggleScreenShare}
113 className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
114 style={{ width: "15rem" }}
115 >
116 {isScreenSharing ? (
117 <>
118 <StopCircleIcon className="w-5 h-5" />
119 Stop Sharing
120 </>
121 ) : (
122 <>
123 <ScreenShare className="w-5 h-5" />
124 Share Screen
125 </>
126 )}
127 </button>
128 //
129 </div>
130 );
131}
In the above code:
- We added
toggleScreenShare
andstopScreenSharing
functions, wheretoggleScreenShare
usesnavigator.mediaDevices.getDisplayMedia
to capture the user's screen as aMediaStream
, storing it inscreenStream
state and tracking its status withisScreenSharing
. - When screen sharing is activated, it replaces the video tracks for all peer connections using RTCPeerConnection's
getSenders().replaceTrack
method, changing what each participant sees from camera to screen content. - The
stopScreenSharing
function handles the cleanup by stopping all screen-sharing tracks and reverting everyone to camera video.
Adding Real-Time Chat
Next, let's add a chat functionality to allow users to chat in real time while on the call.
Create Chat Component
Create a chat component in src/components/meeting/chat.tsx
:
1"use client";
2
3import { useState, useEffect, useRef } from "react";
4import { useAuthStore } from "@/store/auth-store";
5import { Socket } from "socket.io-client";
6import { User } from "@/types";
7
8interface ChatProps {
9 socketRef: React.MutableRefObject<Socket | undefined>; // Changed from RefObject to MutableRefObject
10 user: User;
11 meetingId: string;
12}
13
14interface Message {
15 userId: string;
16 username: string;
17 text: string;
18 timestamp: number;
19}
20
21function Chat({ socketRef, user, meetingId }: ChatProps) {
22 const [messages, setMessages] = useState<Message[]>([]);
23 const [newMessage, setNewMessage] = useState("");
24 const [isExpanded, setIsExpanded] = useState(true);
25 const chatRef = useRef<HTMLDivElement>(null);
26
27 useEffect(() => {
28 const socket = socketRef.current;
29 if (!socket) return;
30
31 const handleChatMessage = (message: Message) => {
32 setMessages((prev) => [...prev, message]);
33 };
34
35 socket.on("chat-message", handleChatMessage);
36
37 return () => {
38 socket?.off("chat-message", handleChatMessage);
39 };
40 }, [socketRef.current]);
41
42 useEffect(() => {
43 if (chatRef.current) {
44 chatRef.current.scrollTop = chatRef.current.scrollHeight;
45 }
46 }, [messages]);
47
48 const sendMessage = (e: React.FormEvent) => {
49 e.preventDefault();
50 const socket = socketRef.current;
51 if (!socket || !newMessage.trim()) return;
52
53 const message: Message = {
54 userId: user.id.toString(),
55 username: user.username,
56 text: newMessage,
57 timestamp: Date.now(),
58 };
59
60 socket.emit("chat-message", {
61 message,
62 meetingId,
63 });
64
65 setMessages((prev) => [...prev, message]);
66 setNewMessage("");
67 };
68
69 return (
70 <div className="fixed right-4 bottom-4 w-80 bg-white rounded-lg shadow-lg flex flex-col border">
71 <div
72 className="p-3 border-b flex justify-between items-center cursor-pointer"
73 onClick={() => setIsExpanded(!isExpanded)}
74 >
75 <h3 className="font-medium text-gray-600">Chat</h3>
76 <button className="text-gray-500 hover:text-gray-700">
77 {isExpanded ? "▼" : "▲"}
78 </button>
79 </div>
80
81 {isExpanded && (
82 <>
83 <div
84 ref={chatRef}
85 className="flex-1 overflow-y-auto p-4 space-y-4 max-h-96"
86 >
87 {messages.map((message, index) => (
88 <div
89 key={index}
90 className={`flex ${
91 message.userId === user.id.toString()
92 ? "justify-end"
93 : "justify-start"
94 }`}
95 >
96 <div
97 className={`max-w-xs px-4 py-2 rounded-lg ${
98 message.userId === user.id.toString()
99 ? "bg-blue-600 text-white"
100 : "bg-gray-400"
101 }`}
102 >
103 {message.userId !== user.id.toString() && (
104 <p className="text-xs font-medium mb-1">
105 {message.username}
106 </p>
107 )}
108 <p className="break-words">{message.text}</p>
109 <span className="text-xs opacity-75 block mt-1">
110 {new Date(message.timestamp).toLocaleTimeString()}
111 </span>
112 </div>
113 </div>
114 ))}
115 </div>
116
117 <form onSubmit={sendMessage} className="p-4 border-t">
118 <div className="flex gap-2">
119 <input
120 type="text"
121 value={newMessage}
122 onChange={(e) => setNewMessage(e.target.value)}
123 className="flex-1 px-3 py-2 border rounded-lg text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
124 placeholder="Type a message..."
125 />
126 <button
127 type="submit"
128 className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
129 disabled={!newMessage.trim()}
130 >
131 Send
132 </button>
133 </div>
134 </form>
135 </>
136 )}
137 </div>
138 );
139}
140
141
142export default Chat;
Broadcast Chat to All Connected Clients with Strapi
Now update your Strapi socket service in your api/socket/services/socket.ts
file to broadcast the chat to all connected clients in the meeting.
1//...
2socket.on("chat-message", ({ message, meetingId }) => {
3 socket.to(meetingId).emit("chat-message", message);
4});
5//...
Render Chat Component
Then update your app/meetings/[id]/page.tsx
file to render the Chat component in your return statement:
1//...
2import Chat from "@/components/meeting/chat";
3
4//...
5
6export default function ConferenceRoom() {
7
8 //...
9 return (
10 <div className="flex flex-col gap-4 p-4">
11 <div className="text-sm text-gray-500">
12 {isConnected ? "Connected to server" : "Disconnected from server"}
13 </div>
14 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
15 <div className="relative">
16 <video
17 ref={userVideo}
18 autoPlay
19 muted
20 playsInline
21 className="w-full rounded-lg bg-gray-900"
22 />
23 <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
24 You
25 </div>
26 </div>
27 {peers.map(({ peer, userId, stream }) => (
28 <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
29 ))}
30 </div>
31 <button
32 onClick={toggleScreenShare}
33 className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
34 style={{ width: "15rem" }}
35 >
36 {isScreenSharing ? (
37 <>
38 <StopCircleIcon className="w-5 h-5" />
39 Stop Sharing
40 </>
41 ) : (
42 <>
43 <ScreenShare className="w-5 h-5" />
44 Share Screen
45 </>
46 )}
47 </button>
48 <Chat
49 socketRef={socketRef}
50 user={user as User}
51 meetingId={params.id as string}
52 />{" "}
53 </div>
54 );
55}
Setting up Meeting Controls
Now let's take things a little bit further and make our app look more like Google Meet.
Let's have a central control for video conferencing and add features like muting and unmuting the mic, turning the video off and on, screen sharing, and leaving the meeting.
Create The Controls Component
Create a controls component in src/components/meeting/controls.tsx
:
1import { useRouter } from 'next/navigation';
2import {
3 Mic,
4 MicOff,
5 Video,
6 VideoOff,
7 ScreenShare,
8 StopCircleIcon,
9 Phone,
10} from 'lucide-react';
11import { Socket } from 'socket.io-client';
12import { useState } from 'react';
13
14interface ControlsProps {
15 stream: MediaStream | null;
16 screenStream: MediaStream | null;
17 isScreenSharing: boolean;
18 socketRef: React.MutableRefObject<Socket | undefined>;
19 peersRef: React.MutableRefObject<any[]>;
20 meetingId: string;
21 userId: string;
22 onScreenShare: () => Promise<void>;
23}
24
25export default function Controls({
26 stream,
27 screenStream,
28 isScreenSharing,
29 socketRef,
30 peersRef,
31 meetingId,
32 userId,
33 onScreenShare,
34}: ControlsProps) {
35 const router = useRouter();
36 const [isAudioEnabled, setIsAudioEnabled] = useState(true);
37 const [isVideoEnabled, setIsVideoEnabled] = useState(true);
38
39 const toggleAudio = () => {
40 if (stream) {
41 stream.getAudioTracks().forEach((track) => {
42 track.enabled = !isAudioEnabled;
43 });
44 setIsAudioEnabled(!isAudioEnabled);
45
46 // Notify peers about audio state change
47 socketRef.current?.emit('media-state-change', {
48 meetingId,
49 userId,
50 type: 'audio',
51 enabled: !isAudioEnabled,
52 });
53 }
54 };
55
56 const toggleVideo = () => {
57 if (stream) {
58 stream.getVideoTracks().forEach((track) => {
59 track.enabled = !isVideoEnabled;
60 });
61 setIsVideoEnabled(!isVideoEnabled);
62
63 // Notify peers about video state change
64 socketRef.current?.emit('media-state-change', {
65 meetingId,
66 userId,
67 type: 'video',
68 enabled: !isVideoEnabled,
69 });
70 }
71 };
72
73 const handleLeave = () => {
74 // Stop all tracks
75 if (stream) {
76 stream.getTracks().forEach(track => track.stop());
77 }
78 if (screenStream) {
79 screenStream.getTracks().forEach(track => track.stop());
80 }
81
82 // Clean up peer connections
83 peersRef.current.forEach(peer => {
84 if (peer.peer) {
85 peer.peer.destroy();
86 }
87 });
88
89 // Notify server
90 socketRef.current?.emit('leave-meeting', {
91 meetingId,
92 userId,
93 });
94
95 // Disconnect socket
96 socketRef.current?.disconnect();
97 router.push('/meetings');
98 };
99
100 return (
101 <div className="fixed bottom-0 left-0 right-0 bg-white border-t p-4 shadow-lg">
102 <div className="max-w-4xl mx-auto flex justify-center gap-4">
103 <button
104 onClick={toggleAudio}
105 className={`p-3 rounded-full transition-colors ${
106 isAudioEnabled
107 ? 'bg-gray-600 hover:bg-gray-500'
108 : 'bg-red-500 hover:bg-red-600 text-white'
109 }`}
110 title={isAudioEnabled ? 'Mute' : 'Unmute'}
111 >
112 {isAudioEnabled ? <Mic size={24} /> : <MicOff size={24} />}
113 </button>
114
115 <button
116 onClick={toggleVideo}
117 className={`p-3 rounded-full transition-colors ${
118 isVideoEnabled
119 ? 'bg-gray-600 hover:bg-gray-500'
120 : 'bg-red-500 hover:bg-red-600 text-white'
121 }`}
122 title={isVideoEnabled ? 'Stop Video' : 'Start Video'}
123 >
124 {isVideoEnabled ? <Video size={24} /> : <VideoOff size={24} />}
125 </button>
126
127 <button
128 onClick={onScreenShare}
129 className={`p-3 rounded-full transition-colors ${
130 isScreenSharing
131 ? 'bg-blue-500 hover:bg-blue-600 text-white'
132 : 'bg-gray-600 hover:bg-gray-600'
133 }`}
134 title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
135 >
136 {isScreenSharing ? (
137 <StopCircleIcon size={24} />
138 ) : (
139 <ScreenShare size={24} />
140 )}
141 </button>
142
143 <button
144 onClick={handleLeave}
145 className="p-3 rounded-full bg-red-500 hover:bg-red-600 text-white transition-colors"
146 title="Leave Meeting"
147 >
148 <Phone size={24} className="rotate-[135deg]" />
149 </button>
150 </div>
151 </div>
152 );
153}
Render the Controls Component
Now update your app/meetings/[id]/page.tsx
file to render the Control component in your return statement:
1//...
2import Controls from "@/components/meeting/controls";
3
4//...
5
6export default function ConferenceRoom() {
7
8 //...
9 return (
10 <div className="flex flex-col gap-4 p-4">
11 <div className="text-sm text-gray-500">
12 {isConnected ? "Connected to server" : "Disconnected from server"}
13 </div>
14 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
15 <div className="relative">
16 <video
17 ref={userVideo}
18 autoPlay
19 muted
20 playsInline
21 className="w-full rounded-lg bg-gray-900"
22 />
23 <Controls
24 stream={stream}
25 screenStream={screenStream}
26 isScreenSharing={isScreenSharing}
27 socketRef={socketRef}
28 peersRef={peersRef}
29 meetingId={params.id as string}
30 userId={user?.id.toString() || ""}
31 onScreenShare={toggleScreenShare}
32 />
33
34 <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
35 You
36 </div>
37 </div>
38 {peers.map(({ peer, userId, stream }) => (
39 <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
40 ))}
41 </div>
42 <Chat
43 socketRef={socketRef}
44 user={user as User}
45 meetingId={params.id as string}
46 />{" "}
47 </div>
48 );
49
50 //...
51}
Adding Meeting Status Management
To manage the state of users in a meeting, like knowing when a user leaves when a user joins the meeting, and seeing the list of all participants that joined the call, let's create a new store to handle the meeting state.
Create a meeting store in src/store/meeting-store.ts
:
1import { create } from "zustand";
2
3interface Participant {
4 id: string;
5 username: string;
6 isAudioEnabled: boolean;
7 isVideoEnabled: boolean;
8 isScreenSharing: boolean;
9 isHost?: boolean;
10}
11
12interface MeetingState {
13 participants: Record<string, Participant>;
14 addParticipant: (participant: Participant) => void;
15 removeParticipant: (id: string) => void;
16 updateParticipant: (id: string, updates: Partial<Participant>) => void;
17 updateMediaState: (id: string, type: 'audio' | 'video' | 'screen', enabled: boolean) => void;
18 clearParticipants: () => void;
19}
20
21export const useMeetingStore = create<MeetingState>((set) => ({
22 participants: {},
23
24 addParticipant: (participant) =>
25 set((state) => ({
26 participants: {
27 ...state.participants,
28 [participant.id]: {
29 ...participant,
30 isAudioEnabled: true,
31 isVideoEnabled: true,
32 isScreenSharing: false,
33 ...state.participants[participant.id],
34 },
35 },
36 })),
37
38 removeParticipant: (id) =>
39 set((state) => {
40 const { [id]: removed, ...rest } = state.participants;
41 return { participants: rest };
42 }),
43
44 updateParticipant: (id, updates) =>
45 set((state) => ({
46 participants: {
47 ...state.participants,
48 [id]: {
49 ...state.participants[id],
50 ...updates,
51 },
52 },
53 })),
54
55 updateMediaState: (id, type, enabled) =>
56 set((state) => ({
57 participants: {
58 ...state.participants,
59 [id]: {
60 ...state.participants[id],
61 [type === 'audio' ? 'isAudioEnabled' :
62 type === 'video' ? 'isVideoEnabled' : 'isScreenSharing']: enabled,
63 },
64 },
65 })),
66
67 clearParticipants: () =>
68 set({ participants: {} }),
69}));
Here is what the code above does:
- Manages the state of participants in our video conference, using a
Participant
interface to track each user's ID, username, and media states (audio, video, and screen sharing). - Added the
addParticipant
which handles new joiners with default media states enabled. - The
removeParticipant
cleanly removes users using object destructuring. - The
updateParticipant
allows for partial updates to any participant's data. - The
updateMediaState
specifically manages toggling of audio/video/screen states. - The
clearParticipants
wipes the entire participants record clean.
Implementing Participant List
Now let's use the meeting-store
to display a list of participants in a meeting. Create a participant list component in src/components/meeting/participant-list.tsx
:
1'use client';
2
3import { useState } from 'react';
4import { Mic, MicOff, Video, VideoOff, ScreenShare, Users, ChevronDown, ChevronUp } from 'lucide-react';
5import { useMeetingStore } from '@/store/meeting-store';
6
7export default function ParticipantList() {
8 const [isExpanded, setIsExpanded] = useState(true);
9 const participants = useMeetingStore((state) => state.participants);
10 const participantCount = Object.keys(participants).length;
11 return (
12 <div className="fixed left-4 bottom-5 w-80 bg-white rounded-lg shadow-lg border z-50">
13 <div
14 className="p-3 border-b flex justify-between items-center cursor-pointer"
15 onClick={() => setIsExpanded(!isExpanded)}
16 >
17 <div className="flex items-center gap-2">
18 <Users className='text-gray-600' size={20} />
19 <h2 className="font-medium text-gray-600">Participants ({participantCount})</h2>
20 </div>
21 <button className="text-gray-500 hover:text-gray-700">
22 {isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
23 </button>
24 </div>
25
26 {isExpanded && (
27 <div className="max-h-96 overflow-y-auto p-4 space-y-2">
28 {Object.values(participants).map((participant) => (
29 <div
30 key={participant.id}
31 className="flex items-center justify-between p-2 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
32 >
33 <div className="flex items-center gap-2">
34 <span className="font-medium text-gray-600">{participant.username}</span>
35 {participant.isHost && (
36 <span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">
37 Host
38 </span>
39 )}
40 </div>
41 <div className="flex gap-2">
42 {participant.isAudioEnabled ? (
43 <Mic size={16} className="text-green-500" />
44 ) : (
45 <MicOff size={16} className="text-red-500" />
46 )}
47 {participant.isVideoEnabled ? (
48 <Video size={16} className="text-green-500" />
49 ) : (
50 <VideoOff size={16} className="text-red-500" />
51 )}
52 {participant.isScreenSharing && (
53 <ScreenShare size={16} className="text-blue-500" />
54 )}
55 </div>
56 </div>
57 ))}
58 </div>
59 )}
60 </div>
61 );
62}
Then update your app/meetings/id/page.tsx file to render the ParticipantList
component:
Final Integration and Testing
We have completed our Google Meet Clone application using Next.js and Strapi. To test the application, follow the steps below: 1. Start your Strapi backend:
cd google-meet-backend
npm run develop
- Start your Next.js frontend:
cd google-meet-frontend
npm run dev
Github Source Code
The complete source code for this tutorial is available on GitHub. Please note that the Strapi backend code resides on the main
branch and the complete code is on the part_3
of the repo.
Series Wrap Up and Conclusion
In this "Building a Google Meet Clone with Strapi 5 and Next.js" blog series, we built a complete Google Meet clone with the following functionalities:
- Real-time video conferencing using WebRTC
- Screen sharing capabilities
- Chat functionality
- Participant management
See Strapi in action with an interactive demo
I am Software Engineer and Technical Writer. Proficient Server-side scripting and Database setup. Agile knowledge of Python, NodeJS, ReactJS, and PHP. When am not coding, I share my knowledge and experience with the other developers through technical articles