From 84b24775d6600477c84eacae11bf6cd830f853c3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:38:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E6=81=A2=E5=A4=8Ddevelop?= =?UTF-8?q?=E9=A3=98=E5=BF=83=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/home_feed_overlay.dart | 1 - .../widgets/player_double_tap_hearts.dart | 162 +++++++----------- lib/pages/player/widgets/player_overlay.dart | 1 - .../widgets/player_tap_gesture_region.dart | 79 +++------ test/player/double_tap_like_test.dart | 14 +- .../player/player_double_tap_hearts_test.dart | 75 ++------ 6 files changed, 107 insertions(+), 225 deletions(-) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 29232b9..15411ee 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -132,7 +132,6 @@ class _HomeFeedOverlayState extends State { child: PlayerTapGestureRegion( key: const ValueKey('home-feed-tap-gesture-region'), gestureKey: const Key('home-feed-gesture'), - episodeId: widget.episode.id, enabled: !_scrubbing, doubleTapEnabled: !widget.locked, now: widget.now, diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 71f38e6..568c86e 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,37 +1,43 @@ // Author: fruit.com.cn -import 'dart:math'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; +const int _heartDurationMs = 680; +const double _heartPopPortion = 0.18; +const double _heartStartScale = 0.7; +const double _heartHoldPortion = 0.45; + +const List<_HeartParticle> _hearts = [ + _HeartParticle( + delay: 0, + travel: 108, + drift: 0, + size: 48, + color: AppColors.doubleTapHeartRed, + ), + _HeartParticle( + delay: 50, + travel: 96, + drift: -28, + size: 36, + color: AppColors.doubleTapHeartPink, + ), + _HeartParticle( + delay: 90, + travel: 118, + drift: 26, + size: 40, + color: AppColors.doubleTapHeartRed, + ), +]; + /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { - const PlayerDoubleTapHeartBurst({ - super.key, - required this.burstId, - required this.onCompleted, - this.random, - this.origin = Offset.zero, - }); + const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); - final int burstId; final VoidCallback onCompleted; - final Random? random; - final Offset origin; - - @visibleForTesting - static double opacityForProgress(double progress) => - 1 - Curves.easeIn.transform(progress); - - @visibleForTesting - static double scaleForProgress(double progress) { - return progress < 0.3 - ? 0.55 + (1.15 - 0.55) * (progress / 0.3) - : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); - } @override State createState() => @@ -41,41 +47,14 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final List<_HeartParticle> _particles; - final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); - _lifetime.start(); - final random = widget.random ?? Random(); - var delay = 0; - _particles = List.generate(6 + random.nextInt(3), (index) { - if (index > 0) delay += 40 + random.nextInt(21); - return _HeartParticle( - delay: delay, - duration: 700 + random.nextInt(201), - travel: 120 + random.nextInt(41), - drift: -30 + random.nextInt(61), - size: (22 + random.nextInt(13)).toDouble(), - color: random.nextBool() - ? AppColors.doubleTapHeartRed - : AppColors.doubleTapHeartPink, - ); - }); - final total = _particles - .map((particle) => particle.delay + particle.duration) - .reduce(max); - if (kDebugMode) { - debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); - debugPrint( - '[player] phase=heart-burst-start ' - 'id=${widget.burstId} count=${_particles.length} ' - 'at=${widget.origin.dx.toStringAsFixed(1)},' - '${widget.origin.dy.toStringAsFixed(1)}', - ); - } + final total = _hearts + .map((heart) => heart.delay + _heartDurationMs) + .reduce((left, right) => left > right ? left : right); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), @@ -86,29 +65,11 @@ 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} configuredMs=$elapsedMs', - ); - } widget.onCompleted(); } @override void dispose() { - if (kDebugMode) { - final elapsedMs = _completed - ? _controller.duration?.inMilliseconds ?? 0 - : _lifetime.elapsedMilliseconds; - debugPrint( - '[player] phase=heart-burst-dispose ' - 'id=${widget.burstId} elapsedMs=$elapsedMs ' - 'completed=${_completed ? 1 : 0}', - ); - } - _lifetime.stop(); _controller ..removeStatusListener(_onStatus) ..dispose(); @@ -118,44 +79,51 @@ class _PlayerDoubleTapHeartBurstState extends State @override Widget build(BuildContext context) { return IgnorePointer( - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final elapsed = - _controller.value * _controller.duration!.inMilliseconds; - return Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - for (var index = 0; index < _particles.length; index++) - _particle(_particles[index], elapsed, index), - ], - ); - }, + child: SizedBox.shrink( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = + _controller.value * _controller.duration!.inMilliseconds; + return Stack( + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _hearts.length; index++) + _heart(_hearts[index], elapsed, index), + ], + ); + }, + ), ), ); } - Widget _particle(_HeartParticle particle, double elapsed, int index) { - final progress = ((elapsed - particle.delay) / particle.duration).clamp( + Widget _heart(_HeartParticle heart, double elapsed, int index) { + final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); final move = Curves.easeOutCubic.transform(progress); - final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); + final scale = progress < _heartPopPortion + ? _heartStartScale + + (1 - _heartStartScale) * (progress / _heartPopPortion) + : 1.0; + final opacity = progress < _heartHoldPortion + ? 1.0 + : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); return Positioned( - left: widget.origin.dx + particle.drift * move - particle.size / 2, - top: widget.origin.dy - particle.size / 2 - particle.travel * move, + left: heart.drift * move - heart.size / 2, + top: -heart.size / 2 - heart.travel * move, child: Opacity( - opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), + opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( scale: scale, child: Icon( key: Key('double-tap-heart-$index'), Icons.favorite_rounded, - color: particle.color, - size: particle.size, + color: heart.color, + size: heart.size, ), ), ), @@ -166,7 +134,6 @@ class _PlayerDoubleTapHeartBurstState extends State class _HeartParticle { const _HeartParticle({ required this.delay, - required this.duration, required this.travel, required this.drift, required this.size, @@ -174,9 +141,8 @@ class _HeartParticle { }); final int delay; - final int duration; - final int travel; - final int drift; + final double travel; + final double drift; final double size; final Color color; } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 8d3f5f1..11ff157 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -61,7 +61,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: PlayerTapGestureRegion( key: const ValueKey('player-tap-gesture-region'), gestureKey: const Key('player-video-tap'), - episodeId: data.episode.id, enabled: !scrubbing, doubleTapEnabled: !data.locked, onSingleTap: actions.onTogglePlay, diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 7d01fe6..f3b06a6 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -2,7 +2,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'player_double_tap_hearts.dart'; @@ -15,7 +14,6 @@ class PlayerTapGestureRegion extends StatefulWidget { const PlayerTapGestureRegion({ super.key, required this.gestureKey, - required this.episodeId, required this.onSingleTap, required this.onLongPressStart, required this.onInteractionEnd, @@ -26,7 +24,6 @@ class PlayerTapGestureRegion extends StatefulWidget { }); final Key gestureKey; - final String episodeId; final VoidCallback onSingleTap; final ValueChanged? onDoubleTapLike; final PlayerLongPressStartCallback onLongPressStart; @@ -50,18 +47,12 @@ class _PlayerTapGestureRegionState extends State { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final Map _heartOverlayEntries = {}; + final List<({int id, Offset position})> _heartBursts = []; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); - for (final entry in _heartOverlayEntries.values.toList()) { - entry - ..remove() - ..dispose(); - } - _heartOverlayEntries.clear(); super.dispose(); } @@ -87,6 +78,25 @@ class _PlayerTapGestureRegionState extends State { 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), + ), + ), + ], + ), + ), + ), ], ), ), @@ -128,7 +138,7 @@ class _PlayerTapGestureRegionState extends State { void _handleTapUp(TapUpDetails details) { final now = _now(); if (_doubleTapDown || _singleTapBlocked(now)) { - _handleDoubleTapLike(details.globalPosition); + _handleDoubleTapLike(details.localPosition); return; } final serial = _downSerial; @@ -146,54 +156,19 @@ class _PlayerTapGestureRegionState extends State { _singleTapTimer = pending; } - void _handleDoubleTapLike(Offset globalPosition) { + void _handleDoubleTapLike(Offset position) { _blockSingleTapUntil = _now().add(_singleTapDelay); _cancelPendingSingleTap(); if (!widget.doubleTapEnabled || _likedThisPair) return; _likedThisPair = true; - _showHeartBurst(globalPosition); - if (kDebugMode) { - debugPrint( - '[player] phase=double-tap-like episodeId=${widget.episodeId}', - ); - } - widget.onDoubleTapLike?.call(globalPosition); - } - - void _showHeartBurst(Offset globalPosition) { - final overlay = Overlay.maybeOf(context, rootOverlay: true); - if (overlay == null) return; - final overlayBox = overlay.context.findRenderObject(); - final origin = overlayBox is RenderBox - ? overlayBox.globalToLocal(globalPosition) - : globalPosition; final id = _nextHeartBurstId++; - late final OverlayEntry entry; - entry = OverlayEntry( - builder: (context) => Positioned.fill( - child: IgnorePointer( - child: PlayerDoubleTapHeartBurst( - key: ValueKey('heart-overlay-$id'), - burstId: id, - origin: origin, - onCompleted: () => _removeHeartOverlay(id), - ), - ), - ), - ); - _heartOverlayEntries[id] = entry; - overlay.insert(entry); + setState(() => _heartBursts.add((id: id, position: position))); + widget.onDoubleTapLike?.call(position); } - void _removeHeartOverlay(int id) { - final entry = _heartOverlayEntries.remove(id); - if (entry == null) return; - entry - ..remove() - ..dispose(); - if (kDebugMode) { - debugPrint('[player] phase=heart-burst-entry-removed id=$id'); - } + void _removeHeartBurst(int id) { + if (!mounted) return; + setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); } void _handleLongPressStart(LongPressStartDetails details, double width) { diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 65c37c9..f0b63bc 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -144,7 +144,7 @@ void main() { expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); - testWidgets('heart overlay starts at global tap position and is cleaned up', ( + testWidgets('heart burst is cleaned up when gesture region is disposed', ( tester, ) async { final key = GlobalKey<_HarnessState>(); @@ -165,19 +165,14 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); - final firstHeart = find.byKey(const Key('double-tap-heart-0')); - expect(firstHeart, findsOneWidget); - expect( - (tester.getCenter(firstHeart) - tapPosition).distance, - lessThanOrEqualTo(1), - ); + expect(find.byType(PlayerDoubleTapHeartBurst), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); }); - testWidgets('multiple heart overlays run independently', (tester) async { + testWidgets('multiple heart bursts run independently', (tester) async { final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness( @@ -205,9 +200,6 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsNWidgets(2)); - expect(find.byKey(const ValueKey('heart-overlay-0')), findsOneWidget); - expect(find.byKey(const ValueKey('heart-overlay-1')), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index b7640b5..4c3a933 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -1,41 +1,24 @@ // Author: fruit.com.cn -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; void main() { - testWidgets('should_show_randomized_heart_particles_when_burst_starts', ( + testWidgets('should_show_three_develop_hearts_when_burst_starts', ( tester, ) async { - final random = _RecordingRandom(); await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: PlayerDoubleTapHeartBurst( - burstId: 1, - onCompleted: _noop, - random: random, - ), - ), + const MaterialApp( + home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), ), ); - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); - final hearts = find.byIcon(Icons.favorite_rounded); - final count = hearts.evaluate().length; - expect(count, inInclusiveRange(6, 8)); - for (var index = 0; index < count; index++) { - expect(_size(tester, index), inInclusiveRange(22, 34)); - } - expect(random.intBounds.first, 3); - expect(random.intBounds.where((bound) => bound == 21), hasLength(7)); - expect(random.intBounds.where((bound) => bound == 201), hasLength(8)); - expect(random.intBounds.where((bound) => bound == 41), hasLength(8)); - expect(random.intBounds.where((bound) => bound == 61), hasLength(8)); - expect(random.intBounds.where((bound) => bound == 13), hasLength(8)); + expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); + expect(_size(tester, 0), 48); + expect(_size(tester, 1), 36); + expect(_size(tester, 2), 40); expect( tester .widgetList( @@ -49,17 +32,7 @@ void main() { ); }); - test('heart particles fade from the start and scale in two phases', () { - expect(PlayerDoubleTapHeartBurst.opacityForProgress(0.5), lessThan(0.7)); - expect(PlayerDoubleTapHeartBurst.scaleForProgress(0), 0.55); - expect( - PlayerDoubleTapHeartBurst.scaleForProgress(0.3), - closeTo(1.15, 1e-9), - ); - expect(PlayerDoubleTapHeartBurst.scaleForProgress(1), closeTo(0.2, 1e-9)); - }); - - testWidgets('heart burst completes around 1.2 to 1.4 seconds', ( + testWidgets('develop heart burst completes after about 770ms', ( tester, ) async { var completed = false; @@ -67,25 +40,19 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst( - burstId: 2, - random: _RecordingRandom(), - onCompleted: () => completed = true, - ), + body: PlayerDoubleTapHeartBurst(onCompleted: () => completed = true), ), ), ); - while (!completed && elapsedMs < 2000) { + while (!completed && elapsedMs < 1200) { await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(1200)); - expect(elapsedMs, lessThanOrEqualTo(1400)); - final count = find.byIcon(Icons.favorite_rounded).evaluate().length; - expect(count, inInclusiveRange(6, 8)); + expect(elapsedMs, greaterThanOrEqualTo(752)); + expect(elapsedMs, lessThanOrEqualTo(800)); }); } @@ -94,19 +61,3 @@ double _size(WidgetTester tester, int index) { } void _noop() {} - -class _RecordingRandom implements Random { - final List intBounds = []; - - @override - bool nextBool() => true; - - @override - double nextDouble() => 0.5; - - @override - int nextInt(int max) { - intBounds.add(max); - return max - 1; - } -}