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: // Video availability:
// 1. Live server enriched: json['has_video'] == true or json['video_url'] is present // 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) || final bool hasVideoExplicit = (json['has_video'] == true) ||
(json['video_url'] != null && json['video_url'].toString().isNotEmpty) || (json['video_url'] != null && json['video_url'].toString().isNotEmpty) ||
(filePath != null && ( (filePath != null && (
filePath.contains('math_10/semester_1/unit_01') || filePath.contains('math_10/semester_1/unit_01/intro_and_project') ||
filePath.contains('intro_and_project') || filePath.contains('math_10/semester_1/unit_01/lesson_01') ||
filePath.contains('lesson_01') || filePath.contains('math_10/semester_1/unit_01/lesson_02')
filePath.contains('lesson_02') ));
)) ||
((json['title']?.toString() ?? '').contains('معادلات')) ||
((json['title']?.toString() ?? '').contains('مقدمة ومشروع'));
return CurriculumLessonItemModel( return CurriculumLessonItemModel(
id: stableId, id: stableId,
@@ -262,12 +262,17 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
), ),
children: unit.lessons.map((lesson) { children: unit.lessons.map((lesson) {
final hasVid = lesson.hasVideo;
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF09111E), color: const Color(0xFF09111E),
borderRadius: BorderRadius.circular(14), 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( child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
@@ -275,16 +280,28 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
width: 38, width: 38,
height: 38, height: 38,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: hasVid
colors: [widget.subject.primaryColor, widget.subject.secondaryColor], ? LinearGradient(
), colors: [widget.subject.primaryColor, widget.subject.secondaryColor],
)
: null,
color: hasVid ? null : Colors.white.withAlpha(10),
borderRadius: BorderRadius.circular(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( title: Text(
lesson.title, 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( subtitle: Row(
children: [ children: [
@@ -296,17 +313,27 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(25), color: hasVid
? AppColors.saqelCyan.withAlpha(25)
: Colors.white.withAlpha(12),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Text( child: Text(
'${lesson.checkpointsCount} فحوصات', hasVid ? '${lesson.checkpointsCount} فحوصات' : 'قريباً',
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 10, fontWeight: FontWeight.w700), 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: () { onTap: () {
_showLessonVideoSelector(context, lesson); _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) { void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) {
if (!lesson.hasVideo) {
_showNoVideoAvailableSheet(context, lesson);
return;
}
// Direct authentic launch: zero mockups, zero fake teacher names // Direct authentic launch: zero mockups, zero fake teacher names
Navigator.of(context).push( Navigator.of(context).push(
CupertinoPageRoute( 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 // 1. Match by curriculum_key if provided
if ($curriculumKey !== '') { if ($curriculumKey !== '') {
$baseKey = basename($curriculumKeyNoExt); if (str_contains($curriculumKeyNoExt, '/')) {
$matched = Database::select( // Structured hierarchical key: match exact or qualified path suffix
"SELECT * FROM lessons $matched = Database::select(
WHERE curriculum_key = ? OR curriculum_key = ? "SELECT * FROM lessons
OR curriculum_key LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? WHERE curriculum_key = ? OR curriculum_key = ?
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", OR curriculum_key LIKE ? OR local_path LIKE ? OR markdown_content LIKE ?
[$curriculumKey, $curriculumKeyNoExt, '%' . $baseKey . '%', '%' . $curriculumKeyNoExt . '%', '%' . $curriculumKeyNoExt . '%'] 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)) { if (!empty($matched)) {
$candidates = array_merge($candidates, $matched); $candidates = array_merge($candidates, $matched);
} }
@@ -549,14 +558,16 @@ class VideoController
// 2. Match by title if provided // 2. Match by title if provided
if (!empty($title)) { if (!empty($title)) {
$cleanTitle = trim(preg_replace('/^(الدرس\s*\d+:\s*|معملُ\s*[^:]+:\s*|مقدمة\s*[^:]+:\s*)/u', '', $title)); $cleanTitle = trim(preg_replace('/^(الدرس\s*\d+:\s*|معملُ\s*[^:]+:\s*|مقدمة\s*[^:]+:\s*)/u', '', $title));
$matchedTitle = Database::select( if (mb_strlen($cleanTitle) >= 6) {
"SELECT * FROM lessons $matchedTitle = Database::select(
WHERE title = ? OR title LIKE ? OR title LIKE ? "SELECT * FROM lessons
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", WHERE title = ? OR title LIKE ?
[$title, "%{$title}%", "%{$cleanTitle}%"] 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); if (!empty($matchedTitle)) {
$candidates = array_merge($candidates, $matchedTitle);
}
} }
} }
@@ -570,28 +581,17 @@ class VideoController
// 4. Manifest slug lookup // 4. Manifest slug lookup
if (empty($candidates) && !empty($rawId)) { if (empty($candidates) && !empty($rawId)) {
$rawIdNoExt = preg_replace('/\.md$/i', '', $rawId); $manifestLesson = CurriculumService::findLessonById($rawId);
$matchedRaw = Database::select( if ($manifestLesson && !empty($manifestLesson['file'])) {
"SELECT * FROM lessons $fileNoExt = preg_replace('/\.md$/i', '', $manifestLesson['file']);
WHERE curriculum_key = ? OR curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? $matchedManifest = Database::select(
ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", "SELECT * FROM lessons
[$rawId, $rawIdNoExt, "%{$rawId}%", "%{$rawIdNoExt}%", "%{$rawIdNoExt}%"] 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",
if (!empty($matchedRaw)) { [$manifestLesson['file'], $fileNoExt, "%{$fileNoExt}%"]
$candidates = array_merge($candidates, $matchedRaw); );
} if (!empty($matchedManifest)) {
if (empty($candidates)) { $candidates = array_merge($candidates, $matchedManifest);
$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);
}
} }
} }
} }
@@ -604,24 +604,13 @@ class VideoController
break; break;
} }
} }
if (!$lesson) {
$lesson = $candidates[0];
}
} }
if (!$lesson) { // Strict verification: if no matching lesson with an uploaded video is found, return 404
// Fallback to latest available lesson in DB with a video if (!$lesson || empty($lesson['hls_url'])) {
$lesson = Database::selectOne(
"SELECT * FROM lessons
WHERE hls_url IS NOT NULL AND hls_url != ''
ORDER BY id DESC LIMIT 1"
);
}
if (!$lesson) {
$response->status(404)->json([ $response->status(404)->json([
'status' => 'error', 'status' => 'error',
'message' => 'الدرس غير موجود أو لم يتم نشر الفيديو الخاص به بعد' 'message' => 'لم يتم رفع ونشر فيديو شرح لهذا الدرس بعد.'
]); ]);
return; return;
} }
+17 -6
View File
@@ -224,12 +224,23 @@ class CurriculumService
$dbTitle = (string)($dbl['title'] ?? ''); $dbTitle = (string)($dbl['title'] ?? '');
$matched = false; $matched = false;
if ($currKey !== '' && ($currKey === $lessonFile || $currKeyNoExt === $lessonFileNoExt)) { if ($currKeyNoExt !== '' && $lessonFileNoExt !== '') {
$matched = true; if ($currKeyNoExt === $lessonFileNoExt) {
} elseif ($currKeyNoExt !== '' && str_ends_with($currKeyNoExt, $lessonId)) { $matched = true;
$matched = true; } elseif (str_contains($currKeyNoExt, '/') && (
} elseif ($dbTitle !== '' && ($dbTitle === $lessonTitle || str_contains($lessonTitle, $dbTitle) || str_contains($dbTitle, $lessonTitle))) { str_ends_with($lessonFileNoExt, '/' . ltrim($currKeyNoExt, '/')) ||
$matched = true; 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) { if ($matched) {