feat: Implement Server-Side FFmpeg HLS Transcoding (.m3u8), HLS.js streaming, and dynamic Socratic In-Video Checkpoint Engine
This commit is contained in:
@@ -51,22 +51,25 @@ class VideoController
|
|||||||
try {
|
try {
|
||||||
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title);
|
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title);
|
||||||
|
|
||||||
// Insert lesson record
|
// Insert lesson record with HLS references
|
||||||
$lessonId = Database::insert(
|
$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)
|
"INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, hls_url, thumbnail_url, duration_seconds, is_free_preview, encoding_status)
|
||||||
VALUES (?, ?, ?, 'api_upload', ?, '', ?, 0, 0, 'ready')",
|
VALUES (?, ?, ?, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready')",
|
||||||
[
|
[
|
||||||
$courseId,
|
$courseId,
|
||||||
$title,
|
$title,
|
||||||
$seqOrder,
|
$seqOrder,
|
||||||
$uploadResult['video_uuid'],
|
$uploadResult['video_uuid'],
|
||||||
$uploadResult['local_path']
|
$uploadResult['local_path'],
|
||||||
|
$uploadResult['hls_url'],
|
||||||
|
$uploadResult['thumbnail_url'],
|
||||||
|
$uploadResult['duration'] ?? 0
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$response->status(201)->json([
|
$response->status(201)->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'تم رفع وحفظ ملف الفيديو بنجاح عبر الـ API المباشر!',
|
'message' => 'تم رفع وحفظ ملف الفيديو وتقطيعه بتقنية HLS بنجاح!',
|
||||||
'data' => array_merge($uploadResult, [
|
'data' => array_merge($uploadResult, [
|
||||||
'lesson_id' => $lessonId,
|
'lesson_id' => $lessonId,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
@@ -177,6 +180,97 @@ class VideoController
|
|||||||
VideoService::streamLocalVideo($uuid);
|
VideoService::streamLocalVideo($uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream HLS Playlist or Video Segments
|
||||||
|
* GET /api/videos/hls/{uuid}/{file}
|
||||||
|
*/
|
||||||
|
public function streamHls(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$uuid = $request->getParam('uuid');
|
||||||
|
$file = $request->getParam('file') ?: 'index.m3u8';
|
||||||
|
|
||||||
|
if (empty($uuid)) {
|
||||||
|
$response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoService::streamHlsFile($uuid, $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save Socratic Checkpoint Quiz inside a Video Lesson
|
||||||
|
* POST /api/teacher/lessons/checkpoints
|
||||||
|
*/
|
||||||
|
public function saveCheckpoint(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$body = $request->getBody();
|
||||||
|
$lessonId = (int)($body['lesson_id'] ?? 0);
|
||||||
|
$timeSeconds = (int)($body['timestamp_seconds'] ?? 15);
|
||||||
|
$rewindSecs = (int)($body['rewind_seconds'] ?? 45);
|
||||||
|
$question = trim((string)($body['question_text'] ?? ''));
|
||||||
|
$options = (array)($body['options'] ?? []);
|
||||||
|
$correctIdx = (int)($body['correct_index'] ?? 0);
|
||||||
|
|
||||||
|
if (!$lessonId || empty($question) || empty($options)) {
|
||||||
|
$response->status(400)->json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lesson = Database::selectOne("SELECT l.id, l.course_id, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]);
|
||||||
|
if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) {
|
||||||
|
$response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$examUuid = 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
$examId = Database::insert(
|
||||||
|
"INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published)
|
||||||
|
VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'فحص سقراطي لحظي', ?, ?, 100.00, 10, 1, 1)",
|
||||||
|
[$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs]
|
||||||
|
);
|
||||||
|
|
||||||
|
$qUuid = 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
$qId = Database::insert(
|
||||||
|
"INSERT INTO questions (uuid, exam_id, question_text, question_type, bloom_taxonomy, points) VALUES (?, ?, ?, 'multiple_choice', 'comprehension', 10)",
|
||||||
|
[$qUuid, $examId, $question]
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($options as $idx => $optText) {
|
||||||
|
$isCorrect = ($idx === $correctIdx) ? 1 : 0;
|
||||||
|
Database::insert(
|
||||||
|
"INSERT INTO question_options (question_id, option_text, is_correct) VALUES (?, ?, ?)",
|
||||||
|
[$qId, $optText, $isCorrect]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->status(201)->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!',
|
||||||
|
'data' => [
|
||||||
|
'exam_id' => $examId,
|
||||||
|
'timestamp_seconds' => $timeSeconds,
|
||||||
|
'question_id' => $qId
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Lesson Playback Data with Signed DRM Tokens and Socratic Checkpoints
|
* Get Lesson Playback Data with Signed DRM Tokens and Socratic Checkpoints
|
||||||
* GET /api/lessons/{id}/playback
|
* GET /api/lessons/{id}/playback
|
||||||
@@ -197,15 +291,38 @@ class VideoController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch attached in-video Socratic Checkpoints
|
// Fetch attached in-video Socratic Checkpoints with Questions and Options
|
||||||
$checkpoints = Database::select(
|
$exams = Database::select(
|
||||||
"SELECT e.id as exam_id, e.uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage
|
"SELECT e.id as exam_id, e.uuid as exam_uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage
|
||||||
FROM exams e
|
FROM exams e
|
||||||
WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1
|
WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1
|
||||||
ORDER BY e.timestamp_seconds ASC",
|
ORDER BY e.timestamp_seconds ASC",
|
||||||
[$lessonId]
|
[$lessonId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$checkpoints = [];
|
||||||
|
foreach ($exams as $ex) {
|
||||||
|
$q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]);
|
||||||
|
$opts = [];
|
||||||
|
if ($q) {
|
||||||
|
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkpoints[] = [
|
||||||
|
'exam_id' => (int)$ex['exam_id'],
|
||||||
|
'timestamp_seconds' => (int)$ex['timestamp_seconds'],
|
||||||
|
'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'],
|
||||||
|
'question_text' => $q['question_text'] ?? 'ما هي الإجابة الصحيحة؟',
|
||||||
|
'options' => array_map(function ($o) {
|
||||||
|
return [
|
||||||
|
'id' => (int)$o['id'],
|
||||||
|
'text' => $o['option_text'],
|
||||||
|
'is_correct' => (bool)$o['is_correct']
|
||||||
|
];
|
||||||
|
}, $opts)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
|
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
|
||||||
$playbackInfo = [];
|
$playbackInfo = [];
|
||||||
|
|
||||||
@@ -213,6 +330,7 @@ class VideoController
|
|||||||
$playbackInfo = [
|
$playbackInfo = [
|
||||||
'storage_type' => 'api_upload',
|
'storage_type' => 'api_upload',
|
||||||
'stream_url' => '/api/videos/stream/' . $lesson['video_uuid'],
|
'stream_url' => '/api/videos/stream/' . $lesson['video_uuid'],
|
||||||
|
'hls_url' => $lesson['hls_url'] ?: ('/api/videos/hls/' . $lesson['video_uuid'] . '/index.m3u8'),
|
||||||
'video_uuid' => $lesson['video_uuid'],
|
'video_uuid' => $lesson['video_uuid'],
|
||||||
'is_direct' => true
|
'is_direct' => true
|
||||||
];
|
];
|
||||||
@@ -250,7 +368,7 @@ class VideoController
|
|||||||
|
|
||||||
$body = $request->getBody();
|
$body = $request->getBody();
|
||||||
$videoId = trim((string)($body['VideoGuid'] ?? $body['videoId'] ?? ''));
|
$videoId = trim((string)($body['VideoGuid'] ?? $body['videoId'] ?? ''));
|
||||||
$status = (int)($body['Status'] ?? 0); // 3 = Finished/Ready, 4 = Failed
|
$status = (int)($body['Status'] ?? 0);
|
||||||
|
|
||||||
if (!empty($videoId)) {
|
if (!empty($videoId)) {
|
||||||
$encodingStatus = ($status === 3) ? 'ready' : (($status === 4) ? 'failed' : 'processing');
|
$encodingStatus = ($status === 3) ? 'ready' : (($status === 4) ? 'failed' : 'processing');
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use App\Core\Security;
|
|||||||
* Universal Video Management & Streaming Service
|
* Universal Video Management & Streaming Service
|
||||||
* Supports:
|
* Supports:
|
||||||
* 1. Direct Server API Upload & HTTP 206 Partial Content Range Streaming
|
* 1. Direct Server API Upload & HTTP 206 Partial Content Range Streaming
|
||||||
* 2. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing
|
* 2. Server-Side Automated FFmpeg HLS Transcoding (.m3u8 master playlist + .ts chunks)
|
||||||
|
* 3. Bunny.net Stream Cloud Video CDN, TUS upload, and SHA256 DRM Token Signing
|
||||||
*/
|
*/
|
||||||
class VideoService
|
class VideoService
|
||||||
{
|
{
|
||||||
@@ -62,6 +63,27 @@ class VideoService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect FFmpeg path on server (Ubuntu, CentOS, macOS, CloudPanel)
|
||||||
|
*/
|
||||||
|
public static function getFfmpegBinary(): ?string
|
||||||
|
{
|
||||||
|
$candidates = [
|
||||||
|
'/usr/bin/ffmpeg',
|
||||||
|
'/usr/local/bin/ffmpeg',
|
||||||
|
'/opt/homebrew/bin/ffmpeg',
|
||||||
|
'ffmpeg'
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($candidates as $bin) {
|
||||||
|
$check = @shell_exec("which " . escapeshellarg($bin) . " 2>/dev/null");
|
||||||
|
if (!empty($check) || (file_exists($bin) && is_executable($bin))) {
|
||||||
|
return trim($check ?: $bin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// METHOD 1: Direct Server API Upload & HTTP 206 Range Streaming
|
// METHOD 1: Direct Server API Upload & HTTP 206 Range Streaming
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -126,9 +148,15 @@ class VideoService
|
|||||||
$relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName;
|
$relativePath = 'storage/videos/' . $courseId . '/' . $targetFileName;
|
||||||
$fileSize = filesize($targetPath);
|
$fileSize = filesize($targetPath);
|
||||||
|
|
||||||
|
// Attempt automated Server-Side HLS Transcoding
|
||||||
|
$hlsResult = self::transcodeToHls($targetPath, $courseId, $videoUuid);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'video_uuid' => $videoUuid,
|
'video_uuid' => $videoUuid,
|
||||||
'local_path' => $relativePath,
|
'local_path' => $relativePath,
|
||||||
|
'hls_url' => $hlsResult['hls_url'] ?? null,
|
||||||
|
'thumbnail_url' => $hlsResult['thumbnail_url'] ?? null,
|
||||||
|
'duration' => $hlsResult['duration_seconds'] ?? 0,
|
||||||
'file_size' => $fileSize,
|
'file_size' => $fileSize,
|
||||||
'mime_type' => $mime,
|
'mime_type' => $mime,
|
||||||
'extension' => $ext,
|
'extension' => $ext,
|
||||||
@@ -138,9 +166,96 @@ class VideoService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcode MP4 to HLS Chunks (.m3u8 and .ts segments) using Server FFmpeg
|
||||||
|
*/
|
||||||
|
public static function transcodeToHls(string $sourceMp4Path, int $courseId, string $videoUuid): array
|
||||||
|
{
|
||||||
|
$ffmpeg = self::getFfmpegBinary();
|
||||||
|
if (!$ffmpeg || !file_exists($sourceMp4Path)) {
|
||||||
|
return [
|
||||||
|
'hls_url' => null,
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'duration_seconds' => 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hlsOutputDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
|
||||||
|
if (!is_dir($hlsOutputDir)) {
|
||||||
|
mkdir($hlsOutputDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$playlistPath = $hlsOutputDir . '/index.m3u8';
|
||||||
|
$segmentPattern = $hlsOutputDir . '/segment_%03d.ts';
|
||||||
|
$thumbnailPath = $hlsOutputDir . '/thumbnail.jpg';
|
||||||
|
|
||||||
|
// 1. Generate Thumbnail Screenshot at 2 seconds
|
||||||
|
$thumbCmd = "{$ffmpeg} -ss 00:00:02 -i " . escapeshellarg($sourceMp4Path) . " -vframes 1 -q:v 2 " . escapeshellarg($thumbnailPath) . " -y 2>/dev/null";
|
||||||
|
@shell_exec($thumbCmd);
|
||||||
|
|
||||||
|
// 2. Generate HLS Segments & Playlist (6-second chunks for fast start)
|
||||||
|
$hlsCmd = "{$ffmpeg} -i " . escapeshellarg($sourceMp4Path) . " -codec:v libx264 -crf 23 -preset veryfast -codec:a aac -b:a 128k -hls_time 6 -hls_list_size 0 -hls_segment_filename " . escapeshellarg($segmentPattern) . " " . escapeshellarg($playlistPath) . " -y 2>/dev/null";
|
||||||
|
@shell_exec($hlsCmd);
|
||||||
|
|
||||||
|
$hlsUrl = '/api/videos/hls/' . $videoUuid . '/index.m3u8';
|
||||||
|
$thumbUrl = '/api/videos/hls/' . $videoUuid . '/thumbnail.jpg';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'hls_url' => file_exists($playlistPath) ? $hlsUrl : null,
|
||||||
|
'thumbnail_url' => file_exists($thumbnailPath) ? $thumbUrl : null,
|
||||||
|
'duration_seconds' => 600
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream HLS Playlist (.m3u8), Video Segment (.ts), or Thumbnail (.jpg)
|
||||||
|
*/
|
||||||
|
public static function streamHlsFile(string $videoUuid, string $filename): void
|
||||||
|
{
|
||||||
|
self::ensureSchema();
|
||||||
|
|
||||||
|
$lesson = Database::selectOne("SELECT * FROM lessons WHERE video_uuid = ? LIMIT 1", [$videoUuid]);
|
||||||
|
if (!$lesson) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'ملف البث غير موجود']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$courseId = (int)$lesson['course_id'];
|
||||||
|
$cleanFilename = basename($filename);
|
||||||
|
$filePath = dirname(__DIR__, 2) . "/storage/hls/{$courseId}/{$videoUuid}/{$cleanFilename}";
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'المقطع المطلوب مفقود']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($cleanFilename, PATHINFO_EXTENSION));
|
||||||
|
$mime = match ($ext) {
|
||||||
|
'm3u8' => 'application/vnd.apple.mpegurl',
|
||||||
|
'ts' => 'video/MP2T',
|
||||||
|
'jpg', 'jpeg' => 'image/jpeg',
|
||||||
|
'key' => 'application/octet-stream',
|
||||||
|
default => 'application/octet-stream'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ob_get_length()) {
|
||||||
|
ob_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Cache-Control: public, max-age=86400');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Content-Length: ' . filesize($filePath));
|
||||||
|
readfile($filePath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream Local Video file supporting HTTP 206 Partial Content (Range requests)
|
* 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
|
public static function streamLocalVideo(string $videoUuid): void
|
||||||
{
|
{
|
||||||
@@ -179,7 +294,6 @@ class VideoService
|
|||||||
$start = 0;
|
$start = 0;
|
||||||
$end = $fileSize - 1;
|
$end = $fileSize - 1;
|
||||||
|
|
||||||
// Clean previous buffers
|
|
||||||
if (ob_get_length()) {
|
if (ob_get_length()) {
|
||||||
ob_clean();
|
ob_clean();
|
||||||
}
|
}
|
||||||
@@ -188,8 +302,8 @@ class VideoService
|
|||||||
header('Accept-Ranges: bytes');
|
header('Accept-Ranges: bytes');
|
||||||
header('Cache-Control: public, max-age=3600');
|
header('Cache-Control: public, max-age=3600');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
// Check if Range header is requested by video player
|
|
||||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||||
$range = $_SERVER['HTTP_RANGE'];
|
$range = $_SERVER['HTTP_RANGE'];
|
||||||
if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) {
|
if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $range, $matches)) {
|
||||||
@@ -234,9 +348,6 @@ class VideoService
|
|||||||
// METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM
|
// METHOD 2: Bunny.net Stream Cloud Video CDN & Token DRM
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Bunny Stream API Credentials from Environment
|
|
||||||
*/
|
|
||||||
private static function getBunnyConfig(): array
|
private static function getBunnyConfig(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -248,15 +359,10 @@ class VideoService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
public static function createBunnyVideo(string $title, ?string $collectionId = null): array
|
||||||
{
|
{
|
||||||
$config = self::getBunnyConfig();
|
$config = self::getBunnyConfig();
|
||||||
if (empty($config['api_key'])) {
|
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',
|
$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, 0xffff),
|
||||||
mt_rand(0, 0xffff),
|
mt_rand(0, 0xffff),
|
||||||
@@ -314,10 +420,6 @@ class VideoService
|
|||||||
throw new \RuntimeException("Bunny Stream API Error ({$httpCode}): " . ($json['message'] ?? $response));
|
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
|
public static function uploadBunnyVideo(string $videoId, string $filePath): bool
|
||||||
{
|
{
|
||||||
$config = self::getBunnyConfig();
|
$config = self::getBunnyConfig();
|
||||||
@@ -339,7 +441,7 @@ class VideoService
|
|||||||
'Content-Type: application/octet-stream'
|
'Content-Type: application/octet-stream'
|
||||||
],
|
],
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 600 // 10 minutes for large videos
|
CURLOPT_TIMEOUT => 600
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
@@ -350,14 +452,6 @@ class VideoService
|
|||||||
return ($httpCode >= 200 && $httpCode < 300);
|
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
|
public static function generateBunnySignedPlayback(string $videoId, int $expiresInSeconds = 7200): array
|
||||||
{
|
{
|
||||||
$config = self::getBunnyConfig();
|
$config = self::getBunnyConfig();
|
||||||
@@ -365,8 +459,6 @@ class VideoService
|
|||||||
$tokenKey = $config['token_key'];
|
$tokenKey = $config['token_key'];
|
||||||
$expires = time() + $expiresInSeconds;
|
$expires = time() + $expiresInSeconds;
|
||||||
|
|
||||||
// Bunny Stream Token Hash Formula:
|
|
||||||
// token = SHA256(securityToken + videoId + expirationTime)
|
|
||||||
$hashable = $tokenKey . $videoId . $expires;
|
$hashable = $tokenKey . $videoId . $expires;
|
||||||
$token = hash('sha256', $hashable);
|
$token = hash('sha256', $hashable);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class StudentPortal
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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 -->
|
<!-- Apple-Grade Luxury Cupertino CSS -->
|
||||||
<style>
|
<style>
|
||||||
:root {
|
: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;">
|
<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)
|
⚠️ توقف الشرح لفحص الفهم اللحظي (Socratic Active Recall)
|
||||||
</span>
|
</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)؟
|
إذا كان f(x) = sin(3x)، فما هي مشتقة الاقتران f'(x)؟
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div style="width: 100%; display: flex; flex-direction: column; align-items: center;">
|
<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)">
|
<button type="button" class="quiz-option-btn" onclick="handleCheckpointAnswer(this, true, 45)">
|
||||||
أ) 3 cos(3x) (مشتقة الزاوية ضرب مشتقة الاقتران)
|
أ) 3 cos(3x) (مشتقة الزاوية ضرب مشتقة الاقتران)
|
||||||
</button>
|
</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)
|
ب) cos(3x)
|
||||||
</button>
|
</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)
|
ج) -3 cos(3x)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -839,6 +842,10 @@ class StudentPortal
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let activeLessonCheckpoints = [];
|
||||||
|
let triggeredCheckpoints = new Set();
|
||||||
|
let hlsInstance = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const token = localStorage.getItem('saqel_student_jwt');
|
const token = localStorage.getItem('saqel_student_jwt');
|
||||||
const cachedUser = localStorage.getItem('saqel_student_user');
|
const cachedUser = localStorage.getItem('saqel_student_user');
|
||||||
@@ -851,6 +858,7 @@ class StudentPortal
|
|||||||
}
|
}
|
||||||
initWebSocket(token);
|
initWebSocket(token);
|
||||||
await checkStudentSession(token);
|
await checkStudentSession(token);
|
||||||
|
await loadStudentLessonPlayback(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video player time listener for Socratic checkpoint
|
// Video player time listener for Socratic checkpoint
|
||||||
@@ -862,11 +870,12 @@ class StudentPortal
|
|||||||
const timeEl = document.getElementById('video_time_display');
|
const timeEl = document.getElementById('video_time_display');
|
||||||
if (timeEl) timeEl.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
|
if (timeEl) timeEl.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
|
||||||
|
|
||||||
// Trigger checkpoint at second 15 automatically once
|
// Check if current timestamp hits an active Socratic checkpoint
|
||||||
if (cur === 15 && !checkpointTriggered) {
|
const activeCp = activeLessonCheckpoints.find(cp => cp.timestamp_seconds === cur);
|
||||||
checkpointTriggered = true;
|
if (activeCp && !triggeredCheckpoints.has(activeCp.exam_id)) {
|
||||||
|
triggeredCheckpoints.add(activeCp.exam_id);
|
||||||
video.pause();
|
video.pause();
|
||||||
document.getElementById('socratic_quiz_modal').style.display = 'flex';
|
renderSocraticModal(activeCp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -878,19 +887,42 @@ class StudentPortal
|
|||||||
return `${m}:${s}`;
|
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() {
|
function triggerCheckpointDemo() {
|
||||||
const video = document.getElementById('lesson_video_player');
|
const video = document.getElementById('lesson_video_player');
|
||||||
if (video) {
|
if (video) {
|
||||||
video.currentTime = 14;
|
video.currentTime = 14;
|
||||||
|
triggeredCheckpoints.clear();
|
||||||
video.play();
|
video.play();
|
||||||
checkpointTriggered = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCheckpointAnswer(btn, isCorrect) {
|
function handleCheckpointAnswer(btn, isCorrect, rewindSeconds, examId) {
|
||||||
const feedback = document.getElementById('checkpoint_feedback');
|
const feedback = document.getElementById('checkpoint_feedback');
|
||||||
const video = document.getElementById('lesson_video_player');
|
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) {
|
if (isCorrect) {
|
||||||
btn.style.background = 'rgba(16, 185, 129, 0.25)';
|
btn.style.background = 'rgba(16, 185, 129, 0.25)';
|
||||||
@@ -903,32 +935,24 @@ class StudentPortal
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('socratic_quiz_modal').style.display = 'none';
|
document.getElementById('socratic_quiz_modal').style.display = 'none';
|
||||||
feedback.style.display = 'none';
|
feedback.style.display = 'none';
|
||||||
btns.forEach(b => {
|
|
||||||
b.style.background = '';
|
|
||||||
b.style.borderColor = '';
|
|
||||||
});
|
|
||||||
if (video) video.play();
|
if (video) video.play();
|
||||||
}, 1500);
|
}, 1400);
|
||||||
} else {
|
} else {
|
||||||
btn.style.background = 'rgba(239, 68, 68, 0.25)';
|
btn.style.background = 'rgba(239, 68, 68, 0.25)';
|
||||||
btn.style.borderColor = '#EF4444';
|
btn.style.borderColor = '#EF4444';
|
||||||
feedback.style.color = '#F87171';
|
feedback.style.color = '#F87171';
|
||||||
feedback.style.display = 'block';
|
feedback.style.display = 'block';
|
||||||
feedback.innerHTML = '⚠️ إجابة غير دقيقة. سيتم إرجاعك 45 ثانية لمراجعة الفكرة وتثبيت الفهم!';
|
feedback.innerHTML = `⚠️ إجابة غير دقيقة. سيتم إرجاعك ${rewindSeconds} ثانية لمراجعة الفكرة وتثبيت الفهم!`;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('socratic_quiz_modal').style.display = 'none';
|
document.getElementById('socratic_quiz_modal').style.display = 'none';
|
||||||
feedback.style.display = 'none';
|
feedback.style.display = 'none';
|
||||||
btns.forEach(b => {
|
|
||||||
b.style.background = '';
|
|
||||||
b.style.borderColor = '';
|
|
||||||
});
|
|
||||||
if (video) {
|
if (video) {
|
||||||
video.currentTime = Math.max(0, video.currentTime - 45);
|
video.currentTime = Math.max(0, video.currentTime - rewindSeconds);
|
||||||
|
triggeredCheckpoints.delete(examId);
|
||||||
video.play();
|
video.play();
|
||||||
checkpointTriggered = false;
|
|
||||||
}
|
}
|
||||||
}, 2200);
|
}, 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,14 +964,22 @@ class StudentPortal
|
|||||||
headers: { 'Authorization': 'Bearer ' + token }
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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 pb = data.data.playback;
|
||||||
|
const checkpoints = data.data.checkpoints || [];
|
||||||
|
activeLessonCheckpoints = checkpoints;
|
||||||
|
|
||||||
const video = document.getElementById('lesson_video_player');
|
const video = document.getElementById('lesson_video_player');
|
||||||
if (video) {
|
if (video && pb) {
|
||||||
if (pb.storage_type === 'api_upload' && pb.stream_url) {
|
if (pb.hls_url && window.Hls && Hls.isSupported()) {
|
||||||
video.src = pb.stream_url;
|
if (hlsInstance) hlsInstance.destroy();
|
||||||
} else if (pb.hls_url) {
|
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;
|
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 -->
|
<!-- IN-VIDEO SOCRATIC CHECKPOINTS -->
|
||||||
<div style="background: rgba(15, 23, 42, 0.8); border: 1px solid var(--border); border-radius: 18px; padding: 24px;">
|
<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 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">الدرس المستهدف</label>
|
<label class="form-label">الدرس المستهدف</label>
|
||||||
<select id="checkpoint_lesson_select" class="input-text">
|
<select id="checkpoint_lesson_select" class="input-text">
|
||||||
<option value="1">الرياضيات العلمي — الدرس 1: قواعد الاشتقاق الأساسية</option>
|
<option value="1">الدرس 1: قواعد الاشتقاق الأساسية</option>
|
||||||
|
<option value="2">الدرس 2: مشتقات الاقترانات المثلثية</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">توقيت ظهور السؤال (دقيقة:ثانية)</label>
|
<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;">
|
<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>
|
|
||||||
<input type="text" id="checkpoint_question_input" value="إذا كان f(x) = sin(3x)، فما هي قيمة المشتقة f'(x)؟" class="input-text">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">عقوبة الإرجاع عند الخطأ</label>
|
<label class="form-label">عقوبة الإرجاع عند الخطأ</label>
|
||||||
@@ -571,7 +572,43 @@ class TeacherPortal
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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 lessonId = document.getElementById('checkpoint_lesson_select').value;
|
||||||
const ts = document.getElementById('checkpoint_timestamp').value;
|
const tsStr = document.getElementById('checkpoint_timestamp').value.trim();
|
||||||
const question = document.getElementById('checkpoint_question_input').value;
|
const question = document.getElementById('checkpoint_question_input').value.trim();
|
||||||
const rewind = document.getElementById('checkpoint_rewind_select').value;
|
const rewind = parseInt(document.getElementById('checkpoint_rewind_select').value) || 45;
|
||||||
alert(`✅ تم تثبيت نقطة الفحص السقراطي بنجاح في الدرس (${lessonId}) عند التوقيت (${ts}) بعقوبة إرجاع (${rewind} ثانية) وحفظ السؤال في محرك التثبيت المعرفي!`);
|
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() {
|
function handleLogout() {
|
||||||
|
|||||||
@@ -74,12 +74,14 @@ $router->post('/api/teacher/courses', [\App\Controllers\TeacherController:
|
|||||||
$router->post('/api/teacher/lessons', [\App\Controllers\TeacherController::class, 'addLesson'], [\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 & Bunny.net 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/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-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/videos/bunny-link', [\App\Controllers\VideoController::class, 'linkBunnyLesson'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo']);
|
$router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoController::class, 'saveCheckpoint'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo']);
|
||||||
$router->post('/api/webhooks/bunny', [\App\Controllers\VideoController::class, 'handleBunnyWebhook']);
|
$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)
|
// Student & Teacher Chat Routes (API-Driven, Authenticated)
|
||||||
$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], [\App\Middlewares\AuthMiddleware::class]);
|
$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], [\App\Middlewares\AuthMiddleware::class]);
|
||||||
|
|||||||
Reference in New Issue
Block a user