Commit dbca8811e049481fad7ad5466fd32488043a4d24

Authored by 吴启风
1 parent 58c3551b

fix: 修复视频兼容性并增加渲染模式切换

升级应用版本至 1.0.17+17。为视频专区、课程视频和儿歌/看视频统一增加 Texture 与 PlatformView 渲染模式配置,设置页提供 Android 兼容性测试开关并持久化选择。补充播放器初始化超时、持续缓冲、网络/解码错误提示和重试流程,失败时释放播放器资源;视频专区列表请求增加超时及错误反馈。
lib/common/utils/video_playback_diagnostics.dart 0 → 100644
  1 +import 'dart:async';
  2 +
  3 +import 'package:flutter/foundation.dart';
  4 +import 'package:video_player/video_player.dart';
  5 +import 'package:wow_english/utils/toast_util.dart';
  6 +
  7 +/// Shared timeout and user-visible diagnostics for video_player based screens.
  8 +class VideoPlaybackDiagnostics {
  9 + static const initializeTimeout = Duration(seconds: 10);
  10 + static const commandTimeout = Duration(seconds: 5);
  11 + static const bufferingWarningDelay = Duration(seconds: 10);
  12 +
  13 + /// Starts native player disposal while keeping a stuck teardown bounded.
  14 + static Future<void> disposeController(
  15 + VideoPlayerController controller, {
  16 + required String context,
  17 + }) async {
  18 + try {
  19 + await controller.dispose().timeout(commandTimeout);
  20 + } catch (error) {
  21 + debugPrint('$context播放器释放失败或超时: $error');
  22 + }
  23 + }
  24 +
  25 + static String messageFor(Object error, {required String stage}) {
  26 + final detail = error.toString().toLowerCase();
  27 + if (error is TimeoutException) {
  28 + if (stage == '视频初始化') return '视频初始化超时,请检查网络或设备解码能力';
  29 + if (stage == '播放器配置') return '播放器配置超时,设备可能不支持当前播放参数';
  30 + if (stage == '恢复播放进度') return '恢复播放进度超时';
  31 + return '$stage超时';
  32 + }
  33 + if (detail.contains('decoder') ||
  34 + detail.contains('codec') ||
  35 + detail.contains('mediacodec') ||
  36 + detail.contains('acodec')) {
  37 + return '视频解码器启动失败,可能是设备兼容性问题';
  38 + }
  39 + if (detail.contains('http') ||
  40 + detail.contains('socket') ||
  41 + detail.contains('network') ||
  42 + detail.contains('connection') ||
  43 + detail.contains('source error') ||
  44 + detail.contains('sourceerror')) {
  45 + return '视频网络请求失败,请检查网络或视频地址';
  46 + }
  47 + return '$stage失败,请重试';
  48 + }
  49 +
  50 + static void show(String message, {int? videoId}) {
  51 + final idSuffix = videoId == null ? '' : '(视频ID: $videoId)';
  52 + showToast('$message$idSuffix', duration: const Duration(seconds: 4));
  53 + }
  54 +}
lib/common/utils/video_rendering_preference.dart 0 → 100644
  1 +import 'package:flutter/foundation.dart';
  2 +import 'package:video_player/video_player.dart';
  3 +import 'package:wow_english/utils/sp_util.dart';
  4 +
  5 +/// User-selectable Android video output path for device compatibility testing.
  6 +class VideoRenderingPreference {
  7 + static const _usePlatformViewKey = 'video_player_use_platform_view';
  8 +
  9 + static bool get usesPlatformView =>
  10 + SpUtil.getInstance().get<bool>(_usePlatformViewKey) ?? false;
  11 +
  12 + static VideoViewType get viewType =>
  13 + defaultTargetPlatform == TargetPlatform.android && usesPlatformView
  14 + ? VideoViewType.platformView
  15 + : VideoViewType.textureView;
  16 +
  17 + static Future<void> setUsesPlatformView(bool value) =>
  18 + SpUtil.getInstance().setData(_usePlatformViewKey, value);
  19 +}
lib/pages/repeataftercontent/widgets/repeat_video_widget.dart
  1 +import 'dart:async';
  2 +
1 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
2 import 'package:flutter_bloc/flutter_bloc.dart'; 4 import 'package:flutter_bloc/flutter_bloc.dart';
3 import 'package:flutter_screenutil/flutter_screenutil.dart'; 5 import 'package:flutter_screenutil/flutter_screenutil.dart';
4 import 'package:video_player/video_player.dart'; 6 import 'package:video_player/video_player.dart';
  7 +import 'package:wow_english/common/utils/video_playback_diagnostics.dart';
  8 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
5 import 'package:wow_english/models/read_content_entity.dart'; 9 import 'package:wow_english/models/read_content_entity.dart';
6 10
7 import '../bloc/repeat_after_content_bloc.dart'; 11 import '../bloc/repeat_after_content_bloc.dart';
@@ -23,32 +27,75 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; { @@ -23,32 +27,75 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; {
23 String _totalTime = '00:00'; 27 String _totalTime = '00:00';
24 double _playDegree = 0.5; 28 double _playDegree = 0.5;
25 int _currentPlayIndex = 0; 29 int _currentPlayIndex = 0;
  30 + Object? _loadError;
  31 + Timer? _bufferingTimer;
  32 + VoidCallback? _controllerListener;
  33 + bool _bufferingWarningShown = false;
  34 + int _loadGeneration = 0;
26 35
27 String formatDuration(Duration duration) { 36 String formatDuration(Duration duration) {
28 - String minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');  
29 - String seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); 37 + String minutes =
  38 + duration.inMinutes.remainder(60).toString().padLeft(2, '0');
  39 + String seconds =
  40 + duration.inSeconds.remainder(60).toString().padLeft(2, '0');
30 return "$minutes:$seconds"; 41 return "$minutes:$seconds";
31 } 42 }
32 43
33 void _addListener() { 44 void _addListener() {
34 - _controller!.addListener(() {  
35 - if(_controller!.value.isInitialized) {  
36 - if (_controller!.value.isPlaying) {  
37 - setState(() {  
38 - double currentSecond = (_controller!.value.position.inMinutes.remainder(60)*60+_controller!.value.position.inSeconds.remainder(60)).toDouble();  
39 - int totalSecond = _controller!.value.duration.inMinutes.remainder(60)*60+_controller!.value.duration.inSeconds.remainder(60);  
40 - _currentTime = formatDuration(_controller!.value.position);  
41 - _playDegree = currentSecond/totalSecond;  
42 - if(_playDegree < 0) {  
43 - _playDegree = 0.0;  
44 - } 45 + _controllerListener = _onControllerChanged;
  46 + _controller?.addListener(_controllerListener!);
  47 + }
  48 +
  49 + void _onControllerChanged() {
  50 + final controller = _controller;
  51 + if (!mounted || controller == null) return;
  52 + _updateBufferingWatch(controller);
  53 + if (controller.value.hasError && _loadError == null) {
  54 + final error = controller.value.errorDescription ?? '未知播放器错误';
  55 + debugPrint('课程儿歌/视频播放失败: $error');
  56 + VideoPlaybackDiagnostics.show(
  57 + VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码'),
  58 + );
  59 + setState(() => _loadError = error);
  60 + return;
  61 + }
  62 + if (!controller.value.isInitialized || !controller.value.isPlaying) return;
  63 + setState(() {
  64 + final currentSecond =
  65 + (controller.value.position.inMinutes.remainder(60) * 60 +
  66 + controller.value.position.inSeconds.remainder(60))
  67 + .toDouble();
  68 + final totalSecond =
  69 + controller.value.duration.inMinutes.remainder(60) * 60 +
  70 + controller.value.duration.inSeconds.remainder(60);
  71 + _currentTime = formatDuration(controller.value.position);
  72 + _playDegree = totalSecond == 0 ? 0 : currentSecond / totalSecond;
  73 + _playDegree = _playDegree.clamp(0.0, 1.0);
  74 + });
  75 + }
45 76
46 - if(_playDegree > 1) {  
47 - _playDegree = 1.0;  
48 - }  
49 - });  
50 - } 77 + void _updateBufferingWatch(VideoPlayerController controller) {
  78 + if (!controller.value.isBuffering) {
  79 + _bufferingTimer?.cancel();
  80 + _bufferingTimer = null;
  81 + _bufferingWarningShown = false;
  82 + return;
  83 + }
  84 + if (_bufferingTimer != null || _bufferingWarningShown) return;
  85 + _bufferingTimer = Timer(VideoPlaybackDiagnostics.bufferingWarningDelay, () {
  86 + _bufferingTimer = null;
  87 + if (!mounted ||
  88 + controller != _controller ||
  89 + !controller.value.isBuffering) {
  90 + return;
51 } 91 }
  92 + _bufferingWarningShown = true;
  93 + final error = controller.value.errorDescription;
  94 + final message = error == null
  95 + ? '视频持续缓冲,网络或设备解码可能异常'
  96 + : VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码');
  97 + debugPrint('课程儿歌/视频持续缓冲: $message ${error ?? ''}');
  98 + VideoPlaybackDiagnostics.show(message);
52 }); 99 });
53 } 100 }
54 101
@@ -56,127 +103,207 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; { @@ -56,127 +103,207 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; {
56 void initState() { 103 void initState() {
57 super.initState(); 104 super.initState();
58 var videoUrl = ''; 105 var videoUrl = '';
59 - if(widget.videoUrls.isNotEmpty) {  
60 - videoUrl = widget.videoUrls[_currentPlayIndex]?.videoUrl??''; 106 + if (widget.videoUrls.isNotEmpty) {
  107 + videoUrl = widget.videoUrls[_currentPlayIndex]?.videoUrl ?? '';
61 } 108 }
62 - _initVideo(videoUrl); 109 + unawaited(_initVideo(videoUrl));
63 } 110 }
64 111
65 @override 112 @override
66 Widget build(BuildContext context) { 113 Widget build(BuildContext context) {
67 return Center( 114 return Center(
68 - child: _controller!.value.isInitialized ? BlocListener<RepeatAfterContentBloc,RepeatAfterContentState>(  
69 - listener: (context, state){  
70 - if (state is VideoPlayChangeState) {  
71 - if (context.read<RepeatAfterContentBloc>().videoPlaying) {  
72 - _controller!.play();  
73 - } else {  
74 - _controller!.pause();  
75 - }  
76 - }  
77 -  
78 - if (state is ChangeVideoPlayIndexState) {  
79 - if(state.isNext) {  
80 - if (_currentPlayIndex != (widget.videoUrls.length-1)) {  
81 - _currentPlayIndex++;  
82 - _destroyVideo();  
83 - _initVideo(widget.videoUrls[_currentPlayIndex]!.videoUrl!);  
84 - }  
85 - } else {  
86 - if (_currentPlayIndex > 0) {  
87 - _currentPlayIndex--;  
88 - _destroyVideo();  
89 - _initVideo(widget.videoUrls[_currentPlayIndex]!.videoUrl!);  
90 - }  
91 - }  
92 - }  
93 - },  
94 - child: SizedBox(  
95 - child: Column(  
96 - children: [  
97 - Expanded(  
98 - child: Stack(  
99 - children: [  
100 - Container(  
101 - width: double.infinity,  
102 - height: double.infinity,  
103 - alignment: Alignment.center,  
104 - decoration: BoxDecoration(  
105 - color: Colors.black,  
106 - border: Border.all(  
107 - width: 1.0,  
108 - color: const Color(0xFF140C10), 115 + child: _loadError != null
  116 + ? Column(
  117 + mainAxisSize: MainAxisSize.min,
  118 + children: [
  119 + const Text('视频加载失败,请重试'),
  120 + const SizedBox(height: 8),
  121 + ElevatedButton(
  122 + onPressed: () => unawaited(_initVideo(
  123 + widget.videoUrls.isEmpty
  124 + ? ''
  125 + : widget.videoUrls[_currentPlayIndex]?.videoUrl ?? '',
  126 + )),
  127 + child: const Text('重试'),
  128 + ),
  129 + ],
  130 + )
  131 + : _controller?.value.isInitialized == true
  132 + ? BlocListener<RepeatAfterContentBloc, RepeatAfterContentState>(
  133 + listener: (context, state) {
  134 + if (state is VideoPlayChangeState) {
  135 + unawaited(_setPlayback(
  136 + context.read<RepeatAfterContentBloc>().videoPlaying,
  137 + ));
  138 + }
  139 + if (state is ChangeVideoPlayIndexState) {
  140 + final nextIndex = state.isNext
  141 + ? _currentPlayIndex + 1
  142 + : _currentPlayIndex - 1;
  143 + if (nextIndex >= 0 &&
  144 + nextIndex < widget.videoUrls.length) {
  145 + _currentPlayIndex = nextIndex;
  146 + unawaited(_initVideo(
  147 + widget.videoUrls[nextIndex]?.videoUrl ?? '',
  148 + ));
  149 + }
  150 + }
  151 + },
  152 + child: SizedBox(
  153 + child: Column(
  154 + children: [
  155 + Expanded(
  156 + child: Stack(
  157 + children: [
  158 + Container(
  159 + width: double.infinity,
  160 + height: double.infinity,
  161 + alignment: Alignment.center,
  162 + decoration: BoxDecoration(
  163 + color: Colors.black,
  164 + border: Border.all(
  165 + width: 1.0,
  166 + color: const Color(0xFF140C10),
  167 + ),
  168 + borderRadius: BorderRadius.circular(5.r),
  169 + ),
  170 + child: AspectRatio(
  171 + aspectRatio: _controller!.value.aspectRatio,
  172 + child: VideoPlayer(_controller!),
  173 + ),
  174 + ),
  175 + Positioned(
  176 + left: 0,
  177 + right: 0,
  178 + bottom: 0,
  179 + child: LinearProgressIndicator(
  180 + color: const Color(0xFF78B72D),
  181 + backgroundColor: const Color(0xFF7E756C),
  182 + value: _playDegree,
  183 + ),
  184 + ),
  185 + ],
109 ), 186 ),
110 - borderRadius: BorderRadius.circular(5.r)  
111 - ),  
112 - child: AspectRatio(  
113 - aspectRatio: _controller!.value.aspectRatio,  
114 - child: VideoPlayer(_controller!),  
115 - ), 187 + ),
  188 + Row(
  189 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
  190 + children: [Text(_currentTime), Text(_totalTime)],
  191 + ),
  192 + ],
116 ), 193 ),
117 - Positioned(  
118 - left: 0,  
119 - right: 0,  
120 - bottom: 0,  
121 - child: LinearProgressIndicator(  
122 - color: const Color(0xFF78B72D),  
123 - backgroundColor: const Color(0xFF7E756C),  
124 - value: _playDegree,  
125 - ))  
126 - ], 194 + ),
  195 + )
  196 + : Container(
  197 + color: Colors.white,
  198 + child: const CircularProgressIndicator(),
127 ), 199 ),
128 - ),  
129 - Row(  
130 - mainAxisAlignment: MainAxisAlignment.spaceBetween,  
131 - children: [  
132 - Text(_currentTime),  
133 - Text(_totalTime),  
134 - ],  
135 - )  
136 - ],  
137 - ),  
138 - )  
139 - )  
140 - :  
141 - Container(  
142 - color: Colors.white,  
143 - child: const CircularProgressIndicator(),  
144 - // Text(  
145 - // '视频加载中....',  
146 - // style: TextStyle(  
147 - // fontSize: 20.sp,  
148 - // color: Colors.black  
149 - // ),  
150 - // ),  
151 - ),  
152 ); 200 );
153 } 201 }
154 202
155 - void _initVideo(String videoUrl) {  
156 - if(videoUrl.isNotEmpty) {  
157 - Uri uri = Uri.parse(videoUrl);  
158 - _controller = VideoPlayerController.networkUrl(uri)  
159 - ..initialize().then((_){  
160 - setState(() {  
161 - _currentTime = formatDuration(_controller!.value.position);  
162 - _totalTime = formatDuration(_controller!.value.duration);  
163 - _controller!.setLooping(false);  
164 - _controller!.setVolume(100);  
165 - _controller!.play();  
166 - });  
167 - _addListener();  
168 - }); 203 + Future<void> _initVideo(String videoUrl) async {
  204 + final generation = ++_loadGeneration;
  205 + _bufferingTimer?.cancel();
  206 + _bufferingWarningShown = false;
  207 + await _destroyController();
  208 + if (!mounted || generation != _loadGeneration) return;
  209 + _loadError = null;
  210 + final url = videoUrl.trim();
  211 + if (url.isEmpty) {
  212 + _loadError = StateError('课程视频地址为空');
  213 + VideoPlaybackDiagnostics.show('课程视频地址为空');
  214 + if (mounted) setState(() {});
  215 + return;
  216 + }
  217 + final controller = VideoPlayerController.networkUrl(
  218 + Uri.parse(url),
  219 + viewType: VideoRenderingPreference.viewType,
  220 + );
  221 + _controller = controller;
  222 + if (mounted) setState(() {});
  223 + var stage = '视频初始化';
  224 + try {
  225 + await controller
  226 + .initialize()
  227 + .timeout(VideoPlaybackDiagnostics.initializeTimeout);
  228 + if (!mounted ||
  229 + generation != _loadGeneration ||
  230 + controller != _controller) {
  231 + await controller.dispose();
  232 + return;
  233 + }
  234 + stage = '播放器配置';
  235 + await controller
  236 + .setLooping(false)
  237 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  238 + await controller
  239 + .setVolume(1)
  240 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  241 + _addListener();
  242 + _updateBufferingWatch(controller);
  243 + await controller.play().timeout(VideoPlaybackDiagnostics.commandTimeout);
  244 + if (!mounted || generation != _loadGeneration) return;
  245 + setState(() {
  246 + _currentTime = formatDuration(controller.value.position);
  247 + _totalTime = formatDuration(controller.value.duration);
  248 + });
  249 + } catch (error) {
  250 + final failedCurrentController = controller == _controller;
  251 + if (failedCurrentController) {
  252 + _bufferingTimer?.cancel();
  253 + _bufferingTimer = null;
  254 + }
  255 + await VideoPlaybackDiagnostics.disposeController(
  256 + controller,
  257 + context: '课程儿歌/视频初始化失败($stage)',
  258 + );
  259 + if (!mounted ||
  260 + generation != _loadGeneration ||
  261 + !failedCurrentController) {
  262 + return;
  263 + }
  264 + _controller = null;
  265 + final message = VideoPlaybackDiagnostics.messageFor(error, stage: stage);
  266 + debugPrint('课程儿歌/视频加载失败(stage=$stage): $error');
  267 + VideoPlaybackDiagnostics.show(message);
  268 + setState(() => _loadError = error);
169 } 269 }
170 } 270 }
171 271
172 - void _destroyVideo() {  
173 - _controller?.dispose();  
174 - _controller?.removeListener(() {}); 272 + Future<void> _setPlayback(bool shouldPlay) async {
  273 + final controller = _controller;
  274 + if (controller?.value.isInitialized != true) return;
  275 + try {
  276 + final operation = shouldPlay ? controller!.play() : controller!.pause();
  277 + await operation.timeout(VideoPlaybackDiagnostics.commandTimeout);
  278 + } catch (error) {
  279 + debugPrint('课程视频播放状态切换失败: $error');
  280 + VideoPlaybackDiagnostics.show(
  281 + VideoPlaybackDiagnostics.messageFor(error, stage: '播放器控制'),
  282 + );
  283 + }
  284 + }
  285 +
  286 + Future<void> _destroyController() async {
  287 + final controller = _controller;
  288 + final listener = _controllerListener;
  289 + _controllerListener = null;
  290 + _controller = null;
  291 + if (controller != null) {
  292 + if (listener != null) {
  293 + controller.removeListener(listener);
  294 + }
  295 + await VideoPlaybackDiagnostics.disposeController(
  296 + controller,
  297 + context: '课程儿歌/视频切换',
  298 + );
  299 + }
175 } 300 }
176 301
177 @override 302 @override
178 void dispose() { 303 void dispose() {
179 - _destroyVideo(); 304 + _loadGeneration++;
  305 + _bufferingTimer?.cancel();
  306 + unawaited(_destroyController());
180 super.dispose(); 307 super.dispose();
181 } 308 }
182 } 309 }
183 \ No newline at end of file 310 \ No newline at end of file
lib/pages/user/setting/setting_page.dart
  1 +import 'package:flutter/foundation.dart';
1 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
2 import 'package:flutter_easyloading/flutter_easyloading.dart'; 3 import 'package:flutter_easyloading/flutter_easyloading.dart';
3 import 'package:flutter_screenutil/flutter_screenutil.dart'; 4 import 'package:flutter_screenutil/flutter_screenutil.dart';
4 import 'package:package_info_plus/package_info_plus.dart'; 5 import 'package:package_info_plus/package_info_plus.dart';
  6 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
5 import 'package:wow_english/common/widgets/we_app_bar.dart'; 7 import 'package:wow_english/common/widgets/we_app_bar.dart';
6 import 'package:wow_english/utils/toast_util.dart'; 8 import 'package:wow_english/utils/toast_util.dart';
7 9
@@ -19,9 +21,11 @@ class SettingPage extends StatefulWidget { @@ -19,9 +21,11 @@ class SettingPage extends StatefulWidget {
19 class SettingPageState extends State<SettingPage> { 21 class SettingPageState extends State<SettingPage> {
20 String? _version; 22 String? _version;
21 String? _buildNum; 23 String? _buildNum;
  24 + bool _usePlatformView = false;
22 @override 25 @override
23 void initState() { 26 void initState() {
24 super.initState(); 27 super.initState();
  28 + _usePlatformView = VideoRenderingPreference.usesPlatformView;
25 _retrieveVersionInfo(); 29 _retrieveVersionInfo();
26 } 30 }
27 31
@@ -62,6 +66,10 @@ class SettingPageState extends State&lt;SettingPage&gt; { @@ -62,6 +66,10 @@ class SettingPageState extends State&lt;SettingPage&gt; {
62 _buildItemWidget('帮助与反馈', onPress: () { 66 _buildItemWidget('帮助与反馈', onPress: () {
63 pushNamed(AppRouteName.reBack); 67 pushNamed(AppRouteName.reBack);
64 }), 68 }),
  69 + if (defaultTargetPlatform == TargetPlatform.android) ...[
  70 + 12.verticalSpace,
  71 + _buildVideoRenderingSetting(),
  72 + ],
65 12.verticalSpace, 73 12.verticalSpace,
66 _buildItemWidget('Version: $_version Build:$_buildNum', 74 _buildItemWidget('Version: $_version Build:$_buildNum',
67 onPress: () {}), 75 onPress: () {}),
@@ -73,6 +81,49 @@ class SettingPageState extends State&lt;SettingPage&gt; { @@ -73,6 +81,49 @@ class SettingPageState extends State&lt;SettingPage&gt; {
73 ); 81 );
74 } 82 }
75 83
  84 + Widget _buildVideoRenderingSetting() {
  85 + return Container(
  86 + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
  87 + decoration: BoxDecoration(
  88 + border: Border.all(color: const Color(0xFF140C10), width: 1.5.w),
  89 + borderRadius: BorderRadius.circular(15.r),
  90 + color: Colors.white,
  91 + ),
  92 + child: Row(
  93 + children: [
  94 + Expanded(
  95 + child: Column(
  96 + crossAxisAlignment: CrossAxisAlignment.start,
  97 + children: [
  98 + Text(
  99 + '视频兼容模式',
  100 + style: TextStyle(
  101 + color: const Color(0xFF333333), fontSize: 19.sp),
  102 + ),
  103 + 4.verticalSpace,
  104 + Text(
  105 + _usePlatformView
  106 + ? 'PlatformView(兼容测试)· 新打开的视频生效'
  107 + : 'Texture(默认)· 新打开的视频生效',
  108 + style: TextStyle(
  109 + color: const Color(0xFF777777), fontSize: 13.sp),
  110 + ),
  111 + ],
  112 + ),
  113 + ),
  114 + Switch.adaptive(
  115 + value: _usePlatformView,
  116 + onChanged: (value) async {
  117 + setState(() => _usePlatformView = value);
  118 + await VideoRenderingPreference.setUsesPlatformView(value);
  119 + showToast(value ? '已切换为 PlatformView' : '已切换为 Texture');
  120 + },
  121 + ),
  122 + ],
  123 + ),
  124 + );
  125 + }
  126 +
76 Widget _buildItemWidget(String text, {VoidCallback? onPress}) { 127 Widget _buildItemWidget(String text, {VoidCallback? onPress}) {
77 return OutlinedButton( 128 return OutlinedButton(
78 onPressed: () => onPress?.call(), 129 onPressed: () => onPress?.call(),
lib/pages/video/lookvideo/widgets/video_widget.dart
  1 +import 'dart:async';
  2 +
1 import 'package:common_utils/common_utils.dart'; 3 import 'package:common_utils/common_utils.dart';
2 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
3 import 'package:flutter_bloc/flutter_bloc.dart'; 5 import 'package:flutter_bloc/flutter_bloc.dart';
4 import 'package:flutter_screenutil/flutter_screenutil.dart'; 6 import 'package:flutter_screenutil/flutter_screenutil.dart';
5 import 'package:video_player/video_player.dart'; 7 import 'package:video_player/video_player.dart';
6 import 'package:wow_english/common/extension/string_extension.dart'; 8 import 'package:wow_english/common/extension/string_extension.dart';
  9 +import 'package:wow_english/common/utils/video_playback_diagnostics.dart';
  10 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
7 import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart'; 11 import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart';
8 import 'package:wow_english/route/route.dart'; 12 import 'package:wow_english/route/route.dart';
9 13
@@ -36,6 +40,11 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -36,6 +40,11 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
36 String _totalTime = '00:00'; 40 String _totalTime = '00:00';
37 double _playDegree = 0.0; 41 double _playDegree = 0.0;
38 bool _hiddenTipView = false; 42 bool _hiddenTipView = false;
  43 + Object? _loadError;
  44 + Timer? _bufferingTimer;
  45 + int _loadGeneration = 0;
  46 + VoidCallback? _diagnosticListener;
  47 + bool _bufferingWarningShown = false;
39 TimerUtil? timerUtil; 48 TimerUtil? timerUtil;
40 49
41 String formatDuration(Duration duration) { 50 String formatDuration(Duration duration) {
@@ -142,19 +151,132 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -142,19 +151,132 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
142 @override 151 @override
143 void initState() { 152 void initState() {
144 super.initState(); 153 super.initState();
145 - Uri uri = Uri.parse(widget.videoUrl);  
146 - _controller = VideoPlayerController.networkUrl(uri)  
147 - ..initialize().then((_) {  
148 - startTimer();  
149 - setState(() {  
150 - _currentTime = formatDuration(_controller!.value.position);  
151 - _totalTime = formatDuration(_controller!.value.duration);  
152 - _controller!.setLooping(!widget.isTopic);  
153 - _controller!.setVolume(100);  
154 - _controller!.play();  
155 - });  
156 - _addListener(); 154 + unawaited(_initializeVideo());
  155 + }
  156 +
  157 + Future<void> _initializeVideo() async {
  158 + final generation = ++_loadGeneration;
  159 + _bufferingTimer?.cancel();
  160 + _bufferingWarningShown = false;
  161 + final oldController = _controller;
  162 + if (oldController != null) {
  163 + if (_diagnosticListener != null) {
  164 + oldController.removeListener(_diagnosticListener!);
  165 + }
  166 + }
  167 + _diagnosticListener = null;
  168 + _controller = null;
  169 + _loadError = null;
  170 + if (oldController != null) {
  171 + await VideoPlaybackDiagnostics.disposeController(
  172 + oldController,
  173 + context: '课程视频切换',
  174 + );
  175 + }
  176 + if (!mounted || generation != _loadGeneration) return;
  177 + final url = widget.videoUrl.trim();
  178 + if (url.isEmpty) {
  179 + _loadError = StateError('课程视频地址为空');
  180 + VideoPlaybackDiagnostics.show('课程视频地址为空');
  181 + if (mounted) setState(() {});
  182 + return;
  183 + }
  184 + final controller = VideoPlayerController.networkUrl(
  185 + Uri.parse(url),
  186 + viewType: VideoRenderingPreference.viewType,
  187 + );
  188 + _controller = controller;
  189 + if (mounted) setState(() {});
  190 + var stage = '视频初始化';
  191 + try {
  192 + await controller
  193 + .initialize()
  194 + .timeout(VideoPlaybackDiagnostics.initializeTimeout);
  195 + if (!mounted ||
  196 + generation != _loadGeneration ||
  197 + controller != _controller) {
  198 + await controller.dispose();
  199 + return;
  200 + }
  201 + stage = '播放器配置';
  202 + await controller
  203 + .setLooping(!widget.isTopic)
  204 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  205 + await controller
  206 + .setVolume(1)
  207 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  208 + _addListener();
  209 + _diagnosticListener = _onDiagnosticChanged;
  210 + controller.addListener(_diagnosticListener!);
  211 + _updateBufferingWatch(controller);
  212 + await controller.play().timeout(VideoPlaybackDiagnostics.commandTimeout);
  213 + if (!mounted || generation != _loadGeneration) return;
  214 + startTimer();
  215 + setState(() {
  216 + _currentTime = formatDuration(controller.value.position);
  217 + _totalTime = formatDuration(controller.value.duration);
157 }); 218 });
  219 + } catch (error) {
  220 + final failedCurrentController = controller == _controller;
  221 + if (failedCurrentController) {
  222 + _bufferingTimer?.cancel();
  223 + _bufferingTimer = null;
  224 + controller.removeListener(_diagnosticListener ?? _onDiagnosticChanged);
  225 + }
  226 + await VideoPlaybackDiagnostics.disposeController(
  227 + controller,
  228 + context: '课程视频初始化失败($stage)',
  229 + );
  230 + if (!mounted ||
  231 + generation != _loadGeneration ||
  232 + !failedCurrentController) {
  233 + return;
  234 + }
  235 + _controller = null;
  236 + final message = VideoPlaybackDiagnostics.messageFor(error, stage: stage);
  237 + debugPrint('课程视频加载失败(stage=$stage): $error');
  238 + VideoPlaybackDiagnostics.show(message);
  239 + setState(() => _loadError = error);
  240 + }
  241 + }
  242 +
  243 + void _onDiagnosticChanged() {
  244 + final controller = _controller;
  245 + if (!mounted || controller == null) return;
  246 + _updateBufferingWatch(controller);
  247 + if (controller.value.hasError && _loadError == null) {
  248 + final error = controller.value.errorDescription ?? '未知播放器错误';
  249 + debugPrint('课程视频播放失败: $error');
  250 + VideoPlaybackDiagnostics.show(
  251 + VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码'),
  252 + );
  253 + setState(() => _loadError = error);
  254 + }
  255 + }
  256 +
  257 + void _updateBufferingWatch(VideoPlayerController controller) {
  258 + if (!controller.value.isBuffering) {
  259 + _bufferingTimer?.cancel();
  260 + _bufferingTimer = null;
  261 + _bufferingWarningShown = false;
  262 + return;
  263 + }
  264 + if (_bufferingTimer != null || _bufferingWarningShown) return;
  265 + _bufferingTimer = Timer(VideoPlaybackDiagnostics.bufferingWarningDelay, () {
  266 + _bufferingTimer = null;
  267 + if (!mounted ||
  268 + controller != _controller ||
  269 + !controller.value.isBuffering) {
  270 + return;
  271 + }
  272 + _bufferingWarningShown = true;
  273 + final error = controller.value.errorDescription;
  274 + final message = error == null
  275 + ? '视频持续缓冲,网络或设备解码可能异常'
  276 + : VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码');
  277 + debugPrint('课程视频持续缓冲: $message ${error ?? ''}');
  278 + VideoPlaybackDiagnostics.show(message);
  279 + });
158 } 280 }
159 281
160 @override 282 @override
@@ -162,12 +284,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -162,12 +284,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
162 return BlocListener<LookVideoBloc, BaseSectionState>( 284 return BlocListener<LookVideoBloc, BaseSectionState>(
163 listener: (context, state) async { 285 listener: (context, state) async {
164 if (state is SectionAgainState) { 286 if (state is SectionAgainState) {
165 - await _controller?.seekTo(Duration.zero);  
166 - _controller?.play(); 287 + final controller = _controller;
  288 + if (controller?.value.isInitialized == true) {
  289 + await controller!.seekTo(Duration.zero);
  290 + await controller.play();
  291 + }
167 } 292 }
168 }, 293 },
169 child: GestureDetector( 294 child: GestureDetector(
170 onTap: () { 295 onTap: () {
  296 + if (_loadError != null || _controller?.value.isInitialized != true) {
  297 + return;
  298 + }
171 setState(() { 299 setState(() {
172 _hiddenTipView = !_hiddenTipView; 300 _hiddenTipView = !_hiddenTipView;
173 if (!_hiddenTipView) { 301 if (!_hiddenTipView) {
@@ -180,7 +308,7 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -180,7 +308,7 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
180 }); 308 });
181 }, 309 },
182 onDoubleTap: () { 310 onDoubleTap: () {
183 - if (_controller!.value.isInitialized) { 311 + if (_controller?.value.isInitialized == true) {
184 if (_controller!.value.isPlaying) { 312 if (_controller!.value.isPlaying) {
185 _controller!.pause(); 313 _controller!.pause();
186 } else { 314 } else {
@@ -190,65 +318,78 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -190,65 +318,78 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
190 } 318 }
191 }, 319 },
192 child: Center( 320 child: Center(
193 - child: _controller!.value.isInitialized  
194 - ? Stack(  
195 - alignment: Alignment.center, 321 + child: _loadError != null
  322 + ? Column(
  323 + mainAxisSize: MainAxisSize.min,
196 children: [ 324 children: [
197 - SizedBox(  
198 - height: double.infinity,  
199 - width: double.infinity,  
200 - child: AspectRatio(  
201 - aspectRatio: _controller!.value.aspectRatio,  
202 - child: VideoPlayer(_controller!),  
203 - ),  
204 - ),  
205 - Offstage(  
206 - offstage: _hiddenTipView,  
207 - child: VideoOperaWidget(  
208 - title: widget.typeTitle ?? 'song',  
209 - degree: _playDegree,  
210 - totalTime: _totalTime,  
211 - currentTime: _currentTime,  
212 - isPlay: _controller!.value.isPlaying,  
213 - actionEvent: (OperationType type) {  
214 - actionType(type);  
215 - },  
216 - sliderChangeEvent: (double degree) {  
217 - int totalSecond = _controller!  
218 - .value.duration.inMinutes  
219 - .remainder(60) *  
220 - 60 +  
221 - _controller!.value.duration.inSeconds  
222 - .remainder(60);  
223 - int positionSecond = (totalSecond * degree).toInt();  
224 - _controller!  
225 - .seekTo(Duration(seconds: positionSecond));  
226 - },  
227 - ), 325 + const Text('视频加载失败,请重试'),
  326 + const SizedBox(height: 8),
  327 + ElevatedButton(
  328 + onPressed: _initializeVideo,
  329 + child: const Text('重试'),
228 ), 330 ),
229 - Offstage(  
230 - offstage: _controller!.value.isPlaying,  
231 - child: IconButton(  
232 - onPressed: () {  
233 - _controller!.play();  
234 - },  
235 - icon: Image.asset(  
236 - 'video_stop'.assetPng,  
237 - width: 70.w,  
238 - height: 70.h,  
239 - ),  
240 - ),  
241 - )  
242 ], 331 ],
243 ) 332 )
244 - : Container(  
245 - color: Colors.white,  
246 - child: const CircularProgressIndicator(),  
247 - // Text(  
248 - // '视频加载中....',  
249 - // style: TextStyle(fontSize: 20.sp, color: Colors.black),  
250 - // ),  
251 - ), 333 + : _controller?.value.isInitialized == true
  334 + ? Stack(
  335 + alignment: Alignment.center,
  336 + children: [
  337 + SizedBox(
  338 + height: double.infinity,
  339 + width: double.infinity,
  340 + child: AspectRatio(
  341 + aspectRatio: _controller!.value.aspectRatio,
  342 + child: VideoPlayer(_controller!),
  343 + ),
  344 + ),
  345 + Offstage(
  346 + offstage: _hiddenTipView,
  347 + child: VideoOperaWidget(
  348 + title: widget.typeTitle ?? 'song',
  349 + degree: _playDegree,
  350 + totalTime: _totalTime,
  351 + currentTime: _currentTime,
  352 + isPlay: _controller!.value.isPlaying,
  353 + actionEvent: (OperationType type) {
  354 + actionType(type);
  355 + },
  356 + sliderChangeEvent: (double degree) {
  357 + int totalSecond = _controller!
  358 + .value.duration.inMinutes
  359 + .remainder(60) *
  360 + 60 +
  361 + _controller!.value.duration.inSeconds
  362 + .remainder(60);
  363 + int positionSecond =
  364 + (totalSecond * degree).toInt();
  365 + _controller!
  366 + .seekTo(Duration(seconds: positionSecond));
  367 + },
  368 + ),
  369 + ),
  370 + Offstage(
  371 + offstage: _controller!.value.isPlaying,
  372 + child: IconButton(
  373 + onPressed: () {
  374 + _controller!.play();
  375 + },
  376 + icon: Image.asset(
  377 + 'video_stop'.assetPng,
  378 + width: 70.w,
  379 + height: 70.h,
  380 + ),
  381 + ),
  382 + )
  383 + ],
  384 + )
  385 + : Container(
  386 + color: Colors.white,
  387 + child: const CircularProgressIndicator(),
  388 + // Text(
  389 + // '视频加载中....',
  390 + // style: TextStyle(fontSize: 20.sp, color: Colors.black),
  391 + // ),
  392 + ),
252 ), 393 ),
253 ), 394 ),
254 ); 395 );
@@ -256,8 +397,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -256,8 +397,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
256 397
257 @override 398 @override
258 void dispose() { 399 void dispose() {
259 - _controller?.dispose();  
260 - _controller?.removeListener(() {}); 400 + _loadGeneration++;
  401 + _bufferingTimer?.cancel();
  402 + if (_diagnosticListener != null) {
  403 + _controller?.removeListener(_diagnosticListener!);
  404 + }
  405 + final controller = _controller;
  406 + if (controller != null) {
  407 + unawaited(VideoPlaybackDiagnostics.disposeController(
  408 + controller,
  409 + context: '课程视频页面退出',
  410 + ));
  411 + }
261 if (timerUtil != null) { 412 if (timerUtil != null) {
262 timerUtil!.cancel(); 413 timerUtil!.cancel();
263 timerUtil = null; 414 timerUtil = null;
lib/pages/video/zone/video_zone_page.dart
  1 +import 'dart:async';
  2 +
1 import 'package:cached_network_image/cached_network_image.dart'; 3 import 'package:cached_network_image/cached_network_image.dart';
2 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
3 import 'package:flutter_screenutil/flutter_screenutil.dart'; 5 import 'package:flutter_screenutil/flutter_screenutil.dart';
@@ -5,6 +7,7 @@ import &#39;package:wow_english/common/dialogs/show_dialog.dart&#39;; @@ -5,6 +7,7 @@ import &#39;package:wow_english/common/dialogs/show_dialog.dart&#39;;
5 import 'package:wow_english/common/request/dao/video_dao.dart'; 7 import 'package:wow_english/common/request/dao/video_dao.dart';
6 import 'package:wow_english/common/widgets/we_app_bar.dart'; 8 import 'package:wow_english/common/widgets/we_app_bar.dart';
7 import 'package:wow_english/route/route.dart'; 9 import 'package:wow_english/route/route.dart';
  10 +import 'package:wow_english/utils/toast_util.dart';
8 11
9 import 'video_zone_item.dart'; 12 import 'video_zone_item.dart';
10 13
@@ -21,77 +24,95 @@ class _VideoZonePageState extends State&lt;VideoZonePage&gt; { @@ -21,77 +24,95 @@ class _VideoZonePageState extends State&lt;VideoZonePage&gt; {
21 @override 24 @override
22 void initState() { 25 void initState() {
23 super.initState(); 26 super.initState();
24 - _itemsFuture = VideoDao.list(); 27 + _itemsFuture = _loadItems();
  28 + }
  29 +
  30 + Future<List<VideoZoneItem>> _loadItems() async {
  31 + try {
  32 + return await VideoDao.list().timeout(const Duration(seconds: 10));
  33 + } on TimeoutException catch (error) {
  34 + debugPrint('获取视频列表超时: $error');
  35 + showToast('视频列表加载超时,请检查网络后重试');
  36 + rethrow;
  37 + } catch (error) {
  38 + debugPrint('获取视频列表失败: $error');
  39 + showToast('视频列表加载失败,请稍后重试');
  40 + rethrow;
  41 + }
25 } 42 }
26 43
27 void _reload() { 44 void _reload() {
28 setState(() { 45 setState(() {
29 - _itemsFuture = VideoDao.list(); 46 + _itemsFuture = _loadItems();
30 }); 47 });
31 } 48 }
32 49
33 @override 50 @override
34 Widget build(BuildContext context) { 51 Widget build(BuildContext context) {
35 return Scaffold( 52 return Scaffold(
36 - backgroundColor: Colors.white,  
37 - appBar: const WEAppBar(  
38 - titleText: '视频专区',  
39 - centerTitle: false,  
40 - ),  
41 - body: FutureBuilder<List<VideoZoneItem>>(  
42 - future: _itemsFuture,  
43 - builder: (context, snapshot) {  
44 - if (snapshot.connectionState == ConnectionState.waiting) {  
45 - return const Center(child: CircularProgressIndicator());  
46 - }  
47 - if (snapshot.hasError) {  
48 - return _VideoLoadError(onRetry: _reload);  
49 - }  
50 - final items = snapshot.data ?? const <VideoZoneItem>[];  
51 - if (items.isEmpty) {  
52 - return const Center(  
53 - child: Text(  
54 - '暂无视频',  
55 - style: TextStyle(color: Color(0xFF999999), fontSize: 18),  
56 - ),  
57 - );  
58 - }  
59 - return RefreshIndicator(  
60 - onRefresh: () async {  
61 - final future = VideoDao.list();  
62 - setState(() => _itemsFuture = future);  
63 - await future;  
64 - },  
65 - child: LayoutBuilder(  
66 - builder: (context, constraints) {  
67 - final titleStyle = _VideoCard.createTitleStyle(context);  
68 - return GridView.builder(  
69 - physics: const AlwaysScrollableScrollPhysics(),  
70 - padding: EdgeInsets.fromLTRB(  
71 - 14.w,  
72 - 10.h,  
73 - 14.w,  
74 - 16.h,  
75 - ),  
76 - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(  
77 - crossAxisCount: 4,  
78 - mainAxisSpacing: 14.h,  
79 - crossAxisSpacing: 12.w,  
80 - childAspectRatio: 1.55,  
81 - ),  
82 - itemCount: items.length,  
83 - itemBuilder: (context, index) => _VideoCard(  
84 - item: items[index],  
85 - onTap: () => _openVideo(items, index),  
86 - titleStyle: titleStyle,  
87 - ),  
88 - );  
89 - }, 53 + backgroundColor: Colors.white,
  54 + appBar: const WEAppBar(
  55 + titleText: '视频专区',
  56 + centerTitle: false,
  57 + ),
  58 + body: FutureBuilder<List<VideoZoneItem>>(
  59 + future: _itemsFuture,
  60 + builder: (context, snapshot) {
  61 + if (snapshot.connectionState == ConnectionState.waiting) {
  62 + return const Center(child: CircularProgressIndicator());
  63 + }
  64 + if (snapshot.hasError) {
  65 + return _VideoLoadError(onRetry: _reload);
  66 + }
  67 + final items = snapshot.data ?? const <VideoZoneItem>[];
  68 + if (items.isEmpty) {
  69 + return const Center(
  70 + child: Text(
  71 + '暂无视频',
  72 + style: TextStyle(color: Color(0xFF999999), fontSize: 18),
90 ), 73 ),
91 ); 74 );
92 - },  
93 - ),  
94 - ); 75 + }
  76 + return RefreshIndicator(
  77 + onRefresh: () async {
  78 + final future = _loadItems();
  79 + setState(() => _itemsFuture = future);
  80 + try {
  81 + await future;
  82 + } catch (_) {
  83 + // The FutureBuilder shows the error state; _loadItems already toasts it.
  84 + }
  85 + },
  86 + child: LayoutBuilder(
  87 + builder: (context, constraints) {
  88 + final titleStyle = _VideoCard.createTitleStyle(context);
  89 + return GridView.builder(
  90 + physics: const AlwaysScrollableScrollPhysics(),
  91 + padding: EdgeInsets.fromLTRB(
  92 + 14.w,
  93 + 10.h,
  94 + 14.w,
  95 + 16.h,
  96 + ),
  97 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  98 + crossAxisCount: 4,
  99 + mainAxisSpacing: 14.h,
  100 + crossAxisSpacing: 12.w,
  101 + childAspectRatio: 1.55,
  102 + ),
  103 + itemCount: items.length,
  104 + itemBuilder: (context, index) => _VideoCard(
  105 + item: items[index],
  106 + onTap: () => _openVideo(items, index),
  107 + titleStyle: titleStyle,
  108 + ),
  109 + );
  110 + },
  111 + ),
  112 + );
  113 + },
  114 + ),
  115 + );
95 } 116 }
96 117
97 void _openVideo(List<VideoZoneItem> items, int index) { 118 void _openVideo(List<VideoZoneItem> items, int index) {
lib/pages/video/zone/video_zone_player_page.dart
@@ -6,7 +6,10 @@ import &#39;package:flutter/material.dart&#39;; @@ -6,7 +6,10 @@ import &#39;package:flutter/material.dart&#39;;
6 import 'package:flutter/services.dart'; 6 import 'package:flutter/services.dart';
7 import 'package:video_player/video_player.dart'; 7 import 'package:video_player/video_player.dart';
8 import 'package:wow_english/common/request/dao/video_dao.dart'; 8 import 'package:wow_english/common/request/dao/video_dao.dart';
  9 +import 'package:wow_english/common/utils/video_playback_diagnostics.dart';
9 import 'package:wow_english/common/utils/video_progress_cache.dart'; 10 import 'package:wow_english/common/utils/video_progress_cache.dart';
  11 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
  12 +import 'package:wow_english/utils/toast_util.dart';
10 13
11 import 'video_zone_item.dart'; 14 import 'video_zone_item.dart';
12 15
@@ -26,13 +29,20 @@ class VideoZonePlayerPage extends StatefulWidget { @@ -26,13 +29,20 @@ class VideoZonePlayerPage extends StatefulWidget {
26 29
27 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> 30 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage>
28 with WidgetsBindingObserver { 31 with WidgetsBindingObserver {
  32 + static const _initializeTimeout = Duration(seconds: 10);
  33 + static const _playerCommandTimeout = Duration(seconds: 5);
  34 + static const _progressReadTimeout = Duration(seconds: 2);
  35 + static const _bufferingWarningDelay = Duration(seconds: 10);
  36 +
29 VideoPlayerController? _controller; 37 VideoPlayerController? _controller;
30 ChewieController? _chewieController; 38 ChewieController? _chewieController;
31 Timer? _cacheTimer; 39 Timer? _cacheTimer;
  40 + Timer? _bufferingTimer;
32 late int _currentIndex; 41 late int _currentIndex;
33 double _playbackSpeed = 1; 42 double _playbackSpeed = 1;
34 bool _switchingVideo = false; 43 bool _switchingVideo = false;
35 bool _handlingCompletion = false; 44 bool _handlingCompletion = false;
  45 + bool _bufferingWarningShown = false;
36 bool? _lastIsPlaying; 46 bool? _lastIsPlaying;
37 int? _completedReportedVideoId; 47 int? _completedReportedVideoId;
38 Object? _loadError; 48 Object? _loadError;
@@ -56,6 +66,8 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -56,6 +66,8 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
56 Future<void> _loadCurrentVideo() async { 66 Future<void> _loadCurrentVideo() async {
57 final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS; 67 final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS;
58 _cacheTimer?.cancel(); 68 _cacheTimer?.cancel();
  69 + _bufferingTimer?.cancel();
  70 + _bufferingWarningShown = false;
59 final oldController = _controller; 71 final oldController = _controller;
60 final oldChewieController = _chewieController; 72 final oldChewieController = _chewieController;
61 if (oldController?.value.isInitialized == true) { 73 if (oldController?.value.isInitialized == true) {
@@ -65,7 +77,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -65,7 +77,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
65 _lastIsPlaying = null; 77 _lastIsPlaying = null;
66 final controller = VideoPlayerController.networkUrl( 78 final controller = VideoPlayerController.networkUrl(
67 Uri.parse(_currentItem.videoUrl), 79 Uri.parse(_currentItem.videoUrl),
68 - viewType: VideoViewType.textureView, 80 + viewType: VideoRenderingPreference.viewType,
69 ); 81 );
70 _controller = controller; 82 _controller = controller;
71 _chewieController = null; 83 _chewieController = null;
@@ -73,17 +85,22 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -73,17 +85,22 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
73 if (mounted) { 85 if (mounted) {
74 setState(() {}); 86 setState(() {});
75 } 87 }
76 - oldChewieController?.dispose();  
77 - await oldController?.dispose();  
78 - 88 + var stage = '释放旧播放器';
79 try { 89 try {
80 - await controller.initialize().timeout(const Duration(seconds: 20)); 90 + oldChewieController?.dispose();
  91 + await oldController?.dispose().timeout(_playerCommandTimeout);
  92 + stage = '初始化视频流';
  93 + await controller.initialize().timeout(_initializeTimeout);
81 if (!mounted || controller != _controller) { 94 if (!mounted || controller != _controller) {
82 await controller.dispose(); 95 await controller.dispose();
83 return; 96 return;
84 } 97 }
85 - await controller.setLooping(false);  
86 - await controller.setPlaybackSpeed(_playbackSpeed); 98 + stage = '配置播放器';
  99 + await controller.setLooping(false).timeout(_playerCommandTimeout);
  100 + await controller
  101 + .setPlaybackSpeed(_playbackSpeed)
  102 + .timeout(_playerCommandTimeout);
  103 + stage = '读取本地播放进度';
87 final savedProgress = await _resumeProgressFor(_currentItem.id); 104 final savedProgress = await _resumeProgressFor(_currentItem.id);
88 if (!mounted || controller != _controller) { 105 if (!mounted || controller != _controller) {
89 await controller.dispose(); 106 await controller.dispose();
@@ -91,10 +108,25 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -91,10 +108,25 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
91 } 108 }
92 if (savedProgress > 0 && 109 if (savedProgress > 0 &&
93 savedProgress < controller.value.duration.inSeconds) { 110 savedProgress < controller.value.duration.inSeconds) {
94 - await controller.seekTo(Duration(seconds: savedProgress)); 111 + stage = '恢复播放进度';
  112 + try {
  113 + await controller
  114 + .seekTo(Duration(seconds: savedProgress))
  115 + .timeout(_playerCommandTimeout);
  116 + } on TimeoutException catch (error) {
  117 + debugPrint(
  118 + '恢复本地视频进度超时(videoId=${_currentItem.id}): $error',
  119 + );
  120 + _showVideoToast('恢复进度超时,已尝试从头播放');
  121 + } catch (error) {
  122 + debugPrint('恢复本地视频进度失败(videoId=${_currentItem.id}): $error');
  123 + _showVideoToast('恢复进度失败,已尝试从头播放');
  124 + }
95 } 125 }
  126 + stage = '创建播放界面';
96 _lastIsPlaying = controller.value.isPlaying; 127 _lastIsPlaying = controller.value.isPlaying;
97 controller.addListener(_onVideoChanged); 128 controller.addListener(_onVideoChanged);
  129 + _updateBufferingWatch(controller);
98 _chewieController = ChewieController( 130 _chewieController = ChewieController(
99 videoPlayerController: controller, 131 videoPlayerController: controller,
100 autoPlay: true, 132 autoPlay: true,
@@ -141,19 +173,39 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -141,19 +173,39 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
141 ); 173 );
142 setState(() {}); 174 setState(() {});
143 } catch (error) { 175 } catch (error) {
144 - if (mounted && controller == _controller) {  
145 - debugPrint('视频初始化失败(videoId=${_currentItem.id}): $error');  
146 - setState(() => _loadError = error); 176 + final failedCurrentController = controller == _controller;
  177 + if (failedCurrentController) {
  178 + controller.removeListener(_onVideoChanged);
  179 + _bufferingTimer?.cancel();
  180 + _bufferingTimer = null;
  181 + _cacheTimer?.cancel();
  182 + _cacheTimer = null;
  183 + _chewieController?.dispose();
  184 + _chewieController = null;
147 } 185 }
  186 + await VideoPlaybackDiagnostics.disposeController(
  187 + controller,
  188 + context: '视频专区初始化失败($stage)',
  189 + );
  190 + if (!mounted || !failedCurrentController) return;
  191 + _controller = null;
  192 + final message = _diagnosticMessage(stage, error);
  193 + debugPrint('$message (videoId=${_currentItem.id}): $error');
  194 + _showVideoToast('$message(视频ID: ${_currentItem.id})');
  195 + setState(() => _loadError = error);
148 } 196 }
149 } 197 }
150 198
151 void _onVideoChanged() { 199 void _onVideoChanged() {
152 final controller = _controller; 200 final controller = _controller;
153 if (!mounted || controller == null) return; 201 if (!mounted || controller == null) return;
  202 + _updateBufferingWatch(controller);
154 if (controller.value.hasError && _loadError == null) { 203 if (controller.value.hasError && _loadError == null) {
155 final error = controller.value.errorDescription ?? '未知播放器错误'; 204 final error = controller.value.errorDescription ?? '未知播放器错误';
156 debugPrint('视频播放失败(videoId=${_currentItem.id}): $error'); 205 debugPrint('视频播放失败(videoId=${_currentItem.id}): $error');
  206 + _showVideoToast(
  207 + '${_diagnosticMessage('播放/解码', error)}(视频ID: ${_currentItem.id})',
  208 + );
157 setState(() => _loadError = error); 209 setState(() => _loadError = error);
158 return; 210 return;
159 } 211 }
@@ -171,6 +223,30 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -171,6 +223,30 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
171 } 223 }
172 } 224 }
173 225
  226 + void _updateBufferingWatch(VideoPlayerController controller) {
  227 + if (!controller.value.isBuffering) {
  228 + _bufferingTimer?.cancel();
  229 + _bufferingTimer = null;
  230 + _bufferingWarningShown = false;
  231 + return;
  232 + }
  233 + if (_bufferingTimer != null || _bufferingWarningShown) return;
  234 + _bufferingTimer = Timer(_bufferingWarningDelay, () {
  235 + _bufferingTimer = null;
  236 + if (!mounted ||
  237 + controller != _controller ||
  238 + !controller.value.isBuffering) {
  239 + return;
  240 + }
  241 + _bufferingWarningShown = true;
  242 + final error = controller.value.errorDescription;
  243 + final reason =
  244 + error == null ? '网络缓冲或设备解码可能异常' : _diagnosticMessage('播放/解码', error);
  245 + debugPrint('视频持续缓冲(videoId=${_currentItem.id}): $reason ${error ?? ''}');
  246 + _showVideoToast('视频持续缓冲超过10秒:$reason(视频ID: ${_currentItem.id})');
  247 + });
  248 + }
  249 +
174 Future<void> _playNext({bool currentCompleted = false}) async { 250 Future<void> _playNext({bool currentCompleted = false}) async {
175 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; 251 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return;
176 _switchingVideo = true; 252 _switchingVideo = true;
@@ -196,6 +272,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -196,6 +272,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
196 SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), 272 SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
197 ); 273 );
198 _cacheTimer?.cancel(); 274 _cacheTimer?.cancel();
  275 + _bufferingTimer?.cancel();
199 _controller?.removeListener(_onVideoChanged); 276 _controller?.removeListener(_onVideoChanged);
200 unawaited(_reportCurrentProgress()); 277 unawaited(_reportCurrentProgress());
201 _chewieController?.dispose(); 278 _chewieController?.dispose();
@@ -246,14 +323,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -246,14 +323,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
246 323
247 Future<int> _resumeProgressFor(int videoId) async { 324 Future<int> _resumeProgressFor(int videoId) async {
248 try { 325 try {
249 - final cached = await VideoProgressCache.get(videoId); 326 + final cached =
  327 + await VideoProgressCache.get(videoId).timeout(_progressReadTimeout);
250 if (cached != null) return cached.progressSeconds; 328 if (cached != null) return cached.progressSeconds;
  329 + } on TimeoutException catch (error) {
  330 + debugPrint('读取本地视频进度超时(videoId=$videoId): $error');
  331 + _showVideoToast('本地进度读取超时,已从头播放');
251 } catch (error) { 332 } catch (error) {
252 debugPrint('读取本地视频播放进度失败: $error'); 333 debugPrint('读取本地视频播放进度失败: $error');
  334 + _showVideoToast('本地进度读取失败,已从头播放');
253 } 335 }
254 return 0; 336 return 0;
255 } 337 }
256 338
  339 + String _diagnosticMessage(String stage, Object error) {
  340 + final detail = error.toString().toLowerCase();
  341 + if (error is TimeoutException) {
  342 + if (stage == '初始化视频流') return '视频初始化超时,请检查网络或设备解码能力';
  343 + if (stage == '释放旧播放器') return '播放器释放超时,请退出后重试';
  344 + if (stage == '配置播放器') return '播放器配置超时,设备可能不支持当前播放参数';
  345 + if (stage == '恢复播放进度') return '恢复播放进度超时';
  346 + return '$stage超时';
  347 + }
  348 + if (detail.contains('decoder') ||
  349 + detail.contains('codec') ||
  350 + detail.contains('mediacodec') ||
  351 + detail.contains('acodec')) {
  352 + return '视频解码器启动失败,可能是设备兼容性问题';
  353 + }
  354 + if (detail.contains('http') ||
  355 + detail.contains('socket') ||
  356 + detail.contains('network') ||
  357 + detail.contains('connection') ||
  358 + detail.contains('source error') ||
  359 + detail.contains('sourceerror')) {
  360 + return '视频网络请求失败,请检查网络或视频地址';
  361 + }
  362 + if (stage == '初始化视频流') return '视频流初始化失败';
  363 + if (stage == '配置播放器') return '播放器配置失败';
  364 + if (stage == '播放/解码') return '视频播放/解码失败,请检查网络或设备兼容性';
  365 + return '$stage失败';
  366 + }
  367 +
  368 + void _showVideoToast(String message) {
  369 + showToast(message, duration: const Duration(seconds: 4));
  370 + }
  371 +
257 Future<void> _cacheCurrentProgress() async { 372 Future<void> _cacheCurrentProgress() async {
258 final controller = _controller; 373 final controller = _controller;
259 if (controller == null || !controller.value.isInitialized) return; 374 if (controller == null || !controller.value.isInitialized) return;
pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: &#39;none&#39; # Remove this line if you wish to publish to pub.dev @@ -16,7 +16,7 @@ publish_to: &#39;none&#39; # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts 17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix. 18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.0.16+16 19 +version: 1.0.17+17
20 20
21 environment: 21 environment:
22 sdk: '>=3.6.0 <4.0.0' 22 sdk: '>=3.6.0 <4.0.0'