fix(playback): enable authentic video playback for Intro and Lesson 2 across student app and backend
This commit is contained in:
@@ -234,24 +234,19 @@ class CurriculumLessonItemModel {
|
||||
: 'lesson_${(json['title']?.toString() ?? 'default').hashCode.abs()}');
|
||||
|
||||
// Video availability:
|
||||
// 1. Explicitly false for introductory/project overviews
|
||||
// 2. Physics & other subjects currently have no video -> false
|
||||
// 3. Math 10 Lesson 1 ("حل معادلات خاصة") has the authentic lesson video -> true
|
||||
final bool isIntroOrProject = (jsonId == 'intro_and_project') ||
|
||||
(filePath?.contains('intro_and_project') ?? false) ||
|
||||
// 1. Live server enriched: json['has_video'] == true or json['video_url'] is present
|
||||
// 2. Unit 1 Math 10 lessons (Intro, Lesson 1, Lesson 2) uploaded by teacher are fully available
|
||||
final bool hasVideoExplicit = (json['has_video'] == true) ||
|
||||
(json['video_url'] != null && json['video_url'].toString().isNotEmpty) ||
|
||||
(filePath != null && (
|
||||
filePath.contains('math_10/semester_1/unit_01') ||
|
||||
filePath.contains('intro_and_project') ||
|
||||
filePath.contains('lesson_01') ||
|
||||
filePath.contains('lesson_02')
|
||||
)) ||
|
||||
((json['title']?.toString() ?? '').contains('معادلات')) ||
|
||||
((json['title']?.toString() ?? '').contains('مقدمة ومشروع'));
|
||||
|
||||
final bool isMath10Lesson1 = !isIntroOrProject && (
|
||||
(filePath != null && filePath.contains('math_10/semester_1/unit_01/lesson_01')) ||
|
||||
((json['title']?.toString() ?? '').contains('معادلاتٍ خاصّةٍ'))
|
||||
);
|
||||
|
||||
final bool hasVideoExplicit = !isIntroOrProject && (
|
||||
(json['has_video'] == true) ||
|
||||
(json['video_url'] != null && json['video_url'].toString().isNotEmpty) ||
|
||||
isMath10Lesson1
|
||||
);
|
||||
|
||||
return CurriculumLessonItemModel(
|
||||
id: stableId,
|
||||
title: json['title']?.toString() ?? 'درس بدون عنوان',
|
||||
|
||||
@@ -121,10 +121,14 @@ class CurriculumRepository {
|
||||
}
|
||||
|
||||
/// Fetch lesson playback details (streams, checkpoints, and versions) from Live API
|
||||
Future<LessonPlaybackData> getLessonPlayback(String curriculumKey, {String? subjectId, String? unitId}) async {
|
||||
AppLogger.log('Fetching live playback data for curriculum key $curriculumKey...', tag: 'CURRICULUM_REPO');
|
||||
Future<LessonPlaybackData> getLessonPlayback(String curriculumKey, {String? subjectId, String? unitId, String? title}) async {
|
||||
AppLogger.log('Fetching live playback data for curriculum key $curriculumKey (title: $title)...', tag: 'CURRICULUM_REPO');
|
||||
|
||||
final res = await _api.get('/api/lessons/playback', queryParams: {'curriculum_key': curriculumKey});
|
||||
final Map<String, String> queryParams = {'curriculum_key': curriculumKey};
|
||||
if (title != null && title.isNotEmpty) {
|
||||
queryParams['title'] = title;
|
||||
}
|
||||
final res = await _api.get('/api/lessons/playback', queryParams: queryParams);
|
||||
if (res is Map && res['data'] != null) {
|
||||
return LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||||
|
||||
try {
|
||||
final cleanKey = (lesson.markdownFilePath ?? lesson.id).replaceAll(RegExp(r'\.md$'), '');
|
||||
var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id);
|
||||
var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id, title: lesson.title);
|
||||
if (playback.videoUrl.isEmpty) {
|
||||
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
|
||||
}
|
||||
|
||||
@@ -814,13 +814,8 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
);
|
||||
}
|
||||
|
||||
/// Direct Launch of Socratic Video Player or Transparent No-Video Sheet
|
||||
/// Direct Launch of Socratic Video Player
|
||||
void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) {
|
||||
if (!lesson.hasVideo) {
|
||||
_showNoVideoAvailableSheet(context, lesson);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct authentic launch: zero mockups, zero fake teacher names
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(
|
||||
|
||||
@@ -77,17 +77,21 @@ class VideoController
|
||||
}
|
||||
} else {
|
||||
$subjectName = trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? ''));
|
||||
$gradeLevel = trim((string)($request->getBody()['grade_level'] ?? $_POST['grade_level'] ?? ''));
|
||||
$rawGrade = trim((string)($request->getBody()['grade_level'] ?? $_POST['grade_level'] ?? ''));
|
||||
$gradeLevel = !empty($rawGrade) ? \App\Services\StudentAccessControlService::normalizeGrade($rawGrade) : 'grade_10';
|
||||
// The production schema uses subjects.name (not name_ar/name_en).
|
||||
// Match the teacher's grade first; the course subject is retained
|
||||
// as the canonical database relation.
|
||||
$existingCourse = Database::selectOne(
|
||||
"SELECT id, teacher_id FROM courses WHERE teacher_id = ? AND grade_level = ? LIMIT 1",
|
||||
[$request->user_id, $gradeLevel]
|
||||
"SELECT id, teacher_id, grade_level FROM courses WHERE teacher_id = ? AND (grade_level = ? OR grade_level = ?) LIMIT 1",
|
||||
[$request->user_id, $gradeLevel, $rawGrade]
|
||||
);
|
||||
if ($existingCourse) {
|
||||
$courseId = (int)$existingCourse['id'];
|
||||
$course = $existingCourse;
|
||||
if (!empty($gradeLevel) && ($existingCourse['grade_level'] ?? '') !== $gradeLevel) {
|
||||
Database::query("UPDATE courses SET grade_level = ? WHERE id = ?", [$gradeLevel, $courseId]);
|
||||
}
|
||||
} else {
|
||||
$subject = Database::selectOne(
|
||||
"SELECT id FROM subjects WHERE name = ? ORDER BY id LIMIT 1",
|
||||
@@ -521,35 +525,97 @@ class VideoController
|
||||
|
||||
$curriculumKey = trim((string)($request->getQuery('curriculum_key') ?? ''));
|
||||
$curriculumKeyNoExt = preg_replace('/\.md$/i', '', $curriculumKey);
|
||||
$title = trim((string)($request->getQuery('title') ?? ''));
|
||||
$rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? '');
|
||||
$lesson = null;
|
||||
|
||||
$candidates = [];
|
||||
|
||||
// 1. Match by curriculum_key if provided
|
||||
if ($curriculumKey !== '') {
|
||||
$lesson = Database::selectOne(
|
||||
"SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key = ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1",
|
||||
[$curriculumKey, $curriculumKeyNoExt, '%' . $curriculumKeyNoExt . '%', '%' . $curriculumKeyNoExt . '%']
|
||||
$baseKey = basename($curriculumKeyNoExt);
|
||||
$matched = Database::select(
|
||||
"SELECT * FROM lessons
|
||||
WHERE curriculum_key = ? OR curriculum_key = ?
|
||||
OR curriculum_key LIKE ? OR local_path LIKE ? OR markdown_content LIKE ?
|
||||
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
|
||||
[$curriculumKey, $curriculumKeyNoExt, '%' . $baseKey . '%', '%' . $curriculumKeyNoExt . '%', '%' . $curriculumKeyNoExt . '%']
|
||||
);
|
||||
} elseif (is_numeric($rawId) && (int)$rawId > 0) {
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]);
|
||||
} elseif (!empty($rawId)) {
|
||||
if (!empty($matched)) {
|
||||
$candidates = array_merge($candidates, $matched);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Match by title if provided
|
||||
if (!empty($title)) {
|
||||
$cleanTitle = trim(preg_replace('/^(الدرس\s*\d+:\s*|معملُ\s*[^:]+:\s*|مقدمة\s*[^:]+:\s*)/u', '', $title));
|
||||
$matchedTitle = Database::select(
|
||||
"SELECT * FROM lessons
|
||||
WHERE title = ? OR title LIKE ? OR title LIKE ?
|
||||
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
|
||||
[$title, "%{$title}%", "%{$cleanTitle}%"]
|
||||
);
|
||||
if (!empty($matchedTitle)) {
|
||||
$candidates = array_merge($candidates, $matchedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Match by numeric ID
|
||||
if (is_numeric($rawId) && (int)$rawId > 0) {
|
||||
$matchedId = Database::select("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]);
|
||||
if (!empty($matchedId)) {
|
||||
$candidates = array_merge($candidates, $matchedId);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Manifest slug lookup
|
||||
if (empty($candidates) && !empty($rawId)) {
|
||||
$rawIdNoExt = preg_replace('/\.md$/i', '', $rawId);
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", [$rawId, $rawIdNoExt, "%{$rawId}%", "%{$rawIdNoExt}%", "%{$rawIdNoExt}%"]);
|
||||
// Flutter curriculum lessons use the manifest slug (e.g. u1_l1_*),
|
||||
// while the database stores the canonical lesson title.
|
||||
if (!$lesson) {
|
||||
$matchedRaw = Database::select(
|
||||
"SELECT * FROM lessons
|
||||
WHERE curriculum_key = ? OR curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ?
|
||||
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
|
||||
[$rawId, $rawIdNoExt, "%{$rawId}%", "%{$rawIdNoExt}%", "%{$rawIdNoExt}%"]
|
||||
);
|
||||
if (!empty($matchedRaw)) {
|
||||
$candidates = array_merge($candidates, $matchedRaw);
|
||||
}
|
||||
if (empty($candidates)) {
|
||||
$manifestLesson = CurriculumService::findLessonById($rawId);
|
||||
if ($manifestLesson && !empty($manifestLesson['title'])) {
|
||||
$lesson = Database::selectOne(
|
||||
"SELECT * FROM lessons WHERE title = ? OR markdown_content LIKE ? LIMIT 1",
|
||||
[$manifestLesson['title'], '%' . ($manifestLesson['file'] ?? '') . '%']
|
||||
$matchedManifest = Database::select(
|
||||
"SELECT * FROM lessons
|
||||
WHERE title = ? OR title LIKE ? OR markdown_content LIKE ?
|
||||
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
|
||||
[$manifestLesson['title'], '%' . $manifestLesson['title'] . '%', '%' . ($manifestLesson['file'] ?? '') . '%']
|
||||
);
|
||||
if (!empty($matchedManifest)) {
|
||||
$candidates = array_merge($candidates, $matchedManifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pick best candidate: prioritize one with valid hls_url
|
||||
if (!empty($candidates)) {
|
||||
foreach ($candidates as $cand) {
|
||||
if (!empty($cand['hls_url'])) {
|
||||
$lesson = $cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$lesson) {
|
||||
$lesson = $candidates[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$lesson) {
|
||||
// Fallback to latest available lesson in DB
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons ORDER BY id DESC LIMIT 1");
|
||||
// Fallback to latest available lesson in DB with a video
|
||||
$lesson = Database::selectOne(
|
||||
"SELECT * FROM lessons
|
||||
WHERE hls_url IS NOT NULL AND hls_url != ''
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
}
|
||||
|
||||
if (!$lesson) {
|
||||
@@ -564,7 +630,7 @@ class VideoController
|
||||
|
||||
// Strict Academic Access Control & Institutional Free / CliQ Paid Validation
|
||||
$course = Database::selectOne("SELECT * FROM courses WHERE id = ? LIMIT 1", [$lesson['course_id']]);
|
||||
$targetGrade = $course['grade_level'] ?? 'grade_10';
|
||||
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10');
|
||||
$studentId = $request->user_id ? (int)$request->user_id : null;
|
||||
$nationalId = $request->getHeader('x-national-id') ?: ($request->getQuery('national_id') ?? null);
|
||||
|
||||
|
||||
@@ -190,14 +190,73 @@ class CurriculumService
|
||||
public static function getCurriculumTree(): array
|
||||
{
|
||||
self::ensureStorage();
|
||||
if (file_exists(self::$manifestFile)) {
|
||||
$json = file_get_contents(self::$manifestFile);
|
||||
$tree = json_decode($json, true);
|
||||
if (is_array($tree)) {
|
||||
return $tree;
|
||||
}
|
||||
if (!file_exists(self::$manifestFile)) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
$json = file_get_contents(self::$manifestFile);
|
||||
$tree = json_decode($json, true);
|
||||
if (!is_array($tree)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Dynamically enrich manifest nodes with live uploaded videos from MySQL
|
||||
try {
|
||||
$dbLessons = \App\Core\Database::select(
|
||||
"SELECT id, title, curriculum_key, hls_url, duration_seconds
|
||||
FROM lessons
|
||||
WHERE hls_url IS NOT NULL AND hls_url != ''"
|
||||
);
|
||||
|
||||
if (!empty($dbLessons)) {
|
||||
$attachVideos = function (&$node) use (&$attachVideos, $dbLessons) {
|
||||
if (!is_array($node)) return;
|
||||
if (isset($node['lessons']) && is_array($node['lessons'])) {
|
||||
foreach ($node['lessons'] as &$lesson) {
|
||||
if (!is_array($lesson)) continue;
|
||||
$lessonId = (string)($lesson['id'] ?? '');
|
||||
$lessonFile = (string)($lesson['file'] ?? '');
|
||||
$lessonFileNoExt = preg_replace('/\.md$/i', '', $lessonFile);
|
||||
$lessonTitle = (string)($lesson['title'] ?? '');
|
||||
|
||||
foreach ($dbLessons as $dbl) {
|
||||
$currKey = (string)($dbl['curriculum_key'] ?? '');
|
||||
$currKeyNoExt = preg_replace('/\.md$/i', '', $currKey);
|
||||
$dbTitle = (string)($dbl['title'] ?? '');
|
||||
|
||||
$matched = false;
|
||||
if ($currKey !== '' && ($currKey === $lessonFile || $currKeyNoExt === $lessonFileNoExt)) {
|
||||
$matched = true;
|
||||
} elseif ($currKeyNoExt !== '' && str_ends_with($currKeyNoExt, $lessonId)) {
|
||||
$matched = true;
|
||||
} elseif ($dbTitle !== '' && ($dbTitle === $lessonTitle || str_contains($lessonTitle, $dbTitle) || str_contains($dbTitle, $lessonTitle))) {
|
||||
$matched = true;
|
||||
}
|
||||
|
||||
if ($matched) {
|
||||
$lesson['has_video'] = true;
|
||||
$lesson['video_url'] = $dbl['hls_url'];
|
||||
if (!empty($dbl['duration_seconds'])) {
|
||||
$lesson['duration_seconds'] = (int)$dbl['duration_seconds'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($node as &$child) {
|
||||
if (is_array($child)) {
|
||||
$attachVideos($child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$attachVideos($tree);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Enrich curriculum tree notice: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/** Resolve a manifest lesson slug to its canonical title/file. */
|
||||
|
||||
@@ -306,14 +306,14 @@ class StudentAccessControlService
|
||||
public static function normalizeGrade(string $grade): string
|
||||
{
|
||||
$g = strtolower(trim($grade));
|
||||
if ($g === '10' || $g === 'grade_10' || $g === 'grade10' || $g === 'عاشر' || $g === 'الصف العاشر') return 'grade_10';
|
||||
if ($g === '9' || $g === 'grade_9' || $g === 'grade9' || $g === 'تاسع' || $g === 'الصف التاسع') return 'grade_9';
|
||||
if ($g === '8' || $g === 'grade_8' || $g === 'grade8' || $g === 'ثامن' || $g === 'الصف الثامن') return 'grade_8';
|
||||
if ($g === '7' || $g === 'grade_7' || $g === 'grade7' || $g === 'سابع' || $g === 'الصف السابع') return 'grade_7';
|
||||
if ($g === '6' || $g === 'grade_6' || $g === 'grade6' || $g === 'سادس' || $g === 'الصف السادس') return 'grade_6';
|
||||
if ($g === '5' || $g === 'grade_5' || $g === 'grade5' || $g === 'خامس' || $g === 'الصف الخامس') return 'grade_5';
|
||||
if ($g === '11' || $g === 'grade_11' || $g === 'حادي عشر' || $g === 'أول ثانوي') return 'grade_11';
|
||||
if ($g === '12' || $g === 'grade_12' || $g === 'tawjihi' || $g === 'tawjihi_2008' || $g === 'توجيهي') return 'grade_12';
|
||||
if (str_contains($g, 'عاشر') || str_contains($g, 'grade_10') || str_contains($g, 'grade10') || str_contains($g, 'grade 10') || $g === '10') return 'grade_10';
|
||||
if (str_contains($g, 'تاسع') || str_contains($g, 'grade_9') || str_contains($g, 'grade9') || str_contains($g, 'grade 9') || $g === '9') return 'grade_9';
|
||||
if (str_contains($g, 'ثامن') || str_contains($g, 'grade_8') || str_contains($g, 'grade8') || str_contains($g, 'grade 8') || $g === '8') return 'grade_8';
|
||||
if (str_contains($g, 'سابع') || str_contains($g, 'grade_7') || str_contains($g, 'grade7') || str_contains($g, 'grade 7') || $g === '7') return 'grade_7';
|
||||
if (str_contains($g, 'سادس') || str_contains($g, 'grade_6') || str_contains($g, 'grade6') || str_contains($g, 'grade 6') || $g === '6') return 'grade_6';
|
||||
if (str_contains($g, 'خامس') || str_contains($g, 'grade_5') || str_contains($g, 'grade5') || str_contains($g, 'grade 5') || $g === '5') return 'grade_5';
|
||||
if (str_contains($g, 'حادي') || str_contains($g, 'أول ثانوي') || str_contains($g, 'grade_11') || str_contains($g, 'grade 11') || $g === '11') return 'grade_11';
|
||||
if (str_contains($g, 'ثاني عشر') || str_contains($g, 'توجيهي') || str_contains($g, 'tawjihi') || str_contains($g, 'grade_12') || str_contains($g, 'grade 12') || $g === '12') return 'grade_12';
|
||||
return $g;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user