diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index ce6dc4f..29232b9 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -130,6 +130,7 @@ class _HomeFeedOverlayState extends State { children: [ Positioned.fill( child: PlayerTapGestureRegion( + key: const ValueKey('home-feed-tap-gesture-region'), gestureKey: const Key('home-feed-gesture'), episodeId: widget.episode.id, enabled: !_scrubbing, diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 3cd039b..51cf480 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,33 +1,34 @@ // Author: fruit.com.cn +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const int _heartDurationMs = 1300; -const int _lastHeartDelayMs = 200; -const double _heartPopPortion = 0.18; +const int _heartDurationMs = 1900; +const int _lastHeartDelayMs = 260; +const double _heartPopPortion = 0.12; const double _heartStartScale = 0.7; -const double _heartHoldPortion = 0.65; +const double _heartHoldPortion = 0.45; const List<_HeartParticle> _hearts = [ _HeartParticle( delay: 0, - travel: 175, + travel: 120, drift: 0, size: 44, color: AppColors.doubleTapHeartRed, ), _HeartParticle( - delay: 110, - travel: 150, + delay: 140, + travel: 120, drift: -32, size: 34, color: AppColors.doubleTapHeartPink, ), _HeartParticle( delay: _lastHeartDelayMs, - travel: 165, + travel: 120, drift: 30, size: 38, color: AppColors.doubleTapHeartRed, @@ -36,7 +37,11 @@ const List<_HeartParticle> _hearts = [ /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { - const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + const PlayerDoubleTapHeartBurst({ + super.key, + required this.burstId, + required this.onCompleted, + }); static const int particleCount = 3; static const int singleHeartDurationMs = _heartDurationMs; @@ -44,6 +49,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { milliseconds: _lastHeartDelayMs + _heartDurationMs, ); + final int burstId; final VoidCallback onCompleted; @override @@ -54,11 +60,17 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); + _lifetime.start(); + if (kDebugMode) { + debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); + debugPrint('[player] phase=heart-burst-start id=${widget.burstId}'); + } final total = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); @@ -72,11 +84,26 @@ class _PlayerDoubleTapHeartBurstState extends State void _onStatus(AnimationStatus status) { if (status != AnimationStatus.completed || _completed) return; _completed = true; + if (kDebugMode) { + final elapsedMs = _controller.duration?.inMilliseconds ?? 0; + debugPrint( + '[player] phase=heart-burst-complete ' + 'id=${widget.burstId} elapsedMs=$elapsedMs', + ); + } widget.onCompleted(); } @override void dispose() { + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-dispose ' + 'id=${widget.burstId} elapsedMs=${_lifetime.elapsedMilliseconds} ' + 'completed=${_completed ? 1 : 0}', + ); + } + _lifetime.stop(); _controller ..removeStatusListener(_onStatus) ..dispose(); @@ -111,14 +138,17 @@ class _PlayerDoubleTapHeartBurstState extends State 1.0, ); if (progress <= 0) return const SizedBox.shrink(); - final move = Curves.easeOutCubic.transform(progress); + final move = Curves.easeOutSine.transform(progress); final scale = progress < _heartPopPortion ? _heartStartScale + (1 - _heartStartScale) * (progress / _heartPopPortion) : 1.0; final opacity = progress < _heartHoldPortion ? 1.0 - : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); + : 1 - + Curves.easeInOutSine.transform( + (progress - _heartHoldPortion) / (1 - _heartHoldPortion), + ); return Positioned( left: heart.drift * move - heart.size / 2, top: -heart.size / 2 - heart.travel * move, diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 0e187e3..8d3f5f1 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -59,6 +59,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { children: [ Positioned.fill( child: PlayerTapGestureRegion( + key: const ValueKey('player-tap-gesture-region'), gestureKey: const Key('player-video-tap'), episodeId: data.episode.id, enabled: !scrubbing, diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 32a2132..f053a83 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -93,6 +93,7 @@ class _PlayerTapGestureRegionState extends State { top: burst.position.dy - 10, child: PlayerDoubleTapHeartBurst( key: ValueKey(burst.id), + burstId: burst.id, onCompleted: () => _removeHeartBurst(burst.id), ), ), diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 17ee145..9463851 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -6,6 +6,8 @@ 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 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; +import 'package:fruitshorts/pages/player/widgets/player_tap_gesture_region.dart'; import '../support/fake_catalog.dart'; import '../support/fake_haptics.dart'; @@ -91,7 +93,7 @@ void main() { await tester.tapAt(center); await tester.pump(); expect(repository.likeTargets, hasLength(1)); - await tester.pump(const Duration(milliseconds: 1600)); + await tester.pump(const Duration(milliseconds: 2300)); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); @@ -107,6 +109,40 @@ void main() { expect(key.currentState!.pauseToggles, 0); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); + + testWidgets('parent rebuild keeps the heart burst state alive', ( + tester, + ) async { + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + 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(); + await tester.pump(const Duration(milliseconds: 100)); + + final gestureFinder = find.byType(PlayerTapGestureRegion); + final burstFinder = find.byType(PlayerDoubleTapHeartBurst); + final gestureState = tester.state(gestureFinder); + final burstState = tester.state(burstFinder); + + key.currentState!.rebuild(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.state(gestureFinder), same(gestureState)); + expect(tester.state(burstFinder), same(burstState)); + expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + }); } Future _pumpHarness( @@ -155,6 +191,8 @@ class _HarnessState extends State<_Harness> { ); int pauseToggles = 0; + void rebuild() => setState(() {}); + Future _like({bool likeOnly = false}) async { await widget.haptics.lightImpact(); final episodeId = demoDramas.first.episodes.first.id; diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index f22e9dd..cc1a097 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -10,7 +10,9 @@ void main() { ) async { await tester.pumpWidget( const MaterialApp( - home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + home: Scaffold( + body: PlayerDoubleTapHeartBurst(burstId: 1, onCompleted: _noop), + ), ), ); await tester.pump(const Duration(milliseconds: 300)); @@ -33,10 +35,10 @@ void main() { }); test('heart burst stays long and sparse', () { - expect(PlayerDoubleTapHeartBurst.singleHeartDurationMs, 1300); + expect(PlayerDoubleTapHeartBurst.singleHeartDurationMs, 1900); expect( PlayerDoubleTapHeartBurst.animationDuration, - greaterThanOrEqualTo(const Duration(milliseconds: 1200)), + greaterThanOrEqualTo(const Duration(milliseconds: 1800)), ); expect(PlayerDoubleTapHeartBurst.particleCount, lessThanOrEqualTo(4)); expect(PlayerDoubleTapHeartBurst.particleCount, 3); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 84cab43..1c4a022 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -699,7 +699,7 @@ void main() { AppColors.doubleTapHeartRed, ); - await tester.pump(const Duration(milliseconds: 1600)); + await tester.pump(const Duration(milliseconds: 2300)); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(logic.session.userWantsPlay, isTrue); });