یک کلون ملاقات Google را با Strapi 5 و Next.js بسازید – قسمت 2

به قسمت آخر سری آموزش کلون Google Meet ما خوش آمدید! در این بخش ، کنفرانس ویدیویی ، اشتراک گذاری صفحه و عملکرد چت در زمان واقعی را با استفاده از WeBRTC و Socket.io پیاده سازی خواهیم کرد.
برای اهداف مرجع ، در اینجا طرح کلی این مجموعه وبلاگ وجود دارد:
قبل از شروع ، اطمینان حاصل کنید که:
نصب وابستگی ها
برای ادامه از جایی که ما در قسمت 1 از آنجا خارج شدیم ، بیایید بسته های مورد نیاز Webrtc و Socket.io را با اجرای دستور زیر در پوشه پروژه جلوی خود اضافه کنیم:
npm install socket.io-client @types/webrtc simple-peer @types/simple-peer
در اینجا چیزی است که ما در داخل پروژه جلوی خود نصب کردیم:
-
Socket.io-Client: مشتری چارچوب برنامه Realtime
-
@Types/Webrtc: تعاریف TypeScript برای Webrtc.
-
Simple-Peer: برنامه وب موبایل گروه های حداکثر چهار نفر را در یک تماس صوتی و تصویری WEBRTC به همتا به همتا متصل می کند تا بتوانند شخصیت بی نظیر را به طور متقابل اثبات کنند.
-
@Types/Simple-Peer: تعاریف TypeScript برای Peer Simple.
برای Backend Strapi ، بسته Socket.io را نصب کنید:
cd ../google-meet-clone-backend
npm install socket.io
تنظیم سرور WebSocket
اول ، ایجاد یک socket.ts
پرونده در config
پوشه و کد زیر را اضافه کنید:
export default ({ env }) => ({
enabled: true,
config: {
port: env.int("SOCKET_PORT", 1337),
cors: {
origin: env("SOCKET_CORS_ORIGIN", "*"),
methods: ["GET", "POST"],
},
},
});
سپس یک پوشه جدید به نام ایجاد کنید socket
در api
دایرکتوری برای API سوکت. در api/socket
دایرکتوری ، یک پوشه جدید به نام ایجاد کنید services
و الف socket.ts
پرونده در services
پوشه
شنوندگان رویداد را برای اتصال همسالان در زمان واقعی در Strapi ایجاد کنید
قطعه های کد را در زیر اضافه کنید api/socket/services/socket.ts
پرونده برای تنظیم و تنظیم یک اتصال سوکت ، و ایجاد همه شنوندگان رویداد که ما برای برقراری ارتباط با مشتری بعدی خود برای ارتباط با همسالان در زمان واقعی ارتباط برقرار می کنیم:
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);
},
};
};
Socket.io Server را با Strapi Initialize کنید
سپس خود را به روز کنید src/index.ts
پرونده برای اولیه سازی سرور Socket.io ، تنظیم شنوندگان رویداد برای به روزرسانی ها و خلاقیت های کاربر و ادغام سرویس سوکت با 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");
}
},
};
اجرای صفحه جلسه ویدیویی
با اتصال سوکت و وقایع ما ایجاد شده ، بیایید یک صفحه جلسه ویدیویی در زمان واقعی ایجاد کنیم تا اتاق های کنفرانس ویدیویی را اداره کنیم تا کاربران بتوانند جلسات ویدیویی داشته باشند.
ایجاد صفحه برای اتاق کنفرانس ویدیویی
یک صفحه جدید برای اتاق کنفرانس ویدیویی ایجاد کنید src/app/meetings/[id]/page.tsx
پرونده های زیر را وارد کنید و اضافه کنید:
'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>
{/* */}
<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>
)
}
در اینجا آنچه کد فوق انجام می دهد آمده است:
- کد فوق رسیدگی می کند webrtc و Socket.io چت ویدیویی همتا به همتا. استفاده می کند ساده برای رسیدگی به اتصالات WEBRTC ، حفظ آرایه ایالت همسالان ، و
peersRef
برای ردیابی همه کاربران متصل. - این مؤلفه با دریافت ویدیوی و جریان صوتی کاربر با استفاده از برنامه اولیه آغاز می شود
getUserMedia
، سپس اتصالات سوکت را با رویدادهایی مانند تنظیم می کندjoin-meeting
باsignal
باuser-joined
وتuser-left
برای رسیدگی به ارتباطات در زمان واقعی. - در
createPeer
عملکرد ستون فقرات است ، ایجاد اتصالات جدید همسالان با سرورهای یخ برای Nat Traversal ضمن رسیدگی به رویدادهای مختلف همسالان مانندsignal
باconnect
باstream
وتerror
بشر - جریان های ویدئویی با استفاده از
userVideo
Ref برای کاربر محلی و جداگانهPeerVideo
مؤلفه شرکت کنندگان از راه دور ، که عناصر ویدیویی فردی و جریان آنها را مدیریت می کند. استفاده می کندsocket.current
برای حفظ اتصال WebSocket و استفاده از پاکسازی با استفاده ازuseEffect's
عملکرد بازگشت ، اطمینان از نابودی تمام اتصالات همسالان و جریان رسانه ها در هنگام نابودی مؤلفه متوقف می شوند.
اجرای اشتراک گذاری صفحه
برای اینکه کاربران بتوانند هنگام تماس ، صفحه نمایش خود را به اشتراک بگذارند ، بیایید عملکرد اشتراک صفحه نمایش را به اتاق کنفرانس اضافه کنیم:
//...
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>
);
}
در کد فوق:
- ما اضافه کردیم
toggleScreenShare
وتstopScreenSharing
توابع ، کجاtoggleScreenShare
کاربردهایnavigator.mediaDevices.getDisplayMedia
برای ضبط صفحه کاربر به عنوان یکMediaStream
، ذخیره آن درscreenStream
حالت و پیگیری وضعیت آن باisScreenSharing
بشر - هنگامی که به اشتراک گذاری صفحه فعال می شود ، جایگزین آهنگ های ویدیویی برای همه اتصالات همسالان با استفاده از RTCPeerConnection's می شود
getSenders().replaceTrack
روش ، تغییر آنچه هر یک از شرکت کنندگان از دوربین به محتوای صفحه می بیند. - در
stopScreenSharing
عملکرد با متوقف کردن تمام آهنگ های به اشتراک گذاری صفحه نمایش و بازگشت همه به ویدیوی دوربین ، پاکسازی را انجام می دهد. -
اضافه کردن چت در زمان واقعی
در مرحله بعد ، بیایید یک عملکرد چت را اضافه کنیم تا کاربران بتوانند در هنگام تماس در زمان واقعی گپ بزنند.
مؤلفه چت ایجاد کنید
یک مؤلفه چت در 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;
چت را به کلیه مشتریان متصل با Strapi پخش کنید
اکنون سرویس سوکت Strapi خود را در خود به روز کنید api/socket/services/socket.ts
پرونده برای پخش گپ به کلیه مشتریان متصل در جلسه.
//...
socket.on("chat-message", ({ message, meetingId }) => {
socket.to(meetingId).emit("chat-message", message);
});
//...
مؤلفه چت را ارائه دهید
سپس خود را به روز کنید app/meetings/[id]/page.tsx
پرونده برای ارائه گپ مؤلفه در بیانیه بازگشت شما:
//...
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>
);
}
تنظیم کنترل جلسات
حال بیایید کمی چیزها را کمی جلوتر کنیم و برنامه ما را بیشتر شبیه Google Meeting کنیم.
بیایید یک کنترل مرکزی برای کنفرانس ویدیویی داشته باشیم و ویژگی هایی مانند جهش و از بین بردن میکروفون ، خاموش و روشن کردن فیلم ، به اشتراک گذاری صفحه و ترک جلسه را اضافه کنیم.
مؤلفه Controls را ایجاد کنید
یک مؤلفه کنترل در 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} /> : {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} /> : {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>
);
}
مؤلفه کنترل را ارائه دهید
اکنون خود را به روز کنید app/meetings/[id]/page.tsx
پرونده برای ارائه کنترل کردن مؤلفه در بیانیه بازگشت شما:
//...
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>
);
//...
}
افزودن مدیریت وضعیت جلسه
برای مدیریت وضعیت کاربران در یک جلسه ، مانند دانستن اینکه کاربر هنگام ورود کاربر در جلسه ، و دیدن لیست همه شرکت کنندگان که به این تماس پیوسته اند ، می بینیم ، بیایید یک فروشگاه جدید برای رسیدگی به وضعیت جلسه ایجاد کنیم.
ایجاد یک فروشگاه ملاقات در 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: {} }),
}));
در اینجا آنچه کد فوق انجام می دهد آمده است:
- وضعیت شرکت کنندگان در کنفرانس ویدیویی ما را با استفاده از یک
Participant
رابط برای ردیابی شناسه ، نام کاربری و حالت های رسانه ای هر کاربر (به اشتراک گذاری صوتی ، ویدئو و صفحه). - اضافه کردن
addParticipant
که با ایالات متحده به طور پیش فرض ، وصل کننده های جدید را کنترل می کند. - در
removeParticipant
کاربران را با استفاده از تخریب شیء پاک می کند. - در
updateParticipant
اجازه می دهد تا به روزرسانی های جزئی به داده های هر شرکت کننده بپردازید. - در
updateMediaState
به طور خاص مدیریت حالت های صوتی/تصویری/صفحه نمایش را مدیریت می کند. - در
clearParticipants
تمام شرکت کنندگان را پاک می کند.
اجرای لیست شرکت کنندگان
حالا بیایید از meeting-store
برای نمایش لیستی از شرکت کنندگان در یک جلسه. یک مؤلفه لیست شرکت کننده در ایجاد کنید 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} /> : {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>
);
}
سپس برنامه/جلسات خود را به روز کنید/[id]/page.tsx پرونده برای ارائه ParticipantList
مؤلفه:
ادغام و آزمایش نهایی
ما برنامه Clone Google Meet خود را با استفاده از Next.js و Strapi تکمیل کرده ایم. برای آزمایش برنامه ، مراحل زیر را دنبال کنید:
- Backend Strapi خود را شروع کنید:
cd google-meet-backend
npm run develop
- Next.js Frontend خود را شروع کنید:
cd google-meet-frontend
npm run dev
کد منبع GitHub
کد منبع کامل این آموزش در GitHub موجود است. لطفاً توجه داشته باشید که کد باطن Strapi در آن ساکن است main
شاخه و کد کامل در part_3
از repo
پایان سری و نتیجه گیری
در این مجموعه وبلاگ “ساخت یک کلون ملاقات با Google با Strapi 5 و Next.js” ، ما یک کلون کامل Google Meet را با قابلیت های زیر ساختیم:
- کنفرانس ویدیویی در زمان واقعی با استفاده از Webrtc
- قابلیت های اشتراک گذاری صفحه نمایش
- قابلیت چت
- مدیریت شرکت کننده
Strapi 5 اکنون زنده است. 👉 شروع به ساختن!