367 lines
16 KiB
Dart
367 lines
16 KiB
Dart
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import '../../core/utils/app_logger.dart';
|
||
import '../../data/models/socratic_checkpoint_model.dart';
|
||
import '../../data/models/subject_model.dart';
|
||
import '../../data/repositories/curriculum_repository.dart';
|
||
|
||
abstract class VideoPlaybackState {}
|
||
|
||
class VideoPlaybackInitial extends VideoPlaybackState {}
|
||
class VideoPlaybackLoading extends VideoPlaybackState {}
|
||
|
||
class VideoPlaybackReady extends VideoPlaybackState {
|
||
final LessonPlaybackData playbackData;
|
||
final CurriculumLessonItemModel? lessonItem;
|
||
final SubjectModel? subject;
|
||
final int currentPositionSeconds;
|
||
final bool isPlaying;
|
||
final SocraticCheckpointModel? activeCheckpoint;
|
||
final bool isCheckpointPassed;
|
||
final String? remediationNotice;
|
||
final double readinessBonusAdded;
|
||
final Set<int> passedCheckpointIds;
|
||
|
||
VideoPlaybackReady({
|
||
required this.playbackData,
|
||
this.lessonItem,
|
||
this.subject,
|
||
this.currentPositionSeconds = 0,
|
||
this.isPlaying = true,
|
||
this.activeCheckpoint,
|
||
this.isCheckpointPassed = false,
|
||
this.remediationNotice,
|
||
this.readinessBonusAdded = 0.0,
|
||
this.passedCheckpointIds = const {},
|
||
});
|
||
|
||
VideoPlaybackReady copyWith({
|
||
LessonPlaybackData? playbackData,
|
||
CurriculumLessonItemModel? lessonItem,
|
||
SubjectModel? subject,
|
||
int? currentPositionSeconds,
|
||
bool? isPlaying,
|
||
SocraticCheckpointModel? activeCheckpoint,
|
||
bool? isCheckpointPassed,
|
||
String? remediationNotice,
|
||
double? readinessBonusAdded,
|
||
Set<int>? passedCheckpointIds,
|
||
bool clearActiveCheckpoint = false,
|
||
}) {
|
||
return VideoPlaybackReady(
|
||
playbackData: playbackData ?? this.playbackData,
|
||
lessonItem: lessonItem ?? this.lessonItem,
|
||
subject: subject ?? this.subject,
|
||
currentPositionSeconds: currentPositionSeconds ?? this.currentPositionSeconds,
|
||
isPlaying: isPlaying ?? this.isPlaying,
|
||
activeCheckpoint: clearActiveCheckpoint ? null : (activeCheckpoint ?? this.activeCheckpoint),
|
||
isCheckpointPassed: isCheckpointPassed ?? this.isCheckpointPassed,
|
||
remediationNotice: remediationNotice,
|
||
readinessBonusAdded: readinessBonusAdded ?? this.readinessBonusAdded,
|
||
passedCheckpointIds: passedCheckpointIds ?? this.passedCheckpointIds,
|
||
);
|
||
}
|
||
}
|
||
|
||
class VideoPlaybackError extends VideoPlaybackState {
|
||
final String message;
|
||
VideoPlaybackError(this.message);
|
||
}
|
||
|
||
class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||
final CurriculumRepository _repo;
|
||
int _lastObservedPosition = -1;
|
||
|
||
VideoPlaybackCubit({CurriculumRepository? repo})
|
||
: _repo = repo ?? CurriculumRepository(),
|
||
super(VideoPlaybackInitial());
|
||
|
||
String _getLessonStorageKey(CurriculumLessonItemModel? lesson, {String? fallbackId}) {
|
||
if (lesson?.markdownFilePath != null && lesson!.markdownFilePath!.isNotEmpty) {
|
||
return lesson.markdownFilePath!.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
|
||
}
|
||
if (lesson?.id != null && lesson!.id.isNotEmpty) {
|
||
return lesson.id.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
|
||
}
|
||
return fallbackId ?? 'lesson_default';
|
||
}
|
||
|
||
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject}) async {
|
||
AppLogger.log('Loading Socratic playback for lesson: ${lesson.title}', tag: 'VIDEO_CUBIT');
|
||
_lastObservedPosition = -1;
|
||
emit(VideoPlaybackLoading());
|
||
final storageKey = _getLessonStorageKey(lesson);
|
||
|
||
try {
|
||
final playback = await _repo.getLessonPlayback(lesson.markdownFilePath ?? lesson.id, subjectId: subject?.id);
|
||
|
||
// Load saved resume position strictly per video
|
||
int resumePos = playback.lastPositionSeconds ?? 0;
|
||
Set<int> passedIds = {};
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final localPos = prefs.getInt('saved_video_pos_$storageKey') ?? 0;
|
||
if (localPos > resumePos) resumePos = localPos;
|
||
final savedPassed = prefs.getStringList('passed_checkpoints_$storageKey') ?? [];
|
||
passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet();
|
||
} catch (_) {}
|
||
|
||
emit(VideoPlaybackReady(
|
||
playbackData: playback,
|
||
lessonItem: lesson,
|
||
subject: subject,
|
||
currentPositionSeconds: resumePos,
|
||
passedCheckpointIds: passedIds,
|
||
isPlaying: true,
|
||
));
|
||
} catch (e) {
|
||
AppLogger.log('Playback API unavailable (${lesson.title}): $e — Switching to offline Socratic fallback', tag: 'VIDEO_CUBIT');
|
||
final fallback = _buildResilientLessonPlayback(lesson, subject: subject);
|
||
|
||
// Load saved resume position strictly per video
|
||
int resumePos = fallback.lastPositionSeconds ?? 0;
|
||
Set<int> passedIds = {};
|
||
try {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final localPos = prefs.getInt('saved_video_pos_$storageKey') ?? 0;
|
||
if (localPos > resumePos) resumePos = localPos;
|
||
final savedPassed = prefs.getStringList('passed_checkpoints_$storageKey') ?? [];
|
||
passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet();
|
||
} catch (_) {}
|
||
|
||
emit(VideoPlaybackReady(
|
||
playbackData: fallback,
|
||
lessonItem: lesson,
|
||
subject: subject,
|
||
currentPositionSeconds: resumePos,
|
||
passedCheckpointIds: passedIds,
|
||
isPlaying: true,
|
||
));
|
||
}
|
||
}
|
||
|
||
LessonPlaybackData _buildResilientLessonPlayback(CurriculumLessonItemModel lesson, {SubjectModel? subject}) {
|
||
final title = lesson.title.toLowerCase();
|
||
final isEng = (subject?.id ?? '').contains('english') || title.contains('english') || title.contains('unit 01');
|
||
final isPhys = (subject?.id ?? '').contains('physic') || title.contains('فيزياء') || title.contains('متجه');
|
||
|
||
List<SocraticCheckpointModel> points = [];
|
||
|
||
if (isEng) {
|
||
points = [
|
||
const SocraticCheckpointModel(
|
||
id: 101,
|
||
questionText: 'According to the reading passage, in how many seconds do humans make subconscious judgments?',
|
||
timestampSeconds: 20,
|
||
hint: 'Remember the rule of first impressions in psychological studies.',
|
||
pedagogicalExplanation: 'Behavioral research confirms that people form initial impressions within the first 7 seconds.',
|
||
options: [
|
||
SocraticOptionModel(id: 1, text: 'Within 7 seconds', isCorrect: true),
|
||
SocraticOptionModel(id: 2, text: 'Within 5 minutes', isCorrect: false),
|
||
SocraticOptionModel(id: 3, text: 'After prolonged conversation', isCorrect: false),
|
||
],
|
||
),
|
||
const SocraticCheckpointModel(
|
||
id: 102,
|
||
questionText: 'Which article should precede the singular noun "university"?',
|
||
timestampSeconds: 50,
|
||
hint: 'Consider the initial phonetic sound rather than the written letter.',
|
||
pedagogicalExplanation: 'Although "university" starts with the vowel letter "u", it begins with the consonant sound /juː/, so we use "a".',
|
||
options: [
|
||
SocraticOptionModel(id: 4, text: 'a (e.g. a university)', isCorrect: true),
|
||
SocraticOptionModel(id: 5, text: 'an (e.g. an university)', isCorrect: false),
|
||
],
|
||
),
|
||
];
|
||
} else if (isPhys) {
|
||
points = [
|
||
const SocraticCheckpointModel(
|
||
id: 201,
|
||
questionText: 'ما هي النتيجة الصحيحة للضرب القياسي لمتجهين متعامدين (θ = 90°)؟',
|
||
timestampSeconds: 20,
|
||
hint: 'تذكر أن الضرب النقطي يعتمد على جيب التمام cos(θ).',
|
||
pedagogicalExplanation: 'بما أن cos(90°) = 0، فإن الضرب القياسي لمتجهين متعامدين ينعدم تماماً ويساوي صفراً.',
|
||
options: [
|
||
SocraticOptionModel(id: 1, text: 'ينعدم الناتج (يساوي صفراً)', isCorrect: true),
|
||
SocraticOptionModel(id: 2, text: 'يساوي حاصل ضرب مقداريهما', isCorrect: false),
|
||
SocraticOptionModel(id: 3, text: 'يساوي متجهاً رأسياً جديداً', isCorrect: false),
|
||
],
|
||
),
|
||
];
|
||
} else {
|
||
points = [
|
||
const SocraticCheckpointModel(
|
||
id: 301,
|
||
questionText: 'قبل تحليل المعادلة x³ + 4x² = 5x، ما هي الخطوة الجبرية الإلزامية الأولى؟',
|
||
timestampSeconds: 20,
|
||
hint: 'احذر من قسمة طرفي المعادلة على المتغير x فتفقد أحد الجذور.',
|
||
pedagogicalExplanation: 'يجب نقل الحد 5x إلى الطرف الأيسر ليصبح الطرف الأيمن صفراً، ثم إخراج العامل المشترك x.',
|
||
options: [
|
||
SocraticOptionModel(id: 1, text: 'نقل 5x للطرف الأيسر وجعل الطرف الأيمن صفراً', isCorrect: true),
|
||
SocraticOptionModel(id: 2, text: 'القسمة المباشرة على x في الطرفين', isCorrect: false),
|
||
SocraticOptionModel(id: 3, text: 'أخذ الجذر التكعيبي لكافة الحدود', isCorrect: false),
|
||
],
|
||
),
|
||
];
|
||
}
|
||
|
||
return LessonPlaybackData(
|
||
lessonId: int.tryParse(lesson.id.replaceAll(RegExp(r'[^0-9]'), '')) ?? 101,
|
||
title: lesson.title,
|
||
durationSeconds: lesson.durationSeconds > 0 ? lesson.durationSeconds : 1200,
|
||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||
storageType: 'hls_stream',
|
||
checkpoints: points,
|
||
lastPositionSeconds: 0,
|
||
availableVersions: const [
|
||
LessonVersionModel(
|
||
lessonId: 101,
|
||
isAi: true,
|
||
teacherName: 'منصة صَقِل التعليمية المعتمدة',
|
||
schoolName: 'المركز الرقمي المعتمد',
|
||
label: 'الشرح الرقمي الرسمي المعتمد',
|
||
isRecommended: true,
|
||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
void updatePosition(int seconds) {
|
||
final currentState = state;
|
||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
|
||
// Check if natural forward playback reaches a Socratic checkpoint
|
||
for (var cp in currentState.playbackData.checkpoints) {
|
||
final hitCheckpoint = !currentState.passedCheckpointIds.contains(cp.id) &&
|
||
_lastObservedPosition < cp.timestampSeconds &&
|
||
seconds >= cp.timestampSeconds &&
|
||
(seconds - _lastObservedPosition).abs() <= 3; // Natural sequential playback
|
||
|
||
if (hitCheckpoint) {
|
||
AppLogger.log('🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})', tag: 'SOCRATIC_ENGINE');
|
||
_lastObservedPosition = cp.timestampSeconds;
|
||
emit(currentState.copyWith(
|
||
currentPositionSeconds: cp.timestampSeconds,
|
||
isPlaying: false, // Freeze video playback
|
||
activeCheckpoint: cp,
|
||
));
|
||
return;
|
||
}
|
||
}
|
||
|
||
_lastObservedPosition = seconds;
|
||
emit(currentState.copyWith(currentPositionSeconds: seconds));
|
||
}
|
||
}
|
||
|
||
void togglePlayPause() {
|
||
final currentState = state;
|
||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
|
||
emit(currentState.copyWith(isPlaying: !currentState.isPlaying));
|
||
}
|
||
}
|
||
|
||
void seekTo(int seconds) {
|
||
final currentState = state;
|
||
if (currentState is VideoPlaybackReady) {
|
||
// Find the earliest unpassed checkpoint before or at this target
|
||
SocraticCheckpointModel? blockingCheckpoint;
|
||
for (var cp in currentState.playbackData.checkpoints) {
|
||
if (!currentState.passedCheckpointIds.contains(cp.id) && seconds > cp.timestampSeconds) {
|
||
if (blockingCheckpoint == null || cp.timestampSeconds < blockingCheckpoint.timestampSeconds) {
|
||
blockingCheckpoint = cp;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If user tries to skip past an unpassed checkpoint, snap precisely to that checkpoint
|
||
final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds;
|
||
_lastObservedPosition = actualSeek;
|
||
|
||
emit(currentState.copyWith(
|
||
currentPositionSeconds: actualSeek,
|
||
activeCheckpoint: blockingCheckpoint,
|
||
isPlaying: blockingCheckpoint == null ? currentState.isPlaying : false,
|
||
));
|
||
}
|
||
}
|
||
|
||
Future<void> saveProgress({required int positionSeconds, required int watchedSeconds}) async {
|
||
final currentState = state;
|
||
if (currentState is! VideoPlaybackReady) return;
|
||
try {
|
||
final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}');
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setInt('saved_video_pos_$storageKey', positionSeconds);
|
||
if (currentState.passedCheckpointIds.isNotEmpty) {
|
||
await prefs.setStringList(
|
||
'passed_checkpoints_$storageKey',
|
||
currentState.passedCheckpointIds.map((id) => id.toString()).toList(),
|
||
);
|
||
}
|
||
if (currentState.playbackData.lessonId > 0) {
|
||
await _repo.saveProgress(
|
||
lessonId: currentState.playbackData.lessonId,
|
||
positionSeconds: positionSeconds,
|
||
watchedSeconds: watchedSeconds,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||
}
|
||
}
|
||
|
||
/// Submit Socratic Checkpoint Answer
|
||
bool submitCheckpointAnswer(SocraticOptionModel selectedOption) {
|
||
final currentState = state;
|
||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint != null) {
|
||
final cp = currentState.activeCheckpoint!;
|
||
|
||
if (selectedOption.isCorrect) {
|
||
// Correct Answer -> Reward readiness score (+0.5%) & resume video
|
||
AppLogger.log('✅ Correct answer! Rewarding +0.5% readiness.', tag: 'SOCRATIC_ENGINE');
|
||
final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)..add(cp.id);
|
||
_lastObservedPosition = cp.timestampSeconds;
|
||
|
||
// Persist passed checkpoint for this specific video
|
||
final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}');
|
||
SharedPreferences.getInstance().then((prefs) {
|
||
prefs.setStringList('passed_checkpoints_$storageKey', updatedPassed.map((id) => id.toString()).toList());
|
||
}).catchError((_) {});
|
||
|
||
emit(currentState.copyWith(
|
||
clearActiveCheckpoint: true,
|
||
isPlaying: true,
|
||
readinessBonusAdded: currentState.readinessBonusAdded + 0.5,
|
||
passedCheckpointIds: updatedPassed,
|
||
remediationNotice: 'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀',
|
||
));
|
||
if (cp.id > 0) {
|
||
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
|
||
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||
});
|
||
}
|
||
return true;
|
||
} else {
|
||
// Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint
|
||
final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds);
|
||
_lastObservedPosition = rewindTo;
|
||
AppLogger.log('❌ Incorrect answer. Socratic remediation: Rewinding to ${rewindTo}s.', tag: 'SOCRATIC_ENGINE');
|
||
emit(currentState.copyWith(
|
||
clearActiveCheckpoint: true,
|
||
currentPositionSeconds: rewindTo,
|
||
isPlaying: true,
|
||
remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
|
||
));
|
||
if (cp.id > 0 && cp.questionId > 0) {
|
||
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
|
||
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
}
|