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 fit; final List topBar; final Widget bottomBar; final Widget? centerBar; final List overlays; final VoidCallback? onControlsAreaTap; final bool isFullscreen; final Future Function() onToggleFullscreen; final bool isPip; final Future 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? 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 [], 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 createState() => _PlayerVideoWidgetState(); } class _PlayerVideoWidgetState extends State { static const double _volumeStep = 5; static const double _pipResizeEdgeSize = 10; static const List _pipResizeEdges = [ 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? _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 _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 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); } }