From 63296d2a929501db81657b24fb11a2379969dd45 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:05:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E6=81=A2=E5=A4=8D=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=92=AD=E6=94=BE=E9=A1=B5=E5=8F=8C=E5=87=BB=E7=82=B9?= =?UTF-8?q?=E8=B5=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 3 + .../player/widgets/home_feed_overlay.dart | 139 +------------ .../widgets/player_double_tap_hearts.dart | 2 + lib/pages/player/widgets/player_overlay.dart | 20 +- .../player/widgets/player_overlay_models.dart | 6 + .../widgets/player_tap_gesture_region.dart | 192 ++++++++++++++++++ test/player/double_tap_like_test.dart | 37 +++- test/player/player_fullscreen_test.dart | 94 ++++++++- 8 files changed, 348 insertions(+), 145 deletions(-) create mode 100644 lib/pages/player/widgets/player_tap_gesture_region.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 355bb62..1318d93 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1228,6 +1228,9 @@ class _PlayerPageState extends State ? null : () => unawaited(logic.leave(context)), onTogglePlay: _togglePlayback, + onDoubleTapLike: locked + ? null + : (_) => _handleDoubleTapLike(context, interactions, episode), onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), onDetail: () => unawaited(logic.openDetail(context)), diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 467cb53..2fd6fc0 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -14,7 +14,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; -import 'player_double_tap_hearts.dart'; +import 'player_tap_gesture_region.dart'; import 'seek_progress_bar.dart'; import 'scrub_preview_card.dart'; @@ -110,19 +110,8 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { - static const Duration _singleTapDelay = Duration(milliseconds: 400); - bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; - Timer? _singleTapTimer; - DateTime? _lastDownAt; - DateTime? _blockSingleTapUntil; - bool _doubleTapDown = false; - bool _likedThisPair = false; - int _downSerial = 0; - int _tapSeq = 0; - int _nextHeartBurstId = 0; - final List<({int id, Offset position})> _heartBursts = []; @override void didUpdateWidget(covariant HomeFeedOverlay oldWidget) { @@ -132,13 +121,6 @@ class _HomeFeedOverlayState extends State { } } - @override - void dispose() { - _tapSeq++; - _singleTapTimer?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { return SafeArea( @@ -146,21 +128,16 @@ class _HomeFeedOverlayState extends State { child: Stack( children: [ Positioned.fill( - child: LayoutBuilder( - builder: (context, constraints) => Listener( - onPointerUp: (_) => widget.onBoostEnd(), - onPointerCancel: (_) => widget.onBoostEnd(), - onPointerDown: _handlePointerDown, - child: GestureDetector( - key: const Key('home-feed-gesture'), - behavior: HitTestBehavior.opaque, - onTapUp: _handleTapUp, - onLongPressStart: (details) => - _handleLongPressStart(details, constraints.maxWidth), - onLongPressEnd: (_) => widget.onBoostEnd(), - onLongPressCancel: widget.onBoostEnd, - ), - ), + child: PlayerTapGestureRegion( + gestureKey: const Key('home-feed-gesture'), + episodeId: widget.episode.id, + enabled: !_scrubbing, + doubleTapEnabled: !widget.locked, + now: widget.now, + onSingleTap: widget.onTogglePlay, + onDoubleTapLike: widget.onDoubleTapLike, + onLongPressStart: _handleLongPressStart, + onInteractionEnd: widget.onBoostEnd, ), ), Positioned( @@ -222,25 +199,6 @@ class _HomeFeedOverlayState extends State { onSeek: widget.onSeek, ), ), - if (_heartBursts.isNotEmpty) - Positioned.fill( - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final burst in _heartBursts) - Positioned( - left: burst.position.dx, - top: burst.position.dy - 10, - child: PlayerDoubleTapHeartBurst( - key: ValueKey(burst.id), - onCompleted: () => _removeHeartBurst(burst.id), - ), - ), - ], - ), - ), - ), ], ), ); @@ -262,83 +220,8 @@ class _HomeFeedOverlayState extends State { widget.onScrubEnd?.call(wasVisible); } - 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)) { - _showDoubleTapLike(details.localPosition); - 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.onTogglePlay(); - }); - _singleTapTimer = pending; - } - - void _showDoubleTapLike(Offset position) { - if (_likedThisPair) return; - _likedThisPair = true; - _blockSingleTapUntil = _now().add(_singleTapDelay); - _cancelPendingSingleTap(); - _showHearts(position); - } - - void _showHearts(Offset position) { - final id = _nextHeartBurstId++; - setState(() => _heartBursts.add((id: id, position: position))); - widget.onDoubleTapLike?.call(position); - } - - void _removeHeartBurst(int id) { - if (!mounted) return; - setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); - } - void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; - _tapSeq++; - _cancelPendingSingleTap(); - _doubleTapDown = false; - _lastDownAt = null; final dx = details.localPosition.dx; if (dx < third || dx > width - third) { widget.onBoostStart(); diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 568c86e..b071442 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -37,6 +37,8 @@ const List<_HeartParticle> _hearts = [ class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + static const int particleCount = 3; + final VoidCallback onCompleted; @override diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index d5f90b8..ebf9e54 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -14,6 +14,7 @@ import '../../../l10n/app_localizations.dart'; import 'player_center_toggle.dart'; import 'interaction_rail.dart'; import 'player_overlay_models.dart'; +import 'player_tap_gesture_region.dart'; import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; @@ -56,16 +57,15 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: LayoutBuilder( - builder: (context, constraints) => GestureDetector( - key: const Key('player-video-tap'), - behavior: HitTestBehavior.opaque, - onTap: actions.onTogglePlay, - onLongPressStart: (details) => - _handleLongPressStart(details, constraints.maxWidth), - onLongPressEnd: (_) => actions.onBoostEnd(), - onLongPressCancel: actions.onBoostEnd, - ), + child: PlayerTapGestureRegion( + gestureKey: const Key('player-video-tap'), + episodeId: data.episode.id, + enabled: !scrubbing, + doubleTapEnabled: !data.locked, + onSingleTap: actions.onTogglePlay, + onDoubleTapLike: actions.onDoubleTapLike, + onLongPressStart: _handleLongPressStart, + onInteractionEnd: actions.onBoostEnd, ), ), _top(context), diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 0e4cfd4..59399bb 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import 'package:video_player/video_player.dart'; @@ -87,6 +89,7 @@ class PlayerOverlayActions { const PlayerOverlayActions({ required this.onBack, required this.onTogglePlay, + this.onDoubleTapLike, required this.onClear, required this.onExitClear, required this.onDetail, @@ -111,6 +114,9 @@ class PlayerOverlayActions { /// 切换播放状态。 final VoidCallback onTogglePlay; + /// 双击画面点赞;位置用于飘心。 + final ValueChanged? onDoubleTapLike; + /// 进入清屏。 final VoidCallback onClear; diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart new file mode 100644 index 0000000..d28fcf7 --- /dev/null +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -0,0 +1,192 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'player_double_tap_hearts.dart'; + +typedef PlayerLongPressStartCallback = + void Function(LongPressStartDetails details, double width); + +/// 播放画面共用的单击/双击仲裁层。 +class PlayerTapGestureRegion extends StatefulWidget { + const PlayerTapGestureRegion({ + super.key, + required this.gestureKey, + required this.episodeId, + required this.onSingleTap, + required this.onLongPressStart, + required this.onInteractionEnd, + this.onDoubleTapLike, + this.doubleTapEnabled = true, + this.enabled = true, + this.now, + }); + + final Key gestureKey; + final String episodeId; + 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: 320); + + Timer? _singleTapTimer; + DateTime? _lastDownAt; + DateTime? _blockSingleTapUntil; + bool _doubleTapDown = false; + bool _likedThisPair = false; + int _downSerial = 0; + int _tapSeq = 0; + int _nextHeartBurstId = 0; + final List<({int id, Offset position})> _heartBursts = []; + + @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, + children: [ + GestureDetector( + key: widget.gestureKey, + behavior: HitTestBehavior.opaque, + onTapUp: _handleTapUp, + onLongPressStart: (details) => + _handleLongPressStart(details, constraints.maxWidth), + onLongPressEnd: (_) => widget.onInteractionEnd(), + onLongPressCancel: widget.onInteractionEnd, + ), + if (_heartBursts.isNotEmpty) + Positioned.fill( + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final burst in _heartBursts) + Positioned( + left: burst.position.dx, + top: burst.position.dy - 10, + child: PlayerDoubleTapHeartBurst( + key: ValueKey(burst.id), + onCompleted: () => _removeHeartBurst(burst.id), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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.localPosition); + 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 position) { + _blockSingleTapUntil = _now().add(_singleTapDelay); + _cancelPendingSingleTap(); + if (!widget.doubleTapEnabled || _likedThisPair) return; + _likedThisPair = true; + final id = _nextHeartBurstId++; + setState(() => _heartBursts.add((id: id, position: position))); + if (kDebugMode) { + debugPrint( + '[player] phase=double-tap-like episodeId=${widget.episodeId}', + ); + debugPrint( + '[player] phase=heart-burst ' + 'count=${PlayerDoubleTapHeartBurst.particleCount}', + ); + } + widget.onDoubleTapLike?.call(position); + } + + void _removeHeartBurst(int id) { + if (!mounted) return; + setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + } + + void _handleLongPressStart(LongPressStartDetails details, double width) { + _tapSeq++; + _cancelPendingSingleTap(); + _doubleTapDown = false; + _lastDownAt = null; + widget.onLongPressStart(details, width); + } +} diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index a977bbc..cc65410 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -12,8 +12,8 @@ import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { - testWidgets('double tap within 400ms never pauses', (tester) async { - for (final delay in [120, 250, 300, 380]) { + testWidgets('double tap within 320ms never pauses', (tester) async { + for (final delay in [120, 250, 300]) { final repository = FakeInteractionRepository(); final haptics = FakeHaptics(); final key = GlobalKey<_HarnessState>(); @@ -74,6 +74,39 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); expect(key.currentState!.pauseToggles, 2); }); + + testWidgets('liked episode keeps heart feedback without liking again', ( + tester, + ) async { + final repository = FakeInteractionRepository(); + final haptics = FakeHaptics(); + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness(tester, repository, haptics, key, clock); + final center = tester.getCenter(find.byKey(const Key('home-feed-gesture'))); + + await tester.tapAt(center); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(center); + await tester.pump(); + expect(repository.likeTargets, hasLength(1)); + await tester.pump(const Duration(milliseconds: 800)); + + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(center); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(repository.likeTargets, hasLength(1)); + expect(haptics.lightImpacts, 2); + expect(key.currentState!.pauseToggles, 0); + expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + }); } Future _pumpHarness( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 915cadd..54a1d91 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -364,7 +364,7 @@ void main() { // IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。 expect(fullLogic.session.userWantsPlay, isTrue); await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable()); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(fullLogic.session.userWantsPlay, isFalse); final indexBeforeSwipe = fullLogic.session.currentIndex; await tester.drag(fullPlayerPage, const Offset(0, -560)); @@ -632,7 +632,7 @@ void main() { expect(find.byKey(const Key('player-play-toggle')), findsNothing); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect( tester @@ -655,6 +655,82 @@ void main() { expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); + testWidgets('should_double_tap_like_once_without_pausing_full_player', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + final interactions = FakeInteractionRepository( + nextLikeResult: const LikeToggleResult(liked: true, count: 23000), + ); + await _pumpApp(tester, interactions: interactions); + await _openSeriesPlayer(tester); + + final logic = _pageLogic(tester, find.byType(PlayerPage).last); + final gesture = find.byKey(const Key('player-video-tap')).hitTestable(); + final position = tester.getCenter(gesture); + expect(logic.session.userWantsPlay, isTrue); + + for (var index = 0; index < 4; index++) { + await tester.tapAt(position); + } + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(logic.session.userWantsPlay, isTrue); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']); + for (var index = 0; index < 3; index++) { + expect(find.byKey(Key('double-tap-heart-$index')), findsOneWidget); + } + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('player-rail-like')), + matching: find.byType(Image), + ), + ) + .color, + AppColors.doubleTapHeartRed, + ); + + await tester.pump(const Duration(milliseconds: 800)); + expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); + expect(logic.session.userWantsPlay, isTrue); + }); + + testWidgets('should_double_tap_like_in_clear_screen', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', + }); + final interactions = FakeInteractionRepository(); + await _pumpApp(tester, interactions: interactions); + await _openSeriesPlayer(tester); + final logic = _pageLogic(tester, find.byType(PlayerPage).last); + + await _clearFromMore(tester); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + + await tester.tapAt(const Offset(80, 300)); + await tester.tapAt(const Offset(80, 300)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(logic.session.userWantsPlay, isTrue); + expect(interactions.likeTargets, hasLength(1)); + expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + expect(find.byKey(const Key('double-tap-heart-1')), findsOneWidget); + expect(find.byKey(const Key('double-tap-heart-2')), findsOneWidget); + }); + testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( tester, ) async { @@ -666,7 +742,7 @@ void main() { expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( tester @@ -710,13 +786,13 @@ void main() { ); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(find.byType(PlayerCenterToggle), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); @@ -1649,8 +1725,10 @@ void main() { testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { + final interactions = FakeInteractionRepository(); await _pumpApp( tester, + interactions: interactions, playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'city-lights-demo-1', @@ -1663,6 +1741,12 @@ void main() { await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget); + await tester.tapAt(const Offset(80, 300)); + await tester.tapAt(const Offset(80, 300)); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); + expect(interactions.likeTargets, isEmpty); + await tester.tap(find.byKey(const Key('player-open-unlock'))); await tester.pumpAndSettle();