flutter_integrated_whb/lib/customWidget/video_player_widget.dart

485 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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);
// 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<void> _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<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) {
// 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)}';
}
}