diff --git a/lib/common/widgets/gesture/immediate_tap_gesture_recognizer.dart b/lib/common/widgets/gesture/immediate_tap_gesture_recognizer.dart new file mode 100644 index 000000000..46623d33e --- /dev/null +++ b/lib/common/widgets/gesture/immediate_tap_gesture_recognizer.dart @@ -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('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('onTapUp', () => onTapUp(details)); + + if (onTap != null) { + invokeCallback('onTap', onTap!); + } + } + + _reset(); + } + + void _cancelGesture(String reason) { + if (_sentTapDown) { + invokeCallback('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', + _up, + defaultValue: null, + ), + ); + } +} diff --git a/lib/common/widgets/gesture/interactive_viewer.dart b/lib/common/widgets/gesture/mouse_interactive_viewer.dart similarity index 100% rename from lib/common/widgets/gesture/interactive_viewer.dart rename to lib/common/widgets/gesture/mouse_interactive_viewer.dart diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index 773489ae7..2d8b98ac3 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -131,6 +131,7 @@ class _PlDanmakuState extends State { e.colorful == DmColorfulType.VipGradualColor, count: e.hasCount() ? e.count : null, selfSend: e.isSelf, + extra: VideoDanmaku(id: e.id.toInt(), mid: e.midHash), ), ); } diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index 01239683b..99cc41c7c 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -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) { diff --git a/lib/pages/setting/models/play_settings.dart b/lib/pages/setting/models/play_settings.dart index d6e922026..cdd165a01 100644 --- a/lib/pages/setting/models/play_settings.dart +++ b/lib/pages/setting/models/play_settings.dart @@ -29,6 +29,13 @@ List 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'), diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 98776979f..e49a52d50 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -320,6 +320,7 @@ class PlPlayerController { } /// 弹幕权重 + late final enableTapDm = Pref.enableTapDm; late int danmakuWeight = Pref.danmakuWeight; late RuleFilter filters = Pref.danmakuFilterRule; // 关联弹幕控制器 diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index 655a136d2..78c0fdb11 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -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 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 }); } - _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 FlutterVolumeController.removeListener(); } transformationController.dispose(); + _refreshDmCallback = null; + _removeDmAction(); super.dispose(); } @@ -900,7 +918,7 @@ class _PLVideoPlayerState extends State 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 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 } } - 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 ), ), + 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 }), ], ); - if (Utils.isDesktop) { + if (isDesktop) { return Obx( () => MouseRegion( cursor: !plPlayerController.showControls.value && isFullScreen @@ -2124,6 +2175,144 @@ class _PLVideoPlayerState extends State }, ); } + + static const _overlaySpacing = 10.0; + static const _overlayWidth = 130.0; + static const _overlayHeight = 35.0; + + DanmakuItem? _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 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; +} diff --git a/lib/utils/storage_key.dart b/lib/utils/storage_key.dart index 574a5f466..483d0d149 100644 --- a/lib/utils/storage_key.dart +++ b/lib/utils/storage_key.dart @@ -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', diff --git a/lib/utils/storage_pref.dart b/lib/utils/storage_pref.dart index c7ba98656..f309cefdd 100644 --- a/lib/utils/storage_pref.dart +++ b/lib/utils/storage_pref.dart @@ -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); }