fix(playback): isolate curriculum keys and prevent non-uploaded lessons from falling back to previous video
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user