From dbca8811e049481fad7ad5466fd32488043a4d24 Mon Sep 17 00:00:00 2001 From: wuqifeng <540416539@qq.com> Date: Sun, 25 Oct 2026 19:05:53 +0800 Subject: [PATCH] fix: 修复视频兼容性并增加渲染模式切换 --- lib/common/utils/video_playback_diagnostics.dart | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/common/utils/video_rendering_preference.dart | 19 +++++++++++++++++++ lib/pages/repeataftercontent/widgets/repeat_video_widget.dart | 367 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------ lib/pages/user/setting/setting_page.dart | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ lib/pages/video/lookvideo/widgets/video_widget.dart | 295 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------ lib/pages/video/zone/video_zone_page.dart | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------- lib/pages/video/zone/video_zone_player_page.dart | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ pubspec.yaml | 2 +- 8 files changed, 802 insertions(+), 264 deletions(-) create mode 100644 lib/common/utils/video_playback_diagnostics.dart create mode 100644 lib/common/utils/video_rendering_preference.dart diff --git a/lib/common/utils/video_playback_diagnostics.dart b/lib/common/utils/video_playback_diagnostics.dart new file mode 100644 index 0000000..0fdbe5b --- /dev/null +++ b/lib/common/utils/video_playback_diagnostics.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wow_english/utils/toast_util.dart'; + +/// Shared timeout and user-visible diagnostics for video_player based screens. +class VideoPlaybackDiagnostics { + static const initializeTimeout = Duration(seconds: 10); + static const commandTimeout = Duration(seconds: 5); + static const bufferingWarningDelay = Duration(seconds: 10); + + /// Starts native player disposal while keeping a stuck teardown bounded. + static Future disposeController( + VideoPlayerController controller, { + required String context, + }) async { + try { + await controller.dispose().timeout(commandTimeout); + } catch (error) { + debugPrint('$context播放器释放失败或超时: $error'); + } + } + + static String messageFor(Object error, {required String stage}) { + final detail = error.toString().toLowerCase(); + if (error is TimeoutException) { + if (stage == '视频初始化') return '视频初始化超时,请检查网络或设备解码能力'; + if (stage == '播放器配置') return '播放器配置超时,设备可能不支持当前播放参数'; + if (stage == '恢复播放进度') return '恢复播放进度超时'; + return '$stage超时'; + } + if (detail.contains('decoder') || + detail.contains('codec') || + detail.contains('mediacodec') || + detail.contains('acodec')) { + return '视频解码器启动失败,可能是设备兼容性问题'; + } + if (detail.contains('http') || + detail.contains('socket') || + detail.contains('network') || + detail.contains('connection') || + detail.contains('source error') || + detail.contains('sourceerror')) { + return '视频网络请求失败,请检查网络或视频地址'; + } + return '$stage失败,请重试'; + } + + static void show(String message, {int? videoId}) { + final idSuffix = videoId == null ? '' : '(视频ID: $videoId)'; + showToast('$message$idSuffix', duration: const Duration(seconds: 4)); + } +} diff --git a/lib/common/utils/video_rendering_preference.dart b/lib/common/utils/video_rendering_preference.dart new file mode 100644 index 0000000..6f8f39c --- /dev/null +++ b/lib/common/utils/video_rendering_preference.dart @@ -0,0 +1,19 @@ +import 'package:flutter/foundation.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wow_english/utils/sp_util.dart'; + +/// User-selectable Android video output path for device compatibility testing. +class VideoRenderingPreference { + static const _usePlatformViewKey = 'video_player_use_platform_view'; + + static bool get usesPlatformView => + SpUtil.getInstance().get(_usePlatformViewKey) ?? false; + + static VideoViewType get viewType => + defaultTargetPlatform == TargetPlatform.android && usesPlatformView + ? VideoViewType.platformView + : VideoViewType.textureView; + + static Future setUsesPlatformView(bool value) => + SpUtil.getInstance().setData(_usePlatformViewKey, value); +} diff --git a/lib/pages/repeataftercontent/widgets/repeat_video_widget.dart b/lib/pages/repeataftercontent/widgets/repeat_video_widget.dart index 3b9c9fa..a49fcb8 100644 --- a/lib/pages/repeataftercontent/widgets/repeat_video_widget.dart +++ b/lib/pages/repeataftercontent/widgets/repeat_video_widget.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + 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/utils/video_playback_diagnostics.dart'; +import 'package:wow_english/common/utils/video_rendering_preference.dart'; import 'package:wow_english/models/read_content_entity.dart'; import '../bloc/repeat_after_content_bloc.dart'; @@ -23,32 +27,75 @@ class _RepeatVideoWidgetState extends State { String _totalTime = '00:00'; double _playDegree = 0.5; int _currentPlayIndex = 0; + Object? _loadError; + Timer? _bufferingTimer; + VoidCallback? _controllerListener; + bool _bufferingWarningShown = false; + int _loadGeneration = 0; String formatDuration(Duration duration) { - String minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - String seconds = duration.inSeconds.remainder(60).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 "$minutes:$seconds"; } void _addListener() { - _controller!.addListener(() { - if(_controller!.value.isInitialized) { - if (_controller!.value.isPlaying) { - setState(() { - double currentSecond = (_controller!.value.position.inMinutes.remainder(60)*60+_controller!.value.position.inSeconds.remainder(60)).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 < 0) { - _playDegree = 0.0; - } + _controllerListener = _onControllerChanged; + _controller?.addListener(_controllerListener!); + } + + void _onControllerChanged() { + 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); + return; + } + if (!controller.value.isInitialized || !controller.value.isPlaying) return; + setState(() { + final currentSecond = + (controller.value.position.inMinutes.remainder(60) * 60 + + controller.value.position.inSeconds.remainder(60)) + .toDouble(); + final totalSecond = + controller.value.duration.inMinutes.remainder(60) * 60 + + controller.value.duration.inSeconds.remainder(60); + _currentTime = formatDuration(controller.value.position); + _playDegree = totalSecond == 0 ? 0 : currentSecond / totalSecond; + _playDegree = _playDegree.clamp(0.0, 1.0); + }); + } - if(_playDegree > 1) { - _playDegree = 1.0; - } - }); - } + 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); }); } @@ -56,127 +103,207 @@ class _RepeatVideoWidgetState extends State { void initState() { super.initState(); var videoUrl = ''; - if(widget.videoUrls.isNotEmpty) { - videoUrl = widget.videoUrls[_currentPlayIndex]?.videoUrl??''; + if (widget.videoUrls.isNotEmpty) { + videoUrl = widget.videoUrls[_currentPlayIndex]?.videoUrl ?? ''; } - _initVideo(videoUrl); + unawaited(_initVideo(videoUrl)); } @override Widget build(BuildContext context) { return Center( - child: _controller!.value.isInitialized ? BlocListener( - listener: (context, state){ - if (state is VideoPlayChangeState) { - if (context.read().videoPlaying) { - _controller!.play(); - } else { - _controller!.pause(); - } - } - - if (state is ChangeVideoPlayIndexState) { - if(state.isNext) { - if (_currentPlayIndex != (widget.videoUrls.length-1)) { - _currentPlayIndex++; - _destroyVideo(); - _initVideo(widget.videoUrls[_currentPlayIndex]!.videoUrl!); - } - } else { - if (_currentPlayIndex > 0) { - _currentPlayIndex--; - _destroyVideo(); - _initVideo(widget.videoUrls[_currentPlayIndex]!.videoUrl!); - } - } - } - }, - child: SizedBox( - child: Column( - children: [ - Expanded( - child: Stack( - children: [ - Container( - width: double.infinity, - height: double.infinity, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.black, - border: Border.all( - width: 1.0, - color: const Color(0xFF140C10), + child: _loadError != null + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('视频加载失败,请重试'), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => unawaited(_initVideo( + widget.videoUrls.isEmpty + ? '' + : widget.videoUrls[_currentPlayIndex]?.videoUrl ?? '', + )), + child: const Text('重试'), + ), + ], + ) + : _controller?.value.isInitialized == true + ? BlocListener( + listener: (context, state) { + if (state is VideoPlayChangeState) { + unawaited(_setPlayback( + context.read().videoPlaying, + )); + } + if (state is ChangeVideoPlayIndexState) { + final nextIndex = state.isNext + ? _currentPlayIndex + 1 + : _currentPlayIndex - 1; + if (nextIndex >= 0 && + nextIndex < widget.videoUrls.length) { + _currentPlayIndex = nextIndex; + unawaited(_initVideo( + widget.videoUrls[nextIndex]?.videoUrl ?? '', + )); + } + } + }, + child: SizedBox( + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + Container( + width: double.infinity, + height: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black, + border: Border.all( + width: 1.0, + color: const Color(0xFF140C10), + ), + borderRadius: BorderRadius.circular(5.r), + ), + child: AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + color: const Color(0xFF78B72D), + backgroundColor: const Color(0xFF7E756C), + value: _playDegree, + ), + ), + ], ), - borderRadius: BorderRadius.circular(5.r) - ), - child: AspectRatio( - aspectRatio: _controller!.value.aspectRatio, - child: VideoPlayer(_controller!), - ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(_currentTime), Text(_totalTime)], + ), + ], ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: LinearProgressIndicator( - color: const Color(0xFF78B72D), - backgroundColor: const Color(0xFF7E756C), - value: _playDegree, - )) - ], + ), + ) + : Container( + color: Colors.white, + child: const CircularProgressIndicator(), ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_currentTime), - Text(_totalTime), - ], - ) - ], - ), - ) - ) - : - Container( - color: Colors.white, - child: const CircularProgressIndicator(), - // Text( - // '视频加载中....', - // style: TextStyle( - // fontSize: 20.sp, - // color: Colors.black - // ), - // ), - ), ); } - void _initVideo(String videoUrl) { - if(videoUrl.isNotEmpty) { - Uri uri = Uri.parse(videoUrl); - _controller = VideoPlayerController.networkUrl(uri) - ..initialize().then((_){ - setState(() { - _currentTime = formatDuration(_controller!.value.position); - _totalTime = formatDuration(_controller!.value.duration); - _controller!.setLooping(false); - _controller!.setVolume(100); - _controller!.play(); - }); - _addListener(); - }); + Future _initVideo(String videoUrl) async { + final generation = ++_loadGeneration; + _bufferingTimer?.cancel(); + _bufferingWarningShown = false; + await _destroyController(); + if (!mounted || generation != _loadGeneration) return; + _loadError = null; + final url = 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(false) + .timeout(VideoPlaybackDiagnostics.commandTimeout); + await controller + .setVolume(1) + .timeout(VideoPlaybackDiagnostics.commandTimeout); + _addListener(); + _updateBufferingWatch(controller); + await controller.play().timeout(VideoPlaybackDiagnostics.commandTimeout); + if (!mounted || generation != _loadGeneration) return; + setState(() { + _currentTime = formatDuration(controller.value.position); + _totalTime = formatDuration(controller.value.duration); + }); + } catch (error) { + final failedCurrentController = controller == _controller; + if (failedCurrentController) { + _bufferingTimer?.cancel(); + _bufferingTimer = null; + } + 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 _destroyVideo() { - _controller?.dispose(); - _controller?.removeListener(() {}); + Future _setPlayback(bool shouldPlay) async { + final controller = _controller; + if (controller?.value.isInitialized != true) return; + try { + final operation = shouldPlay ? controller!.play() : controller!.pause(); + await operation.timeout(VideoPlaybackDiagnostics.commandTimeout); + } catch (error) { + debugPrint('课程视频播放状态切换失败: $error'); + VideoPlaybackDiagnostics.show( + VideoPlaybackDiagnostics.messageFor(error, stage: '播放器控制'), + ); + } + } + + Future _destroyController() async { + final controller = _controller; + final listener = _controllerListener; + _controllerListener = null; + _controller = null; + if (controller != null) { + if (listener != null) { + controller.removeListener(listener); + } + await VideoPlaybackDiagnostics.disposeController( + controller, + context: '课程儿歌/视频切换', + ); + } } @override void dispose() { - _destroyVideo(); + _loadGeneration++; + _bufferingTimer?.cancel(); + unawaited(_destroyController()); super.dispose(); } } \ No newline at end of file diff --git a/lib/pages/user/setting/setting_page.dart b/lib/pages/user/setting/setting_page.dart index 2660f18..d35fab8 100644 --- a/lib/pages/user/setting/setting_page.dart +++ b/lib/pages/user/setting/setting_page.dart @@ -1,7 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:wow_english/common/utils/video_rendering_preference.dart'; import 'package:wow_english/common/widgets/we_app_bar.dart'; import 'package:wow_english/utils/toast_util.dart'; @@ -19,9 +21,11 @@ class SettingPage extends StatefulWidget { class SettingPageState extends State { String? _version; String? _buildNum; + bool _usePlatformView = false; @override void initState() { super.initState(); + _usePlatformView = VideoRenderingPreference.usesPlatformView; _retrieveVersionInfo(); } @@ -62,6 +66,10 @@ class SettingPageState extends State { _buildItemWidget('帮助与反馈', onPress: () { pushNamed(AppRouteName.reBack); }), + if (defaultTargetPlatform == TargetPlatform.android) ...[ + 12.verticalSpace, + _buildVideoRenderingSetting(), + ], 12.verticalSpace, _buildItemWidget('Version: $_version Build:$_buildNum', onPress: () {}), @@ -73,6 +81,49 @@ class SettingPageState extends State { ); } + Widget _buildVideoRenderingSetting() { + return Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFF140C10), width: 1.5.w), + borderRadius: BorderRadius.circular(15.r), + color: Colors.white, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '视频兼容模式', + style: TextStyle( + color: const Color(0xFF333333), fontSize: 19.sp), + ), + 4.verticalSpace, + Text( + _usePlatformView + ? 'PlatformView(兼容测试)· 新打开的视频生效' + : 'Texture(默认)· 新打开的视频生效', + style: TextStyle( + color: const Color(0xFF777777), fontSize: 13.sp), + ), + ], + ), + ), + Switch.adaptive( + value: _usePlatformView, + onChanged: (value) async { + setState(() => _usePlatformView = value); + await VideoRenderingPreference.setUsesPlatformView(value); + showToast(value ? '已切换为 PlatformView' : '已切换为 Texture'); + }, + ), + ], + ), + ); + } + Widget _buildItemWidget(String text, {VoidCallback? onPress}) { return OutlinedButton( onPressed: () => onPress?.call(), diff --git a/lib/pages/video/lookvideo/widgets/video_widget.dart b/lib/pages/video/lookvideo/widgets/video_widget.dart index 4e29028..dd558c8 100644 --- a/lib/pages/video/lookvideo/widgets/video_widget.dart +++ b/lib/pages/video/lookvideo/widgets/video_widget.dart @@ -1,9 +1,13 @@ +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'; @@ -36,6 +40,11 @@ class _VideoWidgetState extends State { 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) { @@ -142,19 +151,132 @@ class _VideoWidgetState extends State { @override void initState() { super.initState(); - Uri uri = Uri.parse(widget.videoUrl); - _controller = VideoPlayerController.networkUrl(uri) - ..initialize().then((_) { - startTimer(); - setState(() { - _currentTime = formatDuration(_controller!.value.position); - _totalTime = formatDuration(_controller!.value.duration); - _controller!.setLooping(!widget.isTopic); - _controller!.setVolume(100); - _controller!.play(); - }); - _addListener(); + 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 @@ -162,12 +284,18 @@ class _VideoWidgetState extends State { return BlocListener( listener: (context, state) async { if (state is SectionAgainState) { - await _controller?.seekTo(Duration.zero); - _controller?.play(); + 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) { @@ -180,7 +308,7 @@ class _VideoWidgetState extends State { }); }, onDoubleTap: () { - if (_controller!.value.isInitialized) { + if (_controller?.value.isInitialized == true) { if (_controller!.value.isPlaying) { _controller!.pause(); } else { @@ -190,65 +318,78 @@ class _VideoWidgetState extends State { } }, child: Center( - child: _controller!.value.isInitialized - ? Stack( - alignment: Alignment.center, + child: _loadError != null + ? Column( + mainAxisSize: MainAxisSize.min, 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)); - }, - ), + const Text('视频加载失败,请重试'), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _initializeVideo, + child: const Text('重试'), ), - 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), - // ), - ), + : _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), + // ), + ), ), ), ); @@ -256,8 +397,18 @@ class _VideoWidgetState extends State { @override void dispose() { - _controller?.dispose(); - _controller?.removeListener(() {}); + _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; diff --git a/lib/pages/video/zone/video_zone_page.dart b/lib/pages/video/zone/video_zone_page.dart index 68a07f2..7d6f88a 100644 --- a/lib/pages/video/zone/video_zone_page.dart +++ b/lib/pages/video/zone/video_zone_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -5,6 +7,7 @@ import 'package:wow_english/common/dialogs/show_dialog.dart'; import 'package:wow_english/common/request/dao/video_dao.dart'; import 'package:wow_english/common/widgets/we_app_bar.dart'; import 'package:wow_english/route/route.dart'; +import 'package:wow_english/utils/toast_util.dart'; import 'video_zone_item.dart'; @@ -21,77 +24,95 @@ class _VideoZonePageState extends State { @override void initState() { super.initState(); - _itemsFuture = VideoDao.list(); + _itemsFuture = _loadItems(); + } + + Future> _loadItems() async { + try { + return await VideoDao.list().timeout(const Duration(seconds: 10)); + } on TimeoutException catch (error) { + debugPrint('获取视频列表超时: $error'); + showToast('视频列表加载超时,请检查网络后重试'); + rethrow; + } catch (error) { + debugPrint('获取视频列表失败: $error'); + showToast('视频列表加载失败,请稍后重试'); + rethrow; + } } void _reload() { setState(() { - _itemsFuture = VideoDao.list(); + _itemsFuture = _loadItems(); }); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.white, - appBar: const WEAppBar( - titleText: '视频专区', - centerTitle: false, - ), - body: FutureBuilder>( - future: _itemsFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return _VideoLoadError(onRetry: _reload); - } - final items = snapshot.data ?? const []; - if (items.isEmpty) { - return const Center( - child: Text( - '暂无视频', - style: TextStyle(color: Color(0xFF999999), fontSize: 18), - ), - ); - } - return RefreshIndicator( - onRefresh: () async { - final future = VideoDao.list(); - setState(() => _itemsFuture = future); - await future; - }, - child: LayoutBuilder( - builder: (context, constraints) { - final titleStyle = _VideoCard.createTitleStyle(context); - return GridView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.fromLTRB( - 14.w, - 10.h, - 14.w, - 16.h, - ), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 14.h, - crossAxisSpacing: 12.w, - childAspectRatio: 1.55, - ), - itemCount: items.length, - itemBuilder: (context, index) => _VideoCard( - item: items[index], - onTap: () => _openVideo(items, index), - titleStyle: titleStyle, - ), - ); - }, + backgroundColor: Colors.white, + appBar: const WEAppBar( + titleText: '视频专区', + centerTitle: false, + ), + body: FutureBuilder>( + future: _itemsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _VideoLoadError(onRetry: _reload); + } + final items = snapshot.data ?? const []; + if (items.isEmpty) { + return const Center( + child: Text( + '暂无视频', + style: TextStyle(color: Color(0xFF999999), fontSize: 18), ), ); - }, - ), - ); + } + return RefreshIndicator( + onRefresh: () async { + final future = _loadItems(); + setState(() => _itemsFuture = future); + try { + await future; + } catch (_) { + // The FutureBuilder shows the error state; _loadItems already toasts it. + } + }, + child: LayoutBuilder( + builder: (context, constraints) { + final titleStyle = _VideoCard.createTitleStyle(context); + return GridView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB( + 14.w, + 10.h, + 14.w, + 16.h, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 14.h, + crossAxisSpacing: 12.w, + childAspectRatio: 1.55, + ), + itemCount: items.length, + itemBuilder: (context, index) => _VideoCard( + item: items[index], + onTap: () => _openVideo(items, index), + titleStyle: titleStyle, + ), + ); + }, + ), + ); + }, + ), + ); } void _openVideo(List items, int index) { diff --git a/lib/pages/video/zone/video_zone_player_page.dart b/lib/pages/video/zone/video_zone_player_page.dart index c43d01d..164aff1 100644 --- a/lib/pages/video/zone/video_zone_player_page.dart +++ b/lib/pages/video/zone/video_zone_player_page.dart @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:wow_english/common/request/dao/video_dao.dart'; +import 'package:wow_english/common/utils/video_playback_diagnostics.dart'; import 'package:wow_english/common/utils/video_progress_cache.dart'; +import 'package:wow_english/common/utils/video_rendering_preference.dart'; +import 'package:wow_english/utils/toast_util.dart'; import 'video_zone_item.dart'; @@ -26,13 +29,20 @@ class VideoZonePlayerPage extends StatefulWidget { class _VideoZonePlayerPageState extends State with WidgetsBindingObserver { + static const _initializeTimeout = Duration(seconds: 10); + static const _playerCommandTimeout = Duration(seconds: 5); + static const _progressReadTimeout = Duration(seconds: 2); + static const _bufferingWarningDelay = Duration(seconds: 10); + VideoPlayerController? _controller; ChewieController? _chewieController; Timer? _cacheTimer; + Timer? _bufferingTimer; late int _currentIndex; double _playbackSpeed = 1; bool _switchingVideo = false; bool _handlingCompletion = false; + bool _bufferingWarningShown = false; bool? _lastIsPlaying; int? _completedReportedVideoId; Object? _loadError; @@ -56,6 +66,8 @@ class _VideoZonePlayerPageState extends State Future _loadCurrentVideo() async { final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS; _cacheTimer?.cancel(); + _bufferingTimer?.cancel(); + _bufferingWarningShown = false; final oldController = _controller; final oldChewieController = _chewieController; if (oldController?.value.isInitialized == true) { @@ -65,7 +77,7 @@ class _VideoZonePlayerPageState extends State _lastIsPlaying = null; final controller = VideoPlayerController.networkUrl( Uri.parse(_currentItem.videoUrl), - viewType: VideoViewType.textureView, + viewType: VideoRenderingPreference.viewType, ); _controller = controller; _chewieController = null; @@ -73,17 +85,22 @@ class _VideoZonePlayerPageState extends State if (mounted) { setState(() {}); } - oldChewieController?.dispose(); - await oldController?.dispose(); - + var stage = '释放旧播放器'; try { - await controller.initialize().timeout(const Duration(seconds: 20)); + oldChewieController?.dispose(); + await oldController?.dispose().timeout(_playerCommandTimeout); + stage = '初始化视频流'; + await controller.initialize().timeout(_initializeTimeout); if (!mounted || controller != _controller) { await controller.dispose(); return; } - await controller.setLooping(false); - await controller.setPlaybackSpeed(_playbackSpeed); + stage = '配置播放器'; + await controller.setLooping(false).timeout(_playerCommandTimeout); + await controller + .setPlaybackSpeed(_playbackSpeed) + .timeout(_playerCommandTimeout); + stage = '读取本地播放进度'; final savedProgress = await _resumeProgressFor(_currentItem.id); if (!mounted || controller != _controller) { await controller.dispose(); @@ -91,10 +108,25 @@ class _VideoZonePlayerPageState extends State } if (savedProgress > 0 && savedProgress < controller.value.duration.inSeconds) { - await controller.seekTo(Duration(seconds: savedProgress)); + stage = '恢复播放进度'; + try { + await controller + .seekTo(Duration(seconds: savedProgress)) + .timeout(_playerCommandTimeout); + } on TimeoutException catch (error) { + debugPrint( + '恢复本地视频进度超时(videoId=${_currentItem.id}): $error', + ); + _showVideoToast('恢复进度超时,已尝试从头播放'); + } catch (error) { + debugPrint('恢复本地视频进度失败(videoId=${_currentItem.id}): $error'); + _showVideoToast('恢复进度失败,已尝试从头播放'); + } } + stage = '创建播放界面'; _lastIsPlaying = controller.value.isPlaying; controller.addListener(_onVideoChanged); + _updateBufferingWatch(controller); _chewieController = ChewieController( videoPlayerController: controller, autoPlay: true, @@ -141,19 +173,39 @@ class _VideoZonePlayerPageState extends State ); setState(() {}); } catch (error) { - if (mounted && controller == _controller) { - debugPrint('视频初始化失败(videoId=${_currentItem.id}): $error'); - setState(() => _loadError = error); + final failedCurrentController = controller == _controller; + if (failedCurrentController) { + controller.removeListener(_onVideoChanged); + _bufferingTimer?.cancel(); + _bufferingTimer = null; + _cacheTimer?.cancel(); + _cacheTimer = null; + _chewieController?.dispose(); + _chewieController = null; } + await VideoPlaybackDiagnostics.disposeController( + controller, + context: '视频专区初始化失败($stage)', + ); + if (!mounted || !failedCurrentController) return; + _controller = null; + final message = _diagnosticMessage(stage, error); + debugPrint('$message (videoId=${_currentItem.id}): $error'); + _showVideoToast('$message(视频ID: ${_currentItem.id})'); + setState(() => _loadError = error); } } void _onVideoChanged() { final controller = _controller; if (!mounted || controller == null) return; + _updateBufferingWatch(controller); if (controller.value.hasError && _loadError == null) { final error = controller.value.errorDescription ?? '未知播放器错误'; debugPrint('视频播放失败(videoId=${_currentItem.id}): $error'); + _showVideoToast( + '${_diagnosticMessage('播放/解码', error)}(视频ID: ${_currentItem.id})', + ); setState(() => _loadError = error); return; } @@ -171,6 +223,30 @@ class _VideoZonePlayerPageState extends State } } + void _updateBufferingWatch(VideoPlayerController controller) { + if (!controller.value.isBuffering) { + _bufferingTimer?.cancel(); + _bufferingTimer = null; + _bufferingWarningShown = false; + return; + } + if (_bufferingTimer != null || _bufferingWarningShown) return; + _bufferingTimer = Timer(_bufferingWarningDelay, () { + _bufferingTimer = null; + if (!mounted || + controller != _controller || + !controller.value.isBuffering) { + return; + } + _bufferingWarningShown = true; + final error = controller.value.errorDescription; + final reason = + error == null ? '网络缓冲或设备解码可能异常' : _diagnosticMessage('播放/解码', error); + debugPrint('视频持续缓冲(videoId=${_currentItem.id}): $reason ${error ?? ''}'); + _showVideoToast('视频持续缓冲超过10秒:$reason(视频ID: ${_currentItem.id})'); + }); + } + Future _playNext({bool currentCompleted = false}) async { if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; _switchingVideo = true; @@ -196,6 +272,7 @@ class _VideoZonePlayerPageState extends State SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), ); _cacheTimer?.cancel(); + _bufferingTimer?.cancel(); _controller?.removeListener(_onVideoChanged); unawaited(_reportCurrentProgress()); _chewieController?.dispose(); @@ -246,14 +323,52 @@ class _VideoZonePlayerPageState extends State Future _resumeProgressFor(int videoId) async { try { - final cached = await VideoProgressCache.get(videoId); + final cached = + await VideoProgressCache.get(videoId).timeout(_progressReadTimeout); if (cached != null) return cached.progressSeconds; + } on TimeoutException catch (error) { + debugPrint('读取本地视频进度超时(videoId=$videoId): $error'); + _showVideoToast('本地进度读取超时,已从头播放'); } catch (error) { debugPrint('读取本地视频播放进度失败: $error'); + _showVideoToast('本地进度读取失败,已从头播放'); } return 0; } + String _diagnosticMessage(String stage, Object error) { + final detail = error.toString().toLowerCase(); + if (error is TimeoutException) { + if (stage == '初始化视频流') return '视频初始化超时,请检查网络或设备解码能力'; + if (stage == '释放旧播放器') return '播放器释放超时,请退出后重试'; + if (stage == '配置播放器') return '播放器配置超时,设备可能不支持当前播放参数'; + if (stage == '恢复播放进度') return '恢复播放进度超时'; + return '$stage超时'; + } + if (detail.contains('decoder') || + detail.contains('codec') || + detail.contains('mediacodec') || + detail.contains('acodec')) { + return '视频解码器启动失败,可能是设备兼容性问题'; + } + if (detail.contains('http') || + detail.contains('socket') || + detail.contains('network') || + detail.contains('connection') || + detail.contains('source error') || + detail.contains('sourceerror')) { + return '视频网络请求失败,请检查网络或视频地址'; + } + if (stage == '初始化视频流') return '视频流初始化失败'; + if (stage == '配置播放器') return '播放器配置失败'; + if (stage == '播放/解码') return '视频播放/解码失败,请检查网络或设备兼容性'; + return '$stage失败'; + } + + void _showVideoToast(String message) { + showToast(message, duration: const Duration(seconds: 4)); + } + Future _cacheCurrentProgress() async { final controller = _controller; if (controller == null || !controller.value.isInitialized) return; diff --git a/pubspec.yaml b/pubspec.yaml index f004a60..0a24d6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.16+16 +version: 1.0.17+17 environment: sdk: '>=3.6.0 <4.0.0' -- libgit2 0.22.2