import 'dart:async'; import 'package:chewie/chewie.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:wow_english/common/request/dao/video_dao.dart'; import 'package:wow_english/common/utils/video_playback_diagnostics.dart'; import 'package:wow_english/common/utils/video_progress_cache.dart'; import 'package:wow_english/common/utils/video_rendering_preference.dart'; import 'package:wow_english/utils/toast_util.dart'; import 'video_zone_item.dart'; class VideoZonePlayerPage extends StatefulWidget { const VideoZonePlayerPage({ super.key, required this.items, this.initialVideoId, }) : assert(items.length > 0, '视频播放列表不能为空'); final List items; final int? initialVideoId; @override State createState() => _VideoZonePlayerPageState(); } class _VideoZonePlayerPageState extends State with WidgetsBindingObserver { static const _initializeTimeout = Duration(seconds: 10); static const _playerCommandTimeout = Duration(seconds: 5); static const _progressReadTimeout = Duration(seconds: 2); static const _bufferingWarningDelay = Duration(seconds: 10); VideoPlayerController? _controller; ChewieController? _chewieController; Timer? _cacheTimer; Timer? _bufferingTimer; late int _currentIndex; double _playbackSpeed = 1; bool _switchingVideo = false; bool _handlingCompletion = false; bool _bufferingWarningShown = false; bool? _lastIsPlaying; int? _completedReportedVideoId; Object? _loadError; VideoZoneItem get _currentItem => widget.items[_currentIndex]; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); unawaited( SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), ); final index = widget.initialVideoId == null ? 0 : widget.items.indexWhere((item) => item.id == widget.initialVideoId); _currentIndex = index < 0 ? 0 : index; _loadCurrentVideo(); } Future _loadCurrentVideo() async { final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS; _cacheTimer?.cancel(); _bufferingTimer?.cancel(); _bufferingWarningShown = false; final oldController = _controller; final oldChewieController = _chewieController; if (oldController?.value.isInitialized == true) { _playbackSpeed = oldController!.value.playbackSpeed; } oldController?.removeListener(_onVideoChanged); _lastIsPlaying = null; final controller = VideoPlayerController.networkUrl( Uri.parse(_currentItem.videoUrl), viewType: VideoRenderingPreference.viewType, ); _controller = controller; _chewieController = null; _loadError = null; if (mounted) { setState(() {}); } var stage = '释放旧播放器'; try { oldChewieController?.dispose(); await oldController?.dispose().timeout(_playerCommandTimeout); stage = '初始化视频流'; await controller.initialize().timeout(_initializeTimeout); if (!mounted || controller != _controller) { await controller.dispose(); return; } stage = '配置播放器'; await controller.setLooping(false).timeout(_playerCommandTimeout); await controller .setPlaybackSpeed(_playbackSpeed) .timeout(_playerCommandTimeout); stage = '读取本地播放进度'; final savedProgress = await _resumeProgressFor(_currentItem.id); if (!mounted || controller != _controller) { await controller.dispose(); return; } if (savedProgress > 0 && savedProgress < controller.value.duration.inSeconds) { stage = '恢复播放进度'; try { await controller .seekTo(Duration(seconds: savedProgress)) .timeout(_playerCommandTimeout); } on TimeoutException catch (error) { debugPrint( '恢复本地视频进度超时(videoId=${_currentItem.id}): $error', ); _showVideoToast('恢复进度超时,已尝试从头播放'); } catch (error) { debugPrint('恢复本地视频进度失败(videoId=${_currentItem.id}): $error'); _showVideoToast('恢复进度失败,已尝试从头播放'); } } stage = '创建播放界面'; _lastIsPlaying = controller.value.isPlaying; controller.addListener(_onVideoChanged); _updateBufferingWatch(controller); _chewieController = ChewieController( videoPlayerController: controller, autoPlay: true, looping: false, allowFullScreen: false, allowPlaybackSpeedChanging: true, draggableProgressBar: true, allowedScreenSleep: false, playbackSpeeds: const [0.5, 0.75, 1, 1.25, 1.5, 2], customControls: useCupertinoControls ? null : const _VideoMaterialControls(), optionsTranslation: OptionsTranslation( playbackSpeedButtonText: '播放速度', cancelButtonText: '取消', ), materialProgressColors: ChewieProgressColors( playedColor: const Color(0xFF00B6F1), handleColor: const Color(0xFF00B6F1), bufferedColor: Colors.white54, backgroundColor: Colors.white24, ), cupertinoProgressColors: ChewieProgressColors( playedColor: const Color(0xFF00B6F1), handleColor: const Color(0xFF00B6F1), bufferedColor: Colors.white54, backgroundColor: Colors.white24, ), placeholder: const ColoredBox(color: Colors.black), errorBuilder: (_, __) => const Center( child: Text( '视频播放失败', style: TextStyle(color: Colors.white, fontSize: 18), ), ), ); _handlingCompletion = false; _cacheTimer = Timer.periodic( const Duration(seconds: 30), (_) { if (_controller?.value.isPlaying == true) { unawaited(_cacheCurrentProgress()); } }, ); setState(() {}); } catch (error) { final failedCurrentController = controller == _controller; if (failedCurrentController) { controller.removeListener(_onVideoChanged); _bufferingTimer?.cancel(); _bufferingTimer = null; _cacheTimer?.cancel(); _cacheTimer = null; _chewieController?.dispose(); _chewieController = null; } await VideoPlaybackDiagnostics.disposeController( controller, context: '视频专区初始化失败($stage)', ); if (!mounted || !failedCurrentController) return; _controller = null; final message = _diagnosticMessage(stage, error); debugPrint('$message (videoId=${_currentItem.id}): $error'); _showVideoToast('$message(视频ID: ${_currentItem.id})'); setState(() => _loadError = error); } } void _onVideoChanged() { final controller = _controller; if (!mounted || controller == null) return; _updateBufferingWatch(controller); if (controller.value.hasError && _loadError == null) { final error = controller.value.errorDescription ?? '未知播放器错误'; debugPrint('视频播放失败(videoId=${_currentItem.id}): $error'); _showVideoToast( '${_diagnosticMessage('播放/解码', error)}(视频ID: ${_currentItem.id})', ); setState(() => _loadError = error); return; } final isPlaying = controller.value.isPlaying; if (_lastIsPlaying != null && _lastIsPlaying != isPlaying) { unawaited(_cacheCurrentProgress()); } _lastIsPlaying = isPlaying; if (controller.value.isCompleted && !_switchingVideo && !_handlingCompletion && _completedReportedVideoId != _currentItem.id) { _handlingCompletion = true; unawaited(_handleVideoCompleted()); } } void _updateBufferingWatch(VideoPlayerController controller) { if (!controller.value.isBuffering) { _bufferingTimer?.cancel(); _bufferingTimer = null; _bufferingWarningShown = false; return; } if (_bufferingTimer != null || _bufferingWarningShown) return; _bufferingTimer = Timer(_bufferingWarningDelay, () { _bufferingTimer = null; if (!mounted || controller != _controller || !controller.value.isBuffering) { return; } _bufferingWarningShown = true; final error = controller.value.errorDescription; final reason = error == null ? '网络缓冲或设备解码可能异常' : _diagnosticMessage('播放/解码', error); debugPrint('视频持续缓冲(videoId=${_currentItem.id}): $reason ${error ?? ''}'); _showVideoToast('视频持续缓冲超过10秒:$reason(视频ID: ${_currentItem.id})'); }); } Future _playNext({bool currentCompleted = false}) async { if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; _switchingVideo = true; await _reportCurrentProgress(completed: currentCompleted); setState(() => _currentIndex++); await _loadCurrentVideo(); _switchingVideo = false; } Future _handleVideoCompleted() async { if (_currentIndex < widget.items.length - 1) { await _playNext(currentCompleted: true); } else { await _reportCurrentProgress(completed: true); } _handlingCompletion = false; } @override void dispose() { WidgetsBinding.instance.removeObserver(this); unawaited( SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), ); _cacheTimer?.cancel(); _bufferingTimer?.cancel(); _controller?.removeListener(_onVideoChanged); unawaited(_reportCurrentProgress()); _chewieController?.dispose(); _controller?.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { unawaited( SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), ); } else if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { unawaited(_cacheCurrentProgress()); } } Future _reportCurrentProgress({bool? completed}) async { final controller = _controller; if (controller == null || !controller.value.isInitialized) return; final currentItem = _currentItem; final durationSeconds = controller.value.duration.inSeconds; final progressSeconds = controller.value.position.inSeconds .clamp(0, durationSeconds > 0 ? durationSeconds : 0); final isCompleted = completed ?? controller.value.isCompleted; await _persistCachedProgress( videoId: currentItem.id, progressSeconds: progressSeconds, durationSeconds: durationSeconds, completed: isCompleted, ); if (isCompleted && _completedReportedVideoId == currentItem.id) return; try { await VideoDao.savePlayRecord( videoId: currentItem.id, progressSeconds: progressSeconds, durationSeconds: durationSeconds, completed: isCompleted, ); if (isCompleted) _completedReportedVideoId = currentItem.id; } catch (error) { debugPrint('保存视频播放记录失败: $error'); } } Future _resumeProgressFor(int videoId) async { try { final cached = await VideoProgressCache.get(videoId).timeout(_progressReadTimeout); if (cached != null) return cached.progressSeconds; } on TimeoutException catch (error) { debugPrint('读取本地视频进度超时(videoId=$videoId): $error'); _showVideoToast('本地进度读取超时,已从头播放'); } catch (error) { debugPrint('读取本地视频播放进度失败: $error'); _showVideoToast('本地进度读取失败,已从头播放'); } return 0; } String _diagnosticMessage(String stage, Object error) { final detail = error.toString().toLowerCase(); if (error is TimeoutException) { if (stage == '初始化视频流') return '视频初始化超时,请检查网络或设备解码能力'; if (stage == '释放旧播放器') return '播放器释放超时,请退出后重试'; if (stage == '配置播放器') return '播放器配置超时,设备可能不支持当前播放参数'; if (stage == '恢复播放进度') return '恢复播放进度超时'; return '$stage超时'; } if (detail.contains('decoder') || detail.contains('codec') || detail.contains('mediacodec') || detail.contains('acodec')) { return '视频解码器启动失败,可能是设备兼容性问题'; } if (detail.contains('http') || detail.contains('socket') || detail.contains('network') || detail.contains('connection') || detail.contains('source error') || detail.contains('sourceerror')) { return '视频网络请求失败,请检查网络或视频地址'; } if (stage == '初始化视频流') return '视频流初始化失败'; if (stage == '配置播放器') return '播放器配置失败'; if (stage == '播放/解码') return '视频播放/解码失败,请检查网络或设备兼容性'; return '$stage失败'; } void _showVideoToast(String message) { showToast(message, duration: const Duration(seconds: 4)); } Future _cacheCurrentProgress() async { final controller = _controller; if (controller == null || !controller.value.isInitialized) return; final currentItem = _currentItem; final durationSeconds = controller.value.duration.inSeconds; final progressSeconds = controller.value.position.inSeconds .clamp(0, durationSeconds > 0 ? durationSeconds : 0); await _persistCachedProgress( videoId: currentItem.id, progressSeconds: progressSeconds, durationSeconds: durationSeconds, completed: controller.value.isCompleted, ); } Future _persistCachedProgress({ required int videoId, required int progressSeconds, required int durationSeconds, required bool completed, }) async { try { if (completed) { await VideoProgressCache.remove(videoId); } else { await VideoProgressCache.save( videoId: videoId, progressSeconds: progressSeconds, durationSeconds: durationSeconds, ); } } catch (error) { debugPrint('保存本地视频播放进度失败: $error'); } } @override Widget build(BuildContext context) { final controller = _controller; final chewieController = _chewieController; return MediaQuery.removePadding( context: context, removeTop: true, child: Scaffold( backgroundColor: Colors.black, body: Stack( fit: StackFit.expand, children: [ if (controller?.value.isInitialized == true && chewieController != null) Chewie(controller: chewieController) else if (_loadError != null) Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('视频加载失败', style: TextStyle(color: Colors.white, fontSize: 18)), const SizedBox(height: 12), FilledButton( onPressed: _loadCurrentVideo, child: const Text('重试')), ], ), ) else const Center(child: CircularProgressIndicator()), SafeArea( top: false, child: Align( alignment: Alignment.topLeft, child: Row( children: [ IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), ), Expanded( child: Text( _currentItem.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white, fontSize: 20), ), ), ], ), ), ), ], ), ), ); } } class _VideoMaterialControls extends StatelessWidget { const _VideoMaterialControls(); @override Widget build(BuildContext context) { return Theme( data: Theme.of(context).copyWith( iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( minimumSize: const Size.square(72), padding: const EdgeInsets.all(20), ), ), ), child: const MaterialControls(), ); } }