197 lines
5.6 KiB
Dart
197 lines
5.6 KiB
Dart
// 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<Offset>? onDoubleTapLike;
|
|
final PlayerLongPressStartCallback onLongPressStart;
|
|
final VoidCallback onInteractionEnd;
|
|
final bool doubleTapEnabled;
|
|
final bool enabled;
|
|
final DateTime Function()? now;
|
|
|
|
@override
|
|
State<PlayerTapGestureRegion> createState() => _PlayerTapGestureRegionState();
|
|
}
|
|
|
|
class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
|
|
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);
|
|
}
|
|
}
|