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 3 import 'package:flutter/material.dart';
2 4 import 'package:flutter_bloc/flutter_bloc.dart';
3 5 import 'package:flutter_screenutil/flutter_screenutil.dart';
4 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 9 import 'package:wow_english/models/read_content_entity.dart';
6 10  
7 11 import '../bloc/repeat_after_content_bloc.dart';
... ... @@ -23,32 +27,75 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; {
23 27 String _totalTime = '00:00';
24 28 double _playDegree = 0.5;
25 29 int _currentPlayIndex = 0;
  30 + Object? _loadError;
  31 + Timer? _bufferingTimer;
  32 + VoidCallback? _controllerListener;
  33 + bool _bufferingWarningShown = false;
  34 + int _loadGeneration = 0;
26 35  
27 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 41 return "$minutes:$seconds";
31 42 }
32 43  
33 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 103 void initState() {
57 104 super.initState();
58 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 112 @override
66 113 Widget build(BuildContext context) {
67 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 302 @override
178 303 void dispose() {
179   - _destroyVideo();
  304 + _loadGeneration++;
  305 + _bufferingTimer?.cancel();
  306 + unawaited(_destroyController());
180 307 super.dispose();
181 308 }
182 309 }
183 310 \ No newline at end of file
... ...
lib/pages/user/setting/setting_page.dart
  1 +import 'package:flutter/foundation.dart';
1 2 import 'package:flutter/material.dart';
2 3 import 'package:flutter_easyloading/flutter_easyloading.dart';
3 4 import 'package:flutter_screenutil/flutter_screenutil.dart';
4 5 import 'package:package_info_plus/package_info_plus.dart';
  6 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
5 7 import 'package:wow_english/common/widgets/we_app_bar.dart';
6 8 import 'package:wow_english/utils/toast_util.dart';
7 9  
... ... @@ -19,9 +21,11 @@ class SettingPage extends StatefulWidget {
19 21 class SettingPageState extends State<SettingPage> {
20 22 String? _version;
21 23 String? _buildNum;
  24 + bool _usePlatformView = false;
22 25 @override
23 26 void initState() {
24 27 super.initState();
  28 + _usePlatformView = VideoRenderingPreference.usesPlatformView;
25 29 _retrieveVersionInfo();
26 30 }
27 31  
... ... @@ -62,6 +66,10 @@ class SettingPageState extends State&lt;SettingPage&gt; {
62 66 _buildItemWidget('帮助与反馈', onPress: () {
63 67 pushNamed(AppRouteName.reBack);
64 68 }),
  69 + if (defaultTargetPlatform == TargetPlatform.android) ...[
  70 + 12.verticalSpace,
  71 + _buildVideoRenderingSetting(),
  72 + ],
65 73 12.verticalSpace,
66 74 _buildItemWidget('Version: $_version Build:$_buildNum',
67 75 onPress: () {}),
... ... @@ -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 127 Widget _buildItemWidget(String text, {VoidCallback? onPress}) {
77 128 return OutlinedButton(
78 129 onPressed: () => onPress?.call(),
... ...
lib/pages/video/lookvideo/widgets/video_widget.dart
  1 +import 'dart:async';
  2 +
1 3 import 'package:common_utils/common_utils.dart';
2 4 import 'package:flutter/material.dart';
3 5 import 'package:flutter_bloc/flutter_bloc.dart';
4 6 import 'package:flutter_screenutil/flutter_screenutil.dart';
5 7 import 'package:video_player/video_player.dart';
6 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 11 import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart';
8 12 import 'package:wow_english/route/route.dart';
9 13  
... ... @@ -36,6 +40,11 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
36 40 String _totalTime = '00:00';
37 41 double _playDegree = 0.0;
38 42 bool _hiddenTipView = false;
  43 + Object? _loadError;
  44 + Timer? _bufferingTimer;
  45 + int _loadGeneration = 0;
  46 + VoidCallback? _diagnosticListener;
  47 + bool _bufferingWarningShown = false;
39 48 TimerUtil? timerUtil;
40 49  
41 50 String formatDuration(Duration duration) {
... ... @@ -142,19 +151,132 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
142 151 @override
143 152 void initState() {
144 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 282 @override
... ... @@ -162,12 +284,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
162 284 return BlocListener<LookVideoBloc, BaseSectionState>(
163 285 listener: (context, state) async {
164 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 294 child: GestureDetector(
170 295 onTap: () {
  296 + if (_loadError != null || _controller?.value.isInitialized != true) {
  297 + return;
  298 + }
171 299 setState(() {
172 300 _hiddenTipView = !_hiddenTipView;
173 301 if (!_hiddenTipView) {
... ... @@ -180,7 +308,7 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
180 308 });
181 309 },
182 310 onDoubleTap: () {
183   - if (_controller!.value.isInitialized) {
  311 + if (_controller?.value.isInitialized == true) {
184 312 if (_controller!.value.isPlaying) {
185 313 _controller!.pause();
186 314 } else {
... ... @@ -190,65 +318,78 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
190 318 }
191 319 },
192 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 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 397  
257 398 @override
258 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 412 if (timerUtil != null) {
262 413 timerUtil!.cancel();
263 414 timerUtil = null;
... ...
lib/pages/video/zone/video_zone_page.dart
  1 +import 'dart:async';
  2 +
1 3 import 'package:cached_network_image/cached_network_image.dart';
2 4 import 'package:flutter/material.dart';
3 5 import 'package:flutter_screenutil/flutter_screenutil.dart';
... ... @@ -5,6 +7,7 @@ import &#39;package:wow_english/common/dialogs/show_dialog.dart&#39;;
5 7 import 'package:wow_english/common/request/dao/video_dao.dart';
6 8 import 'package:wow_english/common/widgets/we_app_bar.dart';
7 9 import 'package:wow_english/route/route.dart';
  10 +import 'package:wow_english/utils/toast_util.dart';
8 11  
9 12 import 'video_zone_item.dart';
10 13  
... ... @@ -21,77 +24,95 @@ class _VideoZonePageState extends State&lt;VideoZonePage&gt; {
21 24 @override
22 25 void initState() {
23 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 44 void _reload() {
28 45 setState(() {
29   - _itemsFuture = VideoDao.list();
  46 + _itemsFuture = _loadItems();
30 47 });
31 48 }
32 49  
33 50 @override
34 51 Widget build(BuildContext context) {
35 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 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 6 import 'package:flutter/services.dart';
7 7 import 'package:video_player/video_player.dart';
8 8 import 'package:wow_english/common/request/dao/video_dao.dart';
  9 +import 'package:wow_english/common/utils/video_playback_diagnostics.dart';
9 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 14 import 'video_zone_item.dart';
12 15  
... ... @@ -26,13 +29,20 @@ class VideoZonePlayerPage extends StatefulWidget {
26 29  
27 30 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage>
28 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 37 VideoPlayerController? _controller;
30 38 ChewieController? _chewieController;
31 39 Timer? _cacheTimer;
  40 + Timer? _bufferingTimer;
32 41 late int _currentIndex;
33 42 double _playbackSpeed = 1;
34 43 bool _switchingVideo = false;
35 44 bool _handlingCompletion = false;
  45 + bool _bufferingWarningShown = false;
36 46 bool? _lastIsPlaying;
37 47 int? _completedReportedVideoId;
38 48 Object? _loadError;
... ... @@ -56,6 +66,8 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
56 66 Future<void> _loadCurrentVideo() async {
57 67 final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS;
58 68 _cacheTimer?.cancel();
  69 + _bufferingTimer?.cancel();
  70 + _bufferingWarningShown = false;
59 71 final oldController = _controller;
60 72 final oldChewieController = _chewieController;
61 73 if (oldController?.value.isInitialized == true) {
... ... @@ -65,7 +77,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
65 77 _lastIsPlaying = null;
66 78 final controller = VideoPlayerController.networkUrl(
67 79 Uri.parse(_currentItem.videoUrl),
68   - viewType: VideoViewType.textureView,
  80 + viewType: VideoRenderingPreference.viewType,
69 81 );
70 82 _controller = controller;
71 83 _chewieController = null;
... ... @@ -73,17 +85,22 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
73 85 if (mounted) {
74 86 setState(() {});
75 87 }
76   - oldChewieController?.dispose();
77   - await oldController?.dispose();
78   -
  88 + var stage = '释放旧播放器';
79 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 94 if (!mounted || controller != _controller) {
82 95 await controller.dispose();
83 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 104 final savedProgress = await _resumeProgressFor(_currentItem.id);
88 105 if (!mounted || controller != _controller) {
89 106 await controller.dispose();
... ... @@ -91,10 +108,25 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
91 108 }
92 109 if (savedProgress > 0 &&
93 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 127 _lastIsPlaying = controller.value.isPlaying;
97 128 controller.addListener(_onVideoChanged);
  129 + _updateBufferingWatch(controller);
98 130 _chewieController = ChewieController(
99 131 videoPlayerController: controller,
100 132 autoPlay: true,
... ... @@ -141,19 +173,39 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
141 173 );
142 174 setState(() {});
143 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 199 void _onVideoChanged() {
152 200 final controller = _controller;
153 201 if (!mounted || controller == null) return;
  202 + _updateBufferingWatch(controller);
154 203 if (controller.value.hasError && _loadError == null) {
155 204 final error = controller.value.errorDescription ?? '未知播放器错误';
156 205 debugPrint('视频播放失败(videoId=${_currentItem.id}): $error');
  206 + _showVideoToast(
  207 + '${_diagnosticMessage('播放/解码', error)}(视频ID: ${_currentItem.id})',
  208 + );
157 209 setState(() => _loadError = error);
158 210 return;
159 211 }
... ... @@ -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 250 Future<void> _playNext({bool currentCompleted = false}) async {
175 251 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return;
176 252 _switchingVideo = true;
... ... @@ -196,6 +272,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
196 272 SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
197 273 );
198 274 _cacheTimer?.cancel();
  275 + _bufferingTimer?.cancel();
199 276 _controller?.removeListener(_onVideoChanged);
200 277 unawaited(_reportCurrentProgress());
201 278 _chewieController?.dispose();
... ... @@ -246,14 +323,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
246 323  
247 324 Future<int> _resumeProgressFor(int videoId) async {
248 325 try {
249   - final cached = await VideoProgressCache.get(videoId);
  326 + final cached =
  327 + await VideoProgressCache.get(videoId).timeout(_progressReadTimeout);
250 328 if (cached != null) return cached.progressSeconds;
  329 + } on TimeoutException catch (error) {
  330 + debugPrint('读取本地视频进度超时(videoId=$videoId): $error');
  331 + _showVideoToast('本地进度读取超时,已从头播放');
251 332 } catch (error) {
252 333 debugPrint('读取本地视频播放进度失败: $error');
  334 + _showVideoToast('本地进度读取失败,已从头播放');
253 335 }
254 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 372 Future<void> _cacheCurrentProgress() async {
258 373 final controller = _controller;
259 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 16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 17 # In Windows, build-name is used as the major, minor, and patch parts
18 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 21 environment:
22 22 sdk: '>=3.6.0 <4.0.0'
... ...