بناء تطبيق فيديو ومكالمات صوتية في الوقت الحقيقي مع LiveKit و Next.js

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

التواصل في الوقت الحقيقي عبر الفيديو والصوت أصبح من المتطلبات الأساسية في التطبيقات الحديثة — من مؤتمرات الفيديو إلى وكلاء الذكاء الاصطناعي الصوتيين إلى البث المباشر. لكن بناء هذه الأنظمة من الصفر باستخدام WebRTC مباشرة أمر معقد للغاية: تحتاج لإدارة خوادم TURN/STUN، والتفاوض على الجلسات، والتعامل مع مشاكل الشبكة.

LiveKit يحل هذه المشكلة بتوفير بنية تحتية مفتوحة المصدر للتواصل في الوقت الحقيقي. يدير لك كل تعقيدات WebRTC ويقدم واجهات برمجة تطبيقات بسيطة لإنشاء الغرف وإدارة المشاركين وبث الوسائط. مع مكتبات React الجاهزة، يمكنك بناء تطبيق مكالمات فيديو كامل بأقل جهد.

في هذا الدليل، ستبني تطبيق مؤتمرات فيديو يدعم:

  • غرف متعددة المشاركين مع فيديو وصوت
  • مشاركة الشاشة
  • التحكم بالميكروفون والكاميرا
  • عرض شبكي للمشاركين
  • إنشاء رموز وصول آمنة من الخادم
  • واجهة مستخدم متجاوبة وعصرية

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت
  • معرفة أساسية بـ React و TypeScript
  • إلمام بـ Next.js App Router
  • حساب مجاني على LiveKit Cloud (أو خادم LiveKit محلي عبر Docker)
  • محرر أكواد (يُنصح بـ VS Code)

ما ستبنيه

تطبيق مؤتمرات فيديو كامل يتضمن:

  • صفحة انضمام — يُدخل المستخدم اسمه واسم الغرفة ثم ينضم
  • غرفة فيديو — عرض شبكي لجميع المشاركين مع بثهم المباشر
  • شريط أدوات — أزرار للتحكم بالميكروفون والكاميرا ومشاركة الشاشة والمغادرة
  • واجهة API آمنة — إنشاء رموز وصول JWT من جانب الخادم
  • حالة الاتصال — مؤشرات بصرية لحالة كل مشارك

الخطوة 1: إنشاء مشروع Next.js

أنشئ مشروع Next.js 15 جديد:

npx create-next-app@latest livekit-video --typescript --tailwind --eslint --app --src-dir --use-npm
cd livekit-video

ثبّت حزم LiveKit:

npm install livekit-client livekit-server-sdk @livekit/components-react @livekit/components-styles
  • livekit-client — مكتبة العميل للتواصل مع خادم LiveKit
  • livekit-server-sdk — إنشاء رموز الوصول من جانب الخادم
  • @livekit/components-react — مكونات React جاهزة للفيديو والصوت
  • @livekit/components-styles — أنماط CSS الافتراضية للمكونات

الخطوة 2: إعداد متغيرات البيئة

أنشئ ملف .env.local في جذر المشروع:

LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your_api_key
LIVEKIT_API_SECRET=your_api_secret

الحصول على المفاتيح

  1. سجل في LiveKit Cloud
  2. أنشئ مشروعًا جديدًا
  3. انسخ URL و API Key و API Secret من لوحة التحكم

بدلاً من ذلك، يمكنك تشغيل خادم LiveKit محليًا باستخدام Docker:

docker run --rm -p 7880:7880 -p 7881:7881 -p 7882:7882/udp \
  -e LIVEKIT_KEYS="devkey: secret" \
  livekit/livekit-server

في هذه الحالة، استخدم:

LIVEKIT_URL=ws://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret

الخطوة 3: إنشاء واجهة API لرموز الوصول

أنشئ ملف src/app/api/token/route.ts:

import { AccessToken } from "livekit-server-sdk";
import { NextRequest, NextResponse } from "next/server";
 
export async function POST(request: NextRequest) {
  const { roomName, participantName } = await request.json();
 
  if (!roomName || !participantName) {
    return NextResponse.json(
      { error: "roomName and participantName are required" },
      { status: 400 }
    );
  }
 
  const apiKey = process.env.LIVEKIT_API_KEY;
  const apiSecret = process.env.LIVEKIT_API_SECRET;
 
  if (!apiKey || !apiSecret) {
    return NextResponse.json(
      { error: "Server misconfigured" },
      { status: 500 }
    );
  }
 
  const token = new AccessToken(apiKey, apiSecret, {
    identity: participantName,
    name: participantName,
  });
 
  token.addGrant({
    room: roomName,
    roomJoin: true,
    canPublish: true,
    canSubscribe: true,
    canPublishData: true,
  });
 
  const jwt = await token.toJwt();
 
  return NextResponse.json({ token: jwt });
}

هذه الواجهة تقوم بـ:

  1. استقبال اسم الغرفة واسم المشارك من طلب POST
  2. التحقق من وجود البيانات المطلوبة
  3. إنشاء رمز وصول JWT باستخدام LiveKit Server SDK
  4. منح الصلاحيات — الانضمام للغرفة، النشر، والاشتراك
  5. إرجاع الرمز للعميل

الرمز يمنح المشارك كامل الصلاحيات: البث، الاستقبال، وإرسال البيانات. في بيئة الإنتاج، خصص الصلاحيات حسب دور المستخدم.

الخطوة 4: بناء صفحة الانضمام

أنشئ ملف src/app/page.tsx:

"use client";
 
import { useState, FormEvent } from "react";
import { useRouter } from "next/navigation";
 
export default function JoinPage() {
  const [participantName, setParticipantName] = useState("");
  const [roomName, setRoomName] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();
 
  async function handleJoin(e: FormEvent) {
    e.preventDefault();
    if (!participantName.trim() || !roomName.trim()) return;
 
    setIsLoading(true);
 
    const params = new URLSearchParams({
      room: roomName.trim(),
      name: participantName.trim(),
    });
 
    router.push(`/room?${params.toString()}`);
  }
 
  return (
    <main className="min-h-screen flex items-center justify-center bg-gray-950">
      <div className="w-full max-w-md p-8 bg-gray-900 rounded-2xl shadow-2xl">
        <h1 className="text-3xl font-bold text-white text-center mb-2">
          LiveKit Video
        </h1>
        <p className="text-gray-400 text-center mb-8">
          انضم إلى غرفة مكالمة فيديو
        </p>
 
        <form onSubmit={handleJoin} className="space-y-5">
          <div>
            <label
              htmlFor="name"
              className="block text-sm font-medium text-gray-300 mb-1"
            >
              اسمك
            </label>
            <input
              id="name"
              type="text"
              value={participantName}
              onChange={(e) => setParticipantName(e.target.value)}
              placeholder="أدخل اسمك"
              className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
              required
            />
          </div>
 
          <div>
            <label
              htmlFor="room"
              className="block text-sm font-medium text-gray-300 mb-1"
            >
              اسم الغرفة
            </label>
            <input
              id="room"
              type="text"
              value={roomName}
              onChange={(e) => setRoomName(e.target.value)}
              placeholder="أدخل اسم الغرفة"
              className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
              required
            />
          </div>
 
          <button
            type="submit"
            disabled={isLoading}
            className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 text-white font-semibold rounded-lg transition-colors"
          >
            {isLoading ? "جارٍ الانضمام..." : "انضم للغرفة"}
          </button>
        </form>
      </div>
    </main>
  );
}

صفحة بسيطة تحتوي نموذجًا لإدخال اسم المشارك واسم الغرفة. عند الإرسال، يُوجَّه المستخدم لصفحة الغرفة مع المعلومات في معاملات URL.

الخطوة 5: بناء مكون غرفة الفيديو

أنشئ ملف src/components/VideoRoom.tsx:

"use client";
 
import { useEffect, useState } from "react";
import {
  LiveKitRoom,
  VideoConference,
  RoomAudioRenderer,
  ControlBar,
  GridLayout,
  ParticipantTile,
  useTracks,
} from "@livekit/components-react";
import "@livekit/components-styles";
import { Track } from "livekit-client";
 
interface VideoRoomProps {
  roomName: string;
  participantName: string;
  onLeave: () => void;
}
 
export function VideoRoom({
  roomName,
  participantName,
  onLeave,
}: VideoRoomProps) {
  const [token, setToken] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    async function fetchToken() {
      try {
        const response = await fetch("/api/token", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ roomName, participantName }),
        });
 
        if (!response.ok) {
          throw new Error("Failed to get access token");
        }
 
        const data = await response.json();
        setToken(data.token);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Connection error");
      }
    }
 
    fetchToken();
  }, [roomName, participantName]);
 
  if (error) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gray-950">
        <div className="text-center">
          <p className="text-red-400 text-lg mb-4">{error}</p>
          <button
            onClick={onLeave}
            className="px-6 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600"
          >
            العودة
          </button>
        </div>
      </div>
    );
  }
 
  if (!token) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gray-950">
        <div className="text-white text-lg">جارٍ الاتصال بالغرفة...</div>
      </div>
    );
  }
 
  return (
    <LiveKitRoom
      token={token}
      serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
      connect={true}
      onDisconnected={onLeave}
      data-lk-theme="default"
      style={{ height: "100vh" }}
    >
      <VideoConference />
      <RoomAudioRenderer />
    </LiveKitRoom>
  );
}

هذا المكون يقوم بـ:

  1. جلب رمز الوصول من واجهة API عند التحميل
  2. عرض حالة التحميل أثناء جلب الرمز
  3. عرض الخطأ مع زر العودة إذا فشل الاتصال
  4. الاتصال بالغرفة عبر LiveKitRoom بعد الحصول على الرمز
  5. عرض واجهة المؤتمر باستخدام VideoConference الجاهز

مكون VideoConference من LiveKit يوفر واجهة كاملة تشمل عرض المشاركين وشريط الأدوات ومشاركة الشاشة تلقائيًا.

الخطوة 6: بناء صفحة الغرفة

أنشئ ملف src/app/room/page.tsx:

"use client";
 
import { useSearchParams, useRouter } from "next/navigation";
import { Suspense } from "react";
import { VideoRoom } from "@/components/VideoRoom";
 
function RoomContent() {
  const searchParams = useSearchParams();
  const router = useRouter();
 
  const roomName = searchParams.get("room");
  const participantName = searchParams.get("name");
 
  if (!roomName || !participantName) {
    router.push("/");
    return null;
  }
 
  return (
    <VideoRoom
      roomName={roomName}
      participantName={participantName}
      onLeave={() => router.push("/")}
    />
  );
}
 
export default function RoomPage() {
  return (
    <Suspense
      fallback={
        <div className="min-h-screen flex items-center justify-center bg-gray-950">
          <div className="text-white text-lg">جارٍ التحميل...</div>
        </div>
      }
    >
      <RoomContent />
    </Suspense>
  );
}

صفحة الغرفة تستخرج المعلومات من URL وتمررها لمكون VideoRoom. إذا كانت المعلومات ناقصة، يُعاد توجيه المستخدم لصفحة الانضمام.

الخطوة 7: إضافة متغير البيئة العام

أضف NEXT_PUBLIC_LIVEKIT_URL لملف .env.local:

NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your_api_key
LIVEKIT_API_SECRET=your_api_secret

المتغير بادئة NEXT_PUBLIC_ يجعله متاحًا في كود العميل — مطلوب لتوصيل LiveKitRoom بالخادم.

الخطوة 8: بناء مكون فيديو مخصص

المكون VideoConference الجاهز ممتاز للبداية السريعة. لكن لتخصيص الواجهة بالكامل، ابنِ مكونك الخاص. أنشئ ملف src/components/CustomVideoRoom.tsx:

"use client";
 
import { useEffect, useState, useCallback } from "react";
import {
  LiveKitRoom,
  RoomAudioRenderer,
  GridLayout,
  ParticipantTile,
  useTracks,
  useParticipants,
  useLocalParticipant,
  TrackToggle,
  DisconnectButton,
} from "@livekit/components-react";
import "@livekit/components-styles";
import { Track, RoomEvent } from "livekit-client";
 
interface CustomVideoRoomProps {
  roomName: string;
  participantName: string;
  onLeave: () => void;
}
 
function StageArea() {
  const tracks = useTracks(
    [
      { source: Track.Source.Camera, withPlaceholder: true },
      { source: Track.Source.ScreenShare, withPlaceholder: false },
    ],
    { onlySubscribed: false }
  );
 
  return (
    <GridLayout
      tracks={tracks}
      style={{ height: "calc(100vh - 80px)" }}
    >
      <ParticipantTile />
    </GridLayout>
  );
}
 
function CustomControlBar() {
  const participants = useParticipants();
 
  return (
    <div className="h-20 bg-gray-900 border-t border-gray-800 flex items-center justify-between px-6">
      <div className="text-gray-400 text-sm">
        {participants.length} مشارك{participants.length > 1 ? "ين" : ""}
      </div>
 
      <div className="flex items-center gap-3">
        <TrackToggle
          source={Track.Source.Microphone}
          className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-full transition-colors"
        />
        <TrackToggle
          source={Track.Source.Camera}
          className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-full transition-colors"
        />
        <TrackToggle
          source={Track.Source.ScreenShare}
          className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-full transition-colors"
        />
        <DisconnectButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-full transition-colors">
          مغادرة
        </DisconnectButton>
      </div>
 
      <div className="w-24" />
    </div>
  );
}
 
export function CustomVideoRoom({
  roomName,
  participantName,
  onLeave,
}: CustomVideoRoomProps) {
  const [token, setToken] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    async function fetchToken() {
      try {
        const response = await fetch("/api/token", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ roomName, participantName }),
        });
 
        if (!response.ok) throw new Error("Failed to get token");
 
        const data = await response.json();
        setToken(data.token);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Error");
      }
    }
 
    fetchToken();
  }, [roomName, participantName]);
 
  if (error) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gray-950">
        <div className="text-center">
          <p className="text-red-400 text-lg mb-4">{error}</p>
          <button
            onClick={onLeave}
            className="px-6 py-2 bg-gray-700 text-white rounded-lg"
          >
            العودة
          </button>
        </div>
      </div>
    );
  }
 
  if (!token) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gray-950">
        <div className="text-white">جارٍ الاتصال...</div>
      </div>
    );
  }
 
  return (
    <LiveKitRoom
      token={token}
      serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
      connect={true}
      onDisconnected={onLeave}
      data-lk-theme="default"
      style={{ height: "100vh" }}
    >
      <div className="flex flex-col h-screen bg-gray-950">
        <StageArea />
        <CustomControlBar />
      </div>
      <RoomAudioRenderer />
    </LiveKitRoom>
  );
}

الفرق بين هذا المكون والمكون الجاهز:

  • StageArea — يستخدم useTracks لجلب مسارات الكاميرا ومشاركة الشاشة، ويعرضها في شبكة عبر GridLayout
  • CustomControlBar — شريط أدوات مخصص بالكامل مع عدّاد المشاركين وأزرار تحكم مُنسقة
  • TrackToggle — مكون جاهز يُبدّل حالة المسار (تشغيل/إيقاف) مع تحديث الأيقونة تلقائيًا

الخطوة 9: إضافة الأحداث ومعالجة حالات الاتصال

لإضافة معالجة أحداث الغرفة وتتبع حالة المشاركين، أنشئ ملف src/hooks/useRoomEvents.ts:

import { useEffect } from "react";
import { useRoomContext } from "@livekit/components-react";
import { RoomEvent, ConnectionState } from "livekit-client";
 
export function useRoomEvents() {
  const room = useRoomContext();
 
  useEffect(() => {
    function handleParticipantConnected(participant: any) {
      console.log(`${participant.identity} joined the room`);
    }
 
    function handleParticipantDisconnected(participant: any) {
      console.log(`${participant.identity} left the room`);
    }
 
    function handleConnectionStateChanged(state: ConnectionState) {
      console.log(`Connection state: ${state}`);
    }
 
    room.on(RoomEvent.ParticipantConnected, handleParticipantConnected);
    room.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected);
    room.on(
      RoomEvent.ConnectionStateChanged,
      handleConnectionStateChanged
    );
 
    return () => {
      room.off(RoomEvent.ParticipantConnected, handleParticipantConnected);
      room.off(
        RoomEvent.ParticipantDisconnected,
        handleParticipantDisconnected
      );
      room.off(
        RoomEvent.ConnectionStateChanged,
        handleConnectionStateChanged
      );
    };
  }, [room]);
}

يمكنك استخدام هذا الخطاف داخل أي مكون محاط بـ LiveKitRoom:

function StageArea() {
  useRoomEvents(); // تتبع الأحداث
 
  const tracks = useTracks([
    { source: Track.Source.Camera, withPlaceholder: true },
    { source: Track.Source.ScreenShare, withPlaceholder: false },
  ]);
 
  return (
    <GridLayout tracks={tracks}>
      <ParticipantTile />
    </GridLayout>
  );
}

أحداث الغرفة الرئيسية في LiveKit:

الحدثالوصف
ParticipantConnectedمشارك جديد انضم
ParticipantDisconnectedمشارك غادر
TrackSubscribedبدء استقبال مسار وسائط
TrackUnsubscribedتوقف استقبال مسار
ConnectionStateChangedتغيرت حالة الاتصال
DataReceivedاستقبال رسالة بيانات
ActiveSpeakersChangedتغيّر المتحدثون النشطون

الخطوة 10: إرسال واستقبال الرسائل النصية

LiveKit يدعم إرسال البيانات بين المشاركين عبر قناة البيانات (Data Channel). أنشئ ملف src/components/Chat.tsx:

"use client";
 
import { useState, useEffect, useRef, FormEvent } from "react";
import { useRoomContext } from "@livekit/components-react";
import { DataPacket_Kind, RoomEvent } from "livekit-client";
 
interface ChatMessage {
  sender: string;
  text: string;
  timestamp: number;
}
 
export function Chat() {
  const room = useRoomContext();
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState("");
  const scrollRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    function handleDataReceived(
      payload: Uint8Array,
      participant: any
    ) {
      const text = new TextDecoder().decode(payload);
      const message: ChatMessage = {
        sender: participant?.identity || "Unknown",
        text,
        timestamp: Date.now(),
      };
      setMessages((prev) => [...prev, message]);
    }
 
    room.on(RoomEvent.DataReceived, handleDataReceived);
 
    return () => {
      room.off(RoomEvent.DataReceived, handleDataReceived);
    };
  }, [room]);
 
  useEffect(() => {
    scrollRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);
 
  async function sendMessage(e: FormEvent) {
    e.preventDefault();
    if (!input.trim()) return;
 
    const data = new TextEncoder().encode(input.trim());
    await room.localParticipant.publishData(data, {
      reliable: true,
    });
 
    setMessages((prev) => [
      ...prev,
      {
        sender: room.localParticipant.identity,
        text: input.trim(),
        timestamp: Date.now(),
      },
    ]);
 
    setInput("");
  }
 
  return (
    <div className="w-80 bg-gray-900 border-l border-gray-800 flex flex-col">
      <div className="p-4 border-b border-gray-800">
        <h3 className="text-white font-semibold">الدردشة</h3>
      </div>
 
      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {messages.map((msg, i) => (
          <div key={i} className="text-sm">
            <span className="font-medium text-blue-400">
              {msg.sender}:
            </span>{" "}
            <span className="text-gray-300">{msg.text}</span>
          </div>
        ))}
        <div ref={scrollRef} />
      </div>
 
      <form
        onSubmit={sendMessage}
        className="p-4 border-t border-gray-800"
      >
        <div className="flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="اكتب رسالة..."
            className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
          />
          <button
            type="submit"
            className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
          >
            إرسال
          </button>
        </div>
      </form>
    </div>
  );
}

هذا المكون يضيف دردشة نصية داخل الغرفة:

  • publishData — يُرسل البيانات لجميع المشاركين عبر قناة بيانات WebRTC
  • DataReceived — حدث يُطلق عند استقبال بيانات من مشارك آخر
  • reliable: true — يستخدم قناة بيانات موثوقة (مثل TCP) لضمان وصول الرسائل

لدمج الدردشة مع غرفة الفيديو المخصصة:

<LiveKitRoom token={token} serverUrl={url} connect={true}>
  <div className="flex h-screen bg-gray-950">
    <div className="flex-1 flex flex-col">
      <StageArea />
      <CustomControlBar />
    </div>
    <Chat />
  </div>
  <RoomAudioRenderer />
</LiveKitRoom>

الخطوة 11: إضافة إعدادات ما قبل الانضمام

تجربة مستخدم أفضل تتضمن معاينة الكاميرا والميكروفون قبل الانضمام. أنشئ ملف src/components/PreJoin.tsx:

"use client";
 
import { useState, useEffect, useRef } from "react";
 
interface PreJoinProps {
  onJoin: (settings: {
    videoEnabled: boolean;
    audioEnabled: boolean;
  }) => void;
  participantName: string;
}
 
export function PreJoin({ onJoin, participantName }: PreJoinProps) {
  const [videoEnabled, setVideoEnabled] = useState(true);
  const [audioEnabled, setAudioEnabled] = useState(true);
  const [stream, setStream] = useState<MediaStream | null>(null);
  const videoRef = useRef<HTMLVideoElement>(null);
 
  useEffect(() => {
    async function getMedia() {
      try {
        const mediaStream =
          await navigator.mediaDevices.getUserMedia({
            video: videoEnabled,
            audio: audioEnabled,
          });
        setStream(mediaStream);
 
        if (videoRef.current) {
          videoRef.current.srcObject = mediaStream;
        }
      } catch (err) {
        console.error("Failed to access media devices:", err);
      }
    }
 
    getMedia();
 
    return () => {
      stream?.getTracks().forEach((track) => track.stop());
    };
  }, [videoEnabled, audioEnabled]);
 
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-950">
      <div className="w-full max-w-lg p-8 bg-gray-900 rounded-2xl">
        <h2 className="text-xl font-bold text-white text-center mb-6">
          الاستعداد للانضمام
        </h2>
 
        <div className="aspect-video bg-gray-800 rounded-xl overflow-hidden mb-6 relative">
          {videoEnabled ? (
            <video
              ref={videoRef}
              autoPlay
              muted
              playsInline
              className="w-full h-full object-cover"
            />
          ) : (
            <div className="w-full h-full flex items-center justify-center">
              <div className="w-20 h-20 bg-gray-700 rounded-full flex items-center justify-center">
                <span className="text-2xl text-white">
                  {participantName[0]?.toUpperCase()}
                </span>
              </div>
            </div>
          )}
        </div>
 
        <div className="flex justify-center gap-4 mb-6">
          <button
            onClick={() => setAudioEnabled(!audioEnabled)}
            className={`px-4 py-2 rounded-full transition-colors ${
              audioEnabled
                ? "bg-gray-700 text-white"
                : "bg-red-600 text-white"
            }`}
          >
            {audioEnabled ? "🎤 الميكروفون مفعّل" : "🎤 الميكروفون معطّل"}
          </button>
          <button
            onClick={() => setVideoEnabled(!videoEnabled)}
            className={`px-4 py-2 rounded-full transition-colors ${
              videoEnabled
                ? "bg-gray-700 text-white"
                : "bg-red-600 text-white"
            }`}
          >
            {videoEnabled ? "📷 الكاميرا مفعّلة" : "📷 الكاميرا معطّلة"}
          </button>
        </div>
 
        <button
          onClick={() => {
            stream?.getTracks().forEach((track) => track.stop());
            onJoin({ videoEnabled, audioEnabled });
          }}
          className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors"
        >
          انضم الآن
        </button>
      </div>
    </div>
  );
}

هذا المكون يعرض معاينة الكاميرا ويسمح للمستخدم بتشغيل أو إيقاف الميكروفون والكاميرا قبل الانضمام للغرفة.

الخطوة 12: تشغيل التطبيق واختباره

شغّل خادم التطوير:

npm run dev

افتح المتصفح على http://localhost:3000 واتبع هذه الخطوات:

  1. أدخل اسمك واسم غرفة (مثلاً "test-room")
  2. اضغط "انضم للغرفة"
  3. اسمح بالوصول للكاميرا والميكروفون عند الطلب
  4. لاختبار المكالمة، افتح نافذة متصفح ثانية (أو متصفح آخر) وانضم لنفس الغرفة باسم مختلف

يجب أن ترى بث الفيديو لكلا المشاركين وتسمع الصوت.

استكشاف الأخطاء الشائعة

المشكلةالحل
خطأ في الاتصالتأكد من صحة LIVEKIT_URL و NEXT_PUBLIC_LIVEKIT_URL
لا يظهر فيديوتأكد من منح صلاحية الكاميرا في المتصفح
لا يعمل الصوتتأكد من منح صلاحية الميكروفون
خطأ CORSإذا كنت تستخدم خادم محلي، تأكد من تشغيله على المنافذ الصحيحة

الخطوة 13: نشر التطبيق في بيئة الإنتاج

النشر على Vercel

  1. ادفع الكود لمستودع Git:
git init
git add .
git commit -m "feat: livekit video app"
git remote add origin https://github.com/your-username/livekit-video.git
git push -u origin main
  1. اربط المستودع في Vercel

  2. أضف متغيرات البيئة في إعدادات المشروع:

NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your_api_key
LIVEKIT_API_SECRET=your_api_secret
  1. انشر التطبيق

النشر بـ Docker

أنشئ ملف Dockerfile:

FROM node:20-alpine AS base
 
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
 
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

ثم ابنِ وشغّل:

docker build -t livekit-video .
docker run -p 3000:3000 --env-file .env.local livekit-video

ميزات متقدمة

تسجيل المكالمات

LiveKit يدعم تسجيل المكالمات عبر Egress API:

import { EgressClient, EncodedFileOutput } from "livekit-server-sdk";
 
const egressClient = new EgressClient(
  process.env.LIVEKIT_URL!,
  process.env.LIVEKIT_API_KEY!,
  process.env.LIVEKIT_API_SECRET!
);
 
// بدء تسجيل غرفة
const output = new EncodedFileOutput({
  filepath: "recordings/room-{room_name}-{time}.mp4",
  // إعداد S3 أو GCS للتخزين
});
 
await egressClient.startRoomCompositeEgress(roomName, { file: output });

وكيل ذكاء اصطناعي صوتي

يمكنك بناء وكيل AI صوتي ينضم للغرفة ويستجيب بالصوت باستخدام LiveKit Agents:

# agents/voice_agent.py (Python SDK)
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
from livekit.agents.voice_assistant import VoiceAssistant
from livekit.plugins import openai, silero
 
async def entrypoint(ctx: JobContext):
    await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
 
    assistant = VoiceAssistant(
        vad=silero.VAD.load(),
        stt=openai.STT(),
        llm=openai.LLM(),
        tts=openai.TTS(),
    )
 
    assistant.start(ctx.room)
    await assistant.say("مرحبًا! كيف يمكنني مساعدتك؟")

هذا يفتح إمكانيات هائلة لبناء مساعدين صوتيين تفاعليين وروبوتات دعم عملاء وأدوات تعليمية صوتية.

الخطوات التالية

بعد إكمال هذا الدليل، يمكنك:

  • إضافة المصادقة — استخدم NextAuth أو Better Auth لحماية الغرف
  • إضافة غرف الانتظار — اجعل المضيف يقبل المشاركين قبل دخولهم
  • بناء وكيل AI — استخدم LiveKit Agents لإضافة مساعد صوتي ذكي
  • إضافة تسجيل — سجّل المكالمات وخزنها في S3
  • تحسين الأداء — استخدم Simulcast لتكييف الجودة مع سرعة الشبكة
  • إضافة السبورة البيضاء — ادمج أداة رسم تعاونية في الغرفة

الخلاصة

في هذا الدليل، بنينا تطبيق مؤتمرات فيديو كامل باستخدام LiveKit و Next.js. تعلمنا كيفية إنشاء رموز الوصول من الخادم، بناء واجهة فيديو باستخدام مكونات React الجاهزة، تخصيص شريط الأدوات، إضافة دردشة نصية عبر قنوات البيانات، وبناء شاشة معاينة قبل الانضمام.

LiveKit يُبسّط بشكل كبير بناء تطبيقات الفيديو والصوت في الوقت الحقيقي. بنيته المفتوحة المصدر ومكتبات React الجاهزة تجعل من السهل البدء سريعًا، بينما واجهاته المتقدمة (Agents، Egress، Ingress) تمنحك القدرة على بناء أي شيء من مؤتمرات فيديو بسيطة إلى وكلاء ذكاء اصطناعي صوتيين متطورين.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على كيف كيّفنا Autoresearch من Karpathy لمسابقات Kaggle.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة