Commit dbca8811e049481fad7ad5466fd32488043a4d24
1 parent
58c3551b
fix: 修复视频兼容性并增加渲染模式切换
升级应用版本至 1.0.17+17。为视频专区、课程视频和儿歌/看视频统一增加 Texture 与 PlatformView 渲染模式配置,设置页提供 Android 兼容性测试开关并持久化选择。补充播放器初始化超时、持续缓冲、网络/解码错误提示和重试流程,失败时释放播放器资源;视频专区列表请求增加超时及错误反馈。
Showing
8 changed files
with
802 additions
and
264 deletions
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<RepeatVideoWidget> { | @@ -23,32 +27,75 @@ class _RepeatVideoWidgetState extends State<RepeatVideoWidget> { | ||
| 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<RepeatVideoWidget> { | @@ -56,127 +103,207 @@ class _RepeatVideoWidgetState extends State<RepeatVideoWidget> { | ||
| 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<SettingPage> { | @@ -62,6 +66,10 @@ class SettingPageState extends State<SettingPage> { | ||
| 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<SettingPage> { | @@ -73,6 +81,49 @@ class SettingPageState extends State<SettingPage> { | ||
| 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<VideoWidget> { | @@ -36,6 +40,11 @@ class _VideoWidgetState extends State<VideoWidget> { | ||
| 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<VideoWidget> { | @@ -142,19 +151,132 @@ class _VideoWidgetState extends State<VideoWidget> { | ||
| 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<VideoWidget> { | @@ -162,12 +284,18 @@ class _VideoWidgetState extends State<VideoWidget> { | ||
| 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<VideoWidget> { | @@ -180,7 +308,7 @@ class _VideoWidgetState extends State<VideoWidget> { | ||
| 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<VideoWidget> { | @@ -190,65 +318,78 @@ class _VideoWidgetState extends State<VideoWidget> { | ||
| 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<VideoWidget> { | @@ -256,8 +397,18 @@ class _VideoWidgetState extends State<VideoWidget> { | ||
| 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 'package:wow_english/common/dialogs/show_dialog.dart'; | @@ -5,6 +7,7 @@ import 'package:wow_english/common/dialogs/show_dialog.dart'; | ||
| 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<VideoZonePage> { | @@ -21,77 +24,95 @@ class _VideoZonePageState extends State<VideoZonePage> { | ||
| 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 'package:flutter/material.dart'; | @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; | ||
| 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<VideoZonePlayerPage> | @@ -56,6 +66,8 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -65,7 +77,7 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -73,17 +85,22 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -91,10 +108,25 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -141,19 +173,39 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -171,6 +223,30 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -196,6 +272,7 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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<VideoZonePlayerPage> | @@ -246,14 +323,52 @@ class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> | ||
| 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: 'none' # Remove this line if you wish to publish to pub.dev | @@ -16,7 +16,7 @@ publish_to: 'none' # 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' |