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:
|
// 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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user