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

367 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}