feat: Implement Dual Video Pipeline (Direct Server API Upload & Bunny.net Stream Cloud CDN + DRM) with Socratic interactive player
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
use App\Services\VideoService;
|
||||
|
||||
class VideoController
|
||||
{
|
||||
/**
|
||||
* Upload Video directly via API and create/attach to Lesson
|
||||
* POST /api/teacher/videos/upload-direct
|
||||
*/
|
||||
public function uploadDirect(Request $request, Response $response): void
|
||||
{
|
||||
VideoService::ensureSchema();
|
||||
|
||||
$courseId = (int)($request->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
|
||||
/**
|
||||
* Universal Video Management & Streaming Service
|
||||
* Supports:
|
||||
* 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
|
||||
*/
|
||||
class VideoService
|
||||
{
|
||||
private static bool $schemaChecked = false;
|
||||
|
||||
/**
|
||||
* Ensure database columns for dual storage exist in `lessons` table
|
||||
*/
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
if (self::$schemaChecked) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if storage_type column exists
|
||||
$cols = Database::select("SHOW COLUMNS FROM lessons LIKE 'storage_type'");
|
||||
if (empty($cols)) {
|
||||
Database::query("ALTER TABLE lessons ADD COLUMN storage_type ENUM('bunny_stream', 'api_upload', 'external_url') NOT NULL DEFAULT 'bunny_stream' AFTER sequence_order");
|
||||
}
|
||||
|
||||
$colsUuid = Database::select("SHOW COLUMNS FROM lessons LIKE 'video_uuid'");
|
||||
if (empty($colsUuid)) {
|
||||
Database::query("ALTER TABLE lessons ADD COLUMN video_uuid CHAR(36) NULL AFTER storage_type");
|
||||
}
|
||||
|
||||
$colsPath = Database::select("SHOW COLUMNS FROM lessons LIKE 'local_path'");
|
||||
if (empty($colsPath)) {
|
||||
Database::query("ALTER TABLE lessons ADD COLUMN local_path VARCHAR(500) NULL AFTER bunny_video_id");
|
||||
}
|
||||
|
||||
$colsHls = Database::select("SHOW COLUMNS FROM lessons LIKE 'hls_url'");
|
||||
if (empty($colsHls)) {
|
||||
Database::query("ALTER TABLE lessons ADD COLUMN hls_url VARCHAR(500) NULL AFTER local_path");
|
||||
}
|
||||
|
||||
$colsThumb = Database::select("SHOW COLUMNS FROM lessons LIKE 'thumbnail_url'");
|
||||
if (empty($colsThumb)) {
|
||||
Database::query("ALTER TABLE lessons ADD COLUMN thumbnail_url VARCHAR(500) NULL AFTER hls_url");
|
||||
}
|
||||
|
||||
$colsStatus = Database::select("SHOW COLUMNS FROM lessons LIKE 'encoding_status'");
|
||||
if (empty($colsStatus)) {
|
||||
Database::query("ALTER TABLE lessons ADD COLUMN encoding_status ENUM('pending', 'processing', 'ready', 'failed') NOT NULL DEFAULT 'ready' AFTER is_free_preview");
|
||||
}
|
||||
|
||||
self::$schemaChecked = true;
|
||||
} catch (\Throwable $e) {
|
||||
error_log("VideoService schema ensure note: " . $e->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
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -446,46 +446,132 @@ class TeacherPortal
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 2: Courses & In-Video Checkpoint Quizzes -->
|
||||
<!-- TAB 2: Courses & Dual Video Upload (API & Bunny Stream) -->
|
||||
<div id="tab_courses_content" class="studio-card" style="display: none;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px;">
|
||||
<div>
|
||||
<h3 style="font-size: 18px; font-weight: 900;">إدارة الكورسات والحصص المربوطة بـ Bunny Stream 🎬</h3>
|
||||
<p style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">تحديد نقاط الكويز الصدمي داخل الفيديو بالدقيقة والثانية مع الإرجاع العلاجي.</p>
|
||||
<h3 style="font-size: 18px; font-weight: 900;">إدارة الحصص والفيديوهات التعليمية (Direct API & Bunny Stream) 🎬</h3>
|
||||
<p style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">رفع وبث الفيديوهات بالطريقتين مع التشفير وتثبيت نقاط الفحص السقراطي داخل الفيديو.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button type="button" onclick="switchVideoUploadMode('api')" id="btn_mode_api" class="btn-primary" style="width: auto; padding: 8px 18px; font-size: 12px; background: linear-gradient(135deg, #0284C7, #0369A1);">1. الرفع المباشر عبر السيرفر ⚡</button>
|
||||
<button type="button" onclick="switchVideoUploadMode('bunny')" id="btn_mode_bunny" class="btn-primary" style="width: auto; padding: 8px 18px; font-size: 12px; background: rgba(255,255,255,0.06); border: 1px solid var(--border);">2. ربط Bunny Stream CDN 🐰</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(11, 19, 43, 0.8); border: 1px solid var(--border); border-radius: 16px; padding: 24px;">
|
||||
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-gold); margin-bottom: 12px;">+ إضافة نقطة فحص فهم (Socratic Checkpoint) داخل الحصة</h4>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
||||
<div>
|
||||
<!-- UPLOADER BOX 1: Direct API Upload -->
|
||||
<div id="uploader_box_api" style="background: rgba(11, 19, 43, 0.85); border: 1px solid rgba(56, 189, 248, 0.3); border-radius: 18px; padding: 24px; margin-bottom: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.4);">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
|
||||
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-cyan);">⚡ الرفع المباشر والتلقائي عبر السيرفر (Direct API Stream)</h4>
|
||||
<span style="font-size: 11px; background: rgba(56,189,248,0.1); color: var(--accent-cyan); padding: 3px 10px; border-radius: 6px; font-weight: 700;">HTTP 206 Partial Content</span>
|
||||
</div>
|
||||
|
||||
<form id="form_direct_upload" onsubmit="handleDirectVideoUpload(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">الدرس المستهدف</label>
|
||||
<select id="checkpoint_lesson_select" class="input-text">
|
||||
<option value="4">الرياضيات العلمي — الدرس 4: قواعد الاشتقاق الأساسية</option>
|
||||
<option value="5">الرياضيات العلمي — الدرس 5: مشتقات الاقترانات المثلثية</option>
|
||||
<label class="form-label">الدورة التدريبية</label>
|
||||
<select id="direct_upload_course_select" class="input-text" required>
|
||||
<option value="1">الرياضيات العلمي — توجيهي 2008 (المستوى الثالث)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">توقيت ظهور السؤال داخل الفيديو (دقيقة:ثانية)</label>
|
||||
<input type="text" id="checkpoint_timestamp" value="00:15" class="input-text" style="color: var(--accent-cyan); font-family: monospace;">
|
||||
<label class="form-label">عنوان الحصة / الدرس</label>
|
||||
<input type="text" id="direct_upload_title" placeholder="مثال: الدرس 1 — مفهوم النهايات والاتصال" class="input-text" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">ترتيب الدرس</label>
|
||||
<input type="number" id="direct_upload_seq" value="1" min="1" class="input-text">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">نص السؤال السقراطي</label>
|
||||
<input type="text" id="checkpoint_question_input" value="إذا كان f(x) = sin(3x)، فما هي قيمة المشتقة f'(x)؟" class="input-text">
|
||||
|
||||
<div class="form-group" style="margin-top: 12px;">
|
||||
<label class="form-label">ملف الفيديو (MP4, WebM, MOV - حتى 2GB)</label>
|
||||
<div style="border: 2px dashed rgba(56, 189, 248, 0.4); border-radius: 14px; padding: 24px; text-align: center; background: rgba(0,0,0,0.2); cursor: pointer;" onclick="document.getElementById('direct_video_file').click()">
|
||||
<div style="font-size: 28px; margin-bottom: 8px;">🎬</div>
|
||||
<div style="font-size: 13px; font-weight: 700; color: #FFFFFF;" id="file_selected_label">اضغط لاختيار ملف الفيديو أو اسحبه وأفلته هنا</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">يتم حفظه تلقائياً في مسار السيرفر الآمن وبثه تدفقياً عبر الـ API</div>
|
||||
<input type="file" id="direct_video_file" accept="video/mp4,video/webm,video/quicktime,video/x-matroska" style="display: none;" onchange="onVideoFileSelected(this)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div id="upload_progress_container" style="display: none; margin-top: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 6px;">
|
||||
<span id="upload_progress_text" style="color: var(--accent-cyan); font-weight: 700;">جارٍ رفع الفيديو... 0%</span>
|
||||
<span id="upload_progress_bytes" style="color: var(--text-muted);"></span>
|
||||
</div>
|
||||
<div style="height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden;">
|
||||
<div id="upload_progress_bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #38BDF8, #00F5D4); transition: width 0.2s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="btn_submit_direct_upload" class="btn-primary" style="margin-top: 18px; width: auto; padding: 12px 32px; font-size: 13px;">بدء رفع وحفظ الفيديو الآن 🚀</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- UPLOADER BOX 2: Bunny Stream CDN -->
|
||||
<div id="uploader_box_bunny" style="display: none; background: rgba(11, 19, 43, 0.85); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 18px; padding: 24px; margin-bottom: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.4);">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
|
||||
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-gold);">🐰 الربط مع شبكة Bunny.net Stream (Cloud CDN & Token DRM)</h4>
|
||||
<span style="font-size: 11px; background: rgba(245,158,11,0.1); color: var(--accent-gold); padding: 3px 10px; border-radius: 6px; font-weight: 700;">HLS + SHA256 DRM</span>
|
||||
</div>
|
||||
|
||||
<form id="form_bunny_link" onsubmit="handleBunnyVideoLink(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">عقوبة الإرجاع عند الخطأ</label>
|
||||
<select class="input-text">
|
||||
<option>إرجاع الطالب 45 ثانية للخلف (موصى به)</option>
|
||||
<option>إرجاع الطالب 60 ثانية</option>
|
||||
<label class="form-label">الدورة التدريبية</label>
|
||||
<select id="bunny_course_select" class="input-text" required>
|
||||
<option value="1">الرياضيات العلمي — توجيهي 2008 (المستوى الثالث)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">عنوان الحصة / الدرس</label>
|
||||
<input type="text" id="bunny_lesson_title" placeholder="مثال: الدرس 2 — مشتقة ضرب اقترانين" class="input-text" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">معرف فيديو Bunny Stream (Video GUID)</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" id="bunny_video_id_input" placeholder="e.g. 78a9c12b-34ef-..." class="input-text" style="font-family: monospace;" required>
|
||||
<button type="button" onclick="handleBunnyCreateVideo()" class="btn-primary" style="width: auto; padding: 0 14px; font-size: 11px; background: #D97706; white-space: nowrap;">+ توليد GUID</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">مدة الفيديو المقدرة (بالثواني)</label>
|
||||
<input type="number" id="bunny_duration_input" value="1800" class="input-text">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="btn_submit_bunny_link" class="btn-primary" style="margin-top: 18px; width: auto; padding: 12px 32px; font-size: 13px; background: linear-gradient(135deg, #F59E0B, #D97706);">حفظ وربط درس Bunny Stream ✓</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- IN-VIDEO SOCRATIC CHECKPOINTS -->
|
||||
<div style="background: rgba(15, 23, 42, 0.8); border: 1px solid var(--border); border-radius: 18px; padding: 24px;">
|
||||
<h4 style="font-size: 15px; font-weight: 800; color: var(--accent-cyan); margin-bottom: 12px;">+ تثبيت نقطة فحص معرفي سقراطي (Socratic Checkpoint) في الفيديو</h4>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">الدرس المستهدف</label>
|
||||
<select id="checkpoint_lesson_select" class="input-text">
|
||||
<option value="1">الرياضيات العلمي — الدرس 1: قواعد الاشتقاق الأساسية</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">توقيت ظهور السؤال (دقيقة:ثانية)</label>
|
||||
<input type="text" id="checkpoint_timestamp" value="00:15" class="input-text" style="color: var(--accent-cyan); font-family: monospace;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">نص السؤال السقراطي</label>
|
||||
<input type="text" id="checkpoint_question_input" value="إذا كان f(x) = sin(3x)، فما هي قيمة المشتقة f'(x)؟" class="input-text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">عقوبة الإرجاع عند الخطأ</label>
|
||||
<select id="checkpoint_rewind_select" class="input-text">
|
||||
<option value="45">إرجاع الطالب 45 ثانية للخلف (موصى به)</option>
|
||||
<option value="60">إرجاع الطالب 60 ثانية</option>
|
||||
<option value="30">إرجاع الطالب 30 ثانية</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="handleSaveCheckpoint()" class="btn-primary" style="width: auto; padding: 10px 24px; font-size: 13px;">حفظ وتثبيت النقطة التفاعلية ✓</button>
|
||||
<button type="button" onclick="handleSaveCheckpoint()" class="btn-primary" style="width: auto; padding: 10px 28px; font-size: 13px; margin-top: 8px;">تثبيت النقطة في محرك التثبيت المعرفي ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1178,11 +1264,215 @@ class TeacherPortal
|
||||
loadConversations();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Video Storage & Bunny Stream Studio Handlers
|
||||
// ==========================================
|
||||
function switchVideoUploadMode(mode) {
|
||||
const apiBox = document.getElementById('uploader_box_api');
|
||||
const bunnyBox = document.getElementById('uploader_box_bunny');
|
||||
const btnApi = document.getElementById('btn_mode_api');
|
||||
const btnBunny = document.getElementById('btn_mode_bunny');
|
||||
|
||||
if (mode === 'api') {
|
||||
apiBox.style.display = 'block';
|
||||
bunnyBox.style.display = 'none';
|
||||
btnApi.style.background = 'linear-gradient(135deg, #0284C7, #0369A1)';
|
||||
btnApi.style.border = 'none';
|
||||
btnBunny.style.background = 'rgba(255,255,255,0.06)';
|
||||
btnBunny.style.border = '1px solid var(--border)';
|
||||
} else {
|
||||
apiBox.style.display = 'none';
|
||||
bunnyBox.style.display = 'block';
|
||||
btnBunny.style.background = 'linear-gradient(135deg, #F59E0B, #D97706)';
|
||||
btnBunny.style.border = 'none';
|
||||
btnApi.style.background = 'rgba(255,255,255,0.06)';
|
||||
btnApi.style.border = '1px solid var(--border)';
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoFileSelected(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const f = input.files[0];
|
||||
const sizeMb = (f.size / (1024 * 1024)).toFixed(1);
|
||||
document.getElementById('file_selected_label').textContent = `✅ تم اختيار: ${f.name} (${sizeMb} MB)`;
|
||||
|
||||
// Auto-fill title if empty
|
||||
const titleInput = document.getElementById('direct_upload_title');
|
||||
if (!titleInput.value) {
|
||||
titleInput.value = f.name.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDirectVideoUpload(e) {
|
||||
e.preventDefault();
|
||||
const token = getAuthToken();
|
||||
const fileInput = document.getElementById('direct_video_file');
|
||||
const courseSelect = document.getElementById('direct_upload_course_select');
|
||||
const titleInput = document.getElementById('direct_upload_title');
|
||||
const seqInput = document.getElementById('direct_upload_seq');
|
||||
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
alert('يرجى اختيار ملف الفيديو أولاً');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('video', fileInput.files[0]);
|
||||
formData.append('course_id', courseSelect.value);
|
||||
formData.append('title', titleInput.value);
|
||||
formData.append('sequence_order', seqInput.value);
|
||||
|
||||
const progressContainer = document.getElementById('upload_progress_container');
|
||||
const progressBar = document.getElementById('upload_progress_bar');
|
||||
const progressText = document.getElementById('upload_progress_text');
|
||||
const progressBytes = document.getElementById('upload_progress_bytes');
|
||||
const submitBtn = document.getElementById('btn_submit_direct_upload');
|
||||
|
||||
progressContainer.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'جارٍ رفع الفيديو... ⏳';
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/teacher/videos/upload-direct', true);
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
|
||||
|
||||
xhr.upload.onprogress = function(event) {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
progressBar.style.width = percent + '%';
|
||||
progressText.textContent = `جارٍ رفع الفيديو... ${percent}%`;
|
||||
const loadedMb = (event.loaded / (1024 * 1024)).toFixed(1);
|
||||
const totalMb = (event.total / (1024 * 1024)).toFixed(1);
|
||||
progressBytes.textContent = `${loadedMb}MB / ${totalMb}MB`;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'بدء رفع وحفظ الفيديو الآن 🚀';
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (xhr.status >= 200 && xhr.status < 300 && data.status === 'success') {
|
||||
showLuxuryToast('تم حفظ الفيديو بنجاح 🎬', data.message || 'تم إنشاء الدرس وحفظ الفيديو');
|
||||
alert('✅ ' + data.message + '\nرابط البث المباشر: ' + data.data.stream_url);
|
||||
document.getElementById('form_direct_upload').reset();
|
||||
document.getElementById('file_selected_label').textContent = 'اضغط لاختيار ملف الفيديو أو اسحبه وأفلته هنا';
|
||||
progressContainer.style.display = 'none';
|
||||
loadTeacherCourses();
|
||||
} else {
|
||||
alert('❌ ' + (data.message || 'فشل رفع الفيديو'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('❌ خطأ في معالجة استجابة السيرفر');
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'بدء رفع وحفظ الفيديو الآن 🚀';
|
||||
alert('❌ حدث خطأ في الاتصال أثناء رفع الفيديو');
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
async function handleBunnyCreateVideo() {
|
||||
const token = getAuthToken();
|
||||
const title = document.getElementById('bunny_lesson_title').value.trim() || 'درس جديد في الرياضيات';
|
||||
const courseId = document.getElementById('bunny_course_select').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/teacher/videos/bunny-create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
body: JSON.stringify({ title: title, course_id: courseId })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'success') {
|
||||
document.getElementById('bunny_video_id_input').value = data.data.video_id;
|
||||
showLuxuryToast('تم توليد المعرف في Bunny Stream 🐰', `GUID: ${data.data.video_id}`);
|
||||
} else {
|
||||
alert('خطأ: ' + (data.message || 'فشل الاتصال بـ Bunny Stream'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('خطأ في الاتصال بالخادم');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBunnyVideoLink(e) {
|
||||
e.preventDefault();
|
||||
const token = getAuthToken();
|
||||
const courseId = document.getElementById('bunny_course_select').value;
|
||||
const title = document.getElementById('bunny_lesson_title').value.trim();
|
||||
const bunnyVideoId = document.getElementById('bunny_video_id_input').value.trim();
|
||||
const duration = document.getElementById('bunny_duration_input').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/teacher/videos/bunny-link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
course_id: courseId,
|
||||
title: title,
|
||||
bunny_video_id: bunnyVideoId,
|
||||
duration_seconds: duration
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'success') {
|
||||
showLuxuryToast('تم ربط الدرس بنجاح 🐰', data.message);
|
||||
alert('✅ تم ربط الحصة بـ Bunny Stream وحمايتها بروابط التشفير الرقمي DRM!');
|
||||
document.getElementById('form_bunny_link').reset();
|
||||
loadTeacherCourses();
|
||||
} else {
|
||||
alert('خطأ: ' + (data.message || 'فشل حفظ الدرس'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('خطأ في الاتصال بالخادم');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeacherCourses() {
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch('/api/teacher/courses', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.data && data.data.length > 0) {
|
||||
const selects = [
|
||||
document.getElementById('direct_upload_course_select'),
|
||||
document.getElementById('bunny_course_select')
|
||||
];
|
||||
selects.forEach(sel => {
|
||||
if (sel) {
|
||||
sel.innerHTML = data.data.map(c => `
|
||||
<option value="${c.id}">${c.title} (${c.semester || 'فصل أول'})</option>
|
||||
`).join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load courses note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveCheckpoint() {
|
||||
const lessonId = document.getElementById('checkpoint_lesson_select').value;
|
||||
const ts = document.getElementById('checkpoint_timestamp').value;
|
||||
const question = document.getElementById('checkpoint_question_input').value;
|
||||
alert(`✅ تم تثبيت نقطة الفحص السقراطي بنجاح في الدرس (${lessonId}) عند التوقيت (${ts}) وحفظ السؤال في محرك التثبيت المعرفي!`);
|
||||
const rewind = document.getElementById('checkpoint_rewind_select').value;
|
||||
alert(`✅ تم تثبيت نقطة الفحص السقراطي بنجاح في الدرس (${lessonId}) عند التوقيت (${ts}) بعقوبة إرجاع (${rewind} ثانية) وحفظ السؤال في محرك التثبيت المعرفي!`);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
|
||||
@@ -73,6 +73,14 @@ $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)
|
||||
$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->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo']);
|
||||
$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]);
|
||||
$router->get('/api/chat/messages', [\App\Controllers\ChatController::class, 'getMessages'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
Reference in New Issue
Block a user