703 lines
22 KiB
Dart
703 lines
22 KiB
Dart
|
|
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);
|
||
|
|
}
|
||
|
|
}
|