309 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			309 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
| import 'dart:async';
 | |
| import 'dart:math';
 | |
| import 'dart:ui';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.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;
 | |
|   late Timer _positionTimer;
 | |
|   Duration _currentPosition = Duration.zero;
 | |
|   Duration _totalDuration = Duration.zero;
 | |
|   bool _isPlaying = false;
 | |
|   final ValueNotifier<double> _sliderValue = ValueNotifier(0.0);
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     _startHideTimer();
 | |
|     _startPositionTimer();
 | |
|     if (widget.controller != null) {
 | |
|       widget.controller!.addListener(_controllerListener);
 | |
|       if (widget.controller!.value.isInitialized) {
 | |
|         _updateControllerValues();
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void didUpdateWidget(covariant VideoPlayerWidget oldWidget) {
 | |
|     super.didUpdateWidget(oldWidget);
 | |
|     if (widget.controller != oldWidget.controller) {
 | |
|       oldWidget.controller?.removeListener(_controllerListener);
 | |
|       widget.controller?.addListener(_controllerListener);
 | |
|       _updateControllerValues();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _controllerListener() {
 | |
|     if (!mounted) return;
 | |
|     _updateControllerValues();
 | |
|   }
 | |
| 
 | |
|   void _updateControllerValues() {
 | |
|     final c = widget.controller!;
 | |
|     setState(() {
 | |
|       _isPlaying = c.value.isPlaying;
 | |
|       _totalDuration = c.value.duration;
 | |
|       _currentPosition = c.value.position;
 | |
|       _sliderValue.value = _currentPosition.inMilliseconds.toDouble();
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void dispose() {
 | |
|     _hideTimer?.cancel();
 | |
|     _positionTimer.cancel();
 | |
|     _sliderValue.dispose();
 | |
|     widget.controller?.removeListener(_controllerListener);
 | |
|     super.dispose();
 | |
|   }
 | |
| 
 | |
|   void _startHideTimer() {
 | |
|     _hideTimer?.cancel();
 | |
|     _hideTimer = Timer(const Duration(seconds: 3), () {
 | |
|       if (mounted) setState(() => _visibleControls = false);
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   void _startPositionTimer() {
 | |
|     _positionTimer = Timer.periodic(const Duration(milliseconds: 200), (_) {
 | |
|       if (!mounted ||
 | |
|           widget.controller == null ||
 | |
|           !widget.controller!.value.isInitialized) return;
 | |
|       setState(() {
 | |
|         final c = widget.controller!;
 | |
|         _currentPosition = c.value.position;
 | |
|         _totalDuration = c.value.duration;
 | |
|         _isPlaying = c.value.isPlaying;
 | |
|         _sliderValue.value = _currentPosition.inMilliseconds.toDouble();
 | |
|       });
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   void _toggleControls() {
 | |
|     setState(() => _visibleControls = !_visibleControls);
 | |
|     if (_visibleControls) _startHideTimer();
 | |
|     else _hideTimer?.cancel();
 | |
|   }
 | |
| 
 | |
|   void _togglePlayPause() {
 | |
|     if (widget.controller == null) return;
 | |
|     setState(() => _isPlaying = !_isPlaying);
 | |
|     if (_isPlaying) widget.controller!.play();
 | |
|     else widget.controller!.pause();
 | |
|     _startHideTimer();
 | |
|   }
 | |
| 
 | |
|   void _enterFullScreen() {
 | |
|     SystemChrome.setPreferredOrientations([
 | |
|       DeviceOrientation.landscapeLeft,
 | |
|       DeviceOrientation.landscapeRight,
 | |
|     ]);
 | |
|     SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
 | |
| 
 | |
|     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,
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|       ),
 | |
|     )
 | |
|         .then((_) {
 | |
|       SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
 | |
|       SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     final screenW = MediaQuery.of(context).size.width;
 | |
|     final containerW = widget.isFullScreen ? double.infinity : screenW;
 | |
|     final containerH = widget.isFullScreen
 | |
|         ? double.infinity
 | |
|         : containerW / widget.aspectRatio;
 | |
| 
 | |
|     return Center(
 | |
|       child: SizedBox(
 | |
|         width: containerW,
 | |
|         height: containerH,
 | |
|         child: GestureDetector(
 | |
|           behavior: HitTestBehavior.translucent,    // ← 允许空白区域也响应
 | |
|           onTap: _toggleControls,
 | |
|           child: Stack(
 | |
|             fit: StackFit.expand,
 | |
|             children: [
 | |
|               // 视频或封面
 | |
|               if (widget.controller != null &&
 | |
|                   widget.controller!.value.isInitialized)
 | |
|                 FittedBox(
 | |
|                   fit: BoxFit.contain,
 | |
|                   alignment: Alignment.center,
 | |
|                   child: SizedBox(
 | |
|                     width: widget.controller!.value.size.width,
 | |
|                     height: widget.controller!.value.size.height,
 | |
|                     child: VideoPlayer(widget.controller!),
 | |
|                   ),
 | |
|                 )
 | |
|               else
 | |
|                 if (widget.coverUrl.length > 0)
 | |
|                   Image.network(
 | |
|                     widget.coverUrl,
 | |
|                     fit: BoxFit.cover,
 | |
|                     width: containerW,
 | |
|                     height: containerH,
 | |
|                   ),
 | |
| 
 | |
|               // 控制栏
 | |
|               if (_visibleControls)
 | |
|                 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: SliderTheme(
 | |
|                                 data: SliderTheme.of(context).copyWith(
 | |
|                                   activeTrackColor: Colors.white,       // 活跃轨道颜色
 | |
|                                   inactiveTrackColor: Colors.grey[400],// 非活跃轨道颜色
 | |
|                                   thumbColor: Colors.white,             // 滑块颜色
 | |
|                                   overlayColor: Colors.white.withAlpha(0x33), // 滑块按下外圈
 | |
|                                   disabledActiveTrackColor: Colors.white,    // 禁用时也用同样的活跃轨道
 | |
|                                   disabledInactiveTrackColor: Colors.grey[400],
 | |
|                                   disabledThumbColor: Colors.white,
 | |
|                                 ),
 | |
|                                 child: Slider(
 | |
|                                   value: value,
 | |
|                                   min: 0,
 | |
|                                   max: _totalDuration.inMilliseconds.toDouble(),
 | |
|                                   // 不管 allowSeek 如何,都不改变 onChanged
 | |
|                                   onChanged: (v) {
 | |
|                                     if (widget.allowSeek && widget.controller != null) {
 | |
|                                       widget.controller!.seekTo(Duration(milliseconds: v.toInt()));
 | |
|                                       setState(() => _currentPosition = Duration(milliseconds: v.toInt()));
 | |
|                                       _sliderValue.value = v;
 | |
|                                       _startHideTimer();
 | |
|                                     }
 | |
|                                   },
 | |
|                                 ),
 | |
|                               ),
 | |
|                             ),
 | |
|                           ),
 | |
|                         ),
 | |
|                         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: () {
 | |
|                             widget.isFullScreen
 | |
|                                 ? Navigator.of(context).pop()
 | |
|                                 : _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)}';
 | |
|   }
 | |
| }
 |