owned this note
owned this note
Published
Linked with GitHub
# Building a Google Meet Clone with Strapi 5 and Next.js - Part 3
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 with Strapi 5](https://).
- Part 2: [Building the Frontend with Next.js](https://).
- Part 3: [Real-Time Features, Video Integration, and Screen Sharing](https://).
## Prerequisites
Before starting, ensure you have:
- Completed Parts 1 and 2 of the tutorial series.
- Basic understanding of 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 2, let's add the required WebRTC and Socket.io packages by running the command below:
```bash
npm install socket.io-client @types/webrtc simple-peer @types/simple-peer
```
For the Strapi backend, install the Socket.io package:
```bash
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:
```typescript
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. Add the code snippets below 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:
```typescript
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);
},
};
};
```
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:
```typescript
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 a new page for the video conference room in `src/app/meetings/[id]/page.tsx` file and add the code snippets below:
```typescript
'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>
)
}
```
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 like `join-meeting`, `signal`, `user-joined`, and `user-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 like `signal`, `connect`, `stream`, and `error`. The video streams are displayed using the `userVideo` ref for the local user and a separate `PeerVideo` component for remote participants, which manages individual video elements and their streams. It uses `socket.current` for maintaining the WebSocket connection and handles cleanup using `useEffect's` return function, ensuring all peer connections are properly destroyed and media streams are stopped when the component unmounts.
![Screenshot 2024-10-24 at 16.31.52](https://hackmd.io/_uploads/ryjodyOe1l.png)
## Implementing Screen Sharing
To allow users to share their screen while on the call, let's add screen-sharing functionality to the conference room:
```typescript
//...
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` and `stopScreenSharing` functions, where `toggleScreenShare` uses `navigator.mediaDevices.getDisplayMedia` to capture the user's screen as a `MediaStream`, storing it in `screenStream` state and tracking its status with `isScreenSharing`. When screen sharing is activated, it cleverly 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.
![Screen sharing functionality](https://hackmd.io/_uploads/rkICaJ_xkg.png)
## Adding Real-Time Chat
Next, let's add a chat functionality to allow users to chat in real time while on the call. Create a chat component in `src/components/meeting/chat.tsx`:
```typescript
"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;
```
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.
```typescript
//...
socket.on("chat-message", ({ message, meetingId }) => {
socket.to(meetingId).emit("chat-message", message);
});
//...
```
Then update your `app/meetings/[id]/page.tsx` file to render the **Chat** component in your return statement:
```typescript
//...
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>
);
}
```
![Adding chat functionality](https://hackmd.io/_uploads/S1mUgeOxkl.png)
## 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 a controls component in `src/components/meeting/controls.tsx`:
```typescript
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>
);
}
```
Now update your `app/meetings/[id]/page.tsx` file to render the **Control** component in your return statement:
```typescript
//...
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 video controls](https://hackmd.io/_uploads/ryCOzg_ekg.png)
## 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`:
```typescript
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: {} }),
}));
```
The above code 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). We added the `addParticipant` handles new joiners with default media states enabled, `removeParticipant` cleanly removes users using object destructuring, `updateParticipant` allows for partial updates to any participant's data, `updateMediaState` specifically manages toggling of audio/video/screen states, and `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`:
```typescript
'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:
![Showing meeting participants](https://hackmd.io/_uploads/rJUejx_gJg.png)
## 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:
```bash
cd google-meet-backend
npm run develop
```
2. Start your Next.js frontend:
```bash
cd google-meet-frontend
npm run dev
```
## Series Wrap Up
In this "Building a Google Meet Clone with Strapi 5 and Next.js" blog series, built a complete Google Meet clone with the following functionalities:
- Real-time video conferencing using WebRTC
- Screen sharing capabilities
- Chat functionality
- Participant management
The complete code for this tutorial is available [here](https://github.com/icode247/google-meet-clone/tree/part_3) on my Github repository. I hope you enjoyed this series. Happy coding!