import 'dart:async'; import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.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/services/auth_service.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 Map classInfo; final String studentId; final Map studyData; const StudyDetailPage( this.classInfo, this.studyDetailDetail, this.studentId, this.studyData, { 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 _isFace = false; // 上报控制相关字段 bool _endReported = false; // 确保最终结束上报只执行一次 Future? _ongoingSubmit; // 用于串行化提交请求 final int _uploadIntervalSeconds = 5; // 周期性上报间隔(秒) // 新增:防止并发加载视频 bool _isLoadingVideo = false; @override void initState() { super.initState(); WakelockPlus.enable(); _classId = widget.studyDetailDetail['CLASS_ID'] ?? ''; _classCurriculumId = widget.studyDetailDetail['CLASSCURRICULUM_ID'] ?? ''; _isFace = widget.classInfo['ISFACE'] == '1' ? true : false; _tabController = TabController(length: 2, vsync: this); WidgetsBinding.instance.addPostFrameCallback((_) { _showFaceIntro(); _loadData(); }); } @override void dispose() { _tabController.dispose(); WakelockPlus.disable(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); // 让未完成的提交完成(非阻塞) if (_ongoingSubmit != null) { _ongoingSubmit!.whenComplete(() {}); } try { _videoController?.removeListener(_onTimeUpdate); } catch (_) {} try { _videoController?.dispose(); } catch (_) {} _videoController = null; _faceTimer?.cancel(); super.dispose(); } Future _showFaceIntro() async { if (_isFace) { final ok = await CustomAlertDialog.showConfirm( context, title: '温馨提示', content: '重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。', cancelText: '取消', confirmText: '同意并继续', ); if (!ok) { Navigator.of(context).pop(); return; } } } 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, }) { // 播放次数大于 0 视为 100% 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%'; double pct = (resT / vidT) * 100.0; // 限制在合理范围 if (pct <= 0.0) return '0%'; if (pct >= 100.0) return '100%'; // 非零但非常小的值,例如 0.004%,显示为 <0.01% if (pct > 0.0 && pct < 0.01) return '<0.01%'; // 保留最多 2 位小数,然后去掉多余的零和小数点 String s = pct.toStringAsFixed(2); s = s.replaceFirst(RegExp(r'\.?0+$'), ''); // 去掉尾随的 .00 或 .0 return '$s%'; } Future _onVideoTap( Map data, bool hasNodes, int fi, int ni, ) async { if (_isLoadingVideo) { debugPrint('_onVideoTap ignored because a video is loading'); return; } if (_isFace) { try { await ApiService.fnClearUserFaceTime(); } catch (e, st) { debugPrint('fnClearUserFaceTime error: $e\n$st'); } _faceTimer?.cancel(); _faceTimer = null; } // 如果当前有视频播放且有未上报的进度,先做一次快照上报(避免丢失) if (_currentVideoData != null && _lastReported > Duration.zero) { final prevSnapshot = { 'VIDEOCOURSEWARE_ID': _currentVideoData?['VIDEOCOURSEWARE_ID'] ?? '', 'CURRICULUM_ID': _currentVideoData?['CURRICULUM_ID'] ?? '', 'CHAPTER_ID': _currentVideoData?['CHAPTER_ID'] ?? '', 'VIDEOTIME': _currentVideoData?['VIDEOTIME'] ?? 0, 'IS_NODE': _hasNodes, 'FIRST_INDEX': _currentFirstIndex, 'NODE_INDEX': _currentNodeIndex, }; await _submitPlayTime( snapshot: prevSnapshot, end: false, seconds: _lastReported.inSeconds.toString(), ); } // 暂停已有播放器(安全) try { if (_videoController != null && _videoController!.value.isPlaying) { await _videoController!.pause(); } } catch (_) {} await _navigateFaceIfNeeded(data,() async { if ((data['IS_VIDEO'] ?? 0) == 1) { // 文档 if (data['VIDEOFILES'] != null) { try { await _videoController?.pause(); } catch (_) {} final success = await pushPage( RemoteFilePage( fileUrl: ApiService.baseImgPath + data['VIDEOFILES'], countdownSeconds: 30, ), context, ); if (success) { // 对文档直接上报完整时长(用 data 做快照) final docSnapshot = { 'VIDEOCOURSEWARE_ID': data['VIDEOCOURSEWARE_ID'] ?? '', 'CURRICULUM_ID': data['CURRICULUM_ID'] ?? '', 'CHAPTER_ID': data['CHAPTER_ID'] ?? '', 'VIDEOTIME': data['VIDEOTIME'] ?? '0.0', 'IS_NODE': hasNodes, 'FIRST_INDEX': fi, 'NODE_INDEX': ni, 'IS_VIDEO': 1, }; await _submitPlayTime( snapshot: docSnapshot, end: true, seconds: data['VIDEOTIME'] ?? '0.0', ); } } else { ToastUtil.showNormal(context, '课件文件资源已失效,请联系管理员'); } } else { _currentVideoData = data; _lastReported = Duration.zero; _hasNodes = hasNodes; _currentFirstIndex = fi; _currentNodeIndex = ni; LoadingDialogHelper.show(); // 视频 await _getVideoPlayInfo(data['VIDEOCOURSEWARE_ID']); LoadingDialogHelper.hide(); if (_isFace) { _startFaceTimer(); } } }); } Future _navigateFaceIfNeeded(Map data, FutureOr Function() onPass) async { if (!_isFace) { await onPass(); return; } LoadingDialogHelper.show(); final resData = await ApiService.fnGetUserFace(); LoadingDialogHelper.hide(); final pd_data = resData['pd']; if (FormUtils.hasValue(pd_data, 'USERAVATARURL')) { // 先退出可能的全屏并锁定为竖屏,避免横屏导致人脸页面布局错乱 await _exitTopRouteAndWait(); await _lockPortrait(); final passed = await pushPage( FaceRecognitionPage ( studentId: widget.studentId, data: { 'CLASS_ID': widget.studyData['CLASS_ID'], 'STUDENT_ID': widget.studyData['STUDENT_ID'], 'CURRICULUM_ID': data['CURRICULUM_ID'], 'CHAPTER_ID': data['CHAPTER_ID'], 'VIDEOCOURSEWARE_ID': data['VIDEOCOURSEWARE_ID'] }, mode: FaceMode.study, ), context, ); await _restoreDefaultOrientations(); if (passed == true) { LoadingDialogHelper.show(); await ApiService.fnSetUserFaceTime(_faceTime); await onPass(); } else { ToastUtil.showError(context, '人脸验证未通过,无法继续'); if (_videoController != null) { try { _videoController?.removeListener(_onTimeUpdate); } catch (_) {} try { _videoController?.dispose(); } catch (_) {} _videoController = null; } _faceTimer?.cancel(); setState(() {}); } } else { final ok = await CustomAlertDialog.showConfirm( context, title: '温馨提示', content: '您当前还未进行人脸认证,请先进行认证', cancelText: '取消', ); if (ok) { await _exitTopRouteAndWait(); await _lockPortrait(); await pushPage(FaceRecognitionPage ( studentId: widget.studentId, data: { 'CLASS_ID': widget.studyData['CLASS_ID'], 'STUDENT_ID': widget.studyData['STUDENT_ID'], 'CURRICULUM_ID': data['CURRICULUM_ID'], 'CHAPTER_ID': data['CHAPTER_ID'], 'VIDEOCOURSEWARE_ID': data['VIDEOCOURSEWARE_ID'] }, mode: FaceMode.study, ), context); await _restoreDefaultOrientations(); } } } Future _getVideoPlayInfo(String vidId) async { if (_isLoadingVideo) { debugPrint('_getVideoPlayInfo blocked: already loading a video'); return; } _isLoadingVideo = true; try { 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(安全顺序:removeListener -> pause -> dispose -> null) if (_videoController != null) { try { _videoController!.removeListener(_onTimeUpdate); } catch (_) {} try { if (_videoController!.value.isPlaying) { await _videoController!.pause(); } } catch (_) {} try { await _videoController!.dispose(); } catch (_) {} _videoController = null; } // 创建新 controller _videoController = VideoPlayerController.networkUrl(Uri.parse(url)); await _videoController!.initialize(); // 新增:为新视频重置上报相关标志 _endReported = false; _lastReported = Duration.zero; _ongoingSubmit = null; if (mounted) setState(() {}); // 直接从上次播放点 seek,并立即播放 try { await _videoController!.seekTo(Duration(seconds: seen)); await _videoController!.play(); // 移除(防止重复)后再添加 try { _videoController!.removeListener(_onTimeUpdate); } catch (_) {} _videoController!.addListener(_onTimeUpdate); } catch (e, st) { debugPrint('Error during play/seek/addListener: $e\n$st'); } } catch (e, st) { debugPrint('_getVideoPlayInfo error: $e\n$st'); } finally { _isLoadingVideo = false; } } // 改进版本的 _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; // 创建快照:避免 _currentVideoData 后续被替换影响上报 final snapshot = { 'VIDEOCOURSEWARE_ID': _currentVideoData?['VIDEOCOURSEWARE_ID'] ?? '', 'CURRICULUM_ID': _currentVideoData?['CURRICULUM_ID'] ?? '', 'CHAPTER_ID': _currentVideoData?['CHAPTER_ID'] ?? '', 'VIDEOTIME': _currentVideoData?['VIDEOTIME'] ?? 0, 'IS_NODE': _hasNodes, 'FIRST_INDEX': _currentFirstIndex, 'NODE_INDEX': _currentNodeIndex, }; // 串行上报:通过 _ongoingSubmit 链式调用来保证顺序 _ongoingSubmit = (_ongoingSubmit ?? Future.value()) .then((_) { return _submitPlayTime( snapshot: snapshot, end: false, seconds: pos.inSeconds.toString(), ); }) .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(); final snapshot = { 'VIDEOCOURSEWARE_ID': _currentVideoData?['VIDEOCOURSEWARE_ID'] ?? '', 'CURRICULUM_ID': _currentVideoData?['CURRICULUM_ID'] ?? '', 'CHAPTER_ID': _currentVideoData?['CHAPTER_ID'] ?? '', 'VIDEOTIME': _currentVideoData?['VIDEOTIME'] ?? 0, 'IS_NODE': _hasNodes, 'FIRST_INDEX': _currentFirstIndex, 'NODE_INDEX': _currentNodeIndex, }; _ongoingSubmit = (_ongoingSubmit ?? Future.value()) .then((_) async { await _submitPlayTime( snapshot: snapshot, end: true, seconds: finalSeconds.toString(), ); }) .whenComplete(() { _ongoingSubmit = null; // 最终上报完成后尝试退出顶层 route(退出全屏) _exitTopRouteIfPresent(); }); // 仅当开启人脸功能时,清除人脸计时并取消定时器 if (_isFace) { try { ApiService.fnClearUserFaceTime(); } catch (e, st) { debugPrint('fnClearUserFaceTime error on end: $e\n$st'); } _faceTimer?.cancel(); _faceTimer = null; } } } } Future _submitPlayTime({ required Map snapshot, required bool end, required String seconds, }) async { // snapshot 必须包含 VIDEOCOURSEWARE_ID if (snapshot['VIDEOCOURSEWARE_ID'] == null || snapshot['VIDEOCOURSEWARE_ID'] == '') return; // 如果已经上报结束并且当前不是结束上报,则跳过非结束上报(原逻辑) if (_endReported && !end) return; // 构造要上报的参数 Map data = { 'VIDEOCOURSEWARE_ID': snapshot['VIDEOCOURSEWARE_ID'] ?? '', 'CURRICULUM_ID': snapshot['CURRICULUM_ID'] ?? '', 'CHAPTER_ID': snapshot['CHAPTER_ID'] ?? '', 'RESOURCETIME': seconds, 'IS_END': end ? '1' : '0', 'CLASS_ID': _classId, 'CLASSCURRICULUM_ID': _classCurriculumId, 'STUDENT_ID': widget.studentId, 'loading': false, }; // 如果这是“非视频课件”(通过 snapshot['IS_VIDEO']==1 标记), // 则按照“学完即 100%”的规则单次上报并把 UI 设置为 100%。 final isNonVideo = (snapshot['IS_VIDEO'] != null && snapshot['IS_VIDEO'].toString() == '1'); const int maxRetries = 2; int attempt = 0; while (true) { attempt++; try { final resData = await ApiService.fnSubmitPlayTime(data); final pd = resData['pd'] ?? {}; if (isNonVideo) { // 非视频课件:强制显示 100%,不依赖后端返回的 RESOURCETIME 比例 final str = '100%'; if (mounted) { setState(() { if (snapshot['IS_NODE'] == true) { final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex; final ni = snapshot['NODE_INDEX'] as int? ?? _currentNodeIndex; if (fi >= 0 && fi < _videoList.length) { final nodes = _videoList[fi]['nodes'] as List?; if (nodes != null && ni >= 0 && ni < nodes.length) { _videoList[fi]['nodes'][ni]['percent'] = str; } } } else { final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex; if (fi >= 0 && fi < _videoList.length) { _videoList[fi]['percent'] = str; } } }); } // 如果后端在 pd 中表明可以考试,按之前逻辑处理(和视频一致) if (end && pd['CANEXAM'] == '1') { _videoController?.pause(); final ok = await CustomAlertDialog.showConfirm( context, title: '温馨提示', content: '当前任务内所有课程均已学完,是否直接参加考试?', cancelText: '否', confirmText: '是', ); if (ok) { _startExam(resData); } } break; // 非视频已上报并处理完毕 -> 退出 } // 以下为视频上报原有逻辑:解析后端返回数据并更新进度 final resTraw = pd['RESOURCETIME']; final resT = (resTraw is num) ? resTraw.toDouble() : double.tryParse('$resTraw') ?? double.parse(seconds); final videoTimeRaw = snapshot['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); // 保留两位小数并去掉不必要末尾 double pctClamped = pctDouble.clamp(0.00, 100.00); // 四舍五入到 2 位小数 final pctRounded = (pctClamped * 100).round() / 100; final str = '${pctRounded}%'; if (mounted) { setState(() { if (snapshot['IS_NODE'] == true) { final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex; final ni = snapshot['NODE_INDEX'] as int? ?? _currentNodeIndex; if (fi >= 0 && fi < _videoList.length) { final nodes = _videoList[fi]['nodes'] as List?; if (nodes != null && ni >= 0 && ni < nodes.length) { _videoList[fi]['nodes'][ni]['percent'] = str; } } } else { final fi = snapshot['FIRST_INDEX'] as int? ?? _currentFirstIndex; if (fi >= 0 && fi < _videoList.length) { _videoList[fi]['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)); } } } void _startFaceTimer() { // 仅在人脸功能开启时启动计时器 if (!_isFace) return; _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 { // 仅在人脸功能开启时调用( if (!_isFace) return; // 先 cancel 定时器,避免跳转过程中再次触发 _faceTimer?.cancel(); _faceTimer = null; // 退出横屏/全屏并锁定竖屏,然后 push 人脸识别页面 await _exitTopRouteAndWait(); await _lockPortrait(); final passed = await pushPage( FaceRecognitionPage ( studentId: widget.studentId, data: { 'CLASS_ID': widget.studyData['CLASS_ID'], 'STUDENT_ID': widget.studyData['STUDENT_ID'], 'CURRICULUM_ID': _currentVideoData?['CURRICULUM_ID'], 'CHAPTER_ID': _currentVideoData?['CHAPTER_ID'], 'VIDEOCOURSEWARE_ID': _currentVideoData?['VIDEOCOURSEWARE_ID'] }, mode: FaceMode.study, ), context, ); await _restoreDefaultOrientations(); 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'); } // 恢复人脸验证计时器 if (_isFace) { _startFaceTimer(); } } else { ToastUtil.showError(context, '人脸验证未通过,无法继续'); if (_videoController != null) { try { _videoController?.removeListener(_onTimeUpdate); } catch (_) {} try { _videoController?.dispose(); } catch (_) {} _videoController = null; } _faceTimer?.cancel(); setState(() {}); } } /// 开始考试 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, jumpType: 3, ), 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 (_) { // 忽略异常 } } } /// 退出顶部路由并短暂等待(用于退出全屏播放器等) Future _exitTopRouteAndWait({int waitMs = 300}) async { _exitTopRouteIfPresent(); await Future.delayed(Duration(milliseconds: waitMs)); } /// 锁定为竖屏 Future _lockPortrait() async { await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); } /// 恢复允许横竖 Future _restoreDefaultOrientations() async { await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); } 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: [ // 章节头 Container( width: double.infinity, color: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), child: Row( children: [ Icon( item['IS_VIDEO'] == 0 ? Icons.video_collection_rounded : Icons.file_copy_rounded, color: Colors.grey, size: 20, ), const SizedBox(width: 5), Text( (item['NAME'] ?? '').toString(), style: const TextStyle(fontSize: 15), ), ], ), // Text( // (item['NAME'] ?? '').toString(), // style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), // ), ), // 子项列表 ...nodes.asMap().entries.map((e) { final node = e.value as Map; return _buildVideoItem(item, node, true, idx, e.key); }).toList(), const Divider(height: 1), ], ); } // 没有子节点,把 item 本身当成单条记录显示 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.symmetric(horizontal: 10, vertical: 5), child: Column( children: [ // Row( // crossAxisAlignment: CrossAxisAlignment.start, // children: [ // Icon(m['IS_VIDEO'] == 0 ? Icons.video_collection_rounded : 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), ], if (m['IS_VIDEO'] != 0) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('-'), const SizedBox(width: 50)], ), 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']), ], ), ); } }