import 'dart:async'; import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:qhd_prevention/customWidget/remote_file_page.dart'; import 'package:qhd_prevention/pages/home/study/take_exam_page.dart'; import 'package:qhd_prevention/pages/home/study/study_practise_page.dart'; import 'package:qhd_prevention/pages/my_appbar.dart'; import 'package:qhd_prevention/customWidget/custom_alert_dialog.dart'; import 'package:qhd_prevention/customWidget/custom_button.dart'; import 'package:qhd_prevention/http/ApiService.dart'; import 'package:qhd_prevention/tools/tools.dart'; import 'package:video_player/video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../../customWidget/toast_util.dart'; import '../../../customWidget/video_player_widget.dart'; import 'face_ecognition_page.dart'; enum TakeExamType { video_study, strengththen, list } class StudyDetailPage extends StatefulWidget { final Map studyDetailDetail; final String studentId; const StudyDetailPage(this.studyDetailDetail, this.studentId, {super.key}); @override State createState() => _StudyDetailPageState(); } class _StudyDetailPageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; Map? _info; List _videoList = []; bool _loading = true; // player controller 提取出来 VideoPlayerController? _videoController; String _videoCoverUrl = ''; Timer? _faceTimer; late int _faceTime; late String _classId; late String _classCurriculumId; Map? _currentVideoData; int _currentFirstIndex = 0; int _currentNodeIndex = 0; bool _hasNodes = false; Duration _lastReported = Duration.zero; // 上报控制相关字段 bool _endReported = false; // 确保最终结束上报只执行一次 Future? _ongoingSubmit; // 用于串行化提交请求 final int _uploadIntervalSeconds = 1; // 周期性上报间隔(秒) @override void initState() { super.initState(); WakelockPlus.enable(); _classId = widget.studyDetailDetail['CLASS_ID'] ?? ''; _classCurriculumId = widget.studyDetailDetail['CLASSCURRICULUM_ID'] ?? ''; _tabController = TabController(length: 2, vsync: this); WidgetsBinding.instance.addPostFrameCallback((_) { _showFaceIntro(); _loadData(); }); } @override void dispose() { _tabController.dispose(); WakelockPlus.disable(); // 最佳努力:让未完成的提交完成(非阻塞) if (_ongoingSubmit != null) { _ongoingSubmit!.whenComplete(() {}); } try { _videoController?.removeListener(_onTimeUpdate); } catch (_) {} try { _videoController?.dispose(); } catch (_) {} _videoController = null; _faceTimer?.cancel(); super.dispose(); } Future _showFaceIntro() async { final ok = await CustomAlertDialog.showConfirm( context, title: '温馨提示', content: '重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。', cancelText: '取消', confirmText: '同意并继续', ); if (ok) {} } Future _loadData() async { try { final res = await ApiService.getStudyDetailList( _classId, _classCurriculumId, widget.studentId, ); final pd = res['pd'] ?? {}; _faceTime = int.tryParse(pd['FACE_TIME']?.toString() ?? '10') ?? 10; _videoList = List.from(pd['VIDEOLIST'] ?? []); // set initial face time // compute percent for (var item in _videoList) { if (item['nodes'] is List && (item['nodes'] as List).isNotEmpty) { for (var node in item['nodes']) { node['percent'] = _calcPercentStr( playCount: node['PLAYCOUNT'], resourceTime: node['RESOURCETIME'], videoTime: node['VIDEOTIME'], ); } } else { item['percent'] = _calcPercentStr( playCount: item['PLAYCOUNT'], resourceTime: item['RESOURCETIME'], videoTime: item['VIDEOTIME'], ); } } setState(() { _info = pd; _loading = false; }); } catch (_) { setState(() => _loading = false); } } String _calcPercentStr({ required dynamic playCount, required dynamic resourceTime, required dynamic videoTime, }) { final seen = int.tryParse('$playCount') ?? 0; if (seen > 0) return '100%'; final resT = double.tryParse('$resourceTime') ?? 0.0; final vidT = double.tryParse('$videoTime') ?? 0.0; if (vidT == 0) return '0%'; final pct = ((resT / vidT) * 100).clamp(0.0, 100.0).toStringAsFixed(0); return '$pct%'; } Future _onVideoTap( Map data, bool hasNodes, int fi, int ni, ) async { // 后端清除人脸计时 await ApiService.fnClearUserFaceTime(); _faceTimer?.cancel(); if (_currentVideoData != null && _lastReported > Duration.zero) { await _submitPlayTime(end: false, seconds: _lastReported.inSeconds); } _lastReported = Duration.zero; _currentVideoData = data; _hasNodes = hasNodes; _currentFirstIndex = fi; _currentNodeIndex = ni; // 暂停已有播放器 _videoController?.pause(); await _navigateFaceIfNeeded(() async { if ((data['IS_VIDEO'] ?? 0) == 1) { // 文档 if (data['VIDEOFILES'] != null) { _videoController?.pause(); await pushPage( RemoteFilePage( fileUrl: ApiService.baseImgPath + data['VIDEOFILES'], countdownSeconds: 10, ), context, ); await _submitPlayTime( end: true, seconds: int.parse(data['VIDEOTIME'] ?? '0'), ); } else { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('课件文件资源已失效,请联系管理员'))); } } else { // 视频 await _getVideoPlayInfo(data['VIDEOCOURSEWARE_ID']); _startFaceTimer(); } }); } Future _navigateFaceIfNeeded(FutureOr Function() onPass) async { if (_info?['ISFACE'] == '1') { LoadingDialogHelper.show(); final resData = await ApiService.fnGetUserFace(); LoadingDialogHelper.hide(); final pd_data = resData['pd']; if (FormUtils.hasValue(pd_data, 'USERAVATARURL')) { final passed = await pushPage( FaceRecognitionPage(studentId: widget.studentId, mode: FaceMode.auto), context, ); if (passed == true) { await ApiService.fnSetUserFaceTime(_faceTime); await onPass(); } else { ToastUtil.showError(context, '人脸验证未通过,无法继续'); } } else { final ok = await CustomAlertDialog.showConfirm( context, title: '温馨提示', content: '您当前还未进行人脸认证,请先进行认证', cancelText: '取消', ); if (ok) { await pushPage( const FaceRecognitionPage(studentId: '', mode: FaceMode.manual), context, ); } } } else { await onPass(); } } Future _getVideoPlayInfo(String vidId) async { final res = await ApiService.fnGetVideoPlayInfo(vidId); final url = res['videoList']?[0]?['playURL'] ?? ''; _videoCoverUrl = res['videoBase']?['coverURL'] ?? ''; final prog = await ApiService.fnGetVideoPlayProgress( vidId, _currentVideoData!['CURRICULUM_ID'], _classId, widget.studentId, ); final raw = prog['pd']?['RESOURCETIME']; final seen = (() { if (raw == null) return 0; // 如果本身就是数字 if (raw is num) return raw.toInt(); // 否则转成字符串再 parse final s = raw.toString(); return (double.tryParse(s) ?? 0.0).toInt(); })(); // 先销毁旧 controller try { _videoController?.removeListener(_onTimeUpdate); } catch (_) {} try { _videoController?.dispose(); } catch (_) {} _videoController = null; // 创建新 controller _videoController = VideoPlayerController.networkUrl(Uri.parse(url)); await _videoController!.initialize(); // 新增:为新视频重置上报相关标志 _endReported = false; _lastReported = Duration.zero; _ongoingSubmit = null; setState(() {}); // 直接从上次播放点 seek,并立即播放 _videoController! ..seekTo(Duration(seconds: seen)) ..play() ..addListener(_onTimeUpdate); } // 改进版本的 _onTimeUpdate,包含周期性上报 + 接近结束时的最终上报 void _onTimeUpdate() { if (_videoController == null) return; final val = _videoController!.value; if (!val.isInitialized) return; final pos = val.position; final dur = val.duration; // 周期性上报:每隔 _uploadIntervalSeconds 秒上报一次 final secondsSinceLast = (pos - _lastReported).inSeconds; if (secondsSinceLast >= _uploadIntervalSeconds && !_endReported) { _lastReported = pos; // 串行上报:通过 _ongoingSubmit 链式调用来保证顺序 _ongoingSubmit = (_ongoingSubmit ?? Future.value()) .then((_) { return _submitPlayTime(end: false, seconds: pos.inSeconds); }) .whenComplete(() { // 完成后清理引用 _ongoingSubmit = null; }); } // 接近结束的检测,容差 800ms,触发一次最终结束上报 if (dur != null && dur.inMilliseconds > 0) { final remainingMs = dur.inMilliseconds - pos.inMilliseconds; final endedOrClose = remainingMs <= 800; if (endedOrClose && !_endReported) { _endReported = true; final finalSeconds = (dur.inMilliseconds / 1000.0).ceil(); _ongoingSubmit = (_ongoingSubmit ?? Future.value()) .then((_) async { await _submitPlayTime(end: true, seconds: finalSeconds); }) .whenComplete(() { _ongoingSubmit = null; // 最终上报完成后尝试退出顶层 route(退出全屏) _exitTopRouteIfPresent(); }); ApiService.fnClearUserFaceTime(); _faceTimer?.cancel(); } } } Future _submitPlayTime({ required bool end, required int seconds, }) async { if (_currentVideoData == null) return; // 如果已经上报结束并且当前不是结束上报,则跳过非结束上报 if (_endReported && !end) return; Map data = { 'VIDEOCOURSEWARE_ID': _currentVideoData!['VIDEOCOURSEWARE_ID'] ?? '', 'CURRICULUM_ID': _currentVideoData!['CURRICULUM_ID'] ?? '', 'CHAPTER_ID': _currentVideoData!['CHAPTER_ID'] ?? '', 'RESOURCETIME': seconds, 'IS_END': end ? '1' : '0', 'CLASS_ID': _classId, 'CLASSCURRICULUM_ID': _classCurriculumId, 'STUDENT_ID': widget.studentId, 'loading': false, }; const int maxRetries = 2; int attempt = 0; while (true) { attempt++; try { final resData = await ApiService.fnSubmitPlayTime(data); final pd = resData['pd'] ?? {}; // 解析后端返回的 RESOURCETIME(可能是数字或字符串) final resTraw = pd['RESOURCETIME']; final resT = (resTraw is num) ? resTraw.toDouble() : double.tryParse('$resTraw') ?? seconds.toDouble(); // 从当前视频数据中安全解析 videoTime final videoTimeRaw = _currentVideoData!['VIDEOTIME']; final videoTime = (videoTimeRaw is String) ? double.tryParse(videoTimeRaw) ?? 1.0 : (videoTimeRaw is num ? videoTimeRaw.toDouble() : 1.0); final comp = pd['PLAYCOUNT'] != null && pd['PLAYCOUNT'] > 0; final pctDouble = comp ? 100.0 : ((resT / (videoTime > 0 ? videoTime : 1.0)) * 100.0); final pct = (pctDouble.clamp(0.00, 100.00) * 100).round() / 100; final str = '${pct}%'; if (mounted) { setState(() { if (_hasNodes) { _videoList[_currentFirstIndex]['nodes'][_currentNodeIndex]['percent'] = str; } else { _videoList[_currentFirstIndex]['percent'] = str; } }); } // 如果是结束上报且后端标注可考试,则弹窗处理 if (end && pd['CANEXAM'] == '1') { _videoController?.pause(); final ok = await CustomAlertDialog.showConfirm( context, title: '温馨提示', content: '当前任务内所有课程均已学完,是否直接参加考试?', cancelText: '否', confirmText: '是', ); if (ok) { _startExam(resData); } } break; // 成功 -> 退出重试循环 } catch (e, st) { debugPrint('submitPlayTime failed attempt $attempt: $e\n$st'); if (attempt > maxRetries) { // 超过重试次数后放弃(避免无限重试) break; } await Future.delayed(Duration(milliseconds: 500 * attempt)); } } } /// 开始考试 Future _startExam(Map resData) async { Map pd = resData['pd'] ?? {}; Map paper = resData['paper'] ?? {}; setState(() { _loading = true; }); final arguments = { 'STAGEEXAMPAPERINPUT_ID': paper['STAGEEXAMPAPERINPUT_ID'] ?? '', 'STAGEEXAMPAPER_ID': paper['STAGEEXAMPAPER_ID'] ?? '', 'CLASS_ID': _classId, 'POST_ID': pd['POST_ID'] ?? '', 'STUDENT_ID': widget.studentId, 'NUMBEROFEXAMS': pd['NUMBEROFEXAMS'] ?? '', }; print('--_startExam data---$arguments'); final data = await ApiService.getStartExam(arguments); setState(() { _loading = false; }); if (data['result'] == 'success') { pushPage( TakeExamPage( examInfo: { 'CLASS_ID': _classId, 'POST_ID': pd['POST_ID'] ?? '', 'STUDENT_ID': widget.studentId, 'STRENGTHEN_PAPER_QUESTION_ID': paper['STAGEEXAMPAPERINPUT_ID'] ?? '', ...data, }, examType: TakeExamType.video_study, ), context, ); } else { ToastUtil.showError(context, '请求错误'); } } void _exitTopRouteIfPresent() { final route = ModalRoute.of(context); if (route == null) return; // 当当前路由不是最上层时,说明有别的 route 在上面(通常是全屏播放器) if (!route.isCurrent) { // 只尝试 pop 一次(安全) try { Navigator.of(context).maybePop(); } catch (_) { // 忽略异常 } } } void _startFaceTimer() { _faceTimer = Timer.periodic(Duration(seconds: _faceTime), (_) async { final res = await ApiService.fnGetUserFaceTime(); final isPlaying = _videoController?.value.isPlaying == true; final isVideo = _currentVideoData?['IS_VIDEO'] == 0; final needAuth = FormUtils.hasValue(res, 'data'); if (isPlaying && isVideo && !needAuth) { _exitTopRouteIfPresent(); _videoController!.pause(); _videoController!.removeListener(_onTimeUpdate); await _showFaceAuthOnce(); } }); } Future _showFaceAuthOnce() async { // 先 cancel 定时器,避免跳转过程中再次触发 _faceTimer?.cancel(); _faceTimer = null; final passed = await pushPage( FaceRecognitionPage(studentId: widget.studentId, mode: FaceMode.auto), context, ); if (passed == true) { await ApiService.fnSetUserFaceTime(_faceTime); // 认证通过后 —— 恢复播放、重新添加 listener,并更新上报基准 try { // 如果 controller 还存在且已初始化 if (_videoController != null && _videoController!.value.isInitialized) { // 先确保 listener 没有重复添加 try { _videoController!.removeListener(_onTimeUpdate); } catch (_) {} // 更新上报基准,避免立即触发一次短时间内的重复上报 try { final currentPos = _videoController!.value.position; _lastReported = currentPos; debugPrint('Face auth passed — resetting _lastReported to $currentPos'); } catch (_) { _lastReported = Duration.zero; } // 恢复播放并绑定 listener(保证周期性进度上报恢复) await _videoController!.play(); _videoController!.addListener(_onTimeUpdate); } else { // 如果 controller 为空或未初始化,仅尝试更新 UI debugPrint('Face auth passed but _videoController is null or not initialized'); } setState(() {}); } catch (e, st) { debugPrint('Error resuming playback after face auth: $e\n$st'); } // 恢复人脸验证计时器 _startFaceTimer(); } else { ToastUtil.showError(context, '人脸验证未通过,无法继续'); if (_videoController != null) { try { _videoController?.removeListener(_onTimeUpdate); } catch (_) {} try { _videoController?.dispose(); } catch (_) {} _videoController = null; } _faceTimer?.cancel(); setState(() { }); } } Widget _buildVideoOrCover(double containerW, double containerH) { final c = _videoController; if (c != null && c.value.isInitialized) { return VideoPlayerWidget( allowSeek: false, controller: _videoController, coverUrl: _videoCoverUrl.isNotEmpty ? ApiService.baseImgPath + _videoCoverUrl : ApiService.baseImgPath + (_info?['COVERPATH'] ?? ''), aspectRatio: _videoController?.value.aspectRatio ?? 16 / 9, ); } else { // controller 被销毁或未初始化,显示封面 return Image.network( '${ApiService.baseImgPath}${_info?['COVERPATH'] ?? ''}', fit: BoxFit.fill, width: containerW, height: containerH, ); } } @override Widget build(BuildContext context) { if (_loading) { return Scaffold( appBar: MyAppbar(title: '学习详情'), body: const Center(child: CircularProgressIndicator()), ); } final info = _info!; return Scaffold( appBar: MyAppbar(title: '学习详情'), body: SafeArea( child: Column( children: [ _buildVideoOrCover(screenWidth(context), 250), const SizedBox(height: 5), Container( width: double.infinity, color: Colors.white, padding: const EdgeInsets.all(10.0), child: Text( info['CURRICULUMNAME'] ?? '', style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), ), const SizedBox(height: 10), Container( color: Colors.white, child: TabBar( controller: _tabController, labelStyle: const TextStyle(fontSize: 16), indicator: const UnderlineTabIndicator( borderSide: BorderSide(width: 3.0, color: Colors.blue), insets: EdgeInsets.symmetric(horizontal: 0.0), ), labelColor: Colors.blue, unselectedLabelColor: Colors.grey, tabs: const [Tab(text: '课件目录'), Tab(text: '详情')], ), ), const SizedBox(height: 10), Expanded( child: TabBarView( controller: _tabController, children: [_buildVideoList(), _buildDetailView(info)], ), ), ], ), ), ); } Widget _buildVideoList() { return ListView.builder( itemCount: _videoList.length, itemBuilder: (ctx, idx) { final item = _videoList[idx] as Map; final nodes = item['nodes'] as List?; if (nodes != null && nodes.isNotEmpty) { // 章节标题 + 直接展开的子项列表(不折叠) return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ...nodes.asMap().entries.map( (e) => _buildVideoItem( item, e.value as Map, true, idx, e.key, ), ), const Divider(height: 1), // 每章节后一个分割线 ], ); } // 没有子节点,直接显示该条目 return _buildVideoItem(item, item, false, idx, 0); }, ); } Widget _buildVideoItem( Map item, Map m, bool hasNodes, int fi, int ni, ) { return Container( color: Colors.white, padding: const EdgeInsets.all(20), child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.file_copy_rounded, color: Colors.grey, size: 20), const SizedBox(width: 8), Expanded( child: Text( item['NAME'] ?? '', style: const TextStyle(fontSize: 14), ), ), ], ), const SizedBox(height: 10), GestureDetector( onTap: () => _onVideoTap(m, hasNodes, fi, ni), child: Container( height: 80, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), decoration: BoxDecoration( color: const Color(0xFFFAFAFA), borderRadius: BorderRadius.circular(5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( m['COURSEWARENAME'] ?? '', style: const TextStyle(fontSize: 14), ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( "进度:${m['percent']}", style: const TextStyle(color: Colors.blue), ), ), if (m['IS_VIDEO'] == 0) ...[ Text(secondsCount(m['VIDEOTIME'])), const SizedBox(width: 20), const Icon(Icons.play_circle, color: Colors.blue), ], SizedBox(width: 20), CustomButton( onPressed: () => pushPage( StudyPractisePage( videoCoursewareId: m['VIDEOCOURSEWARE_ID'], ), context, ), text: "课后练习", backgroundColor: Colors.blue, height: 30, textStyle: const TextStyle( fontSize: 12, color: Colors.white, ), padding: const EdgeInsets.symmetric( vertical: 2, horizontal: 12, ), borderRadius: 15, ), ], ), ], ), ), ), ], ), ); } Widget _buildDetailView(Map info) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( info['CURRICULUMINTRODUCE'] ?? '', style: const TextStyle(fontSize: 16), ), const SizedBox(height: 16), if (info['COVERPATH'] != null) Image.network(ApiService.baseImgPath + info['COVERPATH']), ], ), ); } }