برنامه نویسی

یک کلون ملاقات 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 عملکرد بازگشت ، اطمینان از نابودی تمام اتصالات همسالان و جریان رسانه ها در هنگام نابودی مؤلفه متوقف می شوند.

ایجاد صفحه برای اتاق کنفرانس ویدیویی. png

اجرای اشتراک گذاری صفحه

برای اینکه کاربران بتوانند هنگام تماس ، صفحه نمایش خود را به اشتراک بگذارند ، بیایید عملکرد اشتراک صفحه نمایش را به اتاق کنفرانس اضافه کنیم:

//...
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 عملکرد با متوقف کردن تمام آهنگ های به اشتراک گذاری صفحه نمایش و بازگشت همه به ویدیوی دوربین ، پاکسازی را انجام می دهد.
  • اجرای اشتراک گذاری صفحه نمایش. png

اضافه کردن چت در زمان واقعی

در مرحله بعد ، بیایید یک عملکرد چت را اضافه کنیم تا کاربران بتوانند در هنگام تماس در زمان واقعی گپ بزنند.

مؤلفه چت ایجاد کنید

یک مؤلفه چت در 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>
  );
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

رندر مؤلفه چت. png

تنظیم کنترل جلسات

حال بیایید کمی چیزها را کمی جلوتر کنیم و برنامه ما را بیشتر شبیه 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>
  );

  //...
}
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

controls component.png را ارائه دهید

افزودن مدیریت وضعیت جلسه

برای مدیریت وضعیت کاربران در یک جلسه ، مانند دانستن اینکه کاربر هنگام ورود کاربر در جلسه ، و دیدن لیست همه شرکت کنندگان که به این تماس پیوسته اند ، می بینیم ، بیایید یک فروشگاه جدید برای رسیدگی به وضعیت جلسه ایجاد کنیم.

ایجاد یک فروشگاه ملاقات در 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 مؤلفه:

اجرای لیست شرکت کنندگان. png

ادغام و آزمایش نهایی

ما برنامه Clone Google Meet خود را با استفاده از Next.js و Strapi تکمیل کرده ایم. برای آزمایش برنامه ، مراحل زیر را دنبال کنید:

  1. Backend Strapi خود را شروع کنید:
cd google-meet-backend
npm run develop
حالت تمام صفحه را وارد کنید

از حالت تمام صفحه خارج شوید

  1. 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 اکنون زنده است. 👉 شروع به ساختن!

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا