תארו לעצמכם סטודנט עם חיבור אינטרנט חלש שמנסה לצפות בשיעור וידאו, אבל הנגן נתקע והחזרה לאחור מאפסת את ההתקדמות—ומאלצת אותו להתחיל מההתחלה. זו בעיה נפוצה בפלטפורמות LMS רבות שנבנו ללא ארכיטקטורה מוצקה. פיתוח מערכת שיעורים היא משימה מורכבת: צריך מעקב יציב לתוכן וידאו, טקסט ואודיו, אספקה אדפטיבית וסבילות לתקלות.
פיתוח ה-LMS שלנו כולל מערכת שיעורים חזקה עם שיעורי וידאו ב-LMS, שיעורי אודיו, שיעורי טקסט והמרת HLS למעקב התקדמות. באמצעות עורך Tiptap לטקסט, נגן ReactPlayer לווידאו ו-backend LMS מוצק, אנו מבטיחים שיעורים עם כתוביות ומעקב התקדמות מדויק. אנו בונים מערכות שיעורים שבהן לכל סוג תוכן יש מעקב אמין ואספקה אדפטיבית. בהתבסס על ניסיון עם יותר מ-50 פורטלים חינוכיים, זיהינו אתגרים טכניים מרכזיים: מעקב שביר במהלך החזרה לאחור בווידאו, טעינה איטית של קבצים גדולים, חוסר סנכרון בין אודיו לתמלול. הנה איך אנחנו פותרים אותם.
אילו בעיות טכניות מערכת השיעורים פותרת?
- מעקב התקדמות וידאו — משתמשים עשויים לגלול אחורה, ולכן ההתקדמות צריכה לתעד רק את המיקום המקסימלי. אנו שומרים אותה כל 10% ומסמנים את השיעור כהושלם ב-90%+ מזמן הצפייה, לא רק בסוף.
- המרת קבצים גדולים — MP4 גולמי נתקע בחיבורים איטיים. אנו ממירים וידאו ל-HLS עם מספר קצבי סיביות (360p–1080p) ומשתמשים ב-workers ברקע (Bull) לעיבוד אסינכרוני.
- שיעורי טקסט ללא אישור קריאה — פשוט גלילה לתחתית לא מספיקה. אנו משתמשים ב-Intersection Observer כדי לזהות 90% גלילה ולהשלים את השיעור אוטומטית.
- אודיו ללא סנכרון — מאזינים מאבדים את ההקשר ללא טקסט. אנו מציגים תמלול לצד הנגן ולחלופין מדגישים את המילה הנוכחית.
איך אנו מבטיחים מעקב התקדמות אמין לשיעורי וידאו
האתגר המרכזי: משתמשים יכולים לחפש אחורה, אבל ההתקדמות חייבת רק לעלות. ברכיב ReactPlayer, אנו עוקבים אחר הערך המקסימלי של import ReactPlayer from 'react-player'; import { useState, useRef, useCallback } from 'react'; function VideoLesson({ lesson, enrollmentId, onComplete }) { const playerRef = useRef<ReactPlayer>(null); const [played, setPlayed] = useState(0); const [completed, setCompleted] = useState(lesson.progress?.completed ?? false); const maxPlayedRef = useRef(lesson.progress?.progress ?? 0); const handleProgress = useCallback(async ({ played: p }) => { setPlayed(p); if (p > maxPlayedRef.current) { maxPlayedRef.current = p; if (Math.floor(p * 10) > Math.floor((p - 0.01) * 10)) { await saveProgress(enrollmentId, lesson.id, p); } } }, []); const handleEnded = useCallback(async () => { if (!completed) { setCompleted(true); await fetch(`/api/enrollments/${enrollmentId}/lessons/${lesson.id}/complete`, { method: 'POST', }); onComplete?.(lesson.id); } }, [completed]); const handleProgress2 = useCallback(({ played: p }) => { handleProgress({ played: p }); if (p >= 0.9 && !completed) { handleEnded(); } }, [handleProgress, handleEnded, completed]); return ( <div className="space-y-4"> <div className="relative aspect-video bg-black rounded-xl overflow-hidden"> <ReactPlayer ref={playerRef} url={lesson.content.videoUrl} width="100%" height="100%" controls onProgress={handleProgress2} onEnded={handleEnded} config={{ file: { tracks: lesson.content.subtitles.map(s => ({ kind: 'subtitles', src: s.url, srcLang: s.lang, label: s.label, })), }, }} /> </div> {lesson.content.chapters?.length > 0 && ( <div> <h3 className="font-semibold text-gray-800 mb-2">Содержание</h3> <ul className="space-y-1"> {lesson.content.chapters.map((ch, i) => ( <li key={i}> <button onClick={() => playerRef.current?.seekTo(ch.time)} className="text-sm text-blue-600 hover:underline" > {formatTime(ch.time)} — {ch.title} </button> </li> ))} </ul> </div> )} {completed && ( <div className="flex items-center gap-2 text-green-600 text-sm"> <span>✓</span> Урок завершён </div> )} </div> ); } (0–1) ושומרים אותו כל 10%.
import ReactPlayer from 'react-player';
import { useState, useRef, useCallback } from 'react';
function VideoLesson({ lesson, enrollmentId, onComplete }) {
const playerRef = useRef<ReactPlayer>(null);
const [played, setPlayed] = useState(0);
const [completed, setCompleted] = useState(lesson.progress?.completed ?? false);
const maxPlayedRef = useRef(lesson.progress?.progress ?? 0);
const handleProgress = useCallback(async ({ played: p }) => {
setPlayed(p);
if (p > maxPlayedRef.current) {
maxPlayedRef.current = p;
if (Math.floor(p * 10) > Math.floor((p - 0.01) * 10)) {
await saveProgress(enrollmentId, lesson.id, p);
}
}
}, []);
const handleEnded = useCallback(async () => {
if (!completed) {
setCompleted(true);
await fetch(`/api/enrollments/${enrollmentId}/lessons/${lesson.id}/complete`, {
method: 'POST',
});
onComplete?.(lesson.id);
}
}, [completed]);
const handleProgress2 = useCallback(({ played: p }) => {
handleProgress({ played: p });
if (p >= 0.9 && !completed) {
handleEnded();
}
}, [handleProgress, handleEnded, completed]);
return (
<div className="space-y-4">
<div className="relative aspect-video bg-black rounded-xl overflow-hidden">
<ReactPlayer
ref={playerRef}
url={lesson.content.videoUrl}
width="100%"
height="100%"
controls
onProgress={handleProgress2}
onEnded={handleEnded}
config={{
file: {
tracks: lesson.content.subtitles.map(s => ({
kind: 'subtitles',
src: s.url,
srcLang: s.lang,
label: s.label,
})),
},
}}
/>
</div>
{lesson.content.chapters?.length > 0 && (
<div>
<h3 className="font-semibold text-gray-800 mb-2">Содержание</h3>
<ul className="space-y-1">
{lesson.content.chapters.map((ch, i) => (
<li key={i}>
<button
onClick={() => playerRef.current?.seekTo(ch.time)}
className="text-sm text-blue-600 hover:underline"
>
{formatTime(ch.time)} — {ch.title}
</button>
</li>
))}
</ul>
</div>
)}
{completed && (
<div className="flex items-center gap-2 text-green-600 text-sm">
<span>✓</span> Урок завершён
</div>
)}
</div>
);
} למה אנו משתמשים ב-HLS במקום MP4 ישיר?
MP4 ישיר טוען את כל הקובץ — בחיבור חלש, משתמשים ממתינים דקות. HLS מפצל וידאו למקטעים של 6 שניות: ההשמעה מתחילה מיד, עם מעבר איכות לפי המהירות. HLS מתחיל פי 3–10 מהר יותר מ-MP4, ובחיבורים חלשים ההבדל יכול להגיע לפי 15. אנו ממירים וידאו שהועלה באמצעות ffmpeg ברקע.
התקן HTTP Live Streaming מתאר אספקת וידאו אדפטיבית ומבטיח השמעה חלקה.
import { createReadStream } from 'fs';
import ffmpeg from 'fluent-ffmpeg';
async function transcodeToHLS(inputPath: string, outputDir: string): Promise<string> {
await fs.promises.mkdir(outputDir, { recursive: true });
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-profile:v baseline',
'-level 3.0',
'-start_number 0',
'-hls_time 6',
'-hls_list_size 0',
'-f hls',
])
.output(path.join(outputDir, 'index.m3u8'))
.outputOptions([
'-vf scale=-2:720',
'-b:v 2500k',
])
.on('end', () => resolve(`${outputDir}/index.m3u8`))
.on('error', reject)
.run();
});
}
videoProcessingQueue.process(async (job) => {
const { uploadedPath, lessonId } = job.data;
const outputDir = `storage/lessons/${lessonId}/hls`;
await job.progress(10);
const hlsPath = await transcodeToHLS(uploadedPath, outputDir);
await job.progress(80);
const url = await uploadHLSToS3(outputDir, `lessons/${lessonId}`);
await db.lessons.update(lessonId, { content: { videoUrl: url, status: 'ready' } });
await job.progress(100);
});
מידע נוסף על המרה ברקע
אנו משתמשים בתור Bull לעיבוד וידאו אסינכרוני. לאחר העלאת הקובץ לשרת, המשימה נכנסת לתור, ותהליך worker ממיר את הווידאו ל-HLS דרך ffmpeg. זה לא חוסם את ה-thread הראשי ומאפשר קנה מידה למספר workers. זמן ההמרה תלוי בגודל הווידאו: קליפ של 10 דקות ב-1080p לוקח כ-5 דקות. שימוש ב-HLS יכול להפחית עלויות אירוח: עבור ספריית וידאו של 100 שעות, החיסכון החודשי ברוחב פס עולה על $200.שיעורי טקסט ואודיו
איך אנו משתמשים ב-Intersection Observer כדי לזהות השלמת קריאה
לשיעורי טקסט, אנו משתמשים בעורך Tiptap (המבוסס על ProseMirror). התוכן נשמר כ-HTML או Markdown. כדי לסמן שיעור כנקרא, אנו עוקבים אחר גלילה לסוף דרך Intersection Observer.
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import { Youtube } from '@tiptap/extension-youtube';
function TextLessonViewer({ content, onComplete }) {
const editor = useEditor({
content: content.body,
editable: false,
extensions: [StarterKit, Image, Youtube],
});
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onComplete?.();
},
{ threshold: 0.9 }
);
const marker = containerRef.current?.querySelector('.lesson-end-marker');
if (marker) observer.observe(marker);
return () => observer.disconnect();
}, []);
return (
<div ref={containerRef} className="prose prose-lg max-w-none">
<EditorContent editor={editor} />
{content.attachments?.length > 0 && (
<div className="mt-8 border-t pt-6">
<h3 className="font-semibold">Материалы урока</h3>
<ul className="space-y-2 mt-3">
{content.attachments.map(att => (
<li key={att.url}>
<a href={att.url} download className="flex items-center gap-2 text-blue-600 hover:underline">
<span>📎</span>
<span>{att.name}</span>
<span className="text-gray-400 text-sm">({formatFileSize(att.size)})</span>
</a>
</li>
))}
</ul>
</div>
)}
<div className="lesson-end-marker h-1" />
</div>
);
} שיעורי אודיו עם תמלול
שיעורי אודיו פשוטים יותר: אנו משתמשים באלמנט import { createReadStream } from 'fs'; import ffmpeg from 'fluent-ffmpeg'; async function transcodeToHLS(inputPath: string, outputDir: string): Promise<string> { await fs.promises.mkdir(outputDir, { recursive: true }); return new Promise((resolve, reject) => { ffmpeg(inputPath) .outputOptions([ '-profile:v baseline', '-level 3.0', '-start_number 0', '-hls_time 6', '-hls_list_size 0', '-f hls', ]) .output(path.join(outputDir, 'index.m3u8')) .outputOptions([ '-vf scale=-2:720', '-b:v 2500k', ]) .on('end', () => resolve(`${outputDir}/index.m3u8`)) .on('error', reject) .run(); }); } videoProcessingQueue.process(async (job) => { const { uploadedPath, lessonId } = job.data; const outputDir = `storage/lessons/${lessonId}/hls`; await job.progress(10); const hlsPath = await transcodeToHLS(uploadedPath, outputDir); await job.progress(80); const url = await uploadHLSToS3(outputDir, `lessons/${lessonId}`); await db.lessons.update(lessonId, { content: { videoUrl: url, status: 'ready' } }); await job.progress(100); }); המקורי עם עיצוב מותאם. התמלול מוצג מתחת לנגן, ועוזר למשתמשים עם לקות שמיעה ולאלה שלא יכולים להשמיע אודיו.
function AudioLesson({ lesson, onComplete }) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
return (
<div className="space-y-6">
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-2xl p-8">
{lesson.content.thumbnailUrl && (
<img src={lesson.content.thumbnailUrl} className="w-32 h-32 rounded-full mx-auto mb-6 object-cover" alt="" />
)}
<audio
ref={audioRef}
src={lesson.content.audioUrl}
controls
className="w-full"
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onEnded={onComplete}
/>
</div>
{lesson.content.transcript && (
<div className="prose">
<h3>Транскрипция</h3>
<p className="text-gray-700 whitespace-pre-line">
{lesson.content.transcript}
</p>
</div>
)}
</div>
);
} השוואת פורמטים
| סוג | עיבוד | התקדמות | אחסון | מורכבות |
|---|---|---|---|---|
| וידאו | ReactPlayer (HLS) | 90% צפייה או סוף | מקטעי HLS ב-S3 | גבוהה (המרה) |
| טקסט | Tiptap (HTML/MD) | 90% גלילה | מסד נתונים (JSON) | בינונית (עורך) |
| אודיו | נגן אודיו מקורי | 90% השמעה | MP3 ב-S3 | נמוכה |
מעבר ל-HLS חוסך עד 50% ברוחב פס בהשוואה ל-MP4, וקצב סיביות אדפטיבי מפחית עלויות רוחב פס ב-30–50%.
| תכונה | HLS | MP4 |
|---|---|---|
| קצב סיביות אדפטיבי | כן | לא |
| זמן עד לפריים ראשון | 1–3 שניות | 10+ שניות |
| חיסכון ברוחב פס | עד 50% לעומת MP4 | לא יעיל |
| תמיכה בכתוביות | כן | רק מוטמעות |
| תמיכת דפדפנים | כל המודרניים | כל |
תהליך ולוח זמנים
- ניתוח — אנו לומדים את הדרישות שלכם: סוגי תוכן, מספר שיעורים, אינטגרציות (SCORM, SSO).
- עיצוב — אנו מתארים את הארכיטקטורה: מודלי נתונים, נקודות קצה API, רכיבי frontend.
- יישום — אנו כותבים קוד מאפס או על גבי ה-CMS שלכם. אנו משתמשים ב-TypeScript, React, Next.js, Laravel (או המחסנית שלכם).
- בדיקות — אנו מוודאים מעקב על כל סוגי התוכן, מדמים אינטרנט איטי, בודקים העלאות קבצים גדולים.
- פריסה — אנו פורסים על האירוח שלכם או על התשתית שלנו. אנו מספקים גישה לקוד המקור ולתיעוד.
לוח זמנים משוער: בין שבוע לשבועיים למערכת בסיסית עם וידאו (HLS), טקסט ואודיו. אם נדרשים סוגים נוספים (SCORM, שיעורים חיים) או אנליטיקה מורכבת, לוח הזמנים נדון בנפרד. העלות מחושבת באופן אישי. פיתוח מערכת בסיסית מתחיל ב-$5,000. הזמינו פיתוח מערכת שיעורים – נכין הצעה מסחרית מותאמת תוך 24 שעות.
מה כלול ואחריות
- קוד מקור של מערכת השיעורים (frontend + backend)
- API לאינטגרציה עם ה-LMS הקיים שלכם
- תיעוד פריסה והתאמה אישית
- קונסולת ניהול שיעורים (העלאה, עריכה, סידור)
- תמיכה לאחר השקה: שבועיים בחינם
אנו מפתחים LMS לבתי ספר, אוניברסיטאות ופורטלים ארגוניים כבר שנים רבות. כל הפרויקטים נמסרים בזמן ונתמכים עד ליציבות מלאה. אנו מבטיחים שהמעקב יעבוד ללא קשר לפעולות המשתמש (חיפוש, השהיה, הפעלה חוזרת). קבלו ייעוץ לפרויקט שלכם – צרו קשר. נשמח לעמוד לרשותכם.







