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 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? 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 { final CurriculumRepository _repo; int _lastObservedPosition = -1; String? _watchSessionId; int _watchSequence = 1; int _lastWatchEventPosition = 0; 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 loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async { AppLogger.log('Loading Socratic playback for lesson: ${lesson.title}', tag: 'VIDEO_CUBIT'); _lastObservedPosition = -1; emit(VideoPlaybackLoading()); final storageKey = _getLessonStorageKey(lesson); try { final versionId = lesson.curriculumLessonId; if (versionId == null || versionId.isEmpty) { throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.'); } final published = await _repo.getPublishedLessonVideos(versionId); if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.'); // Selection is performed in the lesson screen when several teachers exist. var playback = await _repo.getVideoVersionPlayback(selectedVideoVersionId ?? published.first.videoVersionId); if (playback.videoUrl.isEmpty) { throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.'); } _watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0; if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) { try { _watchSessionId = await _repo.startWatchSession(playback.videoVersionId!); } catch (_) {} } // Load saved resume position strictly per video int resumePos = playback.lastPositionSeconds ?? 0; Set 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.error('Playback API unavailable (${lesson.title})', error: e, tag: 'VIDEO_CUBIT'); emit(VideoPlaybackError(e.toString())); } } 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)); } } Future endWatchSession() async { final session = _watchSessionId; if (session == null) return; _watchSessionId = null; try { await _repo.recordWatchEvent(session, ++_watchSequence, 'end', _lastObservedPosition.clamp(0, 1 << 31) * 1000); } catch (_) {} } 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 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); } if (_watchSessionId != null && positionSeconds - _lastWatchEventPosition >= 30) { _watchSequence++; _lastWatchEventPosition = positionSeconds; await _repo.recordWatchEvent(_watchSessionId!, _watchSequence, 'heartbeat', positionSeconds * 1000); } } 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.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; } }