// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'player_double_tap_hearts.dart'; import 'player_heart_overlay_scope.dart'; typedef PlayerLongPressStartCallback = void Function(LongPressStartDetails details, double width); /// 播放画面共用的单击/双击仲裁层。 class PlayerTapGestureRegion extends StatefulWidget { const PlayerTapGestureRegion({ super.key, required this.gestureKey, required this.onSingleTap, required this.onLongPressStart, required this.onInteractionEnd, this.onDoubleTapLike, this.doubleTapEnabled = true, this.enabled = true, this.now, }); final Key gestureKey; final VoidCallback onSingleTap; final ValueChanged? onDoubleTapLike; final PlayerLongPressStartCallback onLongPressStart; final VoidCallback onInteractionEnd; final bool doubleTapEnabled; final bool enabled; final DateTime Function()? now; @override State createState() => _PlayerTapGestureRegionState(); } class _PlayerTapGestureRegionState extends State { static const Duration _singleTapDelay = Duration(milliseconds: 240); Timer? _singleTapTimer; DateTime? _lastDownAt; DateTime? _blockSingleTapUntil; bool _doubleTapDown = false; bool _likedThisPair = false; int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( ignoring: !widget.enabled, child: LayoutBuilder( builder: (context, constraints) => Listener( onPointerUp: (_) => widget.onInteractionEnd(), onPointerCancel: (_) => widget.onInteractionEnd(), onPointerDown: _handlePointerDown, child: Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ GestureDetector( key: widget.gestureKey, behavior: HitTestBehavior.opaque, onTapUp: _handleTapUp, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => widget.onInteractionEnd(), onLongPressCancel: widget.onInteractionEnd, ), ], ), ), ), ); } DateTime _now() => widget.now?.call() ?? DateTime.now(); void _handlePointerDown(PointerDownEvent event) { final now = _now(); final previous = _lastDownAt; _downSerial++; _lastDownAt = now; final repeated = previous != null && now.difference(previous) <= _singleTapDelay; if (!repeated) { if (_singleTapBlocked(now)) return; _doubleTapDown = false; _likedThisPair = false; return; } _doubleTapDown = true; _blockSingleTapUntil = now.add(_singleTapDelay); _cancelPendingSingleTap(); } bool _singleTapBlocked(DateTime now) { final blockedUntil = _blockSingleTapUntil; return blockedUntil != null && now.isBefore(blockedUntil); } void _cancelPendingSingleTap() { _tapSeq++; _singleTapTimer?.cancel(); _singleTapTimer = null; } void _handleTapUp(TapUpDetails details) { final now = _now(); if (_doubleTapDown || _singleTapBlocked(now)) { _handleDoubleTapLike(details.globalPosition); return; } final serial = _downSerial; final tapSeq = ++_tapSeq; _singleTapTimer?.cancel(); late final Timer pending; pending = Timer(_singleTapDelay, () { if (!identical(_singleTapTimer, pending)) return; _singleTapTimer = null; if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return; if (_doubleTapDown || _singleTapBlocked(_now())) return; _lastDownAt = null; widget.onSingleTap(); }); _singleTapTimer = pending; } void _handleDoubleTapLike(Offset globalPosition) { _blockSingleTapUntil = _now().add(_singleTapDelay); _cancelPendingSingleTap(); if (!widget.doubleTapEnabled || _likedThisPair) return; _likedThisPair = true; _showHeartBurst(globalPosition); widget.onDoubleTapLike?.call(globalPosition); } void _showHeartBurst(Offset globalPosition) { final overlay = Overlay.maybeOf(context, rootOverlay: true); if (overlay == null) return; final overlayBox = overlay.context.findRenderObject(); final origin = overlayBox is RenderBox ? overlayBox.globalToLocal(globalPosition) : globalPosition; final id = _nextHeartBurstId++; final controller = PlayerHeartOverlayScope.maybeOf(context); var removed = false; late final OverlayEntry entry; void removeEntry() { if (removed) return; removed = true; controller?.untrack(id); if (entry.mounted) entry.remove(); entry.dispose(); if (kDebugMode) { debugPrint('[player] phase=heart-burst-entry-removed id=$id'); } } entry = OverlayEntry( builder: (context) => Positioned.fill( child: IgnorePointer( child: PlayerDoubleTapHeartBurst( key: ValueKey('heart-overlay-$id'), burstId: id, origin: origin, now: widget.now, onCompleted: removeEntry, ), ), ), ); controller?.track(id, entry); overlay.insert(entry); } void _handleLongPressStart(LongPressStartDetails details, double width) { _tapSeq++; _cancelPendingSingleTap(); _doubleTapDown = false; _lastDownAt = null; widget.onLongPressStart(details, width); } }