Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,702 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../gestures/gesture_action.dart';
import '../gestures/gesture_bindings.dart';
import 'player_controls_chrome.dart';
import 'player_hold_speed_prompt.dart';
class PlayerVideoWidget extends StatefulWidget {
final PlayerEngine player;
final ValueListenable<BoxFit> fit;
final List<Widget> topBar;
final Widget bottomBar;
final Widget? centerBar;
final List<Widget> overlays;
final VoidCallback? onControlsAreaTap;
final bool isFullscreen;
final Future<void> Function() onToggleFullscreen;
final bool isPip;
final Future<void> Function()? onTogglePip;
final double topBarLeftInset;
final double topBarTopInset;
final bool lockControls;
final bool keyboardEnabled;
final int seekForwardSeconds;
final int seekBackwardSeconds;
final bool volumeBoost;
final GestureBindings gestureBindings;
final double currentRate;
final ValueChanged<double>? onSetRate;
final VoidCallback? onPrevItem;
final VoidCallback? onNextItem;
final VoidCallback? onExit;
final void Function(Offset globalPosition)? onSecondaryContextMenu;
final Widget Function(VoidCallback toggleControls, VoidCallback showControls)?
mobileGestureBuilder;
final Widget? gestureFeedback;
final bool suppressControlsChrome;
final Widget? leftBar;
final Widget? rightBar;
const PlayerVideoWidget({
super.key,
required this.player,
required this.fit,
required this.topBar,
required this.bottomBar,
this.centerBar,
this.overlays = const <Widget>[],
this.onControlsAreaTap,
required this.isFullscreen,
required this.onToggleFullscreen,
this.isPip = false,
this.onTogglePip,
this.topBarLeftInset = 8,
this.topBarTopInset = 8,
this.lockControls = false,
this.keyboardEnabled = true,
this.seekForwardSeconds = 15,
this.seekBackwardSeconds = 15,
this.volumeBoost = false,
this.gestureBindings = GestureBindings.defaults,
this.currentRate = 1.0,
this.onSetRate,
this.onPrevItem,
this.onNextItem,
this.onExit,
this.onSecondaryContextMenu,
this.mobileGestureBuilder,
this.gestureFeedback,
this.suppressControlsChrome = false,
this.leftBar,
this.rightBar,
});
@override
State<PlayerVideoWidget> createState() => _PlayerVideoWidgetState();
}
class _PlayerVideoWidgetState extends State<PlayerVideoWidget> {
static const double _volumeStep = 5;
static const double _pipResizeEdgeSize = 10;
static const List<ResizeEdge> _pipResizeEdges = <ResizeEdge>[
ResizeEdge.topLeft,
ResizeEdge.top,
ResizeEdge.topRight,
ResizeEdge.left,
ResizeEdge.right,
ResizeEdge.bottomLeft,
ResizeEdge.bottom,
ResizeEdge.bottomRight,
];
bool _controlsVisible = true;
bool _cursorVisible = true;
bool _hoveringControls = false;
Timer? _controlsHideTimer;
Timer? _cursorHideTimer;
StreamSubscription<bool>? _playingSub;
Offset? _lastMousePosition;
bool _holdingLeft = false;
bool _holdingRight = false;
double? _savedRateBeforeHold;
bool _holdSpeedPromptVisible = false;
bool _holdSpeedPromptIsLeft = false;
double _holdSpeedPromptRate = 1.0;
Timer? _pendingSeekLeft;
Timer? _pendingSeekRight;
static const _arrowHoldThreshold = Duration(milliseconds: 200);
static const _topHoverTrigger = 120.0;
static const _bottomHoverTrigger = 180.0;
Timer? _secondaryTapTimer;
Offset? _secondaryTapDownPos;
@override
void initState() {
super.initState();
_playingSub = widget.player.stream.playing.listen((playing) {
if (!mounted) return;
if (playing) {
_scheduleHide();
_scheduleHideCursor();
} else {
_showControls(lock: true);
_showCursor(lock: true);
}
});
if (widget.player.state.playing) {
_scheduleHide();
_scheduleHideCursor();
}
}
@override
void didUpdateWidget(covariant PlayerVideoWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.player != widget.player) {
unawaited(_playingSub?.cancel());
_playingSub = widget.player.stream.playing.listen((playing) {
if (!mounted) return;
if (playing) {
_scheduleHide();
_scheduleHideCursor();
} else {
_showControls(lock: true);
_showCursor(lock: true);
}
});
}
if (oldWidget.lockControls != widget.lockControls) {
if (widget.lockControls) {
_controlsHideTimer?.cancel();
_cursorHideTimer?.cancel();
_showControls(lock: true);
_showCursor(lock: true);
} else if (widget.player.state.playing) {
_scheduleHide();
_scheduleHideCursor();
}
}
if (oldWidget.keyboardEnabled && !widget.keyboardEnabled) {
_cancelPendingArrowSeeks();
_restoreHoldRateIfNeeded();
}
}
@override
void dispose() {
_controlsHideTimer?.cancel();
_cursorHideTimer?.cancel();
_secondaryTapTimer?.cancel();
_cancelPendingArrowSeeks();
_restoreHoldRateIfNeeded();
unawaited(_playingSub?.cancel());
super.dispose();
}
void _cancelPendingArrowSeeks() {
_pendingSeekLeft?.cancel();
_pendingSeekLeft = null;
_pendingSeekRight?.cancel();
_pendingSeekRight = null;
}
void _showControls({bool lock = false}) {
_controlsHideTimer?.cancel();
if (!_controlsVisible && mounted) {
setState(() => _controlsVisible = true);
}
if (!lock && widget.player.state.playing) {
_scheduleHide();
}
}
void _scheduleHide() {
_controlsHideTimer?.cancel();
if (_hoveringControls || widget.lockControls) return;
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
if (mounted &&
widget.player.state.playing &&
!_hoveringControls &&
!widget.lockControls) {
setState(() => _controlsVisible = false);
}
});
}
void _showCursor({bool lock = false}) {
_cursorHideTimer?.cancel();
if (!_cursorVisible && mounted) {
setState(() => _cursorVisible = true);
}
if (!lock && widget.player.state.playing) {
_scheduleHideCursor();
}
}
void _scheduleHideCursor() {
_cursorHideTimer?.cancel();
if (_hoveringControls || widget.lockControls) return;
_cursorHideTimer = Timer(const Duration(seconds: 3), () {
if (mounted &&
widget.player.state.playing &&
!_hoveringControls &&
!widget.lockControls) {
setState(() => _cursorVisible = false);
}
});
}
bool _inEdgeZone(double localY, double viewHeight) =>
localY <= _topHoverTrigger || localY >= viewHeight - _bottomHoverTrigger;
Future<void> _togglePlayPause() async {
if (widget.player.state.playing) {
await widget.player.pause();
} else {
await widget.player.play();
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (!widget.keyboardEnabled) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowLeft ||
key == LogicalKeyboardKey.arrowRight) {
return _handleArrowKey(event, key == LogicalKeyboardKey.arrowLeft);
}
final isRepeat = event is KeyRepeatEvent;
if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored;
if (key == LogicalKeyboardKey.space) {
if (isRepeat) return KeyEventResult.handled;
unawaited(_togglePlayPause());
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
if (Platform.isAndroid) return KeyEventResult.ignored;
final maxVol = widget.volumeBoost ? 150.0 : 100.0;
final next = (widget.player.state.volume + _volumeStep).clamp(
0.0,
maxVol,
);
unawaited(widget.player.setVolume(next));
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
if (Platform.isAndroid) return KeyEventResult.ignored;
final maxVol = widget.volumeBoost ? 150.0 : 100.0;
final next = (widget.player.state.volume - _volumeStep).clamp(
0.0,
maxVol,
);
unawaited(widget.player.setVolume(next));
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyF ||
(key == LogicalKeyboardKey.escape && widget.isFullscreen)) {
if (isRepeat) return KeyEventResult.handled;
unawaited(widget.onToggleFullscreen());
_showControls();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyP) {
if (isRepeat) return KeyEventResult.handled;
final togglePip = widget.onTogglePip;
if (togglePip != null) {
unawaited(togglePip());
_showControls();
return KeyEventResult.handled;
}
}
if (key == LogicalKeyboardKey.escape && widget.isPip) {
if (isRepeat) return KeyEventResult.handled;
final togglePip = widget.onTogglePip;
if (togglePip != null) {
unawaited(togglePip());
_showControls();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
KeyEventResult _handleArrowKey(KeyEvent event, bool isLeft) {
final seekSeconds = isLeft
? -widget.seekBackwardSeconds
: widget.seekForwardSeconds;
if (event is KeyDownEvent) {
if (isLeft) {
_pendingSeekLeft?.cancel();
_pendingSeekLeft = Timer(_arrowHoldThreshold, () {
_pendingSeekLeft = null;
if (!mounted) return;
_enterHoldSpeed(isLeft: true);
});
} else {
_pendingSeekRight?.cancel();
_pendingSeekRight = Timer(_arrowHoldThreshold, () {
_pendingSeekRight = null;
if (!mounted) return;
_enterHoldSpeed(isLeft: false);
});
}
return KeyEventResult.handled;
}
if (event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
final pending = isLeft ? _pendingSeekLeft : _pendingSeekRight;
if (pending != null) {
pending.cancel();
if (isLeft) {
_pendingSeekLeft = null;
} else {
_pendingSeekRight = null;
}
_seekBy(seekSeconds);
_showControls();
}
final wasHolding = isLeft ? _holdingLeft : _holdingRight;
if (wasHolding) {
if (isLeft) {
_holdingLeft = false;
} else {
_holdingRight = false;
}
if (!_holdingLeft && !_holdingRight) {
_hideHoldSpeedPrompt();
final restore = _savedRateBeforeHold ?? widget.currentRate;
_savedRateBeforeHold = null;
widget.onSetRate?.call(restore);
}
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _enterHoldSpeed({required bool isLeft}) {
final holding = isLeft ? _holdingLeft : _holdingRight;
if (holding) return;
final holdSpeed = isLeft
? widget.gestureBindings.keyboardHoldLeftSpeed
: widget.gestureBindings.keyboardHoldRightSpeed;
_savedRateBeforeHold ??= widget.currentRate;
widget.onSetRate?.call(holdSpeed);
if (isLeft) {
_holdingLeft = true;
} else {
_holdingRight = true;
}
_showHoldSpeedPrompt(isLeft: isLeft, rate: holdSpeed);
}
void _showHoldSpeedPrompt({required bool isLeft, required double rate}) {
if (!mounted) return;
setState(() {
_holdSpeedPromptVisible = true;
_holdSpeedPromptIsLeft = isLeft;
_holdSpeedPromptRate = rate;
});
}
void _hideHoldSpeedPrompt() {
if (!_holdSpeedPromptVisible) return;
if (!mounted) {
_holdSpeedPromptVisible = false;
return;
}
setState(() => _holdSpeedPromptVisible = false);
}
void _restoreHoldRateIfNeeded() {
if (!_holdingLeft && !_holdingRight) return;
_holdingLeft = false;
_holdingRight = false;
_hideHoldSpeedPrompt();
final restore = _savedRateBeforeHold;
_savedRateBeforeHold = null;
if (restore != null) widget.onSetRate?.call(restore);
}
void _seekBy(int seconds) {
final target = widget.player.state.position + Duration(seconds: seconds);
unawaited(
widget.player.seek(target < Duration.zero ? Duration.zero : target),
);
}
void _toggleControlsVisibility() {
if (_controlsVisible) {
setState(() => _controlsVisible = false);
_controlsHideTimer?.cancel();
} else {
_showControls();
_showCursor();
}
}
void _handleSecondaryTap() {
final contextMenu = widget.onSecondaryContextMenu;
if (contextMenu != null) {
contextMenu(_secondaryTapDownPos ?? Offset.zero);
return;
}
if (widget.gestureBindings.mouseRightDoubleClickAction ==
GestureAction.none) {
_dispatchGesture(GestureKind.rightClick);
return;
}
final pending = _secondaryTapTimer;
if (pending != null && pending.isActive) {
pending.cancel();
_secondaryTapTimer = null;
_dispatchGesture(GestureKind.rightDoubleClick);
return;
}
_secondaryTapTimer = Timer(kDoubleTapTimeout, () {
_secondaryTapTimer = null;
if (!mounted) return;
_dispatchGesture(GestureKind.rightClick);
});
}
void _dispatchGesture(GestureKind kind) {
final action = widget.gestureBindings.actionFor(kind);
switch (action) {
case GestureAction.none:
return;
case GestureAction.playPause:
unawaited(_togglePlayPause());
break;
case GestureAction.toggleControls:
_toggleControlsVisibility();
return;
case GestureAction.fullscreen:
unawaited(widget.onToggleFullscreen());
break;
case GestureAction.seekBackward:
_seekBy(-widget.seekBackwardSeconds);
break;
case GestureAction.seekForward:
_seekBy(widget.seekForwardSeconds);
break;
case GestureAction.previousItem:
widget.onPrevItem?.call();
break;
case GestureAction.nextItem:
widget.onNextItem?.call();
break;
case GestureAction.exit:
widget.onExit?.call();
break;
}
_showControls();
_showCursor();
}
Widget _withPipResizeAffordance(Widget child) {
if (!widget.isPip) return child;
return DragToResizeArea(
resizeEdgeSize: _pipResizeEdgeSize,
resizeEdgeColor: Colors.transparent,
enableResizeEdges: _pipResizeEdges,
child: child,
);
}
@override
Widget build(BuildContext context) {
final isWindowed = !widget.isFullscreen && !widget.isPip;
return Focus(
autofocus: true,
onKeyEvent: _handleKeyEvent,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final viewHeight = constraints.maxHeight;
if (widget.mobileGestureBuilder != null) {
return _withPipResizeAffordance(
Stack(
fit: StackFit.expand,
children: [
PlayerVideoSurface(
player: widget.player,
fit: widget.fit,
),
widget.mobileGestureBuilder!(
_toggleControlsVisibility,
_showControls,
),
...widget.overlays,
if (widget.gestureFeedback != null)
widget.gestureFeedback!,
HoldSpeedPrompt(
visible: _holdSpeedPromptVisible,
isLeft: _holdSpeedPromptIsLeft,
rate: _holdSpeedPromptRate,
),
ControlsChrome(
visible:
_controlsVisible &&
!widget.suppressControlsChrome,
topBar: widget.topBar,
topBarLeftInset: widget.topBarLeftInset,
topBarTopInset: widget.topBarTopInset,
bottomBar: widget.bottomBar,
centerBar: widget.centerBar,
leftBar: widget.leftBar,
rightBar: widget.rightBar,
onControlsAreaTap: widget.onControlsAreaTap,
isWindowed: isWindowed,
isPip: widget.isPip,
onControlsHoverChanged: (hovering) {
_hoveringControls = hovering;
if (!hovering && widget.player.state.playing) {
_scheduleHide();
}
},
),
],
),
);
} else {
return MouseRegion(
cursor: _cursorVisible
? SystemMouseCursors.basic
: SystemMouseCursors.none,
onEnter: (event) {
_lastMousePosition = event.position;
_showCursor();
if (widget.isPip ||
_inEdgeZone(event.localPosition.dy, viewHeight)) {
_showControls();
}
},
onExit: (_) {
_lastMousePosition = null;
},
onHover: (event) {
final pos = event.position;
final last = _lastMousePosition;
_lastMousePosition = pos;
if (last != null &&
_cursorVisible &&
_controlsVisible &&
(pos - last).distanceSquared < 9) {
return;
}
_showCursor();
if (widget.isPip ||
_inEdgeZone(event.localPosition.dy, viewHeight)) {
_showControls();
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.lockControls
? null
: () => _dispatchGesture(GestureKind.leftClick),
onDoubleTap:
widget.lockControls ||
widget
.gestureBindings
.mouseLeftDoubleClickAction ==
GestureAction.none
? null
: () => _dispatchGesture(GestureKind.leftDoubleClick),
onSecondaryTap: widget.lockControls
? null
: _handleSecondaryTap,
onSecondaryTapDown: widget.lockControls
? null
: (d) => _secondaryTapDownPos = d.globalPosition,
child: _withPipResizeAffordance(
Stack(
fit: StackFit.expand,
children: [
PlayerVideoSurface(
player: widget.player,
fit: widget.fit,
),
...widget.overlays,
HoldSpeedPrompt(
visible: _holdSpeedPromptVisible,
isLeft: _holdSpeedPromptIsLeft,
rate: _holdSpeedPromptRate,
),
ControlsChrome(
visible:
_controlsVisible &&
!widget.suppressControlsChrome,
topBar: widget.topBar,
topBarLeftInset: widget.topBarLeftInset,
topBarTopInset: widget.topBarTopInset,
bottomBar: widget.bottomBar,
centerBar: widget.centerBar,
leftBar: widget.leftBar,
rightBar: widget.rightBar,
onControlsAreaTap: widget.onControlsAreaTap,
isWindowed: isWindowed,
isPip: widget.isPip,
onControlsHoverChanged: (hovering) {
_hoveringControls = hovering;
if (!hovering && widget.player.state.playing) {
_scheduleHide();
}
},
),
],
),
),
),
);
}
},
),
),
],
),
);
}
}
class PlayerVideoSurface extends StatelessWidget {
final PlayerEngine player;
final ValueListenable<BoxFit> fit;
const PlayerVideoSurface({
super.key,
required this.player,
required this.fit,
});
@override
Widget build(BuildContext context) {
final child = player.buildVideoView(context, fit);
if (player.usesPlatformSurface) return child;
return ColoredBox(color: Colors.black, child: child);
}
}