Files
2026-05-15 09:03:09 +08:00

765 lines
22 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../core/utils/video_fullscreen.dart';
/// 自定义视频控制栏叠加层(Bilibili 风格)
class VideoControlsOverlay extends StatefulWidget {
final VideoState state;
final String title;
const VideoControlsOverlay({super.key, required this.state, required this.title});
@override
State<VideoControlsOverlay> createState() => _VideoControlsOverlayState();
}
class _VideoControlsOverlayState extends State<VideoControlsOverlay> with WindowListener {
Player get player => widget.state.widget.controller.player;
bool _controlsVisible = true;
Timer? _hideTimer;
bool _isLongPressing = false;
double _rateBeforeLongPress = 1.0;
// 进度条拖拽状态
bool _isSeeking = false;
Duration _seekPosition = Duration.zero;
// 音量控制
bool _volumeSliderVisible = false;
double _volumeBeforeMute = 100.0;
// 自管全屏状态
bool _isFullscreen = false;
// 键盘焦点
final FocusNode _focusNode = FocusNode();
// 桌面端鼠标悬停
Timer? _mouseHoverTimer;
bool get _isDesktop => Platform.isWindows || Platform.isLinux || Platform.isMacOS;
@override
void initState() {
super.initState();
_startHideTimer();
if (_isDesktop) {
windowManager.addListener(this);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
@override
void dispose() {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
_focusNode.dispose();
if (_isDesktop) {
windowManager.removeListener(this);
}
if (_isFullscreen) {
_doExitFullscreen();
}
super.dispose();
}
// ========== WindowListener(桌面端) ==========
@override
void onWindowEnterFullScreen() {
if (!_isFullscreen && mounted) {
setState(() => _isFullscreen = true);
}
}
@override
void onWindowLeaveFullScreen() {
if (_isFullscreen && mounted) {
setState(() => _isFullscreen = false);
videoFullscreenNotifier.value = false;
}
}
// ========== 全屏控制 ==========
Future<void> _toggleFullscreen() async {
if (_isFullscreen) {
await _doExitFullscreen();
} else {
await _doEnterFullscreen();
}
if (mounted) setState(() {});
}
Future<void> _doEnterFullscreen() async {
_isFullscreen = true;
if (_isDesktop) {
videoFullscreenNotifier.value = true;
await windowManager.setFullScreen(true);
} else {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []),
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]),
]);
}
}
Future<void> _doExitFullscreen() async {
_isFullscreen = false;
if (_isDesktop) {
await windowManager.setFullScreen(false);
videoFullscreenNotifier.value = false;
} else {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values),
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]),
]);
}
}
void _onBack() {
if (_isFullscreen) {
_doExitFullscreen().then((_) {
if (mounted) setState(() {});
});
} else {
Navigator.of(context).pop();
}
}
// ========== 控制栏显隐 ==========
void _startHideTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted && !_isLongPressing) {
_hideControls();
}
});
}
void _hideControls() {
setState(() {
_controlsVisible = false;
_volumeSliderVisible = false;
});
}
void _showControls() {
setState(() {
_controlsVisible = true;
});
_startHideTimer();
}
void _toggleControls() {
if (_controlsVisible) {
_hideControls();
} else {
_showControls();
}
}
void _onLongPressStart() {
_hideTimer?.cancel();
_rateBeforeLongPress = player.state.rate;
player.setRate(2.0);
setState(() {
_isLongPressing = true;
_controlsVisible = false;
_volumeSliderVisible = false;
});
}
void _onLongPressEnd() {
player.setRate(_rateBeforeLongPress);
setState(() {
_isLongPressing = false;
_controlsVisible = true;
});
_startHideTimer();
}
// ========== 桌面端鼠标悬停 ==========
void _onMouseHover(PointerHoverEvent event) {
if (!_controlsVisible) {
setState(() => _controlsVisible = true);
}
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
_mouseHoverTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isLongPressing && !_volumeSliderVisible && !_isSeeking) {
_hideControls();
}
});
}
void _onMouseExit(PointerExitEvent event) {
_mouseHoverTimer?.cancel();
_startHideTimer();
}
// ========== 键盘快捷键 ==========
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.space) {
player.playOrPause();
} else if (key == LogicalKeyboardKey.keyM) {
_toggleMute();
} else if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
_toggleFullscreen();
} else if (key == LogicalKeyboardKey.escape) {
if (_isFullscreen) _onBack();
} else if (key == LogicalKeyboardKey.arrowLeft) {
final pos = player.state.position - const Duration(seconds: 5);
player.seek(pos < Duration.zero ? Duration.zero : pos);
} else if (key == LogicalKeyboardKey.arrowRight) {
final pos = player.state.position + const Duration(seconds: 5);
final dur = player.state.duration;
player.seek(pos > dur ? dur : pos);
} else if (key == LogicalKeyboardKey.arrowUp) {
final vol = (player.state.volume + 5).clamp(0.0, 100.0);
player.setVolume(vol);
} else if (key == LogicalKeyboardKey.arrowDown) {
final vol = (player.state.volume - 5).clamp(0.0, 100.0);
player.setVolume(vol);
}
}
void _toggleMute() {
if (player.state.volume <= 0) {
player.setVolume(_volumeBeforeMute);
} else {
_volumeBeforeMute = player.state.volume;
player.setVolume(0);
}
}
// ========== 音量图标点击 ==========
void _onVolumeIconTap(double currentVolume) {
if (_volumeSliderVisible) {
// 滑条已展开,再次点击小喇叭 → 静音
if (currentVolume <= 0) {
player.setVolume(_volumeBeforeMute);
} else {
_volumeBeforeMute = currentVolume;
player.setVolume(0);
}
} else {
// 滑条未展开,展开滑条
_hideTimer?.cancel();
setState(() => _volumeSliderVisible = true);
}
}
// ========== 倍速菜单 ==========
void _showSpeedMenu() {
_hideTimer?.cancel();
final rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
final currentRate = player.state.rate;
showModalBottomSheet(
context: context,
backgroundColor: Colors.black87,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text('倍速播放',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.white70,
)),
),
const Divider(height: 1, color: Colors.white24),
Wrap(
children: rates.map((rate) {
final selected = (rate - currentRate).abs() < 0.01;
return SizedBox(
width: (MediaQuery.of(context).size.width - 32) / 4,
child: ListTile(
title: Text(
'${rate}x',
textAlign: TextAlign.center,
style: TextStyle(
color: selected ? const Color(0xFFE94560) : Colors.white,
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
fontSize: 14,
),
),
onTap: () {
player.setRate(rate);
Navigator.of(ctx).pop();
_startHideTimer();
},
),
);
}).toList(),
),
const SizedBox(height: 8),
],
),
),
);
}
// ========== 工具方法 ==========
String _formatDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
// ========== 构建 UI ==========
@override
Widget build(BuildContext context) {
Widget overlay = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _toggleControls,
onDoubleTap: () => player.playOrPause(),
onLongPressStart: (_) => _onLongPressStart(),
onLongPressEnd: (_) => _onLongPressEnd(),
child: Stack(
fit: StackFit.expand,
children: [
// 长按2倍速提示
if (_isLongPressing)
Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.fast_forward, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
'2.0x 快进中',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 4)],
),
),
],
),
),
),
// Buffering 指示器
StreamBuilder<bool>(
stream: player.stream.buffering,
builder: (context, snapshot) {
final buffering = snapshot.data ?? false;
if (!buffering) return const SizedBox.shrink();
return const Center(
child: CircularProgressIndicator(color: Colors.white70),
);
},
),
// 控制栏
AnimatedOpacity(
opacity: _controlsVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: Column(
children: [
_buildTopBar(),
const Spacer(),
_buildBottomBar(),
],
),
),
),
// 桌面端快捷键提示
if (_isDesktop)
AnimatedOpacity(
opacity: _controlsVisible ? 0.4 : 0.0,
duration: const Duration(milliseconds: 250),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: _buildShortcutsHint(),
),
),
],
),
);
// 桌面端:鼠标悬停 + 键盘
if (_isDesktop) {
overlay = MouseRegion(
onHover: _onMouseHover,
onExit: _onMouseExit,
child: KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: overlay,
),
);
}
return PopScope(
canPop: !_isFullscreen,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _onBack();
},
child: overlay,
);
}
Widget _buildTopBar() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 4,
left: 4,
right: 4,
bottom: 8,
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
Expanded(
child: Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildBottomBar() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black54, Colors.transparent],
),
),
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSeekBar(),
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: _buildControlsRow(),
),
],
),
);
}
Widget _buildControlsRow() {
return Row(
children: [
// 播放/暂停
StreamBuilder<bool>(
stream: player.stream.playing,
builder: (context, snapshot) {
final playing = snapshot.data ?? false;
return IconButton(
icon: Icon(playing ? Icons.pause : Icons.play_arrow, color: Colors.white),
onPressed: () => player.playOrPause(),
);
},
),
// 音量控制
_buildVolumeControl(),
// 时间
StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, posSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
builder: (context, durSnapshot) {
final pos = posSnapshot.data ?? Duration.zero;
final dur = durSnapshot.data ?? Duration.zero;
return Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'${_formatDuration(pos)} / ${_formatDuration(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
);
},
);
},
),
const Spacer(),
// 倍速按钮
StreamBuilder<double>(
stream: player.stream.rate,
builder: (context, snapshot) {
final rate = snapshot.data ?? 1.0;
return GestureDetector(
onTap: _showSpeedMenu,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
'${rate}x',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
);
},
),
// 全屏按钮
IconButton(
icon: Icon(
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
),
onPressed: _toggleFullscreen,
),
],
);
}
Widget _buildVolumeControl() {
return StreamBuilder<double>(
stream: player.stream.volume,
builder: (context, snapshot) {
final volume = snapshot.data ?? 100.0;
final isMuted = volume <= 0;
IconData volumeIcon;
if (isMuted) {
volumeIcon = Icons.volume_off;
} else if (volume < 50) {
volumeIcon = Icons.volume_down;
} else {
volumeIcon = Icons.volume_up;
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => _onVolumeIconTap(volume),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(volumeIcon, color: Colors.white, size: 22),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.centerLeft,
child: _volumeSliderVisible
? SizedBox(
width: 100,
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white24,
thumbColor: const Color(0xFFE94560),
),
child: Slider(
value: volume.clamp(0.0, 100.0),
min: 0,
max: 100,
onChanged: (v) {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
player.setVolume(v);
},
onChangeEnd: (_) {
_startHideTimer();
if (_isDesktop) _startMouseHoverTimer();
},
),
),
)
: const SizedBox(width: 0),
),
],
);
},
);
}
void _startMouseHoverTimer() {
_mouseHoverTimer?.cancel();
_mouseHoverTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isLongPressing && !_volumeSliderVisible && !_isSeeking) {
_hideControls();
}
});
}
Widget _buildSeekBar() {
return StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, posSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
builder: (context, durSnapshot) {
final position = posSnapshot.data ?? Duration.zero;
final duration = durSnapshot.data ?? Duration.zero;
final displayPos = _isSeeking ? _seekPosition : position;
final value = duration.inMilliseconds > 0
? displayPos.inMilliseconds / duration.inMilliseconds
: 0.0;
return SizedBox(
height: 20,
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white24,
thumbColor: const Color(0xFFE94560),
),
child: Slider(
value: value.clamp(0.0, 1.0),
onChanged: (v) {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
setState(() {
_isSeeking = true;
_seekPosition = Duration(
milliseconds: (duration.inMilliseconds * v).round(),
);
});
},
onChangeEnd: (v) {
final seekTo = Duration(
milliseconds: (duration.inMilliseconds * v).round(),
);
player.seek(seekTo);
setState(() => _isSeeking = false);
_startHideTimer();
if (_isDesktop) _startMouseHoverTimer();
},
),
),
);
},
);
},
);
}
Widget _buildShortcutsHint() {
const shortcuts = [
('Space', '暂停/播放'),
('M', '静音'),
('Enter', '全屏'),
('Esc', '退出全屏'),
('←→', '快退/快进 5s'),
('↑↓', '音量 ±5'),
];
return Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(
right: 12,
top: MediaQuery.of(context).padding.top + 48,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: shortcuts.map((s) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 42,
child: Text(
s.$1,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
Text(
s.$2,
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
),
);
}).toList(),
),
),
),
);
}
}