feat: Implement Dual Video Pipeline (Direct Server API Upload & Bunny.net Stream Cloud CDN + DRM) with Socratic interactive player

This commit is contained in:
Hamza-Ayed
2026-08-27 19:18:51 +03:00
parent 70176deb3b
commit 8044f86798
5 changed files with 1054 additions and 25 deletions
+80 -1
View File
@@ -859,7 +859,8 @@ class StudentPortal
video.addEventListener('timeupdate', () => {
const cur = Math.floor(video.currentTime);
const dur = Math.floor(video.duration || 596);
document.getElementById('video_time_display').textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
const timeEl = document.getElementById('video_time_display');
if (timeEl) timeEl.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
// Trigger checkpoint at second 15 automatically once
if (cur === 15 && !checkpointTriggered) {
@@ -877,6 +878,84 @@ class StudentPortal
return `${m}:${s}`;
}
function triggerCheckpointDemo() {
const video = document.getElementById('lesson_video_player');
if (video) {
video.currentTime = 14;
video.play();
checkpointTriggered = false;
}
}
function handleCheckpointAnswer(btn, isCorrect) {
const feedback = document.getElementById('checkpoint_feedback');
const video = document.getElementById('lesson_video_player');
const btns = document.querySelectorAll('#socratic_quiz_modal .quiz-option-btn');
if (isCorrect) {
btn.style.background = 'rgba(16, 185, 129, 0.25)';
btn.style.borderColor = '#10B981';
feedback.style.color = '#34D399';
feedback.style.display = 'block';
feedback.innerHTML = '✓ إجابة ممتازة وصحيحة 100%! سيتم استئناف الشرح فوراً...';
playChimeNotification();
setTimeout(() => {
document.getElementById('socratic_quiz_modal').style.display = 'none';
feedback.style.display = 'none';
btns.forEach(b => {
b.style.background = '';
b.style.borderColor = '';
});
if (video) video.play();
}, 1500);
} else {
btn.style.background = 'rgba(239, 68, 68, 0.25)';
btn.style.borderColor = '#EF4444';
feedback.style.color = '#F87171';
feedback.style.display = 'block';
feedback.innerHTML = '⚠️ إجابة غير دقيقة. سيتم إرجاعك 45 ثانية لمراجعة الفكرة وتثبيت الفهم!';
setTimeout(() => {
document.getElementById('socratic_quiz_modal').style.display = 'none';
feedback.style.display = 'none';
btns.forEach(b => {
b.style.background = '';
b.style.borderColor = '';
});
if (video) {
video.currentTime = Math.max(0, video.currentTime - 45);
video.play();
checkpointTriggered = false;
}
}, 2200);
}
}
async function loadStudentLessonPlayback(lessonId = 1) {
const token = localStorage.getItem('saqel_student_jwt');
if (!token) return;
try {
const res = await fetch(`/api/lessons/${lessonId}/playback`, {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (res.ok && data.status === 'success' && data.data?.playback) {
const pb = data.data.playback;
const video = document.getElementById('lesson_video_player');
if (video) {
if (pb.storage_type === 'api_upload' && pb.stream_url) {
video.src = pb.stream_url;
} else if (pb.hls_url) {
video.src = pb.hls_url;
}
}
}
} catch (e) {
console.error('Load lesson playback notice:', e);
}
}
let wsPingInterval = null;
// 1. Initialize Real-time WebSocket (Workerman)
+314 -24
View File
@@ -446,46 +446,132 @@ class TeacherPortal
</div>
</div>
<!-- TAB 2: Courses & In-Video Checkpoint Quizzes -->
<!-- TAB 2: Courses & Dual Video Upload (API & Bunny Stream) -->
<div id="tab_courses_content" class="studio-card" style="display: none;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px;">
<div>
<h3 style="font-size: 18px; font-weight: 900;">إدارة الكورسات والحصص المربوطة بـ Bunny Stream 🎬</h3>
<p style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">تحديد نقاط الكويز الصدمي داخل الفيديو بالدقيقة والثانية مع الإرجاع العلاجي.</p>
<h3 style="font-size: 18px; font-weight: 900;">إدارة الحصص والفيديوهات التعليمية (Direct API & Bunny Stream) 🎬</h3>
<p style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">رفع وبث الفيديوهات بالطريقتين مع التشفير وتثبيت نقاط الفحص السقراطي داخل الفيديو.</p>
</div>
<div style="display: flex; gap: 10px;">
<button type="button" onclick="switchVideoUploadMode('api')" id="btn_mode_api" class="btn-primary" style="width: auto; padding: 8px 18px; font-size: 12px; background: linear-gradient(135deg, #0284C7, #0369A1);">1. الرفع المباشر عبر السيرفر ⚡</button>
<button type="button" onclick="switchVideoUploadMode('bunny')" id="btn_mode_bunny" class="btn-primary" style="width: auto; padding: 8px 18px; font-size: 12px; background: rgba(255,255,255,0.06); border: 1px solid var(--border);">2. ربط Bunny Stream CDN 🐰</button>
</div>
</div>
<div style="background: rgba(11, 19, 43, 0.8); border: 1px solid var(--border); border-radius: 16px; padding: 24px;">
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-gold); margin-bottom: 12px;">+ إضافة نقطة فحص فهم (Socratic Checkpoint) داخل الحصة</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div>
<!-- UPLOADER BOX 1: Direct API Upload -->
<div id="uploader_box_api" style="background: rgba(11, 19, 43, 0.85); border: 1px solid rgba(56, 189, 248, 0.3); border-radius: 18px; padding: 24px; margin-bottom: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.4);">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-cyan);">⚡ الرفع المباشر والتلقائي عبر السيرفر (Direct API Stream)</h4>
<span style="font-size: 11px; background: rgba(56,189,248,0.1); color: var(--accent-cyan); padding: 3px 10px; border-radius: 6px; font-weight: 700;">HTTP 206 Partial Content</span>
</div>
<form id="form_direct_upload" onsubmit="handleDirectVideoUpload(event)">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px;">
<div class="form-group">
<label class="form-label">الدرس المستهدف</label>
<select id="checkpoint_lesson_select" class="input-text">
<option value="4">الرياضيات العلمي — الدرس 4: قواعد الاشتقاق الأساسية</option>
<option value="5">الرياضيات العلمي — الدرس 5: مشتقات الاقترانات المثلثية</option>
<label class="form-label">الدورة التدريبية</label>
<select id="direct_upload_course_select" class="input-text" required>
<option value="1">الرياضيات العلمي — توجيهي 2008 (المستوى الثالث)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">توقيت ظهور السؤال داخل الفيديو (دقيقة:ثانية)</label>
<input type="text" id="checkpoint_timestamp" value="00:15" class="input-text" style="color: var(--accent-cyan); font-family: monospace;">
<label class="form-label">عنوان الحصة / الدرس</label>
<input type="text" id="direct_upload_title" placeholder="مثال: الدرس 1 — مفهوم النهايات والاتصال" class="input-text" required>
</div>
<div class="form-group">
<label class="form-label">ترتيب الدرس</label>
<input type="number" id="direct_upload_seq" value="1" min="1" class="input-text">
</div>
</div>
<div>
<div class="form-group">
<label class="form-label">نص السؤال السقراطي</label>
<input type="text" id="checkpoint_question_input" value="إذا كان f(x) = sin(3x)، فما هي قيمة المشتقة f'(x)؟" class="input-text">
<div class="form-group" style="margin-top: 12px;">
<label class="form-label">ملف الفيديو (MP4, WebM, MOV - حتى 2GB)</label>
<div style="border: 2px dashed rgba(56, 189, 248, 0.4); border-radius: 14px; padding: 24px; text-align: center; background: rgba(0,0,0,0.2); cursor: pointer;" onclick="document.getElementById('direct_video_file').click()">
<div style="font-size: 28px; margin-bottom: 8px;">🎬</div>
<div style="font-size: 13px; font-weight: 700; color: #FFFFFF;" id="file_selected_label">اضغط لاختيار ملف الفيديو أو اسحبه وأفلته هنا</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">يتم حفظه تلقائياً في مسار السيرفر الآمن وبثه تدفقياً عبر الـ API</div>
<input type="file" id="direct_video_file" accept="video/mp4,video/webm,video/quicktime,video/x-matroska" style="display: none;" onchange="onVideoFileSelected(this)">
</div>
</div>
<!-- Progress Bar -->
<div id="upload_progress_container" style="display: none; margin-top: 16px;">
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 6px;">
<span id="upload_progress_text" style="color: var(--accent-cyan); font-weight: 700;">جارٍ رفع الفيديو... 0%</span>
<span id="upload_progress_bytes" style="color: var(--text-muted);"></span>
</div>
<div style="height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden;">
<div id="upload_progress_bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #38BDF8, #00F5D4); transition: width 0.2s ease;"></div>
</div>
</div>
<button type="submit" id="btn_submit_direct_upload" class="btn-primary" style="margin-top: 18px; width: auto; padding: 12px 32px; font-size: 13px;">بدء رفع وحفظ الفيديو الآن 🚀</button>
</form>
</div>
<!-- UPLOADER BOX 2: Bunny Stream CDN -->
<div id="uploader_box_bunny" style="display: none; background: rgba(11, 19, 43, 0.85); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 18px; padding: 24px; margin-bottom: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.4);">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-gold);">🐰 الربط مع شبكة Bunny.net Stream (Cloud CDN & Token DRM)</h4>
<span style="font-size: 11px; background: rgba(245,158,11,0.1); color: var(--accent-gold); padding: 3px 10px; border-radius: 6px; font-weight: 700;">HLS + SHA256 DRM</span>
</div>
<form id="form_bunny_link" onsubmit="handleBunnyVideoLink(event)">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px;">
<div class="form-group">
<label class="form-label">عقوبة الإرجاع عند الخطأ</label>
<select class="input-text">
<option>إرجاع الطالب 45 ثانية للخلف (موصى به)</option>
<option>إرجاع الطالب 60 ثانية</option>
<label class="form-label">الدورة التدريبية</label>
<select id="bunny_course_select" class="input-text" required>
<option value="1">الرياضيات العلمي — توجيهي 2008 (المستوى الثالث)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">عنوان الحصة / الدرس</label>
<input type="text" id="bunny_lesson_title" placeholder="مثال: الدرس 2 — مشتقة ضرب اقترانين" class="input-text" required>
</div>
<div class="form-group">
<label class="form-label">معرف فيديو Bunny Stream (Video GUID)</label>
<div style="display: flex; gap: 8px;">
<input type="text" id="bunny_video_id_input" placeholder="e.g. 78a9c12b-34ef-..." class="input-text" style="font-family: monospace;" required>
<button type="button" onclick="handleBunnyCreateVideo()" class="btn-primary" style="width: auto; padding: 0 14px; font-size: 11px; background: #D97706; white-space: nowrap;">+ توليد GUID</button>
</div>
</div>
<div class="form-group">
<label class="form-label">مدة الفيديو المقدرة (بالثواني)</label>
<input type="number" id="bunny_duration_input" value="1800" class="input-text">
</div>
</div>
<button type="submit" id="btn_submit_bunny_link" class="btn-primary" style="margin-top: 18px; width: auto; padding: 12px 32px; font-size: 13px; background: linear-gradient(135deg, #F59E0B, #D97706);">حفظ وربط درس Bunny Stream ✓</button>
</form>
</div>
<!-- IN-VIDEO SOCRATIC CHECKPOINTS -->
<div style="background: rgba(15, 23, 42, 0.8); border: 1px solid var(--border); border-radius: 18px; padding: 24px;">
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-cyan); margin-bottom: 12px;">+ تثبيت نقطة فحص معرفي سقراطي (Socratic Checkpoint) في الفيديو</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px;">
<div class="form-group">
<label class="form-label">الدرس المستهدف</label>
<select id="checkpoint_lesson_select" class="input-text">
<option value="1">الرياضيات العلمي — الدرس 1: قواعد الاشتقاق الأساسية</option>
</select>
</div>
<div class="form-group">
<label class="form-label">توقيت ظهور السؤال (دقيقة:ثانية)</label>
<input type="text" id="checkpoint_timestamp" value="00:15" class="input-text" style="color: var(--accent-cyan); font-family: monospace;">
</div>
<div class="form-group">
<label class="form-label">نص السؤال السقراطي</label>
<input type="text" id="checkpoint_question_input" value="إذا كان f(x) = sin(3x)، فما هي قيمة المشتقة f'(x)؟" class="input-text">
</div>
<div class="form-group">
<label class="form-label">عقوبة الإرجاع عند الخطأ</label>
<select id="checkpoint_rewind_select" class="input-text">
<option value="45">إرجاع الطالب 45 ثانية للخلف (موصى به)</option>
<option value="60">إرجاع الطالب 60 ثانية</option>
<option value="30">إرجاع الطالب 30 ثانية</option>
</select>
</div>
</div>
<button type="button" onclick="handleSaveCheckpoint()" class="btn-primary" style="width: auto; padding: 10px 24px; font-size: 13px;">حفظ وتثبيت النقطة التفاعلية ✓</button>
<button type="button" onclick="handleSaveCheckpoint()" class="btn-primary" style="width: auto; padding: 10px 28px; font-size: 13px; margin-top: 8px;">تثبيت النقطة في محرك التثبيت المعرفي ✓</button>
</div>
</div>
@@ -1178,11 +1264,215 @@ class TeacherPortal
loadConversations();
}
// ==========================================
// Video Storage & Bunny Stream Studio Handlers
// ==========================================
function switchVideoUploadMode(mode) {
const apiBox = document.getElementById('uploader_box_api');
const bunnyBox = document.getElementById('uploader_box_bunny');
const btnApi = document.getElementById('btn_mode_api');
const btnBunny = document.getElementById('btn_mode_bunny');
if (mode === 'api') {
apiBox.style.display = 'block';
bunnyBox.style.display = 'none';
btnApi.style.background = 'linear-gradient(135deg, #0284C7, #0369A1)';
btnApi.style.border = 'none';
btnBunny.style.background = 'rgba(255,255,255,0.06)';
btnBunny.style.border = '1px solid var(--border)';
} else {
apiBox.style.display = 'none';
bunnyBox.style.display = 'block';
btnBunny.style.background = 'linear-gradient(135deg, #F59E0B, #D97706)';
btnBunny.style.border = 'none';
btnApi.style.background = 'rgba(255,255,255,0.06)';
btnApi.style.border = '1px solid var(--border)';
}
}
function onVideoFileSelected(input) {
if (input.files && input.files[0]) {
const f = input.files[0];
const sizeMb = (f.size / (1024 * 1024)).toFixed(1);
document.getElementById('file_selected_label').textContent = `✅ تم اختيار: ${f.name} (${sizeMb} MB)`;
// Auto-fill title if empty
const titleInput = document.getElementById('direct_upload_title');
if (!titleInput.value) {
titleInput.value = f.name.replace(/\.[^/.]+$/, "");
}
}
}
function handleDirectVideoUpload(e) {
e.preventDefault();
const token = getAuthToken();
const fileInput = document.getElementById('direct_video_file');
const courseSelect = document.getElementById('direct_upload_course_select');
const titleInput = document.getElementById('direct_upload_title');
const seqInput = document.getElementById('direct_upload_seq');
if (!fileInput.files || !fileInput.files[0]) {
alert('يرجى اختيار ملف الفيديو أولاً');
return;
}
const formData = new FormData();
formData.append('video', fileInput.files[0]);
formData.append('course_id', courseSelect.value);
formData.append('title', titleInput.value);
formData.append('sequence_order', seqInput.value);
const progressContainer = document.getElementById('upload_progress_container');
const progressBar = document.getElementById('upload_progress_bar');
const progressText = document.getElementById('upload_progress_text');
const progressBytes = document.getElementById('upload_progress_bytes');
const submitBtn = document.getElementById('btn_submit_direct_upload');
progressContainer.style.display = 'block';
submitBtn.disabled = true;
submitBtn.textContent = 'جارٍ رفع الفيديو... ⏳';
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/teacher/videos/upload-direct', true);
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
progressBar.style.width = percent + '%';
progressText.textContent = `جارٍ رفع الفيديو... ${percent}%`;
const loadedMb = (event.loaded / (1024 * 1024)).toFixed(1);
const totalMb = (event.total / (1024 * 1024)).toFixed(1);
progressBytes.textContent = `${loadedMb}MB / ${totalMb}MB`;
}
};
xhr.onload = function() {
submitBtn.disabled = false;
submitBtn.textContent = 'بدء رفع وحفظ الفيديو الآن 🚀';
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && data.status === 'success') {
showLuxuryToast('تم حفظ الفيديو بنجاح 🎬', data.message || 'تم إنشاء الدرس وحفظ الفيديو');
alert('✅ ' + data.message + '\nرابط البث المباشر: ' + data.data.stream_url);
document.getElementById('form_direct_upload').reset();
document.getElementById('file_selected_label').textContent = 'اضغط لاختيار ملف الفيديو أو اسحبه وأفلته هنا';
progressContainer.style.display = 'none';
loadTeacherCourses();
} else {
alert('❌ ' + (data.message || 'فشل رفع الفيديو'));
}
} catch (err) {
alert('❌ خطأ في معالجة استجابة السيرفر');
}
};
xhr.onerror = function() {
submitBtn.disabled = false;
submitBtn.textContent = 'بدء رفع وحفظ الفيديو الآن 🚀';
alert('❌ حدث خطأ في الاتصال أثناء رفع الفيديو');
};
xhr.send(formData);
}
async function handleBunnyCreateVideo() {
const token = getAuthToken();
const title = document.getElementById('bunny_lesson_title').value.trim() || 'درس جديد في الرياضيات';
const courseId = document.getElementById('bunny_course_select').value;
try {
const res = await fetch('/api/teacher/videos/bunny-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ title: title, course_id: courseId })
});
const data = await res.json();
if (res.ok && data.status === 'success') {
document.getElementById('bunny_video_id_input').value = data.data.video_id;
showLuxuryToast('تم توليد المعرف في Bunny Stream 🐰', `GUID: ${data.data.video_id}`);
} else {
alert('خطأ: ' + (data.message || 'فشل الاتصال بـ Bunny Stream'));
}
} catch (err) {
console.error(err);
alert('خطأ في الاتصال بالخادم');
}
}
async function handleBunnyVideoLink(e) {
e.preventDefault();
const token = getAuthToken();
const courseId = document.getElementById('bunny_course_select').value;
const title = document.getElementById('bunny_lesson_title').value.trim();
const bunnyVideoId = document.getElementById('bunny_video_id_input').value.trim();
const duration = document.getElementById('bunny_duration_input').value;
try {
const res = await fetch('/api/teacher/videos/bunny-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({
course_id: courseId,
title: title,
bunny_video_id: bunnyVideoId,
duration_seconds: duration
})
});
const data = await res.json();
if (res.ok && data.status === 'success') {
showLuxuryToast('تم ربط الدرس بنجاح 🐰', data.message);
alert('✅ تم ربط الحصة بـ Bunny Stream وحمايتها بروابط التشفير الرقمي DRM!');
document.getElementById('form_bunny_link').reset();
loadTeacherCourses();
} else {
alert('خطأ: ' + (data.message || 'فشل حفظ الدرس'));
}
} catch (err) {
console.error(err);
alert('خطأ في الاتصال بالخادم');
}
}
async function loadTeacherCourses() {
const token = getAuthToken();
if (!token) return;
try {
const res = await fetch('/api/teacher/courses', {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (res.ok && data.data && data.data.length > 0) {
const selects = [
document.getElementById('direct_upload_course_select'),
document.getElementById('bunny_course_select')
];
selects.forEach(sel => {
if (sel) {
sel.innerHTML = data.data.map(c => `
<option value="${c.id}">${c.title} (${c.semester || 'فصل أول'})</option>
`).join('');
}
});
}
} catch (e) {
console.error('Load courses note:', e);
}
}
function handleSaveCheckpoint() {
const lessonId = document.getElementById('checkpoint_lesson_select').value;
const ts = document.getElementById('checkpoint_timestamp').value;
const question = document.getElementById('checkpoint_question_input').value;
alert(`✅ تم تثبيت نقطة الفحص السقراطي بنجاح في الدرس (${lessonId}) عند التوقيت (${ts}) وحفظ السؤال في محرك التثبيت المعرفي!`);
const rewind = document.getElementById('checkpoint_rewind_select').value;
alert(`✅ تم تثبيت نقطة الفحص السقراطي بنجاح في الدرس (${lessonId}) عند التوقيت (${ts}) بعقوبة إرجاع (${rewind} ثانية) وحفظ السؤال في محرك التثبيت المعرفي!`);
}
function handleLogout() {