478 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			478 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
| import 'dart:async';
 | ||
| import 'dart:math';
 | ||
| import 'dart:ui';
 | ||
| import 'package:flutter/material.dart';
 | ||
| import 'package:flutter/services.dart';
 | ||
| import 'package:qhd_prevention/tools/tools.dart';
 | ||
| import 'package:video_player/video_player.dart';
 | ||
| 
 | ||
| class VideoPlayerWidget extends StatefulWidget {
 | ||
|   final VideoPlayerController? controller;
 | ||
|   final String coverUrl;
 | ||
|   final double aspectRatio;
 | ||
|   final bool allowSeek;
 | ||
|   final bool isFullScreen;
 | ||
| 
 | ||
|   const VideoPlayerWidget({
 | ||
|     Key? key,
 | ||
|     required this.controller,
 | ||
|     required this.coverUrl,
 | ||
|     required this.aspectRatio,
 | ||
|     this.allowSeek = true,
 | ||
|     this.isFullScreen = false,
 | ||
|   }) : super(key: key);
 | ||
| 
 | ||
|   @override
 | ||
|   _VideoPlayerWidgetState createState() => _VideoPlayerWidgetState();
 | ||
| }
 | ||
| 
 | ||
| class _VideoPlayerWidgetState extends State<VideoPlayerWidget> {
 | ||
|   bool _visibleControls = true;
 | ||
|   Timer? _hideTimer;
 | ||
|   Timer? _positionTimer;
 | ||
|   Duration _currentPosition = Duration.zero;
 | ||
|   Duration _totalDuration = Duration.zero;
 | ||
|   bool _isPlaying = false;
 | ||
|   final ValueNotifier<double> _sliderValue = ValueNotifier<double>(0.0);
 | ||
| 
 | ||
|   // 判断是否有controller,避免被销毁后访问
 | ||
|   bool get _hasController => widget.controller != null;
 | ||
| 
 | ||
|   /// 安全判断 controller 是否初始化,避免抛异常
 | ||
|   bool _controllerInitializedSafe() {
 | ||
|     try {
 | ||
|       final c = widget.controller;
 | ||
|       if (c == null) return false;
 | ||
|       return c.value.isInitialized;
 | ||
|     } catch (e) {
 | ||
|       return false;
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   @override
 | ||
|   void initState() {
 | ||
|     super.initState();
 | ||
|     // 初始化时立即启动隐藏控制栏的定时器
 | ||
|     _startHideTimer();
 | ||
|     // 如果有controller并且初始化了,则启动进度定时器
 | ||
|     _maybeStartPositionTimer();
 | ||
|     // 给controller添加监听
 | ||
|     _addControllerListenerSafely();
 | ||
|     // 如果controller已经初始化,获取初始值
 | ||
|     if (_controllerInitializedSafe()) {
 | ||
|       _updateControllerValuesSafe();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   @override
 | ||
|   void didUpdateWidget(covariant VideoPlayerWidget oldWidget) {
 | ||
|     super.didUpdateWidget(oldWidget);
 | ||
| 
 | ||
|     // 如果controller发生变化,需要移除旧的监听,添加新的监听
 | ||
|     if (oldWidget.controller != widget.controller) {
 | ||
|       _removeControllerListenerSafely(oldWidget.controller);
 | ||
|       _addControllerListenerSafely();
 | ||
|       _restartPositionTimer();
 | ||
|       _updateControllerValuesSafe();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   void _addControllerListenerSafely() {
 | ||
|     try {
 | ||
|       widget.controller?.addListener(_controllerListener);
 | ||
|     } catch (_) {
 | ||
|       // 忽略已经被销毁的情况
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   void _removeControllerListenerSafely([VideoPlayerController? ctrl]) {
 | ||
|     final c = ctrl ?? widget.controller;
 | ||
|     if (c == null) return;
 | ||
|     try {
 | ||
|       c.removeListener(_controllerListener);
 | ||
|     } catch (_) {
 | ||
|       // 忽略
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   void _controllerListener() {
 | ||
|     if (!mounted) return;
 | ||
|     _updateControllerValuesSafe();
 | ||
|   }
 | ||
| 
 | ||
|   // 更新播放状态、进度等信息
 | ||
|   void _updateControllerValuesSafe() {
 | ||
|     final c = widget.controller;
 | ||
|     if (c == null) {
 | ||
|       // 没有controller时清空数据
 | ||
|       if (mounted) {
 | ||
|         setState(() {
 | ||
|           _isPlaying = false;
 | ||
|           _totalDuration = Duration.zero;
 | ||
|           _currentPosition = Duration.zero;
 | ||
|           _sliderValue.value = 0;
 | ||
|         });
 | ||
|       }
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     try {
 | ||
|       final value = c.value;
 | ||
|       if (!value.isInitialized) {
 | ||
|         if (mounted) {
 | ||
|           setState(() {
 | ||
|             _isPlaying = false;
 | ||
|             _totalDuration = Duration.zero;
 | ||
|             _currentPosition = Duration.zero;
 | ||
|             _sliderValue.value = 0;
 | ||
|           });
 | ||
|         }
 | ||
|         return;
 | ||
|       }
 | ||
| 
 | ||
|       final pos = value.position;
 | ||
|       final dur = value.duration;
 | ||
|       final playing = value.isPlaying;
 | ||
| 
 | ||
|       if (mounted) {
 | ||
|         setState(() {
 | ||
|           _isPlaying = playing;
 | ||
|           _totalDuration = dur;
 | ||
|           _currentPosition = pos;
 | ||
|           _sliderValue.value = pos.inMilliseconds.toDouble();
 | ||
|         });
 | ||
|       }
 | ||
|     } catch (e) {
 | ||
|       // controller被销毁时忽略
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   // 启动隐藏控制栏的定时器(3秒后隐藏)
 | ||
|   void _startHideTimer() {
 | ||
|     _hideTimer?.cancel();
 | ||
|     _hideTimer = Timer(const Duration(seconds: 3), () {
 | ||
|       if (mounted) setState(() => _visibleControls = false);
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   // 启动进度定时器(定时刷新播放进度)
 | ||
|   void _maybeStartPositionTimer() {
 | ||
|     if (_positionTimer != null && _positionTimer!.isActive) return;
 | ||
|     if (!_hasController) return;
 | ||
|     _positionTimer = Timer.periodic(const Duration(milliseconds: 300), (_) {
 | ||
|       if (!mounted) return;
 | ||
|       if (!_controllerInitializedSafe()) return;
 | ||
|       try {
 | ||
|         final c = widget.controller!;
 | ||
|         final pos = c.value.position;
 | ||
|         final dur = c.value.duration;
 | ||
|         final playing = c.value.isPlaying;
 | ||
|         setState(() {
 | ||
|           _currentPosition = pos;
 | ||
|           _totalDuration = dur;
 | ||
|           _isPlaying = playing;
 | ||
|           _sliderValue.value = pos.inMilliseconds.toDouble();
 | ||
|         });
 | ||
|       } catch (_) {
 | ||
|         // controller销毁时忽略
 | ||
|       }
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   void _restartPositionTimer() {
 | ||
|     _positionTimer?.cancel();
 | ||
|     _maybeStartPositionTimer();
 | ||
|   }
 | ||
| 
 | ||
|   void _stopPositionTimer() {
 | ||
|     try {
 | ||
|       _positionTimer?.cancel();
 | ||
|     } catch (_) {}
 | ||
|     _positionTimer = null;
 | ||
|   }
 | ||
| 
 | ||
|   // 点击切换控制栏显示/隐藏
 | ||
|   void _toggleControls() {
 | ||
|     setState(() => _visibleControls = !_visibleControls);
 | ||
|     if (_visibleControls)
 | ||
|       _startHideTimer();
 | ||
|     else
 | ||
|       _hideTimer?.cancel();
 | ||
|   }
 | ||
| 
 | ||
|   // 播放/暂停切换
 | ||
|   void _togglePlayPause() {
 | ||
|     final c = widget.controller;
 | ||
|     if (c == null) return;
 | ||
|     try {
 | ||
|       if (_isPlaying) {
 | ||
|         c.pause();
 | ||
|         setState(() => _isPlaying = false);
 | ||
|       } else {
 | ||
|         c.play();
 | ||
|         setState(() => _isPlaying = true);
 | ||
|       }
 | ||
|       _startHideTimer();
 | ||
|     } catch (_) {}
 | ||
|   }
 | ||
| 
 | ||
|   // 进入全屏播放
 | ||
|   Future<void> _enterFullScreen() async {
 | ||
|     try {
 | ||
|       // 设置横屏、沉浸式UI
 | ||
|       await SystemChrome.setPreferredOrientations([
 | ||
|         DeviceOrientation.landscapeLeft,
 | ||
|         DeviceOrientation.landscapeRight,
 | ||
|       ]);
 | ||
|       await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
 | ||
|       await NativeOrientation.setLandscape();
 | ||
|     } catch (_) {}
 | ||
| 
 | ||
|     // 跳转到全屏页面
 | ||
|     await Navigator.of(context).push(
 | ||
|       MaterialPageRoute(
 | ||
|         builder: (ctx) => Scaffold(
 | ||
|           backgroundColor: Colors.black,
 | ||
|           body: SafeArea(
 | ||
|             top: false,
 | ||
|             bottom: false,
 | ||
|             child: VideoPlayerWidget(
 | ||
|               controller: widget.controller,
 | ||
|               coverUrl: widget.coverUrl,
 | ||
|               aspectRatio: max(
 | ||
|                 widget.aspectRatio,
 | ||
|                 MediaQuery.of(ctx).size.width / MediaQuery.of(ctx).size.height,
 | ||
|               ),
 | ||
|               allowSeek: widget.allowSeek,
 | ||
|               isFullScreen: true,
 | ||
|             ),
 | ||
|           ),
 | ||
|         ),
 | ||
|       ),
 | ||
|     );
 | ||
| 
 | ||
|     // 返回后恢复竖屏和UI
 | ||
|     try {
 | ||
|       await NativeOrientation.setPortrait();
 | ||
|       await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
 | ||
|       await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 | ||
|     } catch (_) {}
 | ||
| 
 | ||
|     if (!mounted) return;
 | ||
|     setState(() {}); // 强制刷新,修复退出全屏后UI问题
 | ||
|   }
 | ||
| 
 | ||
|   @override
 | ||
|   void dispose() {
 | ||
|     _hideTimer?.cancel();
 | ||
|     _stopPositionTimer();
 | ||
|     _sliderValue.dispose();
 | ||
|     _removeControllerListenerSafely();
 | ||
|     super.dispose();
 | ||
|   }
 | ||
| 
 | ||
|   @override
 | ||
|   Widget build(BuildContext context) {
 | ||
|     final media = MediaQuery.of(context);
 | ||
|     final screenW = media.size.width;
 | ||
|     final screenH = media.size.height;
 | ||
| 
 | ||
|     // 非全屏高度:按照宽高比计算,但不超过屏幕一半高度
 | ||
|     final preferredNonFullHeight = min(
 | ||
|       screenW / (widget.aspectRatio <= 0 ? (16 / 9) : widget.aspectRatio),
 | ||
|       screenH * 0.5,
 | ||
|     );
 | ||
| 
 | ||
|     final containerW = screenW;
 | ||
|     final containerH = widget.isFullScreen ? screenH : preferredNonFullHeight;
 | ||
| 
 | ||
|     return Center(
 | ||
|       child: SizedBox(
 | ||
|         width: containerW,
 | ||
|         height: containerH,
 | ||
|         child: GestureDetector(
 | ||
|           behavior: HitTestBehavior.translucent,
 | ||
|           onTap: _toggleControls,
 | ||
|           child: Stack(
 | ||
|             fit: StackFit.expand,
 | ||
|             children: [
 | ||
|               // 视频画面或封面图
 | ||
|               _buildVideoOrCover(containerW, containerH),
 | ||
|               // 全屏模式时左上角显示返回按钮
 | ||
|               if (widget.isFullScreen)
 | ||
|                 Positioned(
 | ||
|                   top: MediaQuery.of(context).padding.top + 8,
 | ||
|                   left: 8,
 | ||
|                   child: SafeArea(
 | ||
|                     top: true,
 | ||
|                     bottom: false,
 | ||
|                     child: ClipOval(
 | ||
|                       child: Material(
 | ||
|                         color: Colors.black38,
 | ||
|                         child: InkWell(
 | ||
|                           onTap: () async {
 | ||
|                             try {
 | ||
|                               await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
 | ||
|                               await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 | ||
|                             } catch (_) {}
 | ||
|                             Navigator.of(context).maybePop();
 | ||
|                           },
 | ||
|                           child: const Padding(
 | ||
|                             padding: EdgeInsets.all(8.0),
 | ||
|                             child: Icon(
 | ||
|                               Icons.arrow_back,
 | ||
|                               color: Colors.white,
 | ||
|                               size: 22,
 | ||
|                             ),
 | ||
|                           ),
 | ||
|                         ),
 | ||
|                       ),
 | ||
|                     ),
 | ||
|                   ),
 | ||
|                 ),
 | ||
|               // 控制栏
 | ||
|               if (_visibleControls) _buildControls(),
 | ||
|             ],
 | ||
|           ),
 | ||
|         ),
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| 
 | ||
|   // 构建视频画面或封面图
 | ||
|   Widget _buildVideoOrCover(double containerW, double containerH) {
 | ||
|     final c = widget.controller;
 | ||
| 
 | ||
|     if (c != null) {
 | ||
|       try {
 | ||
|         if (c.value.isInitialized) {
 | ||
|           final vidAspect =
 | ||
|           (c.value.size.height > 0) ? (c.value.size.width / c.value.size.height) : widget.aspectRatio;
 | ||
|           return Center(
 | ||
|             child: AspectRatio(
 | ||
|               aspectRatio: vidAspect > 0 ? vidAspect : widget.aspectRatio,
 | ||
|               child: VideoPlayer(c),
 | ||
|             ),
 | ||
|           );
 | ||
|         }
 | ||
|       } catch (_) {}
 | ||
|     }
 | ||
| 
 | ||
|     if (widget.coverUrl.isNotEmpty) {
 | ||
|       return Image.network(
 | ||
|         widget.coverUrl,
 | ||
|         width: containerW,
 | ||
|         height: containerH,
 | ||
|         fit: BoxFit.cover,
 | ||
|         errorBuilder: (_, __, ___) => Container(color: Colors.black),
 | ||
|       );
 | ||
|     }
 | ||
| 
 | ||
|     return Container(color: Colors.black);
 | ||
|   }
 | ||
| 
 | ||
|   // 构建底部控制栏
 | ||
|   Widget _buildControls() {
 | ||
|     final totalMs = _totalDuration.inMilliseconds.toDouble();
 | ||
|     final sliderMax = totalMs > 0 ? totalMs : 1.0;
 | ||
|     final sliderValue = _sliderValue.value.clamp(0.0, sliderMax).toDouble();
 | ||
| 
 | ||
|     return Positioned(
 | ||
|       bottom: 0,
 | ||
|       left: 0,
 | ||
|       right: 0,
 | ||
|       child: Container(
 | ||
|         height: 50,
 | ||
|         decoration: BoxDecoration(
 | ||
|           gradient: LinearGradient(
 | ||
|             begin: Alignment.bottomCenter,
 | ||
|             end: Alignment.topCenter,
 | ||
|             colors: [
 | ||
|               Colors.black.withOpacity(0.7),
 | ||
|               Colors.transparent,
 | ||
|             ],
 | ||
|           ),
 | ||
|         ),
 | ||
|         padding: const EdgeInsets.symmetric(horizontal: 12),
 | ||
|         child: Row(
 | ||
|           children: [
 | ||
|             IconButton(
 | ||
|               padding: EdgeInsets.zero,
 | ||
|               icon: Icon(
 | ||
|                 _isPlaying ? Icons.pause : Icons.play_arrow,
 | ||
|                 size: 28,
 | ||
|                 color: Colors.white,
 | ||
|               ),
 | ||
|               onPressed: _togglePlayPause,
 | ||
|             ),
 | ||
|             Expanded(
 | ||
|               child: ValueListenableBuilder<double>(
 | ||
|                 valueListenable: _sliderValue,
 | ||
|                 builder: (_, value, __) => SliderTheme(
 | ||
|                   data: SliderTheme.of(context).copyWith(
 | ||
|                     activeTrackColor: Colors.white,
 | ||
|                     inactiveTrackColor: Colors.white54,
 | ||
|                     thumbColor: Colors.white,
 | ||
|                     overlayColor: Colors.white24,
 | ||
|                     trackHeight: 2,
 | ||
|                     thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8),
 | ||
|                   ),
 | ||
|                   child: Slider(
 | ||
|                     value: sliderValue,
 | ||
|                     min: 0,
 | ||
|                     max: sliderMax,
 | ||
|                     onChanged: (v) {
 | ||
|                       if (widget.allowSeek && widget.controller != null) {
 | ||
|                         try {
 | ||
|                           widget.controller!.seekTo(Duration(milliseconds: v.toInt()));
 | ||
|                           setState(() => _currentPosition = Duration(milliseconds: v.toInt()));
 | ||
|                           _sliderValue.value = v;
 | ||
|                           _startHideTimer();
 | ||
|                         } catch (_) {}
 | ||
|                       }
 | ||
|                     },
 | ||
|                   ),
 | ||
|                 ),
 | ||
|               ),
 | ||
|             ),
 | ||
|             SizedBox(
 | ||
|               width: 110,
 | ||
|               child: Text(
 | ||
|                 '${_formatDuration(_currentPosition)} / ${_formatDuration(_totalDuration)}',
 | ||
|                 style: TextStyle(
 | ||
|                   color: Colors.white,
 | ||
|                   fontSize: 12,
 | ||
|                   fontFeatures: [FontFeature.tabularFigures()],
 | ||
|                 ),
 | ||
|               ),
 | ||
|             ),
 | ||
|             IconButton(
 | ||
|               padding: EdgeInsets.zero,
 | ||
|               icon: Icon(
 | ||
|                 widget.isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
 | ||
|                 size: 28,
 | ||
|                 color: Colors.white,
 | ||
|               ),
 | ||
|               onPressed: () {
 | ||
|                 if (widget.isFullScreen) {
 | ||
|                   Navigator.of(context).maybePop();
 | ||
|                 } else {
 | ||
|                   _enterFullScreen();
 | ||
|                 }
 | ||
|               },
 | ||
|             ),
 | ||
|           ],
 | ||
|         ),
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| 
 | ||
|   // 格式化时间
 | ||
|   String _formatDuration(Duration d) {
 | ||
|     String twoDigits(int n) => n.toString().padLeft(2, '0');
 | ||
|     final h = d.inHours, m = d.inMinutes.remainder(60), s = d.inSeconds.remainder(60);
 | ||
|     if (h > 0) return '${twoDigits(h)}:${twoDigits(m)}:${twoDigits(s)}';
 | ||
|     return '${twoDigits(m)}:${twoDigits(s)}';
 | ||
|   }
 | ||
| }
 |