Files
saqel/apps/student_app/lib/logic/cubits/video_playback_cubit.dart
T

368 lines
15 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';
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<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;
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<void> 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<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 (_) {}
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<void> 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<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);
}
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<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.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;
}
}