import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:video_player/video_player.dart'; import 'package:wow_english/common/extension/string_extension.dart'; import 'package:wow_english/common/utils/video_playback_diagnostics.dart'; import 'package:wow_english/common/utils/video_rendering_preference.dart'; import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart'; import 'package:wow_english/route/route.dart'; import '../../../section/subsection/base_section/event.dart'; import '../../../section/subsection/base_section/state.dart'; import 'video_opera_widget.dart'; class VideoWidget extends StatefulWidget { const VideoWidget( {super.key, this.videoUrl = '', this.typeTitle, this.courseLessonId = '', this.isTopic = false}); final String videoUrl; final String? typeTitle; final String courseLessonId; final bool isTopic; @override State createState() { return _VideoWidgetState(); } } class _VideoWidgetState extends State { VideoPlayerController? _controller; String _currentTime = '00:00'; String _totalTime = '00:00'; double _playDegree = 0.0; bool _hiddenTipView = false; Object? _loadError; Timer? _bufferingTimer; int _loadGeneration = 0; VoidCallback? _diagnosticListener; bool _bufferingWarningShown = false; TimerUtil? timerUtil; String formatDuration(Duration duration) { String hours = duration.inHours.toString().padLeft(2, '0'); String minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); String seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return "$hours:$minutes:$seconds"; } void _addListener() { _controller!.addListener(() { if (_controller!.value.isInitialized) { if (_controller!.value.isPlaying) { setState(() { double currentSecond = getCurrentPositionSeconds().toDouble(); int totalSecond = _controller!.value.duration.inMinutes.remainder(60) * 60 + _controller!.value.duration.inSeconds.remainder(60); _currentTime = formatDuration(_controller!.value.position); _playDegree = currentSecond / totalSecond; if (_playDegree > 1.0) { _playDegree = 1.0; } if (_playDegree < 0) { _playDegree = 0.0; } }); } else if (widget.isTopic && //受限于video_player库没有唯一的播放完成状态标识,发现isBuffering=false的时候暂时是安全唯一的 _controller?.value.isBuffering == false && _controller!.value.isCompleted && _controller!.value.position.inSeconds == _controller!.value.duration.inSeconds) { final lookVideoBloc = context.read(); lookVideoBloc.sectionComplete( () { popPage(data: { 'courseLessonId': widget.courseLessonId, 'currentTime': getCurrentPositionSeconds(), 'isCompleted': true, 'nextSection': widget.isTopic }); } as VoidCallback, againSectionTap: (() { if (mounted && !lookVideoBloc.isClosed) { lookVideoBloc.add(SectionAgainEvent()); } }), context: context); } } }); } //开始倒计时 void startTimer() { if (timerUtil == null) { timerUtil = TimerUtil(mInterval: 1000, mTotalTime: 1000 * 10); timerUtil!.setOnTimerTickCallback((int tick) { double currentTick = tick / 1000; if (currentTick.toInt() == 0) { //倒计时结束 setState(() { _hiddenTipView = true; }); timerUtil!.cancel(); timerUtil = null; } }); timerUtil!.startCountDown(); } } //取消倒计时 void cancelTimer() { timerUtil!.cancel(); timerUtil = null; } void actionType(OperationType type) async { if (type == OperationType.back) { if (widget.courseLessonId.isEmpty) { popPage(); } else { if (_controller == null) { popPage(); return; } popPage(data: { 'courseLessonId': widget.courseLessonId, 'currentTime': getCurrentPositionSeconds(), }); } } else if (type == OperationType.playState) { if (_controller!.value.isPlaying) { _controller!.pause(); } else { _controller!.play(); } setState(() {}); } } @override void initState() { super.initState(); unawaited(_initializeVideo()); } Future _initializeVideo() async { final generation = ++_loadGeneration; _bufferingTimer?.cancel(); _bufferingWarningShown = false; final oldController = _controller; if (oldController != null) { if (_diagnosticListener != null) { oldController.removeListener(_diagnosticListener!); } } _diagnosticListener = null; _controller = null; _loadError = null; if (oldController != null) { await VideoPlaybackDiagnostics.disposeController( oldController, context: '课程视频切换', ); } if (!mounted || generation != _loadGeneration) return; final url = widget.videoUrl.trim(); if (url.isEmpty) { _loadError = StateError('课程视频地址为空'); VideoPlaybackDiagnostics.show('课程视频地址为空'); if (mounted) setState(() {}); return; } final controller = VideoPlayerController.networkUrl( Uri.parse(url), viewType: VideoRenderingPreference.viewType, ); _controller = controller; if (mounted) setState(() {}); var stage = '视频初始化'; try { await controller .initialize() .timeout(VideoPlaybackDiagnostics.initializeTimeout); if (!mounted || generation != _loadGeneration || controller != _controller) { await controller.dispose(); return; } stage = '播放器配置'; await controller .setLooping(!widget.isTopic) .timeout(VideoPlaybackDiagnostics.commandTimeout); await controller .setVolume(1) .timeout(VideoPlaybackDiagnostics.commandTimeout); _addListener(); _diagnosticListener = _onDiagnosticChanged; controller.addListener(_diagnosticListener!); _updateBufferingWatch(controller); await controller.play().timeout(VideoPlaybackDiagnostics.commandTimeout); if (!mounted || generation != _loadGeneration) return; startTimer(); setState(() { _currentTime = formatDuration(controller.value.position); _totalTime = formatDuration(controller.value.duration); }); } catch (error) { final failedCurrentController = controller == _controller; if (failedCurrentController) { _bufferingTimer?.cancel(); _bufferingTimer = null; controller.removeListener(_diagnosticListener ?? _onDiagnosticChanged); } await VideoPlaybackDiagnostics.disposeController( controller, context: '课程视频初始化失败($stage)', ); if (!mounted || generation != _loadGeneration || !failedCurrentController) { return; } _controller = null; final message = VideoPlaybackDiagnostics.messageFor(error, stage: stage); debugPrint('课程视频加载失败(stage=$stage): $error'); VideoPlaybackDiagnostics.show(message); setState(() => _loadError = error); } } void _onDiagnosticChanged() { final controller = _controller; if (!mounted || controller == null) return; _updateBufferingWatch(controller); if (controller.value.hasError && _loadError == null) { final error = controller.value.errorDescription ?? '未知播放器错误'; debugPrint('课程视频播放失败: $error'); VideoPlaybackDiagnostics.show( VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码'), ); setState(() => _loadError = error); } } void _updateBufferingWatch(VideoPlayerController controller) { if (!controller.value.isBuffering) { _bufferingTimer?.cancel(); _bufferingTimer = null; _bufferingWarningShown = false; return; } if (_bufferingTimer != null || _bufferingWarningShown) return; _bufferingTimer = Timer(VideoPlaybackDiagnostics.bufferingWarningDelay, () { _bufferingTimer = null; if (!mounted || controller != _controller || !controller.value.isBuffering) { return; } _bufferingWarningShown = true; final error = controller.value.errorDescription; final message = error == null ? '视频持续缓冲,网络或设备解码可能异常' : VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码'); debugPrint('课程视频持续缓冲: $message ${error ?? ''}'); VideoPlaybackDiagnostics.show(message); }); } @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) async { if (state is SectionAgainState) { final controller = _controller; if (controller?.value.isInitialized == true) { await controller!.seekTo(Duration.zero); await controller.play(); } } }, child: GestureDetector( onTap: () { if (_loadError != null || _controller?.value.isInitialized != true) { return; } setState(() { _hiddenTipView = !_hiddenTipView; if (!_hiddenTipView) { startTimer(); } else { if (timerUtil!.isActive()) { cancelTimer(); } } }); }, onDoubleTap: () { if (_controller?.value.isInitialized == true) { if (_controller!.value.isPlaying) { _controller!.pause(); } else { _controller!.play(); } setState(() {}); } }, child: Center( child: _loadError != null ? Column( mainAxisSize: MainAxisSize.min, children: [ const Text('视频加载失败,请重试'), const SizedBox(height: 8), ElevatedButton( onPressed: _initializeVideo, child: const Text('重试'), ), ], ) : _controller?.value.isInitialized == true ? Stack( alignment: Alignment.center, children: [ SizedBox( height: double.infinity, width: double.infinity, child: AspectRatio( aspectRatio: _controller!.value.aspectRatio, child: VideoPlayer(_controller!), ), ), Offstage( offstage: _hiddenTipView, child: VideoOperaWidget( title: widget.typeTitle ?? 'song', degree: _playDegree, totalTime: _totalTime, currentTime: _currentTime, isPlay: _controller!.value.isPlaying, actionEvent: (OperationType type) { actionType(type); }, sliderChangeEvent: (double degree) { int totalSecond = _controller! .value.duration.inMinutes .remainder(60) * 60 + _controller!.value.duration.inSeconds .remainder(60); int positionSecond = (totalSecond * degree).toInt(); _controller! .seekTo(Duration(seconds: positionSecond)); }, ), ), Offstage( offstage: _controller!.value.isPlaying, child: IconButton( onPressed: () { _controller!.play(); }, icon: Image.asset( 'video_stop'.assetPng, width: 70.w, height: 70.h, ), ), ) ], ) : Container( color: Colors.white, child: const CircularProgressIndicator(), // Text( // '视频加载中....', // style: TextStyle(fontSize: 20.sp, color: Colors.black), // ), ), ), ), ); } @override void dispose() { _loadGeneration++; _bufferingTimer?.cancel(); if (_diagnosticListener != null) { _controller?.removeListener(_diagnosticListener!); } final controller = _controller; if (controller != null) { unawaited(VideoPlaybackDiagnostics.disposeController( controller, context: '课程视频页面退出', )); } if (timerUtil != null) { timerUtil!.cancel(); timerUtil = null; } super.dispose(); } ///获取当前进度秒数 int getCurrentPositionSeconds() { return (_controller!.value.position.inMinutes.remainder(60) * 60 + _controller!.value.position.inSeconds.remainder(60)); } }