feat: Implement Server-Side FFmpeg HLS Transcoding (.m3u8), HLS.js streaming, and dynamic Socratic In-Video Checkpoint Engine
This commit is contained in:
@@ -20,6 +20,9 @@ class StudentPortal
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Alexandria:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- HLS.js for Server-Side Adaptive Video Stream -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script>
|
||||
|
||||
<!-- Apple-Grade Luxury Cupertino CSS -->
|
||||
<style>
|
||||
:root {
|
||||
@@ -617,18 +620,18 @@ class StudentPortal
|
||||
<span style="font-size: 11px; font-weight: 800; color: var(--accent-gold); background: rgba(245, 158, 11, 0.15); border: 1px solid rgba(245, 158, 11, 0.4); padding: 4px 14px; border-radius: 980px; margin-bottom: 14px;">
|
||||
⚠️ توقف الشرح لفحص الفهم اللحظي (Socratic Active Recall)
|
||||
</span>
|
||||
<h3 style="font-size: 18px; font-weight: 800; color: #FFFFFF; margin-bottom: 18px; max-width: 580px;">
|
||||
<h3 id="socratic_question_title" style="font-size: 18px; font-weight: 800; color: #FFFFFF; margin-bottom: 18px; max-width: 580px; text-align: center;">
|
||||
إذا كان f(x) = sin(3x)، فما هي مشتقة الاقتران f'(x)؟
|
||||
</h3>
|
||||
|
||||
<div style="width: 100%; display: flex; flex-direction: column; align-items: center;">
|
||||
<button type="button" class="quiz-option-btn" onclick="handleCheckpointAnswer(this, true)">
|
||||
<div id="socratic_options_container" style="width: 100%; display: flex; flex-direction: column; align-items: center; gap: 8px;">
|
||||
<button type="button" class="quiz-option-btn" onclick="handleCheckpointAnswer(this, true, 45)">
|
||||
أ) 3 cos(3x) (مشتقة الزاوية ضرب مشتقة الاقتران)
|
||||
</button>
|
||||
<button type="button" class="quiz-option-btn" onclick="handleCheckpointAnswer(this, false)">
|
||||
<button type="button" class="quiz-option-btn" onclick="handleCheckpointAnswer(this, false, 45)">
|
||||
ب) cos(3x)
|
||||
</button>
|
||||
<button type="button" class="quiz-option-btn" onclick="handleCheckpointAnswer(this, false)">
|
||||
<button type="button" class="quiz-option-btn" onclick="handleCheckpointAnswer(this, false, 45)">
|
||||
ج) -3 cos(3x)
|
||||
</button>
|
||||
</div>
|
||||
@@ -839,6 +842,10 @@ class StudentPortal
|
||||
}
|
||||
];
|
||||
|
||||
let activeLessonCheckpoints = [];
|
||||
let triggeredCheckpoints = new Set();
|
||||
let hlsInstance = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const token = localStorage.getItem('saqel_student_jwt');
|
||||
const cachedUser = localStorage.getItem('saqel_student_user');
|
||||
@@ -851,6 +858,7 @@ class StudentPortal
|
||||
}
|
||||
initWebSocket(token);
|
||||
await checkStudentSession(token);
|
||||
await loadStudentLessonPlayback(1);
|
||||
}
|
||||
|
||||
// Video player time listener for Socratic checkpoint
|
||||
@@ -862,11 +870,12 @@ class StudentPortal
|
||||
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) {
|
||||
checkpointTriggered = true;
|
||||
// Check if current timestamp hits an active Socratic checkpoint
|
||||
const activeCp = activeLessonCheckpoints.find(cp => cp.timestamp_seconds === cur);
|
||||
if (activeCp && !triggeredCheckpoints.has(activeCp.exam_id)) {
|
||||
triggeredCheckpoints.add(activeCp.exam_id);
|
||||
video.pause();
|
||||
document.getElementById('socratic_quiz_modal').style.display = 'flex';
|
||||
renderSocraticModal(activeCp);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -878,19 +887,42 @@ class StudentPortal
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
function renderSocraticModal(cp) {
|
||||
const modal = document.getElementById('socratic_quiz_modal');
|
||||
const titleEl = document.getElementById('socratic_question_title');
|
||||
const container = document.getElementById('socratic_options_container');
|
||||
const feedback = document.getElementById('checkpoint_feedback');
|
||||
|
||||
titleEl.textContent = cp.question_text || 'سؤال فحص الفهم اللحظي:';
|
||||
container.innerHTML = '';
|
||||
feedback.style.display = 'none';
|
||||
|
||||
const letters = ['أ', 'ب', 'ج', 'د'];
|
||||
cp.options.forEach((opt, idx) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'quiz-option-btn';
|
||||
btn.textContent = `${letters[idx] || (idx+1)}) ${opt.text}`;
|
||||
btn.onclick = () => handleCheckpointAnswer(btn, opt.is_correct, cp.rewind_on_fail_seconds || 45, cp.exam_id);
|
||||
container.appendChild(btn);
|
||||
});
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function triggerCheckpointDemo() {
|
||||
const video = document.getElementById('lesson_video_player');
|
||||
if (video) {
|
||||
video.currentTime = 14;
|
||||
triggeredCheckpoints.clear();
|
||||
video.play();
|
||||
checkpointTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCheckpointAnswer(btn, isCorrect) {
|
||||
function handleCheckpointAnswer(btn, isCorrect, rewindSeconds, examId) {
|
||||
const feedback = document.getElementById('checkpoint_feedback');
|
||||
const video = document.getElementById('lesson_video_player');
|
||||
const btns = document.querySelectorAll('#socratic_quiz_modal .quiz-option-btn');
|
||||
const btns = document.querySelectorAll('#socratic_options_container .quiz-option-btn');
|
||||
|
||||
if (isCorrect) {
|
||||
btn.style.background = 'rgba(16, 185, 129, 0.25)';
|
||||
@@ -903,32 +935,24 @@ class StudentPortal
|
||||
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);
|
||||
}, 1400);
|
||||
} 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 ثانية لمراجعة الفكرة وتثبيت الفهم!';
|
||||
feedback.innerHTML = `⚠️ إجابة غير دقيقة. سيتم إرجاعك ${rewindSeconds} ثانية لمراجعة الفكرة وتثبيت الفهم!`;
|
||||
|
||||
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.currentTime = Math.max(0, video.currentTime - rewindSeconds);
|
||||
triggeredCheckpoints.delete(examId);
|
||||
video.play();
|
||||
checkpointTriggered = false;
|
||||
}
|
||||
}, 2200);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,14 +964,22 @@ class StudentPortal
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'success' && data.data?.playback) {
|
||||
if (res.ok && data.status === 'success') {
|
||||
const pb = data.data.playback;
|
||||
const checkpoints = data.data.checkpoints || [];
|
||||
activeLessonCheckpoints = checkpoints;
|
||||
|
||||
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) {
|
||||
if (video && pb) {
|
||||
if (pb.hls_url && window.Hls && Hls.isSupported()) {
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
hlsInstance = new Hls({ enableWorker: true });
|
||||
hlsInstance.loadSource(pb.hls_url);
|
||||
hlsInstance.attachMedia(video);
|
||||
} else if (pb.hls_url && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = pb.hls_url;
|
||||
} else if (pb.stream_url) {
|
||||
video.src = pb.stream_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,21 +546,22 @@ class TeacherPortal
|
||||
|
||||
<!-- 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: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; flex-wrap: wrap; gap: 8px;">
|
||||
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-cyan);">+ تثبيت نقطة فحص معرفي سقراطي (Socratic Checkpoint) في الفيديو</h4>
|
||||
<span style="font-size: 11px; background: rgba(56,189,248,0.1); color: var(--accent-cyan); padding: 3px 10px; border-radius: 6px;">محرك التثبيت الصدمي والعلاجي 🧠</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<option value="1">الدرس 1: قواعد الاشتقاق الأساسية</option>
|
||||
<option value="2">الدرس 2: مشتقات الاقترانات المثلثية</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">
|
||||
<input type="text" id="checkpoint_timestamp" value="00:15" placeholder="00:15" class="input-text" style="color: var(--accent-cyan); font-family: monospace;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">عقوبة الإرجاع عند الخطأ</label>
|
||||
@@ -571,7 +572,43 @@ class TeacherPortal
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="handleSaveCheckpoint()" class="btn-primary" style="width: auto; padding: 10px 28px; font-size: 13px; margin-top: 8px;">تثبيت النقطة في محرك التثبيت المعرفي ✓</button>
|
||||
|
||||
<div class="form-group" style="margin-top: 8px;">
|
||||
<label class="form-label">نص السؤال السقراطي الفوري</label>
|
||||
<input type="text" id="checkpoint_question_input" value="إذا كان f(x) = sin(3x)، فما هي قيمة المشتقة f'(x)؟" placeholder="اكتب نص السؤال لفحص فهم الفكرة..." class="input-text">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">الخيار (أ)</label>
|
||||
<input type="text" id="cp_opt_0" value="3 cos(3x)" class="input-text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">الخيار (ب)</label>
|
||||
<input type="text" id="cp_opt_1" value="cos(3x)" class="input-text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">الخيار (ج)</label>
|
||||
<input type="text" id="cp_opt_2" value="-3 cos(3x)" class="input-text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">الخيار (د)</label>
|
||||
<input type="text" id="cp_opt_3" value="3 sin(3x)" class="input-text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 14px; flex-wrap: wrap; gap: 12px;">
|
||||
<div class="form-group" style="margin: 0; min-width: 220px;">
|
||||
<label class="form-label">الإجابة الصحيحة المعتمدة</label>
|
||||
<select id="cp_correct_idx" class="input-text" style="color: var(--accent-gold); font-weight: 700;">
|
||||
<option value="0">الخيار (أ) هو الصحيح ✓</option>
|
||||
<option value="1">الخيار (ب) هو الصحيح ✓</option>
|
||||
<option value="2">الخيار (ج) هو الصحيح ✓</option>
|
||||
<option value="3">الخيار (د) هو الصحيح ✓</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" id="btn_save_checkpoint" onclick="handleSaveCheckpoint()" class="btn-primary" style="width: auto; padding: 12px 32px; font-size: 13px;">تثبيت النقطة في محرك التثبيت المعرفي ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1467,12 +1504,66 @@ class TeacherPortal
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveCheckpoint() {
|
||||
async function handleSaveCheckpoint() {
|
||||
const token = getAuthToken();
|
||||
const lessonId = document.getElementById('checkpoint_lesson_select').value;
|
||||
const ts = document.getElementById('checkpoint_timestamp').value;
|
||||
const question = document.getElementById('checkpoint_question_input').value;
|
||||
const rewind = document.getElementById('checkpoint_rewind_select').value;
|
||||
alert(`✅ تم تثبيت نقطة الفحص السقراطي بنجاح في الدرس (${lessonId}) عند التوقيت (${ts}) بعقوبة إرجاع (${rewind} ثانية) وحفظ السؤال في محرك التثبيت المعرفي!`);
|
||||
const tsStr = document.getElementById('checkpoint_timestamp').value.trim();
|
||||
const question = document.getElementById('checkpoint_question_input').value.trim();
|
||||
const rewind = parseInt(document.getElementById('checkpoint_rewind_select').value) || 45;
|
||||
const correctIdx = parseInt(document.getElementById('cp_correct_idx').value) || 0;
|
||||
|
||||
// Parse MM:SS to seconds
|
||||
let seconds = 15;
|
||||
if (tsStr.includes(':')) {
|
||||
const parts = tsStr.split(':');
|
||||
seconds = (parseInt(parts[0]) || 0) * 60 + (parseInt(parts[1]) || 0);
|
||||
} else {
|
||||
seconds = parseInt(tsStr) || 15;
|
||||
}
|
||||
|
||||
const options = [
|
||||
document.getElementById('cp_opt_0').value.trim() || 'الخيار الأول',
|
||||
document.getElementById('cp_opt_1').value.trim() || 'الخيار الثاني',
|
||||
document.getElementById('cp_opt_2').value.trim() || 'الخيار الثالث',
|
||||
document.getElementById('cp_opt_3').value.trim() || 'الخيار الرابع'
|
||||
];
|
||||
|
||||
const btn = document.getElementById('btn_save_checkpoint');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'جارٍ الحفظ والتثبيت... ⏳';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/teacher/lessons/checkpoints', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lesson_id: lessonId,
|
||||
timestamp_seconds: seconds,
|
||||
rewind_seconds: rewind,
|
||||
question_text: question,
|
||||
options: options,
|
||||
correct_index: correctIdx
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'تثبيت النقطة في محرك التثبيت المعرفي ✓';
|
||||
|
||||
if (res.ok && data.status === 'success') {
|
||||
showLuxuryToast('تم تثبيت النقطة السقراطية 🧠', `الدرس (${lessonId}) عند التوقيت (${tsStr})`);
|
||||
alert('✅ ' + data.message + `\nالتوقيت: ${tsStr} (ثانية: ${seconds})\nعقوبة الإرجاع: ${rewind} ثانية`);
|
||||
} else {
|
||||
alert('❌ خطأ: ' + (data.message || 'فشل حفظ نقطة الفحص'));
|
||||
}
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'تثبيت النقطة في محرك التثبيت المعرفي ✓';
|
||||
alert('❌ حدث خطأ في الاتصال بالخادم');
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
|
||||
Reference in New Issue
Block a user