326 lines
11 KiB
Dart
326 lines
11 KiB
Dart
import 'dart:async';
|
||
import 'dart:math';
|
||
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) setState(() {});
|
||
|
||
if (mounted && widget.controller != null) {
|
||
_updateControllerValues();
|
||
}
|
||
}
|
||
|
||
void _updateControllerValues() {
|
||
final controller = widget.controller!;
|
||
setState(() {
|
||
_isPlaying = controller.value.isPlaying;
|
||
_totalDuration = controller.value.duration;
|
||
_currentPosition = controller.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) {
|
||
setState(() {
|
||
_currentPosition = widget.controller!.value.position;
|
||
_totalDuration = widget.controller!.value.duration;
|
||
_isPlaying = widget.controller!.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,
|
||
// 使用SafeArea包裹整个全屏播放器
|
||
body: SafeArea(
|
||
top: false, // 顶部不使用安全区域(状态栏区域)
|
||
bottom: false, // 底部不使用安全区域(导航栏区域)
|
||
child: Stack(
|
||
children: [
|
||
// 全屏视频播放器
|
||
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,
|
||
),
|
||
// 添加退出按钮,并包裹在SafeArea中
|
||
SafeArea(
|
||
child: Align(
|
||
alignment: Alignment.topLeft,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16.0),
|
||
child: GestureDetector(
|
||
onTap: () => Navigator.of(context).pop(),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(6),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black38,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
Icons.arrow_back,
|
||
color: Colors.white,
|
||
size: 20,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
)).then((_) {
|
||
// 恢复竖屏
|
||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||
// 恢复系统UI
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final screenSize = MediaQuery.of(context).size;
|
||
final fullScreenAspectRatio = max(
|
||
widget.aspectRatio,
|
||
screenSize.width / screenSize.height
|
||
);
|
||
|
||
return GestureDetector(
|
||
onTap: _toggleControls,
|
||
child: Stack(
|
||
fit: widget.isFullScreen ? StackFit.expand : StackFit.loose,
|
||
children: [
|
||
// 视频播放区域
|
||
if (widget.controller != null && widget.controller!.value.isInitialized)
|
||
AspectRatio(
|
||
aspectRatio: widget.isFullScreen ? fullScreenAspectRatio : widget.aspectRatio,
|
||
child: VideoPlayer(widget.controller!),
|
||
)
|
||
else
|
||
Image.network(
|
||
widget.coverUrl,
|
||
fit: BoxFit.cover,
|
||
width: widget.isFullScreen ? double.infinity : null,
|
||
height: widget.isFullScreen ? double.infinity : null,
|
||
),
|
||
|
||
// 控制面板
|
||
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(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
IconButton(
|
||
padding: EdgeInsets.zero,
|
||
icon: Icon(
|
||
_isPlaying ? Icons.pause : Icons.play_arrow,
|
||
size: 28,
|
||
color: Colors.white,
|
||
),
|
||
onPressed: _togglePlayPause,
|
||
),
|
||
const SizedBox(width: 0),
|
||
Expanded(
|
||
child: ValueListenableBuilder<double>(
|
||
valueListenable: _sliderValue,
|
||
builder: (context, value, child) {
|
||
return SliderTheme(
|
||
data: SliderTheme.of(context).copyWith(
|
||
activeTrackColor: Colors.white,
|
||
inactiveTrackColor: Colors.white54,
|
||
thumbColor: Colors.white,
|
||
overlayColor: Colors.white24,
|
||
trackHeight: 2,
|
||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
|
||
),
|
||
child: Slider(
|
||
value: value,
|
||
min: 0,
|
||
max: _totalDuration.inMilliseconds.toDouble(),
|
||
onChanged: widget.allowSeek && widget.controller != null
|
||
? (v) {
|
||
widget.controller!.seekTo(Duration(milliseconds: v.toInt()));
|
||
setState(() {
|
||
_currentPosition = Duration(milliseconds: v.toInt());
|
||
});
|
||
_sliderValue.value = v;
|
||
_startHideTimer();
|
||
}
|
||
: null,
|
||
),
|
||
);
|
||
}
|
||
),
|
||
),
|
||
const SizedBox(width: 0),
|
||
// 使用固定宽度的文本容器,避免进度条跳动
|
||
SizedBox(
|
||
width: 110, // 固定宽度,防止进度条长度变化
|
||
child: Text(
|
||
'${_formatDuration(_currentPosition)} / ${_formatDuration(_totalDuration)}',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
fontFeatures: [FontFeature.tabularFigures()], // 等宽字体
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 0),
|
||
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 hours = d.inHours;
|
||
final minutes = d.inMinutes.remainder(60);
|
||
final seconds = d.inSeconds.remainder(60);
|
||
|
||
if (hours > 0) {
|
||
return '${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(seconds)}';
|
||
}
|
||
return '${twoDigits(minutes)}:${twoDigits(seconds)}';
|
||
}
|
||
} |