feat: Dynamic student lesson discovery, lesson selector carousel, and live Socratic checkpoint streaming

This commit is contained in:
Hamza-Ayed
2026-08-28 04:08:04 +03:00
parent ab52d44c94
commit dc2ebb108c
3 changed files with 158 additions and 32 deletions
+43 -7
View File
@@ -297,6 +297,35 @@ class VideoController
]);
}
/**
* List all published lessons for student portal
* GET /api/student/lessons
*/
public function getStudentLessons(Request $request, Response $response): void
{
VideoService::ensureSchema();
CurriculumService::ensureSchema();
$courseId = (int)($request->getQuery('course_id') ?: 0);
$where = $courseId > 0 ? "WHERE l.course_id = {$courseId}" : "";
$lessons = Database::select(
"SELECT l.id, l.course_id, l.title, l.sequence_order, l.storage_type, l.video_uuid, l.hls_url, l.thumbnail_url,
l.duration_seconds, l.is_free_preview, l.created_at,
c.title as course_title,
(SELECT COUNT(*) FROM exams WHERE lesson_id = l.id AND scope = 'in_video_checkpoint') as checkpoints_count
FROM lessons l
JOIN courses c ON l.course_id = c.id
{$where}
ORDER BY l.id DESC"
);
$response->json([
'status' => 'success',
'data' => $lessons
]);
}
/**
* Get Lesson Playback Data with Chapters Roadmap and Socratic Checkpoints
* GET /api/lessons/{id}/playback
@@ -307,16 +336,23 @@ class VideoController
CurriculumService::ensureSchema();
$lessonId = (int)$request->getParam('id');
if (!$lessonId) {
$response->status(400)->json(['status' => 'error', 'message' => 'معرف الدرس مطلوب']);
$lesson = null;
if ($lessonId > 0) {
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
}
if (!$lesson) {
// Fallback to latest available lesson
$lesson = Database::selectOne("SELECT * FROM lessons ORDER BY id DESC LIMIT 1");
}
if (!$lesson) {
$response->status(404)->json(['status' => 'error', 'message' => 'لا توجد دروس متاحة حالياً']);
return;
}
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
if (!$lesson) {
$response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']);
return;
}
$lessonId = (int)$lesson['id'];
// If lesson has no checkpoints yet, generate them autonomously
$existingCount = Database::selectOne("SELECT COUNT(*) as cnt FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
+105 -13
View File
@@ -600,13 +600,24 @@ class StudentPortal
<!-- TAB 1: Socratic Interactive Lesson Player -->
<div id="tab_lessons_content" class="studio-card">
<!-- Lesson Selector Carousel -->
<div style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-size: 13px; font-weight: 800; color: var(--accent-cyan);">📚 فهرس الحصص والدروس المتاحة:</span>
<span style="font-size: 11px; color: var(--text-muted);" id="lessons_count_label">جارٍ التحميل...</span>
</div>
<div id="student_lessons_carousel" style="display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px;">
<!-- Dynamically loaded via loadStudentLessonsList() -->
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; flex-wrap: wrap; gap: 10px;">
<div>
<h3 style="font-size: 18px; font-weight: 800;">الدرس 4: قواعد الاشتقاق الأساسية — رياضيات علمي</h3>
<span style="font-size: 12px; color: var(--text-muted);">الأستاذ حمزة الغويري • Bunny Stream DRM Protection</span>
<h3 style="font-size: 18px; font-weight: 800;" id="current_lesson_title">الدرس: قواعد الاشتقاق والتحليل الجنائي</h3>
<span style="font-size: 12px; color: var(--text-muted);" id="current_lesson_subtitle">الأستاذ حمزة الغويري • Cloudflare R2 + HLS Adaptive Stream</span>
</div>
<span style="font-size: 11px; font-weight: 800; color: var(--accent-cyan); background: rgba(0,245,212,0.1); border: 1px solid rgba(0,245,212,0.3); padding: 5px 14px; border-radius: 980px;">
Checkpoint Active (00:15)
<span style="font-size: 11px; font-weight: 800; color: var(--accent-cyan); background: rgba(0,245,212,0.1); border: 1px solid rgba(0,245,212,0.3); padding: 5px 14px; border-radius: 980px;" id="checkpoint_status_badge">
الذكاء الاصطناعي السقراطي نشط 🧠
</span>
</div>
@@ -1048,6 +1059,7 @@ class StudentPortal
let activeLessonCheckpoints = [];
let triggeredCheckpoints = new Set();
let hlsInstance = null;
let activeLessonId = null;
document.addEventListener('DOMContentLoaded', async () => {
const token = localStorage.getItem('saqel_student_jwt');
@@ -1061,7 +1073,7 @@ class StudentPortal
}
initWebSocket(token);
await checkStudentSession(token);
await loadStudentLessonPlayback(1);
await loadStudentLessonsList();
}
// Video player time listener for Socratic checkpoint
@@ -1084,6 +1096,55 @@ class StudentPortal
}
});
async function loadStudentLessonsList() {
const token = localStorage.getItem('saqel_student_jwt');
if (!token) return;
try {
const res = await fetch('/api/student/lessons', {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (res.ok && data.data && data.data.length > 0) {
const lessons = data.data;
const countLabel = document.getElementById('lessons_count_label');
if (countLabel) countLabel.textContent = `${lessons.length} حصص متاحة`;
const carousel = document.getElementById('student_lessons_carousel');
if (carousel) {
carousel.innerHTML = lessons.map((les, idx) => `
<button type="button" onclick="selectStudentLesson(${les.id})" id="btn_lesson_${les.id}" style="padding: 8px 16px; font-size: 12px; border-radius: 12px; cursor: pointer; white-space: nowrap; transition: all 0.2s ease; display: flex; align-items: center; gap: 8px; font-family: inherit; font-weight: 700; ${idx === 0 ? 'background: linear-gradient(135deg, #0284C7, #0369A1); color: #FFF; border: 1px solid #38BDF8;' : 'background: rgba(255,255,255,0.06); color: var(--text-secondary); border: 1px solid var(--border);'}">
<span>🎬 ${escapeHtml(les.title)}</span>
<span style="font-size: 10px; background: rgba(0,0,0,0.35); padding: 2px 8px; border-radius: 4px; color: var(--accent-cyan);">🧠 ${les.checkpoints_count || 0} فحص</span>
</button>
`).join('');
}
if (!activeLessonId && lessons[0]) {
selectStudentLesson(lessons[0].id);
}
}
} catch (e) {
console.error('Load lessons list notice:', e);
}
}
function selectStudentLesson(lessonId) {
activeLessonId = lessonId;
const btns = document.querySelectorAll('#student_lessons_carousel button');
btns.forEach(b => {
b.style.background = 'rgba(255,255,255,0.06)';
b.style.color = 'var(--text-secondary)';
b.style.borderColor = 'var(--border)';
});
const activeBtn = document.getElementById(`btn_lesson_${lessonId}`);
if (activeBtn) {
activeBtn.style.background = 'linear-gradient(135deg, #0284C7, #0369A1)';
activeBtn.style.color = '#FFF';
activeBtn.style.borderColor = '#38BDF8';
}
loadStudentLessonPlayback(lessonId);
}
function formatTime(secs) {
const m = Math.floor(secs / 60).toString().padStart(2, '0');
const s = (secs % 60).toString().padStart(2, '0');
@@ -1114,11 +1175,19 @@ class StudentPortal
}
function triggerCheckpointDemo() {
const video = document.getElementById('lesson_video_player');
if (video) {
video.currentTime = 14;
triggeredCheckpoints.clear();
video.play();
if (activeLessonCheckpoints.length > 0) {
renderSocraticModal(activeLessonCheckpoints[0]);
} else {
renderSocraticModal({
question_text: "إذا كان f(x) = sin(3x)، فما هي مشتقة الاقتران f'(x)؟",
options: [
{ text: "3 cos(3x) (مشتقة الزاوية ضرب مشتقة الاقتران)", is_correct: true },
{ text: "cos(3x)", is_correct: false },
{ text: "-3 cos(3x)", is_correct: false },
{ text: "-cos(3x)", is_correct: false }
],
rewind_on_fail_seconds: 45
});
}
}
@@ -1168,19 +1237,30 @@ class StudentPortal
}
}
async function loadStudentLessonPlayback(lessonId = 1) {
async function loadStudentLessonPlayback(lessonId = 0) {
const token = localStorage.getItem('saqel_student_jwt');
if (!token) return;
try {
const res = await fetch(`/api/lessons/${lessonId}/playback`, {
const targetUrl = lessonId > 0 ? `/api/lessons/${lessonId}/playback` : '/api/lessons/0/playback';
const res = await fetch(targetUrl, {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (res.ok && data.status === 'success') {
const les = data.data.lesson;
const pb = data.data.playback;
const checkpoints = data.data.checkpoints || [];
const chapters = data.data.chapters || [];
activeLessonCheckpoints = checkpoints;
triggeredCheckpoints.clear();
// Update Titles
if (les) {
const titleEl = document.getElementById('current_lesson_title');
const subtitleEl = document.getElementById('current_lesson_subtitle');
if (titleEl) titleEl.textContent = `الدرس: ${les.title}`;
if (subtitleEl) subtitleEl.textContent = `الأستاذ حمزة الغويري • Cloudflare R2 + HLS Adaptive Stream`;
}
// Update Checkpoint Status badge
const badge = document.getElementById('checkpoint_status_badge');
@@ -1190,7 +1270,8 @@ class StudentPortal
// Render AI Timeline Chapters
const chaptersList = document.getElementById('ai_timeline_chapters_list');
if (chaptersList && chapters.length > 0) {
if (chaptersList) {
if (chapters.length > 0) {
chaptersList.innerHTML = chapters.map((ch, idx) => `
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 12px; padding: 12px; cursor: pointer; transition: all 0.2s ease;" onclick="seekToSeconds(${ch.start_seconds})" onmouseover="this.style.borderColor='var(--accent-cyan)'" onmouseout="this.style.borderColor='var(--border)'">
<div style="display: flex; justify-content: space-between; font-size: 11.5px; color: var(--accent-gold); font-weight: 700; margin-bottom: 4px;">
@@ -1200,6 +1281,17 @@ class StudentPortal
<div style="font-size: 12px; color: var(--text-secondary); line-height: 1.4;">${escapeHtml(ch.summary || '')}</div>
</div>
`).join('');
} else {
chaptersList.innerHTML = `
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 12px; padding: 12px; cursor: pointer;" onclick="seekToSeconds(0)">
<div style="display: flex; justify-content: space-between; font-size: 11.5px; color: var(--accent-gold); font-weight: 700; margin-bottom: 4px;">
<span>المحطة 1: الشرح الكامل والتطبيقات</span>
<span style="font-family: monospace; color: var(--accent-cyan);">00:00</span>
</div>
<div style="font-size: 12px; color: var(--text-secondary);">استعراض المفاهيم وتطبيقات المنهاج الوزاري.</div>
</div>
`;
}
}
const video = document.getElementById('lesson_video_player');
+2 -4
View File
@@ -73,15 +73,13 @@ $router->get('/api/teacher/courses', [\App\Controllers\TeacherController:
$router->post('/api/teacher/courses', [\App\Controllers\TeacherController::class, 'addCourse'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/teacher/lessons', [\App\Controllers\TeacherController::class, 'addLesson'], [\App\Middlewares\AuthMiddleware::class]);
// Dual Video Storage & Bunny.net Stream Routes (API-Driven)
// Dual Video Storage & Cloudflare R2 / HLS Stream Routes (API-Driven)
$router->post('/api/teacher/videos/upload-direct', [\App\Controllers\VideoController::class, 'uploadDirect'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/teacher/videos/bunny-create', [\App\Controllers\VideoController::class, 'createBunnyVideo'],[\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/teacher/videos/bunny-link', [\App\Controllers\VideoController::class, 'linkBunnyLesson'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoController::class, 'saveCheckpoint'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo']);
$router->get('/api/videos/hls/{uuid}/{file}', [\App\Controllers\VideoController::class, 'streamHls']);
$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/webhooks/bunny', [\App\Controllers\VideoController::class, 'handleBunnyWebhook']);
// Student & Teacher Chat Routes (API-Driven, Authenticated)
$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], [\App\Middlewares\AuthMiddleware::class]);