feat: ImmediateTapGestureRecognizer (#1572)

This commit is contained in:
My-Responsitories
2025-10-15 18:06:10 +08:00
committed by GitHub
parent 84f972a3ab
commit a2d5ecc51e
9 changed files with 427 additions and 16 deletions

View File

@@ -0,0 +1,180 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
class ImmediateTapGestureRecognizer extends OneSequenceGestureRecognizer {
ImmediateTapGestureRecognizer({
super.debugOwner,
super.supportedDevices,
super.allowedButtonsFilter,
required this.onTapDown,
required this.onTapUp,
required this.onTapCancel,
this.onTap,
});
final GestureTapDownCallback onTapDown;
final GestureTapUpCallback onTapUp;
final GestureTapCancelCallback onTapCancel;
final GestureTapCallback? onTap;
PointerUpEvent? _up;
int _activePointer = 0;
bool _sentTapDown = false;
bool _wonArena = false;
@override
bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) => false;
@override
bool isPointerAllowed(PointerDownEvent event) =>
_activePointer == 0 && super.isPointerAllowed(event);
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
_activePointer = event.pointer;
_sentTapDown = false;
_wonArena = false;
}
@override
void handleEvent(PointerEvent event) {
if (event.pointer != _activePointer) {
stopTrackingPointer(event.pointer);
return;
}
if (event is PointerDownEvent) {
_handleTapDown(event);
} else if (event is PointerMoveEvent) {
_handlePointerMove(event);
} else if (event is PointerUpEvent) {
_up = event;
_handlePointerUp(event);
}
stopTrackingIfPointerNoLongerDown(event);
}
void _handleTapDown(PointerDownEvent event) {
if (_sentTapDown) return;
_sentTapDown = true;
final details = TapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: event.kind,
);
invokeCallback<void>('onTapDown', () => onTapDown(details));
}
void _handlePointerMove(PointerMoveEvent event) {
if (event.delta.distanceSquared > 2.0) {
_cancelGesture('pointer moved');
stopTrackingPointer(event.pointer);
}
}
void _handlePointerUp(PointerUpEvent event) {
if (_wonArena && _sentTapDown) {
_handleTapUp(event);
}
}
void _handleTapUp(PointerUpEvent event) {
if (_sentTapDown) {
final details = TapUpDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: event.kind,
);
invokeCallback<void>('onTapUp', () => onTapUp(details));
if (onTap != null) {
invokeCallback<void>('onTap', onTap!);
}
}
_reset();
}
void _cancelGesture(String reason) {
if (_sentTapDown) {
invokeCallback<void>('onTapCancel: $reason', onTapCancel);
}
_reset();
}
void _reset() {
_activePointer = 0;
_up = null;
_sentTapDown = false;
_wonArena = false;
}
@override
void acceptGesture(int pointer) {
super.acceptGesture(pointer);
if (pointer == _activePointer) {
_wonArena = true;
if (_up != null && _sentTapDown) {
_handleTapUp(_up!);
}
}
}
@override
void rejectGesture(int pointer) {
super.rejectGesture(pointer);
if (pointer == _activePointer) {
_cancelGesture('gesture rejected by arena');
stopTrackingPointer(pointer);
}
}
@override
void didStopTrackingLastPointer(int pointer) {
// wait for arena
}
@override
void dispose() {
if (_sentTapDown) {
_cancelGesture('disposed');
}
_reset();
super.dispose();
}
@override
String get debugDescription => 'immediate tap';
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(IntProperty('activePointer', _activePointer))
..add(
FlagProperty(
'sentTapDown',
value: _sentTapDown,
ifTrue: 'has sentTapDown',
),
)
..add(FlagProperty('wonArena', value: _wonArena, ifTrue: 'wonArena'))
..add(
DiagnosticsProperty<PointerUpEvent>(
'pointerUpEvent',
_up,
defaultValue: null,
),
);
}
}

View File

@@ -131,6 +131,7 @@ class _PlDanmakuState extends State<PlDanmaku> {
e.colorful == DmColorfulType.VipGradualColor,
count: e.hasCount() ? e.count : null,
selfSend: e.isSelf,
extra: VideoDanmaku(id: e.id.toInt(), mid: e.midHash),
),
);
}

View File

@@ -371,6 +371,11 @@ class LiveRoomController extends GetxController {
: DmUtils.decimalToColor(extra['color']),
type: DmUtils.getPosition(extra['mode']),
selfSend: extra['send_from_me'] ?? false,
extra: LiveDanmaku(
id: extra['id_str'],
mid: uid,
uname: user['base']['name'],
),
),
);
if (!disableAutoScroll.value) {

View File

@@ -29,6 +29,13 @@ List<SettingsModel> get playSettings => [
setKey: SettingBoxKey.enableShowDanmaku,
defaultVal: true,
),
// const SettingsModel(
// settingsType: SettingsType.sw1tch,
// title: '启用点击弹幕',
// leading: Icon(Icons.touch_app_outlined),
// setKey: SettingBoxKey.enableTapDm,
// defaultVal: false,
// ),
SettingsModel(
settingsType: SettingsType.normal,
onTap: (setState) => Get.toNamed('/playSpeedSet'),

View File

@@ -320,6 +320,7 @@ class PlPlayerController {
}
/// 弹幕权重
late final enableTapDm = Pref.enableTapDm;
late int danmakuWeight = Pref.danmakuWeight;
late RuleFilter filters = Pref.danmakuFilterRule;
// 关联弹幕控制器

View File

@@ -4,7 +4,8 @@ import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/gesture/interactive_viewer.dart';
import 'package:PiliPlus/common/widgets/gesture/immediate_tap_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/gesture/mouse_interactive_viewer.dart';
import 'package:PiliPlus/common/widgets/loading_widget.dart';
import 'package:PiliPlus/common/widgets/pair.dart';
import 'package:PiliPlus/common/widgets/progress_bar/audio_video_progress_bar.dart';
@@ -22,10 +23,12 @@ import 'package:PiliPlus/models_new/video/video_detail/section.dart';
import 'package:PiliPlus/models_new/video/video_detail/ugc_season.dart';
import 'package:PiliPlus/models_new/video/video_shot/data.dart';
import 'package:PiliPlus/pages/common/common_intro_controller.dart';
import 'package:PiliPlus/pages/danmaku/dnamaku_model.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart';
import 'package:PiliPlus/pages/video/post_panel/popup_menu_text.dart';
import 'package:PiliPlus/pages/video/post_panel/view.dart';
import 'package:PiliPlus/pages/video/widgets/header_control.dart';
import 'package:PiliPlus/plugin/pl_player/controller.dart';
import 'package:PiliPlus/plugin/pl_player/models/bottom_control_type.dart';
import 'package:PiliPlus/plugin/pl_player/models/bottom_progress_behavior.dart';
@@ -47,6 +50,7 @@ import 'package:PiliPlus/utils/image_utils.dart';
import 'package:PiliPlus/utils/storage.dart';
import 'package:PiliPlus/utils/storage_key.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:dio/dio.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:fl_chart/fl_chart.dart';
@@ -102,6 +106,11 @@ class PLVideoPlayer extends StatefulWidget {
class _PLVideoPlayerState extends State<PLVideoPlayer>
with WidgetsBindingObserver, TickerProviderStateMixin {
@pragma("vm:prefer-inline")
bool get isMobile => kDebugMode || Utils.isMobile;
@pragma("vm:prefer-inline")
bool get isDesktop => !kDebugMode && Utils.isDesktop;
late AnimationController animationController;
late VideoController videoController;
late final CommonIntroController introController = widget.introController!;
@@ -214,9 +223,16 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
});
}
_tapGestureRecognizer = TapGestureRecognizer()..onTapUp = onTapUp;
_tapGestureRecognizer = isMobile
? ImmediateTapGestureRecognizer(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _removeDmAction,
allowedButtonsFilter: (buttons) => buttons == kPrimaryButton,
)
: (TapGestureRecognizer()..onTapUp = _onTapUp);
_doubleTapGestureRecognizer = DoubleTapGestureRecognizer()
..onDoubleTapDown = onDoubleTapDown;
..onDoubleTapDown = _onDoubleTapDown;
}
@override
@@ -273,6 +289,8 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
FlutterVolumeController.removeListener();
}
transformationController.dispose();
_refreshDmCallback = null;
_removeDmAction();
super.dispose();
}
@@ -900,7 +918,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
final double tapPosition = details.localFocalPoint.dx;
final double sectionWidth = maxWidth / 3;
if (tapPosition < sectionWidth) {
if (!isMobile || !plPlayerController.enableSlideVolumeBrightness) {
if (isDesktop || !plPlayerController.enableSlideVolumeBrightness) {
return;
}
// 左边区域
@@ -1097,20 +1115,44 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
plPlayerController.triggerFullScreen(status: !isFullScreen);
}
void onTapUp(TapUpDetails details) {
void _onTapUp(TapUpDetails details) {
switch (details.kind) {
case ui.PointerDeviceKind.mouse when Utils.isDesktop:
case ui.PointerDeviceKind.mouse when isDesktop:
onTapDesktop();
break;
default:
plPlayerController.controls = !plPlayerController.showControls.value;
if (_suspendedDm == null) {
plPlayerController.controls = !plPlayerController.showControls.value;
} else {
_dmOffset = details.localPosition;
_refreshDmCallback?.call();
}
break;
}
}
void onDoubleTapDown(TapDownDetails details) {
void _onTapDown(TapDownDetails details) {
if (isMobile) {
final ctr = plPlayerController.danmakuController;
if (ctr != null) {
final pos = details.localPosition;
final item = ctr.findSingleDanmaku(pos);
if (item == null) {
if (_suspendedDm != null) {
_removeDmAction();
}
} else if (item != _suspendedDm) {
_suspendedDm?.suspend = false;
_suspendedDm = item..suspend = true;
_dmOffset = pos;
}
}
}
}
void _onDoubleTapDown(TapDownDetails details) {
switch (details.kind) {
case ui.PointerDeviceKind.mouse when Utils.isDesktop:
case ui.PointerDeviceKind.mouse when isDesktop:
onDoubleTapDesktop();
break;
default:
@@ -1119,18 +1161,17 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
}
}
final isMobile = Utils.isMobile;
LongPressGestureRecognizer? _longPressRecognizer;
LongPressGestureRecognizer get longPressRecognizer =>
_longPressRecognizer ??= LongPressGestureRecognizer()
LongPressGestureRecognizer get longPressRecognizer => _longPressRecognizer ??=
LongPressGestureRecognizer(duration: const Duration(milliseconds: 300))
..onLongPressStart = ((_) =>
plPlayerController.setLongPressStatus(true))
..onLongPressEnd = (_) => plPlayerController.setLongPressStatus(false);
late final TapGestureRecognizer _tapGestureRecognizer;
late final OneSequenceGestureRecognizer _tapGestureRecognizer;
late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer;
void _onPointerDown(PointerDownEvent event) {
if (!isMobile) {
if (isDesktop) {
final buttons = event.buttons;
final isSecondaryBtn = buttons == kSecondaryMouseButton;
if (isSecondaryBtn || buttons == kMiddleMouseButton) {
@@ -1280,6 +1321,16 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
),
),
Builder(
builder: (context) {
_refreshDmCallback = () => ((context) as Element).markNeedsBuild();
if (_dmOffset != null && _suspendedDm != null) {
return _buildDmAction(_suspendedDm!, _dmOffset!);
}
return const SizedBox.shrink();
},
),
/// 长按倍速 toast
if (!isLive)
IgnorePointer(
@@ -1912,7 +1963,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
}),
],
);
if (Utils.isDesktop) {
if (isDesktop) {
return Obx(
() => MouseRegion(
cursor: !plPlayerController.showControls.value && isFullScreen
@@ -2124,6 +2175,144 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
},
);
}
static const _overlaySpacing = 10.0;
static const _overlayWidth = 130.0;
static const _overlayHeight = 35.0;
DanmakuItem<DanmakuExtra>? _suspendedDm;
Offset? _dmOffset;
void Function()? _refreshDmCallback;
void _removeDmAction() {
_suspendedDm?.suspend = false;
_suspendedDm = null;
_dmOffset = null;
_refreshDmCallback?.call();
}
Widget _dmActionItem(Widget child, {required VoidCallback onTap}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
height: _overlayHeight,
width: _overlayWidth / 3,
child: Center(
child: child,
),
),
);
}
Widget _buildDmAction(
DanmakuItem<DanmakuExtra> item,
Offset offset,
) {
// fullscreen
if (offset.dx > maxWidth) {
_removeDmAction();
return const Positioned(left: 0, top: 0, child: SizedBox.shrink());
}
final dy = item.content.type == DanmakuItemType.bottom
? maxHeight - item.yPosition - item.height
: item.yPosition;
final top = dy + item.height + 4;
final right =
maxWidth -
clampDouble(
offset.dx + _overlayWidth / 2,
_overlaySpacing + _overlayWidth,
maxWidth - _overlaySpacing,
);
final extra = item.content.extra as VideoDanmaku;
return Positioned(
right: right,
top: top,
child: Column(
children: [
const CustomPaint(
painter: _TrianglePainter(Colors.black54),
size: Size(12, 6),
),
Container(
width: _overlayWidth,
height: _overlayHeight,
decoration: const BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.all(Radius.circular(18)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_dmActionItem(
Icon(
size: 20,
extra.isLike
? Icons.thumb_up_off_alt_sharp
: Icons.thumb_up_off_alt_outlined,
color: Colors.white,
),
onTap: () {
_removeDmAction();
HeaderControl.likeDanmaku(
extra,
plPlayerController.cid!,
);
},
),
_dmActionItem(
const Icon(
size: 20,
Icons.copy,
color: Colors.white,
),
onTap: () {
_removeDmAction();
Utils.copyText(item.content.text);
},
),
if (item.content.selfSend)
_dmActionItem(
const Icon(
size: 20,
Icons.delete,
color: Colors.white,
),
onTap: () {
_removeDmAction();
HeaderControl.deleteDanmaku(
extra.id,
plPlayerController.cid!,
);
},
)
else
_dmActionItem(
const Icon(
size: 20,
Icons.report_problem_outlined,
color: Colors.white,
),
onTap: () {
_removeDmAction();
HeaderControl.reportDanmaku(
extra,
context,
plPlayerController,
);
},
),
],
),
),
],
),
);
}
}
Widget buildDmChart(
@@ -2468,3 +2657,27 @@ Widget buildViewPointWidget(
),
);
}
class _TrianglePainter extends CustomPainter {
const _TrianglePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final path = Path()
..moveTo(0, size.height)
..lineTo(size.width, size.height)
..lineTo(size.width / 2, 0)
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _TrianglePainter oldDelegate) =>
color != oldDelegate.color;
}

View File

@@ -141,7 +141,8 @@ abstract class SettingBoxKey {
showFsLockBtn = 'showFsLockBtn',
silentDownImg = 'silentDownImg',
showMemberShop = 'showMemberShop',
enablePlayAll = 'enablePlayAll';
enablePlayAll = 'enablePlayAll',
enableTapDm = 'enableTapDm';
static const String minimizeOnExit = 'minimizeOnExit',
windowSize = 'windowSize',

View File

@@ -858,4 +858,7 @@ abstract class Pref {
static bool get enablePlayAll =>
_setting.get(SettingBoxKey.enablePlayAll, defaultValue: true);
static bool get enableTapDm =>
_setting.get(SettingBoxKey.enableTapDm, defaultValue: false);
}