Update Saqel Platform: 2026-09-13 16:31:17

This commit is contained in:
Hamza-Ayed
2026-09-13 16:31:18 +03:00
parent 81fa8c5dc8
commit 72c6575765
25 changed files with 6997 additions and 3852 deletions
@@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/app_logger.dart';
import '../../../core/utils/saqel_toast.dart';
import '../../../core/config/app_config.dart';
import '../../../core/services/storage_service.dart';
@@ -65,34 +66,77 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
bool _showChalkboardMode = false;
final FlutterTts _tts = FlutterTts();
bool _isSpeakingChalkboard = false;
int _chalkboardStep = 0;
bool _isDisposed = false;
bool _isMuted = false;
void _revealVideoControls() {
if (!mounted) return;
if (!mounted || _isDisposed) return;
setState(() => _showVideoControls = true);
_controlsHideTimer?.cancel();
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _showVideoControls = false);
_controlsHideTimer = Timer(const Duration(seconds: 4), () {
if (mounted && !_isDisposed) setState(() => _showVideoControls = false);
});
}
void _stopAllAudioAndVideo() {
AppLogger.log('🛑 [AUDIO_VIDEO] Stopping all audio, video, and speech synthesis...', tag: 'VIDEO_LIFECYCLE');
try {
_tts.stop();
} catch (e) {
AppLogger.error('Error stopping TTS', error: e, tag: 'VIDEO_LIFECYCLE');
}
if (_videoController != null) {
try {
_videoController!.pause();
_videoController!.setVolume(0.0);
_videoController!.dispose();
} catch (e) {
AppLogger.error('Error disposing video controller', error: e, tag: 'VIDEO_LIFECYCLE');
}
_videoController = null;
_isVideoInitialized = false;
}
}
Future<void> _initPlayer(String videoUrl, int resumePos, bool shouldPlay) async {
if (_isDisposed || !mounted) return;
AppLogger.log('🎬 [VIDEO_INIT_REQUEST] Received raw video URL for lesson: "${widget.lesson.title}"\n'
' • Raw URL: "$videoUrl"\n'
' • Resume Position: ${resumePos}s\n'
' • Should AutoPlay: $shouldPlay\n'
' • Curriculum Lesson ID: ${widget.lesson.curriculumLessonId}',
tag: 'VIDEO_PLAYER');
if (videoUrl.isEmpty) {
setState(() => _videoInitError = 'لا يوجد رابط فيديو حقيقي لهذا الدرس.');
AppLogger.error('Video URL is empty for lesson: ${widget.lesson.title}', tag: 'VIDEO_PLAYER');
if (mounted && !_isDisposed) {
setState(() => _videoInitError = 'لم يتم ربط فيديو معتمد لهذا الدرس بعد.');
}
return;
}
final token = await StorageService().getToken();
String finalVideoUrl = videoUrl;
String finalVideoUrl = videoUrl.trim();
// IMPORTANT: Check whether the URL points to our own Saqel API or to an external CDN (BunnyCDN, Cloudflare R2).
// NEVER send the Saqel user JWT Bearer token to external CDNs! CDNs reject unauthorized Bearer headers
// with HTTP 403 Forbidden ("Resource is protected by access token").
// 1. Resolve relative API endpoints to absolute base URL
if (finalVideoUrl.startsWith('/')) {
final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), '');
finalVideoUrl = '$base$finalVideoUrl';
AppLogger.log('🔗 [URL_RESOLVE] Resolved relative path to absolute: $finalVideoUrl', tag: 'VIDEO_PLAYER');
}
// 2. Attach authentication token for our backend streaming endpoints
final token = await StorageService().getToken();
final isOurBackend = finalVideoUrl.contains('/api/videos/') ||
finalVideoUrl.startsWith(AppConfig.baseUrl) ||
finalVideoUrl.contains('localhost') ||
finalVideoUrl.contains('127.0.0.1');
final headers = <String, String>{};
final headers = <String, String>{
'User-Agent': 'SaqelEdTech/2.0 (macOS; Grade10-Production)',
};
if (isOurBackend && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
if (!finalVideoUrl.contains('token=')) {
@@ -101,92 +145,102 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
}
}
_videoController?.dispose();
_videoController = VideoPlayerController.networkUrl(
AppLogger.log('🚀 [VIDEO_CONNECT] Connecting to real video stream: $finalVideoUrl', tag: 'VIDEO_PLAYER');
// Cleanly stop any existing controller before assigning a new one
_stopAllAudioAndVideo();
final controller = VideoPlayerController.networkUrl(
Uri.parse(finalVideoUrl),
httpHeaders: headers,
)
..initialize().then((_) {
if (mounted) {
setState(() {
_isVideoInitialized = true;
_videoInitError = null;
});
_revealVideoControls();
if (resumePos > 3) {
_videoController!.seekTo(Duration(seconds: resumePos));
if (context.mounted) {
SaqelToast.showInfo(
context,
'تم استئناف المشاهدة من الدقيقة ${_formatTime(resumePos)} ⏱️',
title: 'استئناف المشاهدة',
);
}
}
if (shouldPlay) {
_videoController!.play();
}
}
}).catchError((err) {
final errStr = err.toString();
// Resilient Fallback: If 403 Forbidden or Access Token error occurred on an external URL with token query params,
// retry once without the token parameters (e.g. public pull zones or direct CDNs).
if ((errStr.contains('403') || errStr.contains('token') || errStr.contains('permission')) &&
finalVideoUrl.contains('?')) {
final strippedUrl = finalVideoUrl.split('?').first;
if (strippedUrl != finalVideoUrl && strippedUrl.isNotEmpty) {
_videoController?.dispose();
_videoController = VideoPlayerController.networkUrl(
Uri.parse(strippedUrl),
httpHeaders: const {},
)..initialize().then((_) {
if (mounted) {
setState(() {
_isVideoInitialized = true;
_videoInitError = null;
});
_revealVideoControls();
if (shouldPlay) _videoController!.play();
}
}).catchError((retryErr) {
if (mounted) {
setState(() {
_isVideoInitialized = false;
_videoInitError = 'تعذر تشغيل بث الفيديو من المصدر السحابي، يمكنك استخدام السبورة التفاعلية للشرح.';
});
}
});
return;
}
}
);
_videoController = controller;
if (mounted) {
setState(() {
_isVideoInitialized = false;
_videoInitError = 'تعذر تحميل بث الفيديو المباشر؛ انقر لإعادة المحاولة ($err)';
});
}
controller.initialize().then((_) {
if (_isDisposed || !mounted) {
AppLogger.log('🛑 [LIFECYCLE_ABORT] Screen disposed before video completed loading. Silencing controller.', tag: 'VIDEO_LIFECYCLE');
controller.pause();
controller.setVolume(0.0);
controller.dispose();
return;
}
AppLogger.log('✅ [VIDEO_READY] Video successfully initialized!\n'
' • Duration: ${controller.value.duration.inSeconds}s (${_formatTime(controller.value.duration.inSeconds)})\n'
' • Resolution: ${controller.value.size.width.toInt()}x${controller.value.size.height.toInt()}\n'
' • Aspect Ratio: ${controller.value.aspectRatio.toStringAsFixed(2)}',
tag: 'VIDEO_PLAYER');
setState(() {
_isVideoInitialized = true;
_videoInitError = null;
if (_isMuted) controller.setVolume(0.0);
});
_revealVideoControls();
if (resumePos > 3 && resumePos < controller.value.duration.inSeconds) {
controller.seekTo(Duration(seconds: resumePos));
if (context.mounted) {
SaqelToast.showInfo(
context,
'تم استئناف المشاهدة من الدقيقة ${_formatTime(resumePos)} ⏱️',
title: 'استئناف المشاهدة',
);
}
}
if (shouldPlay && !_isDisposed) {
controller.play();
AppLogger.log('▶️ [VIDEO_PLAY] Playback started naturally.', tag: 'VIDEO_PLAYER');
}
}).catchError((err, stack) {
final errStr = err.toString();
AppLogger.error(
'🚨 [VIDEO_PLAYER_ERROR] Real video playback failed!\n'
' • Target URL: $finalVideoUrl\n'
' • Error: $errStr',
error: err,
stackTrace: stack,
tag: 'VIDEO_PLAYER',
);
if (mounted && !_isDisposed) {
setState(() {
_isVideoInitialized = false;
final is403 = errStr.contains('403') || errStr.contains('permission') || errStr.contains('-12660');
final is404 = errStr.contains('404') || errStr.contains('not found');
if (is403) {
_videoInitError = 'بث الفيديو السحابي قيد المزامنة على مزود الاستضافة (HTTP 403).\nيمكنك متابعة دراسة الحصة فوراً عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.';
} else if (is404) {
_videoInitError = 'ملف الفيديو غير متوفر حالياً على الخادم (HTTP 404).\nيمكنك متابعة الدرس عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.';
} else {
_videoInitError = 'تعذر تشغيل الفيديو: $errStr';
}
});
}
});
}
Future<void> _speakConcept(String text) async {
if (text.isEmpty) return;
if (text.isEmpty || _isDisposed || _isMuted) return;
setState(() => _isSpeakingChalkboard = true);
try {
await _tts.setLanguage('ar');
await _tts.setSpeechRate(0.48);
_tts.setCompletionHandler(() {
if (mounted) setState(() => _isSpeakingChalkboard = false);
if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
});
await _tts.speak(text);
} catch (_) {
if (mounted) setState(() => _isSpeakingChalkboard = false);
if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
}
}
@override
void initState() {
super.initState();
AppLogger.log('📱 [SCREEN_ENTER] Opened SocraticVideoPlayerScreen for lesson: ${widget.lesson.title}', tag: 'VIDEO_LIFECYCLE');
context.read<VideoPlaybackCubit>().loadLesson(widget.lesson, subject: widget.subject, selectedVideoVersionId: widget.selectedVideoVersionId);
// Dynamic Floating Forensic Anti-Piracy Watermark Animation
@@ -197,9 +251,17 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
// Keep the UI clock responsive, but sync progress to the API every 15s.
_playbackTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_isDisposed || !mounted) {
timer.cancel();
return;
}
final cubit = context.read<VideoPlaybackCubit>();
final state = cubit.state;
if (state is VideoPlaybackReady && _videoController?.value.isInitialized == true) {
if (state is VideoPlaybackReady &&
_videoController != null &&
_isVideoInitialized &&
!_videoController!.value.hasError &&
_videoController!.value.isInitialized) {
final position = _videoController!.value.position.inSeconds;
if (state.activeCheckpoint == null) cubit.updatePosition(position);
final duration = _videoController!.value.duration.inSeconds;
@@ -214,14 +276,23 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
});
}
@override
void deactivate() {
AppLogger.log('⏸️ [SCREEN_DEACTIVATE] Deactivating video player screen. Stopping audio & video...', tag: 'VIDEO_LIFECYCLE');
_isDisposed = true;
_stopAllAudioAndVideo();
super.deactivate();
}
@override
void dispose() {
AppLogger.log('🧹 [SCREEN_DISPOSE] Disposing video player screen resources.', tag: 'VIDEO_LIFECYCLE');
_isDisposed = true;
context.read<VideoPlaybackCubit>().endWatchSession();
_tts.stop();
_stopAllAudioAndVideo();
_playbackTicker?.cancel();
_controlsHideTimer?.cancel();
_watermarkController.dispose();
_videoController?.dispose();
super.dispose();
}
@@ -252,6 +323,21 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
),
centerTitle: false,
actions: [
IconButton(
tooltip: _isMuted ? 'إلغاء كتم الصوت' : 'كتم الصوت',
icon: Icon(
_isMuted ? CupertinoIcons.volume_off : CupertinoIcons.volume_up,
color: _isMuted ? AppColors.guardianAmber : Colors.white,
),
onPressed: () {
setState(() {
_isMuted = !_isMuted;
_videoController?.setVolume(_isMuted ? 0.0 : 1.0);
if (_isMuted) _tts.stop();
});
AppLogger.log('🔊 [AUDIO_TOGGLE] User toggled audio: ${_isMuted ? "MUTED" : "UNMUTED"}', tag: 'VIDEO_PLAYER');
},
),
Container(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -380,13 +466,16 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
// Video Background Scene
Stack(
children: [
if (_videoController != null && _isVideoInitialized)
if (_videoController != null &&
_isVideoInitialized &&
_videoController!.value.isInitialized &&
!_videoController!.value.hasError)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _videoController!.value.size.width,
height: _videoController!.value.size.height,
child: Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio > 0
? _videoController!.value.aspectRatio
: 16 / 9,
child: VideoPlayer(_videoController!),
),
),
@@ -450,17 +539,45 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
const SizedBox(height: 14),
Wrap(
alignment: WrapAlignment.center,
spacing: 10,
spacing: 12,
runSpacing: 10,
children: [
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16),
label: const Text('إعادة محاولة البث 🎥'),
icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: Colors.black),
label: const Text('بدء الشرح على السبورة الذكية 🎙️', style: TextStyle(color: Colors.black, fontWeight: FontWeight.w800, fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () {
setState(() {
_showChalkboardMode = true;
_videoInitError = null;
});
},
),
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.sparkles, size: 16, color: Colors.white),
label: const Text('المختبر التفاعلي للمبحث 🔬', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.appleBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () {
Navigator.of(context).pop();
},
),
OutlinedButton.icon(
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16, color: Colors.white70),
label: const Text('إعادة المحاولة 🔄', style: TextStyle(color: Colors.white70)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () {
setState(() {
@@ -474,21 +591,6 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
);
},
),
OutlinedButton.icon(
icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: AppColors.appleBlue),
label: const Text('السبورة والشرح الصوتي 🎙️', style: TextStyle(color: Colors.white)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColors.appleBlue.withAlpha(150)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
setState(() {
_showChalkboardMode = true;
_videoInitError = null;
});
},
),
],
),
],
@@ -1104,6 +1206,7 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
],
),
),
if (isMath) _buildMathStepBoard(),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
@@ -1202,6 +1305,143 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
),
);
}
Widget _buildMathStepBoard() {
final steps = [
{
'title': 'الخطوة 1: صياغة النظام الرياضي',
'eq1': 'المعادلة الخطية: y = 2x - 2',
'eq2': 'المعادلة التربيعية: y = x² - 4x + 3',
'note': 'نظام مكون من معادلة خطية وأخرى تربيعية بمتغيرين (x, y).'
},
{
'title': 'الخطوة 2: التعويض ومساواة الطرفين',
'eq1': 'x² - 4x + 3 = 2x - 2',
'eq2': 'نعوض قيمة y من المعادلة الخطية في المعادلة التربيعية',
'note': 'ينتج لدينا معادلة تربيعية بمتغير واحد فقط هو x.'
},
{
'title': 'الخطوة 3: التصفير والتجميع في الصورة القياسية',
'eq1': 'x² - 4x - 2x + 3 + 2 = 0',
'eq2': 'x² - 6x + 5 = 0',
'note': 'المعاملات: a = 1, b = -6, c = 5 (المميز Δ = 36 - 20 = 16 > 0 له حلان حقيقيان).'
},
{
'title': 'الخطوة 4: التحليل إلى العوامل وإيجاد قيم x',
'eq1': '(x - 5)(x - 1) = 0',
'eq2': 'إما x = 5 أو x = 1',
'note': 'أوجدنا الإحداثي السيني لنقطتي تقاطع المستقيم والقطع المكافئ.'
},
{
'title': 'الخطوة 5: التعويض لإيجاد y ومجموعة الحل',
'eq1': 'عند x = 5: y = 2(5) - 2 = 8 ➔ (5, 8)',
'eq2': 'عند x = 1: y = 2(1) - 2 = 0 ➔ (1, 0)',
'note': 'مجموعة حل النظام: {(5, 8), (1, 0)} - تم التحقق بالتعويض.'
},
];
final cur = steps[_chalkboardStep.clamp(0, steps.length - 1)];
return Container(
constraints: const BoxConstraints(maxWidth: 620),
margin: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0A1926),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.saqelCyan.withAlpha(120), width: 1.5),
boxShadow: [
BoxShadow(color: AppColors.saqelCyan.withAlpha(20), blurRadius: 16, offset: const Offset(0, 4)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(40),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'خطوة ${_chalkboardStep + 1} من ${steps.length}',
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700),
),
),
Text(
cur['title']!,
style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withAlpha(150),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Column(
children: [
Text(
cur['eq1']!,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFFFFD60A), fontSize: 15, fontWeight: FontWeight.w800, letterSpacing: 0.5),
),
if (cur['eq2']!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
cur['eq2']!,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF30D158), fontSize: 14.5, fontWeight: FontWeight.w700),
),
],
],
),
),
const SizedBox(height: 8),
Text(
'💡 ${cur['note']!}',
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12, height: 1.4),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_right, size: 14),
label: const Text('السابقة'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
onPressed: _chalkboardStep > 0
? () => setState(() => _chalkboardStep--)
: null,
),
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_left, size: 14),
label: const Text('التالية'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
onPressed: _chalkboardStep < steps.length - 1
? () => setState(() => _chalkboardStep++)
: null,
),
],
),
],
),
);
}
}
class _GridPatternPainter extends CustomPainter {