fix(playback): isolate curriculum keys and prevent non-uploaded lessons from falling back to previous video

This commit is contained in:
Hamza-Ayed
2026-09-09 01:33:56 +03:00
parent a4d6e34a40
commit 78ad245ee5
4 changed files with 196 additions and 77 deletions
@@ -235,17 +235,14 @@ class CurriculumLessonItemModel {
// Video availability:
// 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
// 2. Exact uploaded lessons in Grade 10 Math Unit 1 (Intro, Lesson 1, Lesson 2)
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('مقدمة ومشروع'));
filePath.contains('math_10/semester_1/unit_01/intro_and_project') ||
filePath.contains('math_10/semester_1/unit_01/lesson_01') ||
filePath.contains('math_10/semester_1/unit_01/lesson_02')
));
return CurriculumLessonItemModel(
id: stableId,
@@ -262,12 +262,17 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
),
children: unit.lessons.map((lesson) {
final hasVid = lesson.hasVideo;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF09111E),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.darkCardBorder.withAlpha(80)),
border: Border.all(
color: hasVid
? AppColors.darkCardBorder.withAlpha(80)
: Colors.white.withAlpha(15),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
@@ -275,16 +280,28 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
width: 38,
height: 38,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [widget.subject.primaryColor, widget.subject.secondaryColor],
),
gradient: hasVid
? LinearGradient(
colors: [widget.subject.primaryColor, widget.subject.secondaryColor],
)
: null,
color: hasVid ? null : Colors.white.withAlpha(10),
borderRadius: BorderRadius.circular(10),
border: hasVid ? null : Border.all(color: Colors.white12),
),
child: Icon(
hasVid ? CupertinoIcons.play_fill : CupertinoIcons.film,
color: hasVid ? Colors.black : AppColors.textSecondaryDark,
size: 18,
),
child: const Icon(CupertinoIcons.play_fill, color: Colors.black, size: 18),
),
title: Text(
lesson.title,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13),
style: TextStyle(
color: hasVid ? Colors.white : Colors.white70,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
subtitle: Row(
children: [
@@ -296,17 +313,27 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(25),
color: hasVid
? AppColors.saqelCyan.withAlpha(25)
: Colors.white.withAlpha(12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${lesson.checkpointsCount} فحوصات',
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 10, fontWeight: FontWeight.w700),
hasVid ? '${lesson.checkpointsCount} فحوصات' : 'قريباً',
style: TextStyle(
color: hasVid ? AppColors.saqelCyan : AppColors.textSecondaryDark,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
],
),
trailing: const Icon(CupertinoIcons.chevron_back, color: AppColors.textSecondaryDark, size: 16),
trailing: Icon(
hasVid ? CupertinoIcons.chevron_back : CupertinoIcons.clock,
color: hasVid ? AppColors.textSecondaryDark : Colors.white24,
size: 16,
),
onTap: () {
_showLessonVideoSelector(context, lesson);
},
@@ -814,8 +841,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
);
}
/// Direct Launch of Socratic Video Player
/// Direct Launch of Socratic Video Player or Unavailable Notice
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(
@@ -828,4 +860,94 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
),
);
}
/// Luxury Modal Sheet when a lesson has no uploaded video yet
void _showNoVideoAvailableSheet(BuildContext context, CurriculumLessonItemModel lesson) {
showCupertinoModalPopup(
context: context,
builder: (ctx) => Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
border: Border.all(color: Colors.white12),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag Indicator
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
// Icon
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(20),
shape: BoxShape.circle,
border: Border.all(color: AppColors.saqelCyan.withAlpha(60)),
),
child: const Icon(
CupertinoIcons.film,
color: AppColors.saqelCyan,
size: 32,
),
),
const SizedBox(height: 16),
// Title
const Text(
'فيديو الشرح قيد الإنتاج والمراجعة',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Lesson title
Text(
lesson.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.saqelCyan,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
// Explanation
const Text(
'لم يقم المعلم برفع ونشر فيديو الشرح لهذا الدرس بعد.\nسيتاح فور اعتماده من الإشراف الأكاديمي. يمكنك حالياً الاستفادة من أوراق العمل والملخصات والأنشطة التفاعلية في التبويبات المجاورة.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textSecondaryDark,
fontSize: 13,
height: 1.5,
),
),
const SizedBox(height: 24),
// Action Button
SizedBox(
width: double.infinity,
child: LuxuryButton(
text: 'حسناً، فهمت ذلك',
onPressed: () => Navigator.of(ctx).pop(),
),
),
],
),
),
),
);
}
}
+41 -52
View File
@@ -533,14 +533,23 @@ class VideoController
// 1. Match by curriculum_key if provided
if ($curriculumKey !== '') {
$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 . '%']
);
if (str_contains($curriculumKeyNoExt, '/')) {
// Structured hierarchical key: match exact or qualified path suffix
$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, "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%"]
);
} else {
$matched = Database::select(
"SELECT * FROM lessons
WHERE curriculum_key = ? OR curriculum_key LIKE ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
[$curriculumKey, "%/{$curriculumKeyNoExt}%"]
);
}
if (!empty($matched)) {
$candidates = array_merge($candidates, $matched);
}
@@ -549,14 +558,16 @@ class VideoController
// 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);
if (mb_strlen($cleanTitle) >= 6) {
$matchedTitle = Database::select(
"SELECT * FROM lessons
WHERE title = ? OR title LIKE ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
[$title, "%{$cleanTitle}%"]
);
if (!empty($matchedTitle)) {
$candidates = array_merge($candidates, $matchedTitle);
}
}
}
@@ -570,28 +581,17 @@ class VideoController
// 4. Manifest slug lookup
if (empty($candidates) && !empty($rawId)) {
$rawIdNoExt = preg_replace('/\.md$/i', '', $rawId);
$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'])) {
$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);
}
$manifestLesson = CurriculumService::findLessonById($rawId);
if ($manifestLesson && !empty($manifestLesson['file'])) {
$fileNoExt = preg_replace('/\.md$/i', '', $manifestLesson['file']);
$matchedManifest = Database::select(
"SELECT * FROM lessons
WHERE curriculum_key = ? OR curriculum_key = ? OR local_path LIKE ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5",
[$manifestLesson['file'], $fileNoExt, "%{$fileNoExt}%"]
);
if (!empty($matchedManifest)) {
$candidates = array_merge($candidates, $matchedManifest);
}
}
}
@@ -604,24 +604,13 @@ class VideoController
break;
}
}
if (!$lesson) {
$lesson = $candidates[0];
}
}
if (!$lesson) {
// 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) {
// Strict verification: if no matching lesson with an uploaded video is found, return 404
if (!$lesson || empty($lesson['hls_url'])) {
$response->status(404)->json([
'status' => 'error',
'message' => 'الدرس غير موجود أو لم يتم نشر الفيديو الخاص به بعد'
'message' => 'لم يتم رفع ونشر فيديو شرح لهذا الدرس بعد.'
]);
return;
}
+17 -6
View File
@@ -224,12 +224,23 @@ class CurriculumService
$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 ($currKeyNoExt !== '' && $lessonFileNoExt !== '') {
if ($currKeyNoExt === $lessonFileNoExt) {
$matched = true;
} elseif (str_contains($currKeyNoExt, '/') && (
str_ends_with($lessonFileNoExt, '/' . ltrim($currKeyNoExt, '/')) ||
str_ends_with($currKeyNoExt, '/' . ltrim($lessonFileNoExt, '/'))
)) {
$matched = true;
}
}
if (!$matched && $dbTitle !== '' && $lessonTitle !== '') {
$normDb = preg_replace('/[\s\p{P}]+/u', '', mb_strtolower($dbTitle));
$normLes = preg_replace('/[\s\p{P}]+/u', '', mb_strtolower($lessonTitle));
if ($normDb === $normLes && mb_strlen($normDb) > 8) {
$matched = true;
}
}
if ($matched) {