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:
export default ({ env }) => ({
enabled: true,
config: {
port: env.int("SOCKET_PORT", 1337),
cors: {
origin: env("SOCKET_CORS_ORIGIN", "*"),
methods: ["GET", "POST"],
},
},
});
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:
import { Core } from "@strapi/strapi";
interface MeetingParticipant {
socketId: string;
username: string;
}
interface Meeting {
participants: Map<string, MeetingParticipant>;
lastActivity: number;
}
export default ({ strapi }: { strapi: Core.Strapi }) => {
// Store active meetings and their participants
const activeMeetings = new Map<string, Meeting>();
// Cleanup inactive meetings periodically
const cleanupInterval = setInterval(
() => {
const now = Date.now();
activeMeetings.forEach((meeting, meetingId) => {
if (now - meeting.lastActivity > 1000 * 60 * 60) {
// 1 hour timeout
activeMeetings.delete(meetingId);
}
});
},
1000 * 60 * 15
);
return {
initialize() {
strapi.eventHub.on("socket.ready", async () => {
const io = (strapi as any).io;
if (!io) {
strapi.log.error("Socket.IO is not initialized");
return;
}
io.on("connection", (socket: any) => {
const { meetingId, userId } = socket.handshake.query;
strapi.log.info(
`Client connected - Socket: ${socket.id}, User: ${userId}, Meeting: ${meetingId}`
);
// Initialize meeting if it doesn't exist
if (!activeMeetings.has(meetingId)) {
activeMeetings.set(meetingId, {
participants: new Map(),
lastActivity: Date.now(),
});
}
socket.on("join-meeting", async ({ meetingId, userId }) => {
try {
// Get user data with username
const user = await strapi
.query("plugin::users-permissions.user")
.findOne({
where: { id: userId },
select: ["id", "username"],
});
strapi.log.info(`User ${userId} joining meeting ${meetingId}`);
const meeting = activeMeetings.get(meetingId);
if (!meeting) return;
// Add participant to meeting with both ID and username
meeting.participants.set(userId.toString(), {
socketId: socket.id,
username: user.username,
});
meeting.lastActivity = Date.now();
// Join socket room
socket.join(meetingId);
// Get current participants with their usernames
const currentParticipants = Array.from(
meeting.participants.entries()
)
.filter(([id]) => id !== userId.toString())
.map(([id, data]) => ({
userId: id,
username: data.username,
}));
// Send current participants to the joining user
socket.emit("participants-list", currentParticipants);
// Notify others about the new participant
socket.to(meetingId).emit("user-joined", {
userId: userId.toString(),
username: user.username,
});
strapi.log.info(
`Current participants in meeting ${meetingId}:`,
Array.from(meeting.participants.entries()).map(
([id, data]) => ({
id,
username: data.username,
})
)
);
} catch (error) {
strapi.log.error("Error in join-meeting:", error);
}
});
socket.on("chat-message", ({ message, meetingId }) => {
socket.to(meetingId).emit("chat-message", message);
});
const meeting = activeMeetings.get(meetingId);
if (!meeting) return;
socket.on("signal", ({ to, from, signal }) => {
console.log(
`Forwarding ${signal.type} signal from ${from} to ${to}`
);
const targetSocket = meeting.participants.get(
to.toString()
)?.socketId;
if (targetSocket) {
io.to(targetSocket).emit("signal", {
signal,
userId: from.toString(),
});
} else {
console.log(`No socket found for user ${to}`);
}
});
const handleDisconnect = () => {
const meeting = activeMeetings.get(meetingId);
if (!meeting) return;
// Find and remove the disconnected user
const disconnectedUserId = Array.from(
meeting.participants.entries()
).find(([_, socketId]) => socketId === socket.id)?.[0];
if (disconnectedUserId) {
meeting.participants.delete(disconnectedUserId);
meeting.lastActivity = Date.now();
// Notify others about the user leaving
socket.to(meetingId).emit("user-left", {
userId: disconnectedUserId,
});
strapi.log.info(
`User ${disconnectedUserId} left meeting ${meetingId}`
);
strapi.log.info(
`Remaining participants:`,
Array.from(meeting.participants.keys())
);
// Clean up empty meetings
if (meeting.participants.size === 0) {
activeMeetings.delete(meetingId);
strapi.log.info(
`Meeting ${meetingId} closed - no participants remaining`
);
}
}
};
socket.on("disconnect", handleDisconnect);
socket.on("leave-meeting", handleDisconnect);
});
strapi.log.info("Conference socket service initialized successfully");
});
},
destroy() {
clearInterval(cleanupInterval);
},
};
};
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:
import { Core } from "@strapi/strapi";
import { Server as SocketServer } from "socket.io";
interface SocketConfig {
cors: {
origin: string | string[];
methods: string[];
};
}
export default {
register({ strapi }: { strapi: Core.Strapi }) {
const socketConfig = strapi.config.get("socket.config") as SocketConfig;
if (!socketConfig) {
strapi.log.error("Invalid Socket.IO configuration");
return;
}
strapi.server.httpServer.on("listening", () => {
const io = new SocketServer(strapi.server.httpServer, {
cors: socketConfig.cors,
});
(strapi as any).io = io;
strapi.eventHub.emit("socket.ready");
});
},
bootstrap({ strapi }: { strapi: Core.Strapi }) {
const socketService = strapi.service("api::socket.socket") as {
initialize: () => void;
};
if (socketService && typeof socketService.initialize === "function") {
socketService.initialize();
} else {
strapi.log.error("Socket service or initialize method not found");
}
},
};
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:
'use client'
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import SimplePeer from "simple-peer"
import { io, Socket } from "socket.io-client"
import { getCookie } from "cookies-next"
import { User } from "@/types"
interface ExtendedSimplePeer extends SimplePeer.Instance {
_pc: RTCPeerConnection
}
interface Peer {
peer: SimplePeer.Instance
userId: string
stream?: MediaStream
}
export default function ConferenceRoom() {
const params = useParams()
const [peers, setPeers] = useState<Peer[]>([])
const [stream, setStream] = useState<MediaStream | null>(null)
const socketRef = useRef<Socket>()
const userVideo = useRef<HTMLVideoElement>(null)
const peersRef = useRef<Peer[]>([])
const [user, setUser] = useState<User | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [screenStream, setScreenStream] = useState<MediaStream | null>(null)
const [isScreenSharing, setIsScreenSharing] = useState(false)
useEffect(() => {
try {
const cookieValue = getCookie("auth-storage")
if (cookieValue) {
const parsedAuthState = JSON.parse(String(cookieValue))
setUser(parsedAuthState.state.user)
}
} catch (error) {
console.error("Error parsing auth cookie:", error)
}
}, [])
useEffect(() => {
if (!user?.id || !params.id) return
const cleanupPeers = () => {
peersRef.current.forEach((peer) => {
if (peer.peer) {
peer.peer.destroy()
}
})
peersRef.current = []
setPeers([])
}
cleanupPeers()
socketRef.current = io(process.env.NEXT_PUBLIC_STRAPI_URL || "", {
query: { meetingId: params.id, userId: user.id },
transports: ["websocket"],
reconnection: true,
reconnectionAttempts: 5,
})
socketRef.current.on("connect", () => {
setIsConnected(true)
console.log("Socket connected:", socketRef.current?.id)
})
socketRef.current.on("disconnect", () => {
setIsConnected(false)
console.log("Socket disconnected")
})
navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => {
setStream(stream)
if (userVideo.current) {
userVideo.current.srcObject = stream
}
socketRef.current?.emit("join-meeting", {
userId: user.id,
meetingId: params.id,
})
socketRef.current?.on("signal", ({ userId, signal }) => {
console.log("Received signal from:", userId, "Signal type:", signal.type)
let peer = peersRef.current.find((p) => p.userId === userId)
if (!peer && stream) {
console.log("Creating new peer for signal from:", userId)
const newPeer = createPeer(userId, stream, false)
peer = { peer: newPeer, userId }
peersRef.current.push(peer)
setPeers([...peersRef.current])
}
if (peer) {
try {
peer.peer.signal(signal)
} catch (err) {
console.error("Error processing signal:", err)
}
}
})
socketRef.current?.on("participants-list", (participants) => {
console.log("Received participants list:", participants)
cleanupPeers()
setPeers([...peersRef.current])
})
socketRef.current?.on("user-joined", ({ userId, username }) => {
console.log("New user joined:", userId)
if (userId !== user?.id.toString()) {
if (stream && !peersRef.current.find((p) => p.userId === userId)) {
console.log("Creating non-initiator peer for new user:", userId)
const peer = createPeer(userId, stream, false)
peersRef.current.push({ peer, userId })
setPeers([...peersRef.current])
}
}
})
socketRef.current?.on("user-left", ({ userId }) => {
console.log("User left:", userId)
const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
if (peerIndex !== -1) {
peersRef.current[peerIndex].peer.destroy()
peersRef.current.splice(peerIndex, 1)
setPeers([...peersRef.current])
}
})
})
.catch((error) => {
console.error("Error accessing media devices:", error)
})
return () => {
if (socketRef.current) {
socketRef.current.emit("leave-meeting", {
userId: user?.id,
meetingId: params.id,
})
socketRef.current.off("participants-list")
socketRef.current.off("user-joined")
socketRef.current.off("user-left")
socketRef.current.off("signal")
socketRef.current.disconnect()
}
if (stream) {
stream.getTracks().forEach((track) => track.stop())
}
cleanupPeers()
}
}, [user?.id, params.id])
useEffect(() => {
if (!socketRef.current) return
socketRef.current.on("media-state-change", ({ userId, type, enabled }) => {
})
return () => {
socketRef.current?.off("media-state-change")
}
}, [socketRef.current])
function createPeer(userId: string, stream: MediaStream, initiator: boolean): SimplePeer.Instance {
console.log(`Creating peer connection - initiator: ${initiator}, userId: ${userId}`)
const peer = new SimplePeer({
initiator,
trickle: false,
stream,
config: {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:global.stun.twilio.com:3478" },
],
},
})
peer.on("signal", (signal) => {
console.log(`Sending signal to ${userId}, type: ${signal.type}`)
socketRef.current?.emit("signal", {
signal,
to: userId,
from: user?.id,
})
})
peer.on("connect", () => {
console.log(`Peer connection established with ${userId}`)
})
peer.on("stream", (incomingStream) => {
console.log(`Received stream from ${userId}, tracks:`, incomingStream.getTracks())
const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
if (peerIndex !== -1) {
peersRef.current[peerIndex].stream = incomingStream
setPeers([...peersRef.current])
}
})
peer.on("error", (err) => {
console.error(`Peer error with ${userId}:`, err)
const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
if (peerIndex !== -1) {
peersRef.current[peerIndex].peer.destroy()
peersRef.current.splice(peerIndex, 1)
setPeers([...peersRef.current])
}
})
peer.on("close", () => {
console.log(`Peer connection closed with ${userId}`)
})
return peer
}
return (
<div className="flex flex-col gap-4 p-4">
<div className="text-sm text-gray-500">
{isConnected ? "Connected to server" : "Disconnected from server"}
</div>
{/* <ParticipantList /> */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="relative">
<video
ref={userVideo}
autoPlay
muted
playsInline
className="w-full rounded-lg bg-gray-900"
/>
<div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
You
</div>
</div>
{peers.map(({ peer, userId, stream }) => (
<PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
))}
</div>
</div>
)
}
function PeerVideo({ peer, userId, stream }: { peer: SimplePeer.Instance; userId: string; stream?: MediaStream }) {
const ref = useRef<HTMLVideoElement>(null)
useEffect(() => {
if (stream && ref.current) {
ref.current.srcObject = stream
}
const handleStream = (incomingStream: MediaStream) => {
if (ref.current) {
ref.current.srcObject = incomingStream
}
}
peer.on("stream", handleStream)
return () => {
if (ref.current) {
ref.current.srcObject = null
}
peer.off("stream", handleStream)
}
}, [peer, stream])
return (
<div className="relative">
<video ref={ref} autoPlay playsInline className="w-full rounded-lg bg-gray-900" />
<div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
</div>
</div>
)
}
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:
//...
import { ScreenShare, StopScreenShare } from 'lucide-react';
interface ExtendedSimplePeer extends SimplePeer.Instance {
_pc: RTCPeerConnection;
}
//...
export default function ConferenceRoom() {
//...
const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const toggleScreenShare = async () => {
if (!isScreenSharing) {
try {
const screen = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
// Handle when the user clicks the "Stop sharing" button in the browser
screen.getVideoTracks()[0].addEventListener("ended", () => {
stopScreenSharing();
});
setScreenStream(screen);
setIsScreenSharing(true);
// Replace video track for all peers
peersRef.current.forEach(({ peer }) => {
const videoTrack = screen.getVideoTracks()[0];
const extendedPeer = peer as ExtendedSimplePeer;
const sender = extendedPeer._pc
.getSenders()
.find((s) => s.track?.kind === "video");
if (sender) {
sender.replaceTrack(videoTrack);
}
});
// Replace local video
if (userVideo.current) {
userVideo.current.srcObject = screen;
}
} catch (error) {
console.error("Error sharing screen:", error);
}
} else {
stopScreenSharing();
}
};
const stopScreenSharing = () => {
if (screenStream) {
screenStream.getTracks().forEach((track) => track.stop());
setScreenStream(null);
setIsScreenSharing(false);
// Revert to camera video for all peers
if (stream) {
peersRef.current.forEach(({ peer }) => {
const videoTrack = stream.getVideoTracks()[0];
const extendedPeer = peer as ExtendedSimplePeer;
const sender = extendedPeer._pc
.getSenders()
.find((s) => s.track?.kind === "video");
if (sender) {
sender.replaceTrack(videoTrack);
}
});
// Revert local video
if (userVideo.current) {
userVideo.current.srcObject = stream;
}
}
}
};
//...
return (
<div className="flex flex-col gap-4 p-4">
<div className="text-sm text-gray-500">
{isConnected ? "Connected to server" : "Disconnected from server"}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="relative">
<video
ref={userVideo}
autoPlay
muted
playsInline
className="w-full rounded-lg bg-gray-900"
/>
<div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
You
</div>
</div>
{peers.map(({ peer, userId, stream }) => (
<PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
))}
</div>
{/* added this button to handle the start screen and stop sharing. */}
<button
onClick={toggleScreenShare}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
style={{ width: "15rem" }}
>
{isScreenSharing ? (
<>
<StopCircleIcon className="w-5 h-5" />
Stop Sharing
</>
) : (
<>
<ScreenShare className="w-5 h-5" />
Share Screen
</>
)}
</button>
//
</div>
);
}
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
:
"use client";
import { useState, useEffect, useRef } from "react";
import { useAuthStore } from "@/store/auth-store";
import { Socket } from "socket.io-client";
import { User } from "@/types";
interface ChatProps {
socketRef: React.MutableRefObject<Socket | undefined>; // Changed from RefObject to MutableRefObject
user: User;
meetingId: string;
}
interface Message {
userId: string;
username: string;
text: string;
timestamp: number;
}
function Chat({ socketRef, user, meetingId }: ChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState("");
const [isExpanded, setIsExpanded] = useState(true);
const chatRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const socket = socketRef.current;
if (!socket) return;
const handleChatMessage = (message: Message) => {
setMessages((prev) => [...prev, message]);
};
socket.on("chat-message", handleChatMessage);
return () => {
socket?.off("chat-message", handleChatMessage);
};
}, [socketRef.current]);
useEffect(() => {
if (chatRef.current) {
chatRef.current.scrollTop = chatRef.current.scrollHeight;
}
}, [messages]);
const sendMessage = (e: React.FormEvent) => {
e.preventDefault();
const socket = socketRef.current;
if (!socket || !newMessage.trim()) return;
const message: Message = {
userId: user.id.toString(),
username: user.username,
text: newMessage,
timestamp: Date.now(),
};
socket.emit("chat-message", {
message,
meetingId,
});
setMessages((prev) => [...prev, message]);
setNewMessage("");
};
return (
<div className="fixed right-4 bottom-4 w-80 bg-white rounded-lg shadow-lg flex flex-col border">
<div
className="p-3 border-b flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<h3 className="font-medium text-gray-600">Chat</h3>
<button className="text-gray-500 hover:text-gray-700">
{isExpanded ? "â–¼" : "â–²"}
</button>
</div>
{isExpanded && (
<>
<div
ref={chatRef}
className="flex-1 overflow-y-auto p-4 space-y-4 max-h-96"
>
{messages.map((message, index) => (
<div
key={index}
className={`flex ${
message.userId === user.id.toString()
? "justify-end"
: "justify-start"
}`}
>
<div
className={`max-w-xs px-4 py-2 rounded-lg ${
message.userId === user.id.toString()
? "bg-blue-600 text-white"
: "bg-gray-400"
}`}
>
{message.userId !== user.id.toString() && (
<p className="text-xs font-medium mb-1">
{message.username}
</p>
)}
<p className="break-words">{message.text}</p>
<span className="text-xs opacity-75 block mt-1">
{new Date(message.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
))}
</div>
<form onSubmit={sendMessage} className="p-4 border-t">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Type a message..."
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
disabled={!newMessage.trim()}
>
Send
</button>
</div>
</form>
</>
)}
</div>
);
}
export 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.
//...
socket.on("chat-message", ({ message, meetingId }) => {
socket.to(meetingId).emit("chat-message", message);
});
//...
Render Chat Component
Then update your app/meetings/[id]/page.tsx
file to render the Chat component in your return statement:
//...
import Chat from "@/components/meeting/chat";
//...
export default function ConferenceRoom() {
//...
return (
<div className="flex flex-col gap-4 p-4">
<div className="text-sm text-gray-500">
{isConnected ? "Connected to server" : "Disconnected from server"}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="relative">
<video
ref={userVideo}
autoPlay
muted
playsInline
className="w-full rounded-lg bg-gray-900"
/>
<div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
You
</div>
</div>
{peers.map(({ peer, userId, stream }) => (
<PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
))}
</div>
<button
onClick={toggleScreenShare}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
style={{ width: "15rem" }}
>
{isScreenSharing ? (
<>
<StopCircleIcon className="w-5 h-5" />
Stop Sharing
</>
) : (
<>
<ScreenShare className="w-5 h-5" />
Share Screen
</>
)}
</button>
<Chat
socketRef={socketRef}
user={user as User}
meetingId={params.id as string}
/>{" "}
</div>
);
}
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
:
import { useRouter } from 'next/navigation';
import {
Mic,
MicOff,
Video,
VideoOff,
ScreenShare,
StopCircleIcon,
Phone,
} from 'lucide-react';
import { Socket } from 'socket.io-client';
import { useState } from 'react';
interface ControlsProps {
stream: MediaStream | null;
screenStream: MediaStream | null;
isScreenSharing: boolean;
socketRef: React.MutableRefObject<Socket | undefined>;
peersRef: React.MutableRefObject<any[]>;
meetingId: string;
userId: string;
onScreenShare: () => Promise<void>;
}
export default function Controls({
stream,
screenStream,
isScreenSharing,
socketRef,
peersRef,
meetingId,
userId,
onScreenShare,
}: ControlsProps) {
const router = useRouter();
const [isAudioEnabled, setIsAudioEnabled] = useState(true);
const [isVideoEnabled, setIsVideoEnabled] = useState(true);
const toggleAudio = () => {
if (stream) {
stream.getAudioTracks().forEach((track) => {
track.enabled = !isAudioEnabled;
});
setIsAudioEnabled(!isAudioEnabled);
// Notify peers about audio state change
socketRef.current?.emit('media-state-change', {
meetingId,
userId,
type: 'audio',
enabled: !isAudioEnabled,
});
}
};
const toggleVideo = () => {
if (stream) {
stream.getVideoTracks().forEach((track) => {
track.enabled = !isVideoEnabled;
});
setIsVideoEnabled(!isVideoEnabled);
// Notify peers about video state change
socketRef.current?.emit('media-state-change', {
meetingId,
userId,
type: 'video',
enabled: !isVideoEnabled,
});
}
};
const handleLeave = () => {
// Stop all tracks
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
if (screenStream) {
screenStream.getTracks().forEach(track => track.stop());
}
// Clean up peer connections
peersRef.current.forEach(peer => {
if (peer.peer) {
peer.peer.destroy();
}
});
// Notify server
socketRef.current?.emit('leave-meeting', {
meetingId,
userId,
});
// Disconnect socket
socketRef.current?.disconnect();
router.push('/meetings');
};
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t p-4 shadow-lg">
<div className="max-w-4xl mx-auto flex justify-center gap-4">
<button
onClick={toggleAudio}
className={`p-3 rounded-full transition-colors ${
isAudioEnabled
? 'bg-gray-600 hover:bg-gray-500'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
title={isAudioEnabled ? 'Mute' : 'Unmute'}
>
{isAudioEnabled ? <Mic size={24} /> : <MicOff size={24} />}
</button>
<button
onClick={toggleVideo}
className={`p-3 rounded-full transition-colors ${
isVideoEnabled
? 'bg-gray-600 hover:bg-gray-500'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
title={isVideoEnabled ? 'Stop Video' : 'Start Video'}
>
{isVideoEnabled ? <Video size={24} /> : <VideoOff size={24} />}
</button>
<button
onClick={onScreenShare}
className={`p-3 rounded-full transition-colors ${
isScreenSharing
? 'bg-blue-500 hover:bg-blue-600 text-white'
: 'bg-gray-600 hover:bg-gray-600'
}`}
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
>
{isScreenSharing ? (
<StopCircleIcon size={24} />
) : (
<ScreenShare size={24} />
)}
</button>
<button
onClick={handleLeave}
className="p-3 rounded-full bg-red-500 hover:bg-red-600 text-white transition-colors"
title="Leave Meeting"
>
<Phone size={24} className="rotate-[135deg]" />
</button>
</div>
</div>
);
}
Render the Controls Component
Now update your app/meetings/[id]/page.tsx
file to render the Control component in your return statement:
//...
import Controls from "@/components/meeting/controls";
//...
export default function ConferenceRoom() {
//...
return (
<div className="flex flex-col gap-4 p-4">
<div className="text-sm text-gray-500">
{isConnected ? "Connected to server" : "Disconnected from server"}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="relative">
<video
ref={userVideo}
autoPlay
muted
playsInline
className="w-full rounded-lg bg-gray-900"
/>
<Controls
stream={stream}
screenStream={screenStream}
isScreenSharing={isScreenSharing}
socketRef={socketRef}
peersRef={peersRef}
meetingId={params.id as string}
userId={user?.id.toString() || ""}
onScreenShare={toggleScreenShare}
/>
<div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
You
</div>
</div>
{peers.map(({ peer, userId, stream }) => (
<PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
))}
</div>
<Chat
socketRef={socketRef}
user={user as User}
meetingId={params.id as string}
/>{" "}
</div>
);
//...
}
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
:
import { create } from "zustand";
interface Participant {
id: string;
username: string;
isAudioEnabled: boolean;
isVideoEnabled: boolean;
isScreenSharing: boolean;
isHost?: boolean;
}
interface MeetingState {
participants: Record<string, Participant>;
addParticipant: (participant: Participant) => void;
removeParticipant: (id: string) => void;
updateParticipant: (id: string, updates: Partial<Participant>) => void;
updateMediaState: (id: string, type: 'audio' | 'video' | 'screen', enabled: boolean) => void;
clearParticipants: () => void;
}
export const useMeetingStore = create<MeetingState>((set) => ({
participants: {},
addParticipant: (participant) =>
set((state) => ({
participants: {
...state.participants,
[participant.id]: {
...participant,
isAudioEnabled: true,
isVideoEnabled: true,
isScreenSharing: false,
...state.participants[participant.id],
},
},
})),
removeParticipant: (id) =>
set((state) => {
const { [id]: removed, ...rest } = state.participants;
return { participants: rest };
}),
updateParticipant: (id, updates) =>
set((state) => ({
participants: {
...state.participants,
[id]: {
...state.participants[id],
...updates,
},
},
})),
updateMediaState: (id, type, enabled) =>
set((state) => ({
participants: {
...state.participants,
[id]: {
...state.participants[id],
[type === 'audio' ? 'isAudioEnabled' :
type === 'video' ? 'isVideoEnabled' : 'isScreenSharing']: enabled,
},
},
})),
clearParticipants: () =>
set({ participants: {} }),
}));
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
:
'use client';
import { useState } from 'react';
import { Mic, MicOff, Video, VideoOff, ScreenShare, Users, ChevronDown, ChevronUp } from 'lucide-react';
import { useMeetingStore } from '@/store/meeting-store';
export default function ParticipantList() {
const [isExpanded, setIsExpanded] = useState(true);
const participants = useMeetingStore((state) => state.participants);
const participantCount = Object.keys(participants).length;
return (
<div className="fixed left-4 bottom-5 w-80 bg-white rounded-lg shadow-lg border z-50">
<div
className="p-3 border-b flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<Users className='text-gray-600' size={20} />
<h2 className="font-medium text-gray-600">Participants ({participantCount})</h2>
</div>
<button className="text-gray-500 hover:text-gray-700">
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
</div>
{isExpanded && (
<div className="max-h-96 overflow-y-auto p-4 space-y-2">
{Object.values(participants).map((participant) => (
<div
key={participant.id}
className="flex items-center justify-between p-2 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-600">{participant.username}</span>
{participant.isHost && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">
Host
</span>
)}
</div>
<div className="flex gap-2">
{participant.isAudioEnabled ? (
<Mic size={16} className="text-green-500" />
) : (
<MicOff size={16} className="text-red-500" />
)}
{participant.isVideoEnabled ? (
<Video size={16} className="text-green-500" />
) : (
<VideoOff size={16} className="text-red-500" />
)}
{participant.isScreenSharing && (
<ScreenShare size={16} className="text-blue-500" />
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
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:
- 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
Strapi 5 is now live. 👉 Start building!
Top comments (0)