3667 words
18 minutes
Event-based Code Replayer

بناء Event-Based Playback Engine زي Scrimba#

Scrimba مش مجرد منصة تعليمية بتقدّم فيديوهات تعليمية تقليدية. دي منصة بتعتمد على proprietary event-based playback engine بيحاكي جلسات البرمجة بشكل تفاعلي. بدل ما يسجّلوا فيديو screen capture زي الكورسات العادية، هما بيسجّلوا كل تفاعل بيحصل جوه الـ code editor كـ stream من الأحداث (events) زي ضغط المفاتيح (keypresses)، حركة المؤشر (cursor movement)، تحديد النصوص (text selection)، الحذف (deletions)، وتفاعلات الحافظة (clipboard interactions). الأحداث دي بتتسجّل مع timestamps وبتتزامن مع audio narration. وقت التشغيل، الـ engine بيعيد يشغّل الأحداث دي في DOM حقيقي داخل virtualized code editor، مش مجرد فيديو. النتيجة هي تجربة deterministic simulation للجلسة الأصلية، يعني محاكاة دقيقة بتسمح للمستخدم يتفاعل مع الـ editor، يوقّف، يعدّل الكود، يرجع للخلف، ويستمر من غير ما يكسر الـ state.

في الدليل ده، هنشرح كل concept ومصطلح تكنيكال من الصفر، وهنبني خطوة بخطوة event-based playback engine زي اللي Scrimba بتستخدمه. هنغطي كل حاجة: من تصميم النظام، لتسجيل الأحداث، لإعادة التشغيل، مع كود كامل وشرح لكل جزء.

ليه أصلاً نحتاج نظام زي ده؟#

معظم أدوات تعليم البرمجة بتقع في واحد من اتجاهين:

  1. فيديوهات تقليدية : بتتفرج وخلاص، مفيش أي تفاعل أو تعديل.
  2. بيئات تفاعلية : بتكتب الكود بنفسك، بس غالباً بتبدأ من الصفر ومفيش إرشاد واضح. طيب، ليه منجمعش بين الاتنين؟ نعمل نظام يديك إحساس الفيديو (إرشاد خطوة بخطوة)، وفي نفس الوقت تقدر توقف وتعدل وتجرب بنفسك في أي لحظة.

المفاهيم الأساسية (Core Concepts)#

قبل ما نبدأ الكود، لازم نفهم المصطلحات والمفاهيم اللي هتتكرر:

  1. Event-Based System:

    • النظام اللي بيعتمد على تسجيل ومعالجة الأحداث (events). الحدث هو أي إجراء بيحصل في الـ editor، زي إنك تكتب حرف، تضغط زر، أو تنسخ نص. بدل ما نسجّل فيديو للشاشة، بنسجّل الأحداث دي كـ data (مثل JSON) ونعيد نشغّلها بعدين.
    • مثال: لما تكتب c في الـ editor، ده بيطلّع حدث input مع تفاصيل زي قيمة النص والمؤشر.
  2. DOM (Document Object Model):

    • الـ DOM هو الطريقة اللي المتصفح بيحوّل بيها صفحة الـ HTML لشجرة من الكائنات (objects) يقدر يتفاعل معاها. الـ code editor (زي <textarea> أو CodeMirror) بيكون جزء من الـ DOM، والأحداث زي keypress أو input بتطلّع من الـ DOM.
    • ليه مهم؟ عشان إحنا بنسجّل الأحداث دي ونعيد نشغّلها في نفس الـ DOM عشان نحاكي الجلسة.
  3. Virtualized Code Editor:

    • الـ editor اللي بنستخدمه مش مجرد نص عادي، ده بيئة محاكاة (sandboxed environment) بتسمح للأحداث تتطبّق بشكل controlled. يعني بنقدر نتحكم في كل تغيير بيحصل (زي إضافة حرف أو تحريك المؤشر) من غير ما نسيب الـ browser يتصرف عشوائي.
    • مثال: لو عايز تعيد تشغيل حدث input، بتحط قيمة النص في الـ editor يدويًا وبتحرّك المؤشر بنفس الطريقة.
  4. Event Serialization:

    • عملية تحويل الأحداث لصيغة بيانات قابلة للتخزين (زي JSON). كل حدث بيكون عنده timestamp (الوقت النسبي من بداية التسجيل) وتفاصيل زي نوع الحدث وقيمة الـ editor.
    • مثال JSON:
      { "type": "input", "timestamp": 100, "value": "c", "selectionStart": 1 }
      
  5. Deterministic Simulation:

    • المحاكاة اللي بتضمن إن إعادة تشغيل الأحداث هتدي نفس النتيجة بالظبط زي الجلسة الأصلية. عشان كده بنسجّل كل التفاصيل (زي موقع المؤشر وحالة النص) ونعيد نطبّقها بنفس الترتيب والتوقيت.
    • ليه مهم؟ عشان المستخدم يحس إن التجربة live ومتسقة.
  6. State Diffing:

    • بدل ما نسجّل حالة الـ editor كاملة كل مرة (زي النص كله)، بنسجّل بس التغييرات (deltas). ده بيقلّل حجم البيانات ويخلّي النظام أسرع.
    • مثال: لو النص كان co وبعدها بقى cod، بنسجّل بس إن الحرف d اتضاف.
  7. Controlled Side-Effect Replay:

    • لما بنعيد نشغّل الأحداث، لازم نتحكم في الـ side effects (زي تحريك المؤشر أو إظهار الـ syntax highlighting). يعني بنحاكي التأثيرات دي بشكل يدوي بدل ما نسيب الـ browser يعملها لوحده.
    • مثال: لو الحدث بيقول إن المؤشر اتحرّك، بنعدّل selectionStart وselectionEnd يدويًا.
  8. Sandbox Environment:

    • بيئة معزولة داخل الـ browser بنتحكم فيها بالكامل. الـ editor بيكون جواها، وكل الأحداث بتتطبّق في الـ sandbox دي عشان نضمن إن التجربة متسقة وما تتأثرش بحاجات خارجية زي إعدادات المتصفح.
    • ليه مهم؟ عشان لو المستخدم فتح الصفحة في متصفح مختلف، التجربة هتفضل نفسها.
  9. Audio Synchronization:

    • الصوت (الـ narration) لازم يتزامن مع الأحداث بدقة. يعني لازم تسجيل الصوت يبدأ مع بداية تسجيل الأحداث، ووقت التشغيل، الصوت بيشتغل مع أول حدث.
    • تحدي: لو فيه تأخير في الشبكة أو الجهاز، ممكن الصوت يخرج عن السيطرة، فمحتاجين buffering أو timestamp correction.

تصميم النظام (System Architecture)#

نظام زي Scrimba بيتكوّن من 4 مكوّنات رئيسية:

  1. Event Recorder:

    • المسؤول عن التقاط كل الأحداث من الـ editor (زي input، keydown، paste) وتخزينها مع timestamps. بيحتاج يكون دقيق عشان يسجّل كل التفاصيل زي موقع المؤشر ومحتوى الحافظة.
  2. Event Store:

    • مكان تخزين الأحداث (عادةً كـ JSON). ممكن يكون في الـ localStorage أو على server لو عايزين نسترجع الجلسات بعدين.
  3. Playback Engine:

    • الجزء اللي بيعيد يشغّل الأحداث في الـ DOM. بيستخدم timers (زي setTimeout) عشان يحاكي التوقيتات، وبيطبّق الأحداث على الـ editor بشكل يدوي.
  4. Interactive Layer:

    • اللي بيسمح للمستخدم يتفاعل مع الـ editor أثناء الـ replay (زي الإيقاف، التعديل، أو الرجوع). ده بيحتاج إدارة state دقيقة عشان التفاعل ما يكسرش الـ replay.

الخطوات التفصيلية للتنفيذ#

الخطوة 1: جهّز بيئة Next.js#

1.1 إعداد المشروع#

هنبدأ بإنشاء مشروع Next.js جديد ونضيف المكتبات اللي هنحتاجها.

أوامر الإعداد:

npx create-next-app@latest scrimba-clone
cd scrimba-clone
npm install codemirror @codemirror/lang-javascript prismjs socket.io-client webrtc-adapter mongodb bcryptjs jsonwebtoken multer
npm install --save-dev @types/webrtc

شرح البلاجنز:

  • CodeMirror: محرر كود قوي زي اللي بتشوفه في VSCode. بيدعم تلوين الكود (Syntax Highlighting) وتعديل تفاعلي.
  • Prism.js: لتلوين الكود أثناء العرض (Playback) عشان يبقى شكله جذاب.
  • Socket.IO: للتعاون في الوقت الفعلي بين المستخدمين.
  • WebRTC: لتسجيل الصوت مباشرة من المتصفح.
  • MongoDB: قاعدة بيانات لتخزين الجلسات.
  • jsonwebtoken & bcryptjs: لتسجيل الدخول والتأمين.
  • multer: لرفع ملفات الصوت.

1.2 هيكلية المشروع#

هننظّم المشروع كده:

scrimba-clone/
├── app/
│   ├── api/                    # API Routes للـ Backend
│   ├── components/             # React Components
│   ├── lib/                    # ملفات مساعدة (مثل MongoDB)
│   ├── page.tsx                # الصفحة الرئيسية
├── public/                     # ملفات ثابتة
├── styles/                     # CSS
├── worker/                     # WebWorker للمعالجة
└── package.json

الخطوة 2: بناء نظام التسجيل#

2.1 إعداد CodeMirror لتحرير الكود#

هنستخدم CodeMirror كمحرر كود في الواجهة.

ملف الـcomponent:

app/components/CodeEditor.tsx

"use client";

import { useEffect, useRef } from "react";
import { EditorState } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";

interface CodeEditorProps {
  onChange: (value: string) => void;
}

export default function CodeEditor({ onChange }: CodeEditorProps) {
  const editorRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const startState = EditorState.create({
      doc: "// اكتب كودك هنا\n",
      extensions: [
        keymap.of(defaultKeymap),
        javascript(),
        EditorView.updateListener.of((update) => {
          if (update.docChanged) {
            onChange(update.state.doc.toString());
          }
        }),
      ],
    });

    viewRef.current = new EditorView({
      state: startState,
      parent: editorRef.current,
    });

    return () => {
      viewRef.current?.destroy();
    };
  }, [onChange]);

  return (
    <div
      ref={editorRef}
      className="border rounded p-2 bg-gray-900 text-white"
    />
  );
}
  • CodeMirror بيوفّر محرر كود تفاعلي مع دعم JavaScript.
  • بنستخدم EditorView.updateListener عشان نراقب التغييرات ونمرّرها للـcomponent الأب.
  • الـ useEffect بيضمن إن المحرر يتدمّر لما الـcomponent ينفصل عشان ما يحصلش memory leaks.

2.2 تسجيل الأحداث#

هنعمل كلاس يسجّل events الكود (كتابة، حذف، تحديد).

ملف: app/lib/CodeRecorder.ts

export class CodeRecorder {
  private events: any[] = [];
  private startTime: number | null = null;
  private isRecording: boolean = false;
  private editor: any;

  constructor(editor: any) {
    this.editor = editor;
    this.setupEventListeners();
  }

  private setupEventListeners() {
    this.editor.on("change", (instance: any, changeObj: any) => {
      if (this.isRecording && changeObj.origin !== "setValue") {
        this.recordEvent({
          type: "change",
          timestamp: this.getTimestamp(),
          change: {
            from: changeObj.from,
            to: changeObj.to,
            text: changeObj.text,
            removed: changeObj.removed,
          },
        });
      }
    });

    this.editor.on("cursorActivity", () => {
      if (this.isRecording) {
        const cursor = this.editor.getCursor();
        this.recordEvent({
          type: "cursor",
          timestamp: this.getTimestamp(),
          position: cursor,
        });
      }
    });
  }

  startRecording() {
    this.isRecording = true;
    this.startTime = Date.now();
    this.events = [];

    this.recordEvent({
      type: "init",
      timestamp: 0,
      content: this.editor.getValue(),
    });
  }

  stopRecording() {
    this.isRecording = false;
    return {
      events: this.events,
      duration: Date.now() - this.startTime!,
      metadata: this.getMetadata(),
    };
  }

  private recordEvent(event: any) {
    this.events.push(event);
  }

  private getTimestamp() {
    return Date.now() - this.startTime!;
  }

  private getMetadata() {
    const content = this.editor.getValue();
    return {
      language: "javascript",
      linesCount: content.split("\n").length,
      charactersCount: content.length,
    };
  }
}
  • بنسجّل التغييرات (زي الكتابة أو الحذف) باستخدام حدث change في CodeMirror.
  • بنراقب حركة المؤشر (cursor) عشان نسجّل التحديدات.
  • الحدث init بيحفظ الحالة الأولية للكود.

2.3 تسجيل الصوت بـ WebRTC#

هنستخدم WebRTC لتسجيل الصوت مباشرة في المتصفح.

ملف: app/lib/AudioRecorder.ts

export class AudioRecorder {
  private mediaRecorder: MediaRecorder | null = null;
  private audioChunks: Blob[] = [];
  private stream: MediaStream | null = null;

  async startRecording() {
    try {
      this.stream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          sampleRate: 44100,
        },
      });

      this.mediaRecorder = new MediaRecorder(this.stream, {
        mimeType: "audio/webm;codecs=opus",
      });

      this.mediaRecorder.ondataavailable = (event) => {
        if (event.data.size > 0) {
          this.audioChunks.push(event.data);
        }
      };

      this.mediaRecorder.start(100);
    } catch (error) {
      console.error("مشكلة في تسجيل الصوت:", error);
    }
  }

  stopRecording() {
    return new Promise<Blob>((resolve) => {
      if (!this.mediaRecorder) return;

      this.mediaRecorder.onstop = () => {
        const audioBlob = new Blob(this.audioChunks, { type: "audio/webm" });
        this.stream?.getTracks().forEach((track) => track.stop());
        this.audioChunks = [];
        resolve(audioBlob);
      };

      this.mediaRecorder.stop();
    });
  }
}
  • WebRTC بيسمح بتسجيل الصوت من الميكروفون باستخدام getUserMedia.
  • بنستخدم MediaRecorder لتسجيل الصوت على شكل أجزاء (chunks) كل 100ms.
  • لما بنوقّف التسجيل، بنحوّل الأجزاء لـ Blob عشان نقدر نحفظه.

2.4 دمج الكود والصوت#

هنعمل كلاس يمزّن الكود والصوت.

ملف: app/lib/SynchronizedRecorder.ts

import { CodeRecorder } from "./CodeRecorder";
import { AudioRecorder } from "./AudioRecorder";

export class SynchronizedRecorder {
  private codeRecorder: CodeRecorder;
  private audioRecorder: AudioRecorder;
  private syncMarkers: any[] = [];

  constructor(editor: any) {
    this.codeRecorder = new CodeRecorder(editor);
    this.audioRecorder = new AudioRecorder();
  }

  async startRecording() {
    const startTime = Date.now();

    this.codeRecorder.startRecording();
    await this.audioRecorder.startRecording();

    const syncInterval = setInterval(() => {
      this.syncMarkers.push({
        timestamp: Date.now() - startTime,
        codeEventsCount: this.codeRecorder["events"].length,
      });
    }, 1000);

    this.stopRecording = () => {
      clearInterval(syncInterval);
      return this.stop();
    };
  }

  private async stop() {
    const codeSession = this.codeRecorder.stopRecording();
    const audioBlob = await this.audioRecorder.stopRecording();

    return {
      code: codeSession,
      audio: audioBlob,
      syncMarkers: this.syncMarkers,
      timestamp: new Date().toISOString(),
    };
  }

  stopRecording: () => Promise<any> = () => Promise.resolve({});
}
  • بنستخدم setInterval عشان نسجّل علامات مزامنة (sync markers) كل ثانية.
  • لما بنوقّف التسجيل، بنرجّع الكود، الصوت، وعلامات المزامنة.

الخطوة 3: واجهة المستخدم بـ Next.js#

3.1 الصفحة الرئيسية#

هنعمل صفحة رئيسية فيها المحرر وأزرار التحكم.

ملف: app/page.tsx

"use client";

import { useState, useEffect, useRef } from "react";
import CodeEditor from "./components/CodeEditor";
import { SynchronizedRecorder } from "./lib/SynchronizedRecorder";
import { useRouter } from "next/navigation";

export default function Home() {
  const [code, setCode] = useState("");
  const [isRecording, setIsRecording] = useState(false);
  const [session, setSession] = useState<any>(null);
  const editorRef = useRef<any>(null);
  const recorderRef = useRef<SynchronizedRecorder | null>(null);
  const router = useRouter();

  useEffect(() => {
    if (editorRef.current) {
      recorderRef.current = new SynchronizedRecorder(editorRef.current);
    }
  }, []);

  const startRecording = async () => {
    setIsRecording(true);
    await recorderRef.current?.startRecording();
  };

  const stopRecording = async () => {
    setIsRecording(false);
    const recordedSession = await recorderRef.current?.stopRecording();
    setSession(recordedSession);

    // رفع الجلسة للـ Backend
    const formData = new FormData();
    formData.append("audio", recordedSession.audio);
    formData.append("codeEvents", JSON.stringify(recordedSession.code.events));
    formData.append("syncMarkers", JSON.stringify(recordedSession.syncMarkers));
    formData.append("metadata", JSON.stringify(recordedSession.code.metadata));

    const token = localStorage.getItem("token");
    const response = await fetch("/api/session", {
      method: "POST",
      headers: { Authorization: `Bearer ${token}` },
      body: formData,
    });

    if (response.ok) {
      const { sessionId } = await response.json();
      router.push(`/playback/${sessionId}`);
    }
  };

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-4">Scrimba مصري رايق 🐪</h1>
      <CodeEditor onChange={setCode} ref={editorRef} />
      <div className="mt-4 flex gap-2">
        <button
          onClick={startRecording}
          disabled={isRecording}
          className="bg-green-500 text-white px-4 py-2 rounded disabled:bg-gray-400"
        >
          ابدأ التسجيل
        </button>
        <button
          onClick={stopRecording}
          disabled={!isRecording}
          className="bg-red-500 text-white px-4 py-2 rounded disabled:bg-gray-400"
        >
          وقّف التسجيل
        </button>
      </div>
    </div>
  );
}
  • بنستخدم useRef عشان نربط CodeMirror بالـ SynchronizedRecorder.
  • لما بنضغط “ابدأ التسجيل”، بنشغّل الكود والصوت مع بعض.
  • لما بنوقّف، بنرفع الجلسة للـ Backend باستخدام FormData.

3.2 إضافة CSS بـ Tailwind#

هنستخدم Tailwind CSS (مدمج مع Next.js) عشان الستايل.

ملف: app/globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;

.cm-editor {
  @apply border rounded p-2 bg-gray-900 text-white;
}

.playback-controls button {
  @apply bg-blue-500 text-white px-4 py-2 rounded mr-2;
}

.playback-controls select {
  @apply bg-gray-700 text-white px-2 py-1 rounded;
}
  • Tailwind بيخلّي الستايل سريع ومنظم.
  • بنطبّق كلاسات مخصصة لـ CodeMirror والأزرار.

الخطوة 4: معالجة الـevents#

4.1 تحسين الأحداث بـ WebWorker#

هنستخدم WebWorker عشان ننظّف الأحداث في الخلفية.

ملف: worker/optimizeEvents.ts

self.onmessage = function (e: MessageEvent) {
  const { type, events } = e.data;

  if (type === "optimize-events") {
    const optimizedEvents = optimizeEvents(events);
    self.postMessage({ type: "optimized", data: optimizedEvents });
  }
};

function optimizeEvents(events: any[]) {
  let optimizedEvents = [...events];

  optimizedEvents = groupConsecutiveChanges(optimizedEvents);

  return optimizedEvents;
}

function groupConsecutiveChanges(events: any[]) {
  const grouped: any[] = [];
  let currentGroup: any = null;

  events.forEach((event) => {
    if (event.type === "change" && event.change.text.length === 1) {
      if (
        currentGroup &&
        event.timestamp - currentGroup.timestamp < 100 &&
        event.change.from.line === currentGroup.change.to.line &&
        event.change.from.ch === currentGroup.change.to.ch
      ) {
        currentGroup.change.text = [
          currentGroup.change.text[0] + event.change.text[0],
        ];
        currentGroup.change.to = event.change.to;
      } else {
        if (currentGroup) grouped.push(currentGroup);
        currentGroup = { ...event };
      }
    } else {
      if (currentGroup) {
        grouped.push(currentGroup);
        currentGroup = null;
      }
      grouped.push(event);
    }
  });

  if (currentGroup) grouped.push(currentGroup);
  return grouped;
}

دمج الـ Worker: ملف: app/lib/EventOptimizer.ts

export class EventOptimizer {
  private worker: Worker;

  constructor() {
    this.worker = new Worker("/worker/optimizeEvents.ts");
  }

  optimizeEvents(events: any[]): Promise<any[]> {
    return new Promise((resolve) => {
      this.worker.onmessage = (e) => {
        if (e.data.type === "optimized") {
          resolve(e.data.data);
        }
      };
      this.worker.postMessage({ type: "optimize-events", events });
    });
  }
}
  • الـ WebWorker بيفصل المعالجة عن الـ UI عشان ما يبطّئش الصفحة.
  • بنستخدم groupConsecutiveChanges عشان نجمع التغييرات المتتالية (زي كتابة كلمة كاملة بدل حرف حرف).

الخطوة 5: نظام العرض (Playback)#

5.1 عرض الأحداث بـ Prism.js#

هنعمل صفحة Playback بتستخدم Prism.js لتلوين الكود.

ملف: app/playback/[id]/page.tsx

"use client";

import { useState, useEffect, useRef } from "react";
import Prism from "prismjs";
import "prismjs/themes/prism-dark.css";

interface PlaybackProps {
  params: { id: string };
}

export default function Playback({ params }: PlaybackProps) {
  const [events, setEvents] = useState<any[]>([]);
  const [audioUrl, setAudioUrl] = useState<string | null>(null);
  const [code, setCode] = useState("");
  const [isPlaying, setIsPlaying] = useState(false);
  const audioRef = useRef<HTMLAudioElement>(null);
  const currentIndexRef = useRef(0);
  const startTimeRef = useRef<number | null>(null);

  useEffect(() => {
    async function fetchSession() {
      const response = await fetch(`/api/session/${params.id}`, {
        headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
      });
      const session = await response.json();
      setEvents(session.codeEvents);

      const audioResponse = await fetch(`/api/audio/${params.id}`, {
        headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
      });
      const audioBlob = await audioResponse.blob();
      setAudioUrl(URL.createObjectURL(audioBlob));
    }
    fetchSession();
  }, [params.id]);

  useEffect(() => {
    if (isPlaying) {
      startTimeRef.current = Date.now();
      audioRef.current?.play();
      playEvents();
    }
  }, [isPlaying]);

  function playEvents() {
    if (!isPlaying || currentIndexRef.current >= events.length) {
      setIsPlaying(false);
      return;
    }

    const event = events[currentIndexRef.current];
    const currentTime = Date.now() - startTimeRef.current!;

    if (currentTime >= event.timestamp) {
      applyEvent(event);
      currentIndexRef.current++;
    }

    requestAnimationFrame(playEvents);
  }

  function applyEvent(event: any) {
    if (event.type === "init") {
      setCode(event.content);
    } else if (event.type === "change") {
      let newCode = code;
      const { from, to, text } = event.change;
      const lines = newCode.split("\n");
      const start = lines.slice(0, from.line).join("\n").length + from.ch;
      const end = lines.slice(0, to.line).join("\n").length + to.ch;
      newCode = newCode.slice(0, start) + text.join("\n") + newCode.slice(end);
      setCode(newCode);
    }
  }

  useEffect(() => {
    Prism.highlightAll();
  }, [code]);

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-4">عرض الجلسة</h1>
      <pre className="language-javascript">
        <code>{code}</code>
      </pre>
      <audio ref={audioRef} src={audioUrl || undefined} />
      <div className="playback-controls mt-4">
        <button
          onClick={() => setIsPlaying(true)}
          disabled={isPlaying}
          className="bg-blue-500 text-white px-4 py-2 rounded disabled:bg-gray-400"
        >
          شغّل
        </button>
        <button
          onClick={() => setIsPlaying(false)}
          disabled={!isPlaying}
          className="bg-red-500 text-white px-4 py-2 rounded disabled:bg-gray-400"
        >
          وقّف
        </button>
      </div>
    </div>
  );
}
  • بنستخدم Prism.js لتلوين الكود أثناء العرض.
  • بنشغّل الأحداث بناءً على الـ timestamps باستخدام requestAnimationFrame.
  • الصوت بيشتغل متزامن مع الأحداث باستخدام <audio>.

الخطوة 6: الـ Backend بـ Next.js API Routes#

6.1 إعداد MongoDB#

ملف: app/lib/mongodb.ts

import { MongoClient } from "mongodb";

const uri = process.env.MONGODB_URI || "mongodb://localhost:27017";
const dbName = "scrimba_clone";
let client: MongoClient | null = null;

export async function connectDB() {
  if (!client) {
    client = new MongoClient(uri);
    await client.connect();
  }
  return client.db(dbName);
}
  • بنستخدم MongoDB لتخزين الجلسات (كود، صوت، مزامنة).
  • connectDB بتضمن اتصال واحد بالقاعدة.

6.2 API Routes#

ملف: app/api/session/route.ts

import { NextRequest, NextResponse } from "next/server";
import { connectDB } from "../../lib/mongodb";
import jwt from "jsonwebtoken";
import multer from "multer";
import path from "path";
import fs from "fs";

const upload = multer({
  storage: multer.diskStorage({
    destination: "./public/uploads/",
    filename: (req, file, cb) => {
      cb(null, `${Date.now()}-${file.originalname}`);
    },
  }),
});

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const audio = formData.get("audio") as File;
    const codeEvents = JSON.parse(formData.get("codeEvents") as string);
    const syncMarkers = JSON.parse(formData.get("syncMarkers") as string);
    const metadata = JSON.parse(formData.get("metadata") as string);

    const token = request.headers.get("authorization")?.split(" ")[1];
    if (!token) {
      return NextResponse.json({ error: "التوكن مطلوب" }, { status: 401 });
    }

    const decoded = jwt.verify(
      token,
      process.env.JWT_SECRET || "your-secret-key"
    );
    const userId = (decoded as any).userId;

    const audioPath = audio ? `/uploads/${Date.now()}-${audio.name}` : null;

    if (audio) {
      const buffer = Buffer.from(await audio.arrayBuffer());
      fs.writeFileSync(`./public${audioPath}`, buffer);
    }

    const db = await connectDB();
    const session = {
      userId,
      codeEvents,
      syncMarkers,
      metadata,
      audioPath,
      createdAt: new Date(),
    };

    const result = await db.collection("sessions").insertOne(session);
    return NextResponse.json({ sessionId: result.insertedId });
  } catch (error) {
    console.error("مشكلة في حفظ الجلسة:", error);
    return NextResponse.json(
      { error: "مش عارفين نحفظ الجلسة" },
      { status: 500 }
    );
  }
}

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const id = searchParams.get("id");

  try {
    const db = await connectDB();
    const session = await db.collection("sessions").findOne({ _id: id });
    if (!session) {
      return NextResponse.json({ error: "الجلسة مش موجودة" }, { status: 404 });
    }
    return NextResponse.json(session);
  } catch (error) {
    console.error("مشكلة في جلب الجلسة:", error);
    return NextResponse.json(
      { error: "مش عارفين نجيب الجلسة" },
      { status: 500 }
    );
  }
}

ملف: app/api/audio/[id]/route.ts

import { NextRequest, NextResponse } from "next/server";
import { connectDB } from "../../../lib/mongodb";
import path from "path";
import fs from "fs";

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const db = await connectDB();
    const session = await db.collection("sessions").findOne({ _id: params.id });
    if (!session || !session.audioPath) {
      return NextResponse.json(
        { error: "ملف الصوت مش موجود" },
        { status: 404 }
      );
    }

    const filePath = path.resolve(`./public${session.audioPath}`);
    const fileBuffer = fs.readFileSync(filePath);
    return new NextResponse(fileBuffer, {
      headers: { "Content-Type": "audio/webm" },
    });
  } catch (error) {
    console.error("مشكلة في جلب الصوت:", error);
    return NextResponse.json(
      { error: "مش عارفين نجيب الصوت" },
      { status: 500 }
    );
  }
}
  • بنستخدم Next.js API Routes كـ Backend.
  • /api/session بيخزّن الجلسات (كود وصوت) في MongoDB.
  • /api/audio/[id] بيرجّع ملف الصوت.
  • multer بيدير رفع الملفات.

الخطوة 7: التعاون في الوقت الفعلي بـ Socket.IO#

7.1 إعداد Socket.IO#

هنضيف Socket.IO للتعاون بين المستخدمين.

ملف: app/api/socket/route.ts

import { Server } from "socket.io";
import { NextRequest } from "next/server";

let io: Server | null = null;

export async function GET(request: NextRequest) {
  if (!io) {
    io = new Server({
      path: "/api/socket",
    });

    io.on("connection", (socket) => {
      console.log("مستخدم اتّصل بالـ Socket.IO!");

      socket.on("join-session", (sessionId: string) => {
        socket.join(sessionId);
        socket.emit("message", "اتّصلت بالجلسة يا معلم!");
      });

      socket.on("code-update", (data: { sessionId: string; event: any }) => {
        io?.to(data.sessionId).emit("code-update", data.event);
      });

      socket.on("disconnect", () => {
        console.log("مستخدم قطع الاتصال");
      });
    });
  }

  return new Response("Socket.IO server running", { status: 200 });
}

دمج Socket.IO في العميل: ملف: app/components/CollaborativeEditor.tsx

"use client";

import { useEffect, useRef } from "react";
import io from "socket.io-client";
import CodeEditor from "./CodeEditor";

interface CollaborativeEditorProps {
  sessionId: string;
}

export default function CollaborativeEditor({
  sessionId,
}: CollaborativeEditorProps) {
  const socketRef = useRef<any>(null);
  const editorRef = useRef<any>(null);

  useEffect(() => {
    socketRef.current = io("/api/socket");

    socketRef.current.emit("join-session", sessionId);

    socketRef.current.on("code-update", (event: any) => {
      if (editorRef.current) {
        const { from, to, text } = event.change;
        editorRef.current.replaceRange(text.join("\n"), from, to);
      }
    });

    return () => {
      socketRef.current.disconnect();
    };
  }, [sessionId]);

  const handleChange = (value: string) => {
    if (editorRef.current) {
      const change = {
        from: editorRef.current.getCursor("from"),
        to: editorRef.current.getCursor("to"),
        text: [value],
      };
      socketRef.current.emit("code-update", { sessionId, event: { change } });
    }
  };

  return <CodeEditor onChange={handleChange} ref={editorRef} />;
}
  • Socket.IO بيسمح بإرسال التغييرات في الوقت الفعلي.
  • بنستخدم join-session عشان نربط المستخدمين بنفس الجلسة.
  • لما مستخدم يكتب، التغييرات بتتبعت لكل المستخدمين في الجلسة.

شرح تفصيلي للـ System Design والمدخلات والمخرجات#

8.1 ليه اخترنا الـ System Design ده؟#

النظام اللي بنيناه كل حتة فيها ليها دور، وكله بيشتغل مع بعضه زي السيمفونية. اخترنا Next.js كإطار عمل رئيسي عشان بيدينا كل حاجة في حتة واحدة: واجهة أمامية (Frontend)، خلفية (Backend)، ودعم Server-Side Rendering (SSR) عشان الأداء يبقى صاروخ. 🚀 بنينا النظام على أساس Event-Driven Architecture عشان البيانات تبقى خفيفة ومرنة، واستخدمنا مكتبات وبلاجنز زي CodeMirror وSocket.IO عشان نضيف ميزات قوية بسرعة.

8.2 هيكلية النظام (System Architecture)#

النظام بيتكوّن من 3 طبقات رئيسية:

  1. Frontend (واجهة المستخدم):

    • الدور: بتوفّر واجهة تفاعلية لتسجيل الكود والصوت وعرضهم.
    • التقنيات:
      • Next.js: للواجهة الأمامية والتنقل بين الصفحات.
      • CodeMirror: محرر كود تفاعلي بيدعم تلوين الكود وتسجيل الأحداث.
      • Prism.js: لتلوين الكود أثناء العرض.
      • WebRTC: لتسجيل الصوت مباشرة من المتصفح.
      • Tailwind CSS: للستايل بطريقة سريعة ومرنة.
    • الـcomponents:
      • CodeEditor: لتحرير الكود.
      • CollaborativeEditor: للتعاون في الوقت الفعلي.
      • Playback: لعرض الجلسات المسجلة.
  2. Backend (الخلفية):

    • الدور: بيدير تخزين الجلسات، المصادقة، والتواصل في الوقت الفعلي.
    • التقنيات:
      • Next.js API Routes: كـ Backend مدمج مع الـ Frontend.
      • MongoDB: لتخزين الجلسات (كود، صوت، مزامنة).
      • jsonwebtoken & bcryptjs: لتسجيل الدخول والتأمين.
      • multer: لرفع ملفات الصوت.
      • Socket.IO: للتعاون في الوقت الفعلي.
    • الـcomponents:
      • /api/session: لتخزين واسترجاع الجلسات.
      • /api/audio/[id]: لتقديم ملفات الصوت.
      • /api/socket: لإدارة التواصل عبر WebSocket.
  3. Workers (المعالجة في الخلفية):

    • الدور: بينظّف ويحسّن الأحداث عشان الأداء يبقى أسرع.
    • التقنيات:
      • WebWorker: لمعالجة الأحداث في خيط منفصل.
    • الـcomponents:
      • optimizeEvents.ts: بيجمّع الأحداث المتتالية (زي كتابة كلمة بدل حرف حرف).

رسم توضيحي (مخطط النظام):

┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│    Frontend      │───▶│     Backend      │───▶│      Workers     │
│ (Next.js,        │    │ (Next.js API,    │    │ (WebWorker)      │
│  CodeMirror,     │    │  MongoDB,        │    │                  │
│  WebRTC, Prism)  │    │  Socket.IO)      │    │                  │
│                  │◄───┤                  │◄───┤                  │
└──────────────────┘    └──────────────────┘    └──────────────────┘
       │                     │                     │
       ▼                     ▼                     ▼
  - تسجيل الكود/الصوت     - تخزين الجلسات       - تحسين الأحداث
  - عرض الجلسات           - مزامنة التعاون      - ضغط البيانات

8.3 المدخلات والمخرجات (Inputs/Outputs)#

المدخلات (Inputs):#

  1. تسجيل الكود:

    • البيانات: نص الكود (مثل JavaScript) اللي بيكتبه المستخدم في CodeMirror.
    • الأحداث: كل حركة (كتابة، حذف، تحديد) بتتسجل مع timestamp.
    • مثال:
      [
        { type: "init", timestamp: 0, content: "" },
        {
          type: "change",
          timestamp: 1000,
          change: {
            from: { line: 0, ch: 0 },
            to: { line: 0, ch: 0 },
            text: ["console"],
          },
        },
      ];
      
  2. تسجيل الصوت:

    • البيانات: ملف صوت (webm) من WebRTC.
    • الأحداث: بيانات الصوت الخام (audio chunks) مع timestamps للمزامنة.
    • مثال: ملف audio.webm بحجم كيلوبايتس.
  3. تعديلات التعاون:

    • البيانات: تغييرات الكود من مستخدمين تانيين عبر Socket.IO.
    • مثال:
      { sessionId: "123", event: { change: { from: { line: 1, ch: 0 }, to: { line: 1, ch: 0 }, text: [".log"] } } }
      
  4. طلبات المستخدم:

    • البيانات: طلبات HTTP (مثل تسجيل جلسة أو استرجاعها).
    • مثال: POST /api/session مع FormData فيها الكود والصوت.

المخرجات (Outputs):#

  1. جلسة مسجلة:

    • البيانات: ملف JSON لأحداث الكود، ملف صوت (webm)، وعلامات مزامنة.
    • مثال:
      {
        code: { events: [...], duration: 120000, metadata: { language: "javascript", linesCount: 10 } },
        audio: Blob,
        syncMarkers: [{ timestamp: 1000, codeEventsCount: 5 }],
        timestamp: "2025-05-31T23:54:00Z"
      }
      
  2. عرض الجلسة (Playback):

    • البيانات: نص الكود المتحرك مع تلوين بـ Prism.js، وصوت متزامن.
    • مثال: صفحة ويب فيها الكود بيتغير مع الصوت زي فيديو تفاعلي.
  3. تحديثات التعاون:

    • البيانات: تغييرات الكود بتتوزع على المستخدمين في الوقت الفعلي عبر Socket.IO.
    • مثال: مستخدم يكتب console.log، وكل المستخدمين يشوفوا التغيير فورًا.
  4. ردود الـ Backend:

    • البيانات: ردود HTTP (مثل ID الجلسة أو ملف الصوت).
    • مثال: { sessionId: "abc123" } بعد رفع جلسة.

الخلاصة#

Scrimba مش مجرد منصة تسجيل فيديوهات screen capture زي الكورسات التقليدية. هما بيستخدموا proprietary playback engine مبني على event-based replayer. بدل ما يسجلوا فيديو، هما بيسجلوا كل الأحداث (events) اللي بتحصل جوه الـ code editor كـ discrete DOM + editor-level events، زي:

  • ضغطات المفاتيح (keypresses)
  • تحركات المؤشر (cursor movement)
  • التحديد (selection)
  • الحذف (deletions)
  • تفاعلات الحافظة (clipboard interactions)

إزاي النظام بيشتغل؟#

  1. تسجيل الأحداث:
    • بيسجلوا stream من editor mutations (التغييرات في الكود) وuser input events متزامنة مع التعليق الصوتي (audio narration).
  2. إعادة التشغيل:
    • وقت الـ playback، الـ engine بيعيد ينفذ الأحداث دي في virtualized code editor داخل DOM حقيقي، مش فيديو.
    • يعني اللي إنت بتشوفه هو deterministic simulation للجلسة الأصلية، مش مجرد عرض فيديو.
  3. التفاعلية:
    • النظام بيسمحلك توقف، تعدّل الكود، ترجع لورا، أو تكمل، وكل ده بيحصل statefully من غير ما يكسر الـ flow.
    • ده بفضل استخدامهم لـ event serialization، code state diffing، وcontrolled side-effect replaying.

ليه ده مميز؟#

  • الـ user بيحس إن الجلسة شغالة live، مع إنها فعليًا مجرد replay engine بيتعامل مع الـ editor كأنه sandbox environment متحكم فيه بالكامل.
  • يعني Scrimba مش بتقدم فيديو تعليمي تقليدي، هي بتعيد بناء التجربة نفسها بشكل حرفي!