From 68ccfa21c0e7ff71b062bb63d110c63871a23db0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:30:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E7=82=B9=E8=B5=9E=E4=B8=8E=E5=BF=83=E5=BD=A2=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 5 +- lib/pages/player/player_view.dart | 31 +--- lib/pages/player/player_view_actions.dart | 31 +--- .../player/widgets/home_feed_overlay.dart | 44 ++++-- .../widgets/home_feed_recommend_panel.dart | 6 +- .../player/widgets/interaction_rail.dart | 4 +- lib/pages/player/widgets/player_overlay.dart | 1 + test/player/double_tap_like_test.dart | 142 ++++++++++++++++++ 8 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 test/player/double_tap_like_test.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 88688bf..70a35d1 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1297,6 +1297,7 @@ $ flutter test ## 2026-10-22 播放器双击点赞与飘心 -- 沉浸式播放手势增加原生双击识别,双击优先于单击暂停,长按左右倍速与中间更多保持不变。 -- 双击仅对未点赞剧集调用现有点赞接口,已点赞或请求中只播动画;触发轻震动并在失败时回滚提示。 +- 沉浸式播放器改用 onTapUp + 280ms Timer 判定双击,双击取消单击,长按左右倍速与中间更多保持不变。 +- 双击和右侧心形共用点赞方法,成功后控制器通知并点亮红色实心心形,失败由 controller 回滚并使用 Toast。 - 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。 +- `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7379f51..f1f6d95 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -516,16 +516,8 @@ class _PlayerPageState extends State controller: videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - onTap: () => _handleFeedSurfaceTap(), + onTap: widget.homeMode ? () {} : _handleFeedSurfaceTap, ), - if (widget.homeMode && _chromeVisible && !logic.chromeHidden) - Positioned.fill( - child: GestureDetector( - key: const Key('home-feed-video-toggle'), - behavior: HitTestBehavior.opaque, - onTap: _handleFeedSurfaceTap, - ), - ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( @@ -640,24 +632,11 @@ class _PlayerPageState extends State commentCount: _commentCounts[episode.id] ?? episode.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await interactions.toggleEpisodeLike(episode.id); - if (!result.isSuccess && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? - AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - } + await _toggleLike(context, interactions, episode); }, - onDoubleTapLike: (_) => - unawaited(_likeFromDoubleTap(context, interactions, episode)), + onDoubleTapLike: (_) => unawaited( + _toggleLike(context, interactions, episode, likeOnly: true), + ), onSave: () => unawaited(_toggleSave(context, drama)), onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 6f58164..171047a 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -81,34 +81,17 @@ extension _PlayerViewActions on _PlayerPageState { Future _toggleLike( BuildContext context, InteractionController interactions, - Episode episode, - ) async { - if (!await ensureSignedIn(context) || !context.mounted) return; + Episode episode, { + bool likeOnly = false, + }) async { unawaited(context.read().lightImpact()); - final result = await interactions.toggleEpisodeLike(episode.id); - if (result.isSuccess || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - - Future _likeFromDoubleTap( - BuildContext context, - InteractionController interactions, - Episode episode, - ) async { - unawaited(context.read().lightImpact()); - if (interactions.isEpisodeLiked(episode.id) || - interactions.isEpisodeBusy(episode.id)) { + if (interactions.isEpisodeBusy(episode.id) || + (likeOnly && interactions.isEpisodeLiked(episode.id))) { return; } if (!await ensureSignedIn(context) || !context.mounted) return; - if (interactions.isEpisodeLiked(episode.id) || - interactions.isEpisodeBusy(episode.id)) { + if (interactions.isEpisodeBusy(episode.id) || + (likeOnly && interactions.isEpisodeLiked(episode.id))) { return; } final result = await interactions.toggleEpisodeLike(episode.id); diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 88295de..cf182a2 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -107,9 +107,12 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { + static const Duration _singleTapDelay = Duration(milliseconds: 280); + bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; - Offset? _doubleTapPosition; + Timer? _singleTapTimer; + DateTime? _lastTapAt; int _nextHeartBurstId = 0; final List<({int id, Offset position})> _heartBursts = []; @@ -121,6 +124,12 @@ class _HomeFeedOverlayState extends State { } } + @override + void dispose() { + _singleTapTimer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return SafeArea( @@ -135,10 +144,7 @@ class _HomeFeedOverlayState extends State { child: GestureDetector( key: const Key('home-feed-gesture'), behavior: HitTestBehavior.opaque, - onTap: _handleSurfaceTap, - onDoubleTapDown: (details) => - _doubleTapPosition = details.localPosition, - onDoubleTap: _handleDoubleTap, + onTapUp: _handleTapUp, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => widget.onBoostEnd(), @@ -242,13 +248,28 @@ class _HomeFeedOverlayState extends State { widget.onScrubEnd?.call(wasVisible); } - void _handleSurfaceTap() { - widget.onTogglePlay(); + void _handleTapUp(TapUpDetails details) { + final now = DateTime.now(); + final lastTap = _lastTapAt; + if (_singleTapTimer != null && + lastTap != null && + now.difference(lastTap) <= _singleTapDelay) { + _singleTapTimer?.cancel(); + _singleTapTimer = null; + _lastTapAt = null; + _showHearts(details.localPosition); + return; + } + _lastTapAt = now; + _singleTapTimer?.cancel(); + _singleTapTimer = Timer(_singleTapDelay, () { + _singleTapTimer = null; + _lastTapAt = null; + if (mounted) widget.onTogglePlay(); + }); } - void _handleDoubleTap() { - final position = _doubleTapPosition; - if (position == null) return; + void _showHearts(Offset position) { final id = _nextHeartBurstId++; setState(() => _heartBursts.add((id: id, position: position))); widget.onDoubleTapLike?.call(position); @@ -260,6 +281,9 @@ class _HomeFeedOverlayState extends State { } void _handleLongPressStart(LongPressStartDetails details, double width) { + _singleTapTimer?.cancel(); + _singleTapTimer = null; + _lastTapAt = null; final third = width / 3; final dx = details.localPosition.dx; if (dx < third || dx > width - third) { diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 3299d01..4e509f5 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -371,6 +371,9 @@ class HomeFeedActionRail extends StatelessWidget { icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + iconColor: liked + ? AppColors.doubleTapHeartRed + : AppColors.textPrimary, label: likeCount == null ? l10n.like : formatInteractionCount(likeCount!), @@ -397,6 +400,7 @@ class HomeFeedActionRail extends StatelessWidget { required String label, required Key labelKey, required VoidCallback onTap, + Color iconColor = AppColors.textPrimary, double labelFontSize = 12, }) { return GestureDetector( @@ -409,7 +413,7 @@ class HomeFeedActionRail extends StatelessWidget { Icon( icon, size: _actionIcon, - color: AppColors.textPrimary, + color: iconColor, shadows: const [ Shadow( color: AppColors.shadowSoft, diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 3ee085d..48f0c98 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -19,6 +19,7 @@ class InteractionRailItem { this.count, this.countKey, this.selected = false, + this.selectedColor = AppColors.blue, }); final Key key; @@ -28,6 +29,7 @@ class InteractionRailItem { final int? count; final Key? countKey; final bool selected; + final Color selectedColor; } /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 @@ -71,7 +73,7 @@ class _RailButton extends StatelessWidget { width: _iconSize, height: _iconSize, fit: BoxFit.contain, - color: item.selected ? AppColors.blue : AppColors.onPrimary, + color: item.selected ? item.selectedColor : AppColors.onPrimary, colorBlendMode: BlendMode.srcIn, ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 9781bf3..792c1fd 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -170,6 +170,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { count: data.likeCount, countKey: const Key('player-rail-like-count'), selected: data.liked, + selectedColor: AppColors.doubleTapHeartRed, onTap: actions.onLike, ), InteractionRailItem( diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart new file mode 100644 index 0000000..cd5ecb3 --- /dev/null +++ b/test/player/double_tap_like_test.dart @@ -0,0 +1,142 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; + +void main() { + testWidgets('quick double tap likes without pausing', (tester) async { + final repository = FakeInteractionRepository(); + final haptics = FakeHaptics(); + final key = GlobalKey<_HarnessState>(); + await _pumpHarness(tester, repository, haptics, key); + + final gesture = find.byKey(const Key('home-feed-gesture')); + final center = tester.getCenter(gesture); + + await tester.tapAt(center); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(center); + await tester.pumpAndSettle(); + + expect(key.currentState!.pauseToggles, 0); + expect(repository.likeTargets, hasLength(1)); + expect(haptics.lightImpacts, 1); + expect( + find.descendant( + of: find.byKey(const Key('rail-like')), + matching: find.byIcon(Icons.favorite_rounded), + ), + findsOneWidget, + ); + }); + + testWidgets('single tap toggles playback after 280ms', (tester) async { + final key = GlobalKey<_HarnessState>(); + await _pumpHarness(tester, FakeInteractionRepository(), FakeHaptics(), key); + + await tester.tapAt( + tester.getCenter(find.byKey(const Key('home-feed-gesture'))), + ); + await tester.pump(const Duration(milliseconds: 279)); + expect(key.currentState!.pauseToggles, 0); + + await tester.pump(const Duration(milliseconds: 2)); + expect(key.currentState!.pauseToggles, 1); + }); +} + +Future _pumpHarness( + WidgetTester tester, + FakeInteractionRepository repository, + FakeHaptics haptics, + GlobalKey<_HarnessState> key, +) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: _Harness(key: key, repository: repository, haptics: haptics), + ), + ); + await tester.pumpAndSettle(); +} + +class _Harness extends StatefulWidget { + const _Harness({super.key, required this.repository, required this.haptics}); + + final FakeInteractionRepository repository; + final FakeHaptics haptics; + + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + late final InteractionController interactions = InteractionController( + widget.repository, + ); + int pauseToggles = 0; + + Future _like({bool likeOnly = false}) async { + await widget.haptics.lightImpact(); + final episodeId = demoDramas.first.episodes.first.id; + if (interactions.isEpisodeBusy(episodeId) || + (likeOnly && interactions.isEpisodeLiked(episodeId))) { + return; + } + await interactions.toggleEpisodeLike(episodeId); + } + + @override + void dispose() { + interactions.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: Colors.black, + body: ListenableBuilder( + listenable: interactions, + builder: (context, _) { + final episode = demoDramas.first.episodes.first; + return HomeFeedOverlay( + l10n: l10n, + drama: demoDramas.first, + episode: episode, + controller: null, + locked: false, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: interactions.episodeLikeCount(episode.id), + saved: false, + visible: true, + boosting: false, + onLike: () => _like(), + onDoubleTapLike: (_) => _like(likeOnly: true), + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onTogglePlay: () => setState(() => pauseToggles++), + onBoostStart: () {}, + onBoostEnd: () {}, + onMore: () {}, + ); + }, + ), + ); + } +}