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'; import '../../data/repositories/error_notebook_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.event('LoadLessonPlaybackRequested', details: { 'lessonTitle': lesson.title, 'lessonId': lesson.id, 'curriculumLessonId': lesson.curriculumLessonId, 'selectedVersionId': selectedVideoVersionId, 'hasVideo': lesson.hasVideo, }, 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); AppLogger.event('PublishedVideosLoaded', details: { 'count': published.length, 'teachers': published.map((p) => '${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})').toList(), }, tag: 'VIDEO_CUBIT'); if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.'); final targetVersionId = selectedVideoVersionId ?? published.first.videoVersionId; var playback = await _repo.getVideoVersionPlayback(targetVersionId); AppLogger.event('PlaybackDataLoaded', details: { 'versionId': playback.videoVersionId, 'videoUrl': playback.videoUrl, 'durationSeconds': playback.durationSeconds, 'checkpointsCount': playback.checkpoints.length, }, tag: 'VIDEO_CUBIT'); 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!); AppLogger.event('WatchSessionStarted', details: {'sessionId': _watchSessionId}, tag: 'VIDEO_CUBIT'); } 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 (_) {} AppLogger.event('VideoPlaybackStateEmitted', details: { 'resumePosition': resumePos, 'passedCheckpointsCount': passedIds.length, }, tag: 'VIDEO_CUBIT'); 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) { final nextState = !currentState.isPlaying; AppLogger.event('VideoPlayPauseToggled', details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT'); emit(currentState.copyWith(isPlaying: nextState)); } } void pause() { final currentState = state; if (currentState is VideoPlaybackReady && currentState.isPlaying) { AppLogger.event('VideoPaused', tag: 'VIDEO_CUBIT'); emit(currentState.copyWith(isPlaying: false)); } } void play() { final currentState = state; if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) { AppLogger.event('VideoResumed', tag: 'VIDEO_CUBIT'); emit(currentState.copyWith(isPlaying: true)); } } 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; AppLogger.event('VideoSeekExecuted', details: { 'targetSeconds': seconds, 'actualSeek': actualSeek, 'blockedByCheckpoint': blockingCheckpoint != null, 'checkpointQuestion': blockingCheckpoint?.questionText, }, tag: 'VIDEO_CUBIT'); 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.event('SocraticAnswerCorrect', details: { 'checkpointId': cp.id, 'question': cp.questionText, 'selected': selectedOption.text, 'bonus': 0.5, }, 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.event('SocraticAnswerIncorrect', details: { 'checkpointId': cp.id, 'question': cp.questionText, 'selected': selectedOption.text, 'rewindSeconds': cp.rewindSecondsOnFail, 'rewindTo': rewindTo, }, tag: 'SOCRATIC_ENGINE'); emit(currentState.copyWith( clearActiveCheckpoint: true, currentPositionSeconds: rewindTo, isPlaying: true, remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄', )); // Auto-record gap to Smart Error Notebook final correctOpt = cp.options.firstWhere( (o) => o.isCorrect, orElse: () => const SocraticOptionModel(id: 0, text: '', isCorrect: false), ); ErrorNotebookRepository().logError( subjectId: currentState.subject?.id ?? 'physics_10', subjectName: currentState.subject?.title ?? 'المادة الدراسية', topicName: currentState.lessonItem?.title ?? 'وقفة فحص تفاعلية', sourceType: 'socratic_checkpoint', questionText: cp.questionText, studentWrongAnswer: selectedOption.text, correctAnswer: correctOpt.text, hint: cp.hint, errorCategory: 'conceptual', lessonId: currentState.playbackData.lessonId > 0 ? currentState.playbackData.lessonId : null, options: cp.options.map((o) => o.text).toList(), ); 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; } }