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 { bool _visibleControls = true; Timer? _hideTimer; Timer? _positionTimer; Duration _currentPosition = Duration.zero; Duration _totalDuration = Duration.zero; bool _isPlaying = false; final ValueNotifier _sliderValue = ValueNotifier(0.0); // Helpers to avoid accessing controller after it's disposed bool get _hasController => widget.controller != null; /// Safe check whether controller is initialized without throwing. 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(); // start hide timer immediately _startHideTimer(); // start position timer only if controller exists and initialized _maybeStartPositionTimer(); // add listener if controller present _addControllerListenerSafely(); // if controller already initialized, fetch values if (_controllerInitializedSafe()) { _updateControllerValuesSafe(); } } @override void didUpdateWidget(covariant VideoPlayerWidget oldWidget) { super.didUpdateWidget(oldWidget); // controller changed -> update listeners and timers if (oldWidget.controller != widget.controller) { _removeControllerListenerSafely(oldWidget.controller); _addControllerListenerSafely(); _restartPositionTimer(); _updateControllerValuesSafe(); } } void _addControllerListenerSafely() { try { widget.controller?.addListener(_controllerListener); } catch (_) { // ignore if controller already disposed } } void _removeControllerListenerSafely([VideoPlayerController? ctrl]) { final c = ctrl ?? widget.controller; if (c == null) return; try { c.removeListener(_controllerListener); } catch (_) { // ignore } } void _controllerListener() { if (!mounted) return; _updateControllerValuesSafe(); } void _updateControllerValuesSafe() { final c = widget.controller; if (c == null) { // clear values 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) { // If controller was disposed between checks, ignore } } void _startHideTimer() { _hideTimer?.cancel(); _hideTimer = Timer(const Duration(seconds: 3), () { if (mounted) setState(() => _visibleControls = false); }); } void _maybeStartPositionTimer() { if (_positionTimer != null && _positionTimer!.isActive) return; // only start if controller exists 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 (_) { // ignore if controller disposed mid-tick } }); } 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 (_) { // ignore if controller disposed } } Future _enterFullScreen() async { // prepare full screen orientation try { await SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); await NativeOrientation.setLandscape(); } catch (_) {} // push a new page with the same widget but isFullScreen = true 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, ), ), ), ), ); // on return, restore orientation and UI and force rebuild try { await NativeOrientation.setPortrait(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } catch (_) {} if (!mounted) return; // force a rebuild so parent layouts recalc (fixes overflow after exit) setState(() {}); } @override void dispose() { _hideTimer?.cancel(); _stopPositionTimer(); _sliderValue.dispose(); _removeControllerListenerSafely(); super.dispose(); } @override Widget build(BuildContext context) { // Compute constrained size so widget won't try to be infinite height in non-fullscreen usage. final media = MediaQuery.of(context); final screenW = media.size.width; final screenH = media.size.height; // Non-fullscreen preferred height: based on aspect ratio but not exceeding a fraction of screen height final preferredNonFullHeight = min(screenW / (widget.aspectRatio <= 0 ? (16 / 9) : widget.aspectRatio), screenH * 0.5); // at most 50% of screen height // If isFullScreen, use available height minus safe areas; otherwise use preferredNonFullHeight final containerW = widget.isFullScreen ? screenW : 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: [ // Video or cover: use AspectRatio to avoid forcing Column to expand _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 { // 可选:在 pop 之前恢复方向与系统 UI(也可以只 pop,让上层的 .then 处理恢复) try { await NativeOrientation.setPortrait(); } catch (_) {} 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, ), ), ), ), ), ), ), // Controls overlay if (_visibleControls) _buildControls(), ], ), ), ), ); } Widget _buildVideoOrCover(double containerW, double containerH) { final c = widget.controller; // If controller exists and is initialized (safe check), show video in AspectRatio if (c != null) { try { if (c.value.isInitialized) { // Use video's natural aspect ratio if available, otherwise widget.aspectRatio 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 (_) { // controller may be disposed; fall through to show cover } } // Fallback: show cover image (fills the container) if (widget.coverUrl.isNotEmpty) { return Image.network( widget.coverUrl, width: containerW, height: containerH, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: Colors.black), ); } // final fallback: black box return Container(color: Colors.black); } Widget _buildControls() { // Use local copies to avoid repeated reads that might throw if ctrl disposed 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( 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) { // if this widget is used inside a full screen route, popping will exit fullscreen 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)}'; } }