From 8044f86798ee7fc1c47d0939cdc920c54b3ec0ba Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 27 Aug 2026 19:18:51 +0300 Subject: [PATCH] feat: Implement Dual Video Pipeline (Direct Server API Upload & Bunny.net Stream Cloud CDN + DRM) with Socratic interactive player --- backend/app/Controllers/VideoController.php | 265 ++++++++++++++ backend/app/Services/VideoService.php | 387 ++++++++++++++++++++ backend/app/Views/StudentPortal.php | 81 +++- backend/app/Views/TeacherPortal.php | 338 +++++++++++++++-- backend/public/index.php | 8 + 5 files changed, 1054 insertions(+), 25 deletions(-) create mode 100644 backend/app/Controllers/VideoController.php create mode 100644 backend/app/Services/VideoService.php diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php new file mode 100644 index 0000000..f82e8e7 --- /dev/null +++ b/backend/app/Controllers/VideoController.php @@ -0,0 +1,265 @@ +getBody()['course_id'] ?? $_POST['course_id'] ?? 0); + $title = trim((string)($request->getBody()['title'] ?? $_POST['title'] ?? '')); + $seqOrder = (int)($request->getBody()['sequence_order'] ?? $_POST['sequence_order'] ?? 1); + + if (!$courseId || empty($title)) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'معرف الدورة وعنوان الدرس مطلوبان' + ]); + return; + } + + // Verify Course Ownership + $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); + if (!$course || ($course['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { + $response->status(403)->json([ + 'status' => 'error', + 'message' => 'غير مصرح: لا تملك صلاحية التعديل على هذه الدورة' + ]); + return; + } + + if (empty($_FILES['video'])) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'يرجى إرفاق ملف الفيديو في الطلب (key: video)' + ]); + return; + } + + try { + $uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title); + + // Insert lesson record + $lessonId = Database::insert( + "INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, duration_seconds, is_free_preview, encoding_status) + VALUES (?, ?, ?, 'api_upload', ?, '', ?, 0, 0, 'ready')", + [ + $courseId, + $title, + $seqOrder, + $uploadResult['video_uuid'], + $uploadResult['local_path'] + ] + ); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'تم رفع وحفظ ملف الفيديو بنجاح عبر الـ API المباشر!', + 'data' => array_merge($uploadResult, [ + 'lesson_id' => $lessonId, + 'title' => $title, + 'course_id' => $courseId + ]) + ]); + } catch (\Throwable $e) { + $response->status(500)->json([ + 'status' => 'error', + 'message' => $e->getMessage() + ]); + } + } + + /** + * Create video entity on Bunny Stream + * POST /api/teacher/videos/bunny-create + */ + public function createBunnyVideo(Request $request, Response $response): void + { + VideoService::ensureSchema(); + + $body = $request->getBody(); + $title = trim((string)($body['title'] ?? 'درس جديد')); + $courseId = (int)($body['course_id'] ?? 0); + + if (!$courseId) { + $response->status(400)->json(['status' => 'error', 'message' => 'معرف الدورة مطلوب']); + return; + } + + try { + $result = VideoService::createBunnyVideo($title); + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'تم إنشاء الفيديو في Bunny Stream بنجاح', + 'data' => $result + ]); + } catch (\Throwable $e) { + $response->status(500)->json([ + 'status' => 'error', + 'message' => $e->getMessage() + ]); + } + } + + /** + * Link an existing or newly created Bunny Video ID to a Course Lesson + * POST /api/teacher/videos/bunny-link + */ + public function linkBunnyLesson(Request $request, Response $response): void + { + VideoService::ensureSchema(); + + $body = $request->getBody(); + $courseId = (int)($body['course_id'] ?? 0); + $title = trim((string)($body['title'] ?? '')); + $bunnyVideoId = trim((string)($body['bunny_video_id'] ?? '')); + $duration = (int)($body['duration_seconds'] ?? 0); + $sequenceOrder = (int)($body['sequence_order'] ?? 1); + + if (!$courseId || empty($title) || empty($bunnyVideoId)) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'معرف الدورة، عنوان الدرس، ومعرف فيديو Bunny Stream مطلوبين' + ]); + return; + } + + $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); + if (!$course || ($course['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { + $response->status(403)->json([ + 'status' => 'error', + 'message' => 'غير مصرح: لا تملك هذه الدورة' + ]); + return; + } + + $lessonId = Database::insert( + "INSERT INTO lessons (course_id, title, sequence_order, storage_type, bunny_video_id, duration_seconds, is_free_preview, encoding_status) + VALUES (?, ?, ?, 'bunny_stream', ?, ?, 0, 'ready')", + [$courseId, $title, $sequenceOrder, $bunnyVideoId, $duration] + ); + + $response->status(201)->json([ + 'status' => 'success', + 'message' => 'تم ربط درس Bunny Stream بنجاح!', + 'data' => [ + 'lesson_id' => $lessonId, + 'bunny_video_id' => $bunnyVideoId, + 'storage_type' => 'bunny_stream' + ] + ]); + } + + /** + * Stream Local Video via HTTP 206 Range Streaming + * GET /api/videos/stream/{uuid} + */ + public function streamLocalVideo(Request $request, Response $response): void + { + $uuid = $request->getParam('uuid'); + if (empty($uuid)) { + $response->status(400)->json(['status' => 'error', 'message' => 'معرف الفيديو مطلوب']); + return; + } + + VideoService::streamLocalVideo($uuid); + } + + /** + * Get Lesson Playback Data with Signed DRM Tokens and Socratic Checkpoints + * GET /api/lessons/{id}/playback + */ + public function getPlaybackData(Request $request, Response $response): void + { + VideoService::ensureSchema(); + + $lessonId = (int)$request->getParam('id'); + if (!$lessonId) { + $response->status(400)->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; + } + + // Fetch attached in-video Socratic Checkpoints + $checkpoints = Database::select( + "SELECT e.id as exam_id, e.uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage + FROM exams e + WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1 + ORDER BY e.timestamp_seconds ASC", + [$lessonId] + ); + + $storageType = $lesson['storage_type'] ?? 'bunny_stream'; + $playbackInfo = []; + + if ($storageType === 'api_upload') { + $playbackInfo = [ + 'storage_type' => 'api_upload', + 'stream_url' => '/api/videos/stream/' . $lesson['video_uuid'], + 'video_uuid' => $lesson['video_uuid'], + 'is_direct' => true + ]; + } else { + // Bunny Stream Signed Playback + $bunnyId = $lesson['bunny_video_id'] ?: 'mock-bunny-guid-2026'; + $signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token + $playbackInfo = array_merge(['storage_type' => 'bunny_stream'], $signedData); + } + + $response->json([ + 'status' => 'success', + 'data' => [ + 'lesson' => [ + 'id' => (int)$lesson['id'], + 'course_id' => (int)$lesson['course_id'], + 'title' => $lesson['title'], + 'duration_seconds' => (int)$lesson['duration_seconds'], + 'is_free_preview' => (bool)$lesson['is_free_preview'], + 'storage_type' => $storageType + ], + 'playback' => $playbackInfo, + 'checkpoints' => $checkpoints ?: [] + ] + ]); + } + + /** + * Webhook listener for Bunny Stream encoding notifications + * POST /api/webhooks/bunny + */ + public function handleBunnyWebhook(Request $request, Response $response): void + { + VideoService::ensureSchema(); + + $body = $request->getBody(); + $videoId = trim((string)($body['VideoGuid'] ?? $body['videoId'] ?? '')); + $status = (int)($body['Status'] ?? 0); // 3 = Finished/Ready, 4 = Failed + + if (!empty($videoId)) { + $encodingStatus = ($status === 3) ? 'ready' : (($status === 4) ? 'failed' : 'processing'); + Database::query( + "UPDATE lessons SET encoding_status = ? WHERE bunny_video_id = ?", + [$encodingStatus, $videoId] + ); + } + + $response->json(['status' => 'received']); + } +} diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php new file mode 100644 index 0000000..6ee0255 --- /dev/null +++ b/backend/app/Services/VideoService.php @@ -0,0 +1,387 @@ +getMessage()); + } + } + + // ========================================================================= + // METHOD 1: Direct Server API Upload & HTTP 206 Range Streaming + // ========================================================================= + + /** + * Handles direct file upload from multipart request + * + * @param array $file $_FILES['video'] + * @param int $courseId + * @param string $title + * @return array + */ + public static function handleDirectUpload(array $file, int $courseId, string $title): array + { + self::ensureSchema(); + + if (empty($file) || $file['error'] !== UPLOAD_ERR_OK) { + $errorMsg = match ($file['error'] ?? -1) { + UPLOAD_ERR_INI_SIZE => 'حجم الفيديو يتجاوز الحد المسموح في إعدادات السيرفر (upload_max_filesize)', + UPLOAD_ERR_FORM_SIZE => 'حجم الفيديو يتجاوز الحد المسموح في النموذج', + UPLOAD_ERR_PARTIAL => 'تم رفع جزء من الملف فقط، يرجى إعادة المحاولة', + UPLOAD_ERR_NO_FILE => 'لم يتم تحديد أي ملف فيديو', + default => 'حدث خطأ أثناء استلام ملف الفيديو على السيرفر' + }; + throw new \RuntimeException($errorMsg); + } + + // Validate Extension & Mime + $allowedMimes = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-matroska', 'video/ogg']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + $allowedExts = ['mp4', 'webm', 'mov', 'mkv', 'ogg']; + + if (!in_array($ext, $allowedExts) || !in_array($mime, $allowedMimes)) { + throw new \InvalidArgumentException('نوع الملف غير مدعوم. الصيغ المدعومة هي: MP4, WebM, MOV, MKV.'); + } + + // Generate UUID & Target Directory + $videoUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + + $storageBase = dirname(__DIR__, 2) . '/storage/videos/' . $courseId; + if (!is_dir($storageBase)) { + mkdir($storageBase, 0755, true); + } + + $targetFileName = $videoUuid . '.' . $ext; + $targetPath = $storageBase . '/' . $targetFileName; + + if (!move_uploaded_file($file['tmp_name'], $targetPath)) { + throw new \RuntimeException('فشل حفظ ملف الفيديو على السيرفر، يرجى فحص أذونات المجلد.'); + } + + $relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName; + $fileSize = filesize($targetPath); + + return [ + 'video_uuid' => $videoUuid, + 'local_path' => $relativePath, + 'file_size' => $fileSize, + 'mime_type' => $mime, + 'extension' => $ext, + 'storage_type' => 'api_upload', + 'encoding_status' => 'ready', + 'stream_url' => '/api/videos/stream/' . $videoUuid + ]; + } + + /** + * Stream Local Video file supporting HTTP 206 Partial Content (Range requests) + * Allows seamless scrubbing/seeking in browser without loading full video + */ + public static function streamLocalVideo(string $videoUuid): void + { + self::ensureSchema(); + + $lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]); + if (!$lesson || empty($lesson['local_path'])) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['status' => 'error', 'message' => 'ملف الفيديو غير موجود']); + exit; + } + + $fullPath = dirname(__DIR__, 2) . '/' . ltrim($lesson['local_path'], '/'); + if (!file_exists($fullPath)) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['status' => 'error', 'message' => 'الملف المحفوظ مفقود على القرص']); + exit; + } + + $fileSize = filesize($fullPath); + $fp = @fopen($fullPath, 'rb'); + if (!$fp) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'تعذر فتح ملف الفيديو']); + exit; + } + + $mime = 'video/mp4'; + $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); + if ($ext === 'webm') $mime = 'video/webm'; + if ($ext === 'mov') $mime = 'video/quicktime'; + if ($ext === 'mkv') $mime = 'video/x-matroska'; + + $start = 0; + $end = $fileSize - 1; + + // Clean previous buffers + if (ob_get_length()) { + ob_clean(); + } + + header('Content-Type: ' . $mime); + header('Accept-Ranges: bytes'); + header('Cache-Control: public, max-age=3600'); + header('X-Content-Type-Options: nosniff'); + + // Check if Range header is requested by video player + if (isset($_SERVER['HTTP_RANGE'])) { + $range = $_SERVER['HTTP_RANGE']; + if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) { + $start = (int)$matches[1]; + if (!empty($matches[2])) { + $end = (int)$matches[2]; + } + } + + if ($start > $end || $start >= $fileSize) { + http_response_code(416); + header("Content-Range: bytes */{$fileSize}"); + fclose($fp); + exit; + } + + http_response_code(206); + header("Content-Range: bytes {$start}-{$end}/{$fileSize}"); + $length = ($end - $start) + 1; + header("Content-Length: {$length}"); + } else { + http_response_code(200); + header("Content-Length: {$fileSize}"); + } + + fseek($fp, $start); + $bufferSize = 1024 * 128; // 128KB chunk stream + while (!feof($fp) && ($pos = ftell($fp)) <= $end) { + if ($pos + $bufferSize > $end) { + $bufferSize = $end - $pos + 1; + } + if ($bufferSize <= 0) break; + echo fread($fp, $bufferSize); + flush(); + } + + fclose($fp); + exit; + } + + // ========================================================================= + // METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM + // ========================================================================= + + /** + * Get Bunny Stream API Credentials from Environment + */ + private static function getBunnyConfig(): array + { + return [ + 'library_id' => getenv('BUNNY_STREAM_LIBRARY_ID') ?: '285491', + 'api_key' => getenv('BUNNY_STREAM_API_KEY') ?: '', + 'token_key' => getenv('BUNNY_STREAM_TOKEN_KEY') ?: getenv('JWT_SECRET') ?: 'SaqelSecureBunnyToken2026', + 'pull_zone_host' => getenv('BUNNY_STREAM_HOST') ?: 'vz-saqel.b-cdn.net', + 'embed_host' => 'iframe.mediadelivery.net' + ]; + } + + /** + * Create a new Video placeholder in Bunny Stream Library + * POST https://video.bunnycdn.com/library/{libraryId}/videos + */ + public static function createBunnyVideo(string $title, ?string $collectionId = null): array + { + $config = self::getBunnyConfig(); + if (empty($config['api_key'])) { + // Mock response if API key is not yet set in production .env + $mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + return [ + 'video_id' => $mockGuid, + 'library_id' => $config['library_id'], + 'title' => $title, + 'status' => 'created', + 'is_mock' => true, + 'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$mockGuid}", + 'hls_playlist' => "https://{$config['pull_zone_host']}/{$mockGuid}/playlist.m3u8" + ]; + } + + $url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos"; + $data = ['title' => $title]; + if ($collectionId) { + $data['collectionId'] = $collectionId; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($data), + CURLOPT_HTTPHEADER => [ + 'AccessKey: ' . $config['api_key'], + 'Content-Type: application/json', + 'Accept: application/json' + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15 + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $json = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300 && !empty($json['guid'])) { + $guid = $json['guid']; + return [ + 'video_id' => $guid, + 'library_id' => $config['library_id'], + 'title' => $json['title'] ?? $title, + 'status' => 'created', + 'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$guid}", + 'hls_playlist' => "https://{$config['pull_zone_host']}/{$guid}/playlist.m3u8" + ]; + } + + throw new \RuntimeException("Bunny Stream API Error ({$httpCode}): " . ($json['message'] ?? $response)); + } + + /** + * Upload binary video to an existing Bunny Video ID + * PUT https://video.bunnycdn.com/library/{libraryId}/videos/{videoId} + */ + public static function uploadBunnyVideo(string $videoId, string $filePath): bool + { + $config = self::getBunnyConfig(); + if (empty($config['api_key']) || !file_exists($filePath)) { + return false; + } + + $url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos/{$videoId}"; + $fp = fopen($filePath, 'r'); + $fileSize = filesize($filePath); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_PUT => true, + CURLOPT_INFILE => $fp, + CURLOPT_INFILESIZE => $fileSize, + CURLOPT_HTTPHEADER => [ + 'AccessKey: ' . $config['api_key'], + 'Content-Type: application/octet-stream' + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 600 // 10 minutes for large videos + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + fclose($fp); + curl_close($ch); + + return ($httpCode >= 200 && $httpCode < 300); + } + + /** + * Generate Signed DRM Playback URL using Bunny Token Authentication (SHA256 HMAC) + * Protects video from hotlinking, unauthorized sharing, and downloading + * + * @param string $videoId Bunny Video GUID + * @param int $expiresInSeconds Token validity window (default: 2 hours) + * @return array + */ + public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array + { + $config = self::getBunnyConfig(); + $libraryId = $config['library_id']; + $tokenKey = $config['token_key']; + $expires = time() + $expiresInSeconds; + + // Bunny Stream Token Hash Formula: + // token = SHA256(securityToken + videoId + expirationTime) + $hashable = $tokenKey . $videoId . $expires; + $token = hash('sha256', $hashable); + + $embedUrl = "https://{$config['embed_host']}/embed/{$libraryId}/{$videoId}?token={$token}&expires={$expires}"; + $hlsUrl = "https://{$config['pull_zone_host']}/{$videoId}/playlist.m3u8?token={$token}&expires={$expires}"; + $thumbUrl = "https://{$config['pull_zone_host']}/{$videoId}/thumbnail.jpg?token={$token}&expires={$expires}"; + + return [ + 'video_id' => $videoId, + 'library_id' => $libraryId, + 'embed_url' => $embedUrl, + 'hls_url' => $hlsUrl, + 'thumb_url' => $thumbUrl, + 'token' => $token, + 'expires_at' => $expires + ]; + } +} diff --git a/backend/app/Views/StudentPortal.php b/backend/app/Views/StudentPortal.php index 56f030b..6cbd4ac 100644 --- a/backend/app/Views/StudentPortal.php +++ b/backend/app/Views/StudentPortal.php @@ -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) diff --git a/backend/app/Views/TeacherPortal.php b/backend/app/Views/TeacherPortal.php index d130275..91a9213 100644 --- a/backend/app/Views/TeacherPortal.php +++ b/backend/app/Views/TeacherPortal.php @@ -446,46 +446,132 @@ class TeacherPortal - +