import 'dart:async'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:wow_english/common/request/dao/video_dao.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 { VideoPlayerController? _controller; Timer? _progressTimer; late int _currentIndex; double _playbackSpeed = 1; bool _showControls = true; bool _switchingVideo = false; bool _handlingCompletion = false; int? _lastReportedPosition; int? _completedReportedVideoId; Object? _loadError; VideoZoneItem get _currentItem => widget.items[_currentIndex]; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); final index = widget.initialVideoId == null ? 0 : widget.items.indexWhere((item) => item.id == widget.initialVideoId); _currentIndex = index < 0 ? 0 : index; _loadCurrentVideo(); } Future _loadCurrentVideo() async { _progressTimer?.cancel(); final oldController = _controller; oldController?.removeListener(_onVideoChanged); final controller = VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl)); _controller = controller; _loadError = null; if (mounted) { setState(() {}); } await oldController?.dispose(); try { await controller.initialize(); if (!mounted || controller != _controller) { await controller.dispose(); return; } await controller.setLooping(false); await controller.setPlaybackSpeed(_playbackSpeed); final savedProgress = _currentItem.completed ? 0 : _currentItem.progressSeconds; if (savedProgress > 0 && savedProgress < controller.value.duration.inSeconds) { await controller.seekTo(Duration(seconds: savedProgress)); } controller.addListener(_onVideoChanged); await controller.play(); _lastReportedPosition = null; _handlingCompletion = false; _progressTimer = Timer.periodic( const Duration(seconds: 5), (_) => _reportCurrentProgress(), ); setState(() {}); } catch (error) { if (mounted && controller == _controller) { setState(() => _loadError = error); } } } void _onVideoChanged() { final controller = _controller; if (!mounted || controller == null) return; if (controller.value.isCompleted && !_switchingVideo && !_handlingCompletion && _completedReportedVideoId != _currentItem.id) { _handlingCompletion = true; unawaited(_handleVideoCompleted()); return; } setState(() {}); } Future _playNext({bool currentCompleted = false}) async { if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; _switchingVideo = true; await _reportCurrentProgress(completed: currentCompleted, force: true); setState(() => _currentIndex++); await _loadCurrentVideo(); _switchingVideo = false; } Future _handleVideoCompleted() async { if (_currentIndex < widget.items.length - 1) { await _playNext(currentCompleted: true); } else { await _reportCurrentProgress(completed: true, force: true); } _handlingCompletion = false; } Future _togglePlay() async { final controller = _controller; if (controller == null || !controller.value.isInitialized) return; if (controller.value.isCompleted) { await controller.seekTo(Duration.zero); _completedReportedVideoId = null; } if (controller.value.isPlaying) { await controller.pause(); await _reportCurrentProgress(force: true); } else { await controller.play(); } if (mounted) setState(() {}); } Future _setSpeed(double speed) async { _playbackSpeed = speed; await _controller?.setPlaybackSpeed(speed); if (mounted) setState(() {}); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _progressTimer?.cancel(); _controller?.removeListener(_onVideoChanged); unawaited(_reportCurrentProgress(force: true)); _controller?.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { unawaited(_reportCurrentProgress(force: true)); } } Future _reportCurrentProgress({ bool? completed, bool force = false, }) async { final controller = _controller; if (controller == null || !controller.value.isInitialized) return; final durationSeconds = controller.value.duration.inSeconds > 0 ? controller.value.duration.inSeconds : _currentItem.durationSeconds; final progressSeconds = controller.value.position.inSeconds .clamp(0, durationSeconds > 0 ? durationSeconds : 0); final isCompleted = completed ?? controller.value.isCompleted; if (!force && _lastReportedPosition == progressSeconds) return; if (isCompleted && _completedReportedVideoId == _currentItem.id) return; _lastReportedPosition = progressSeconds; try { await VideoDao.savePlayRecord( videoId: _currentItem.id, progressSeconds: progressSeconds, durationSeconds: durationSeconds, completed: isCompleted, ); if (isCompleted) _completedReportedVideoId = _currentItem.id; } catch (error) { debugPrint('保存视频播放记录失败: $error'); } } @override Widget build(BuildContext context) { final controller = _controller; return Scaffold( backgroundColor: Colors.black, body: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _showControls = !_showControls), onDoubleTap: _togglePlay, child: Stack( fit: StackFit.expand, children: [ if (controller?.value.isInitialized == true) Center( child: AspectRatio( aspectRatio: controller!.value.aspectRatio, child: VideoPlayer(controller), ), ) 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()), if (_showControls) _buildControls(controller), ], ), ), ); } Widget _buildControls(VideoPlayerController? controller) { final initialized = controller?.value.isInitialized == true; return ColoredBox( color: Colors.black38, child: SafeArea( child: Column( children: [ 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), ), ), PopupMenuButton( initialValue: _playbackSpeed, onSelected: _setSpeed, itemBuilder: (_) => const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] .map((speed) => PopupMenuItem( value: speed, child: Text('${speed}x'), )) .toList(), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 12), child: Text('${_playbackSpeed}x', style: const TextStyle(color: Colors.white, fontSize: 16)), ), ), ], ), const Spacer(), if (initialized) IconButton( onPressed: _togglePlay, iconSize: 68, color: Colors.white, icon: Icon(controller!.value.isPlaying ? Icons.pause_circle_filled : Icons.play_circle_fill), ), const Spacer(), if (initialized) Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), child: Row( children: [ Expanded( child: VideoProgressIndicator( controller!, allowScrubbing: true, padding: const EdgeInsets.symmetric(vertical: 12), colors: const VideoProgressColors( playedColor: Color(0xFF00B6F1), bufferedColor: Colors.white54, backgroundColor: Colors.white24, ), ), ), const SizedBox(width: 12), Text( '${_formatDuration(controller.value.position)} / ' '${_formatDuration(controller.value.duration)}', style: const TextStyle(color: Colors.white), ), if (_currentIndex < widget.items.length - 1) ...[ const SizedBox(width: 8), IconButton( tooltip: '下一集', onPressed: _playNext, icon: const Icon(Icons.skip_next, color: Colors.white), ), ], ], ), ), ], ), ), ); } String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); if (duration.inHours == 0) return '$minutes:$seconds'; return '${duration.inHours.toString().padLeft(2, '0')}:$minutes:$seconds'; } }