From 978c309cd75042f797d9e9bbb49d1a30cb865610 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 19:07:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E6=94=B9=E7=94=A8=E5=A2=99?= =?UTF-8?q?=E9=92=9F=E9=A9=B1=E5=8A=A8=E9=A3=98=E5=BF=83=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 114 ++++++++++-------- .../widgets/player_tap_gesture_region.dart | 1 + test/player/double_tap_like_test.dart | 46 ++++--- .../player/player_double_tap_hearts_test.dart | 70 ++++++++--- test/player/player_fullscreen_test.dart | 18 ++- test/player/player_hearts_pixels_test.dart | 86 ++++++++++--- 6 files changed, 235 insertions(+), 100 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index e0f05a5..1d51a9d 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -41,53 +43,84 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { required this.burstId, required this.onCompleted, this.origin = Offset.zero, + this.now, }); final int burstId; final VoidCallback onCompleted; final Offset origin; + final DateTime Function()? now; @override State createState() => _PlayerDoubleTapHeartBurstState(); } -class _PlayerDoubleTapHeartBurstState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller; +class _PlayerDoubleTapHeartBurstState extends State { + Timer? _timer; + late final DateTime _startAt; + late final int _totalMs; final Stopwatch _lifetime = Stopwatch(); + int _elapsedMs = 0; + int _tickCount = 0; bool _completed = false; @override void initState() { super.initState(); _lifetime.start(); - final total = _hearts + _startAt = _now(); + _totalMs = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); if (kDebugMode) { debugPrint( '[player] phase=heart-burst-start ' 'id=${widget.burstId} count=${_hearts.length} ' - 'configuredMs=$total at=${widget.origin.dx.toStringAsFixed(1)},' + 'configuredMs=$_totalMs at=${widget.origin.dx.toStringAsFixed(1)},' '${widget.origin.dy.toStringAsFixed(1)}', ); } - _controller = AnimationController( - vsync: this, - duration: Duration(milliseconds: total), - )..addStatusListener(_onStatus); - _controller.forward(); + _timer = Timer.periodic( + const Duration(milliseconds: 16), + (_) => _handleTick(), + ); } - void _onStatus(AnimationStatus status) { - if (status != AnimationStatus.completed || _completed) return; + DateTime _now() => widget.now?.call() ?? DateTime.now(); + + void _handleTick() { + if (!mounted || _completed) return; + final rawElapsedMs = _now().difference(_startAt).inMilliseconds; + final elapsedMs = rawElapsedMs < 0 + ? 0 + : rawElapsedMs > _totalMs + ? _totalMs + : rawElapsedMs; + _elapsedMs = elapsedMs; + _tickCount++; + if (kDebugMode && _tickCount % 10 == 0) { + debugPrint( + '[player] phase=heart-tick id=${widget.burstId} ' + 'elapsedMs=$_elapsedMs', + ); + } + if (_elapsedMs >= _totalMs) { + _complete(); + return; + } + setState(() {}); + } + + void _complete() { + if (_completed) return; _completed = true; + _timer?.cancel(); if (kDebugMode) { debugPrint( '[player] phase=heart-burst-complete ' 'id=${widget.burstId} ' - 'configuredMs=${_controller.duration?.inMilliseconds ?? 0}', + 'configuredMs=$_totalMs', ); } widget.onCompleted(); @@ -96,9 +129,7 @@ class _PlayerDoubleTapHeartBurstState extends State @override void dispose() { if (kDebugMode) { - final elapsedMs = _completed - ? _controller.duration?.inMilliseconds ?? 0 - : _lifetime.elapsedMilliseconds; + final elapsedMs = _completed ? _totalMs : _elapsedMs; debugPrint( '[player] phase=heart-burst-dispose ' 'id=${widget.burstId} elapsedMs=$elapsedMs ' @@ -114,44 +145,33 @@ class _PlayerDoubleTapHeartBurstState extends State } } _lifetime.stop(); - _controller - ..removeStatusListener(_onStatus) - ..dispose(); + _timer?.cancel(); + _timer = null; super.dispose(); } @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 < _hearts.length; index++) - _heart(_hearts[index], elapsed, index), - if (kDebugMode) - Positioned( - left: widget.origin.dx + 8, - top: widget.origin.dy + 10, - child: IgnorePointer( - child: Text( - '${elapsed.round()}ms', - key: const Key('heart-burst-elapsed-debug'), - style: const TextStyle( - color: Colors.white60, - fontSize: 11, - ), - ), - ), + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + for (var index = 0; index < _hearts.length; index++) + _heart(_hearts[index], _elapsedMs.toDouble(), index), + if (kDebugMode) + Positioned( + left: widget.origin.dx + 8, + top: widget.origin.dy + 10, + child: IgnorePointer( + child: Text( + '${_elapsedMs}ms', + key: const Key('heart-burst-elapsed-debug'), + style: const TextStyle(color: Colors.white60, fontSize: 11), ), - ], - ); - }, + ), + ), + ], ), ); } diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index e6e10e4..032d159 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -176,6 +176,7 @@ class _PlayerTapGestureRegionState extends State { key: ValueKey('heart-overlay-$id'), burstId: id, origin: origin, + now: widget.now, onCompleted: removeEntry, ), ), diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 03c6246..ce253a4 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -48,7 +48,6 @@ void main() { ), findsOneWidget, ); - await tester.pumpAndSettle(); } }); @@ -94,7 +93,7 @@ void main() { await tester.tapAt(center); await tester.pump(); expect(repository.likeTargets, hasLength(1)); - await tester.pump(const Duration(milliseconds: 2300)); + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); @@ -136,24 +135,19 @@ void main() { final burstFinder = find.byType(PlayerDoubleTapHeartBurst); final gestureState = tester.state(gestureFinder); final burstState = tester.state(burstFinder); - var elapsedMs = 100; for (var index = 0; index < 30; index++) { key.currentState!.rebuild(); + clock.advance(const Duration(milliseconds: 16)); await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; } expect(tester.state(gestureFinder), same(gestureState)); expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); - while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { - await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; - } + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); expect(burstFinder, findsNothing); - expect(elapsedMs, greaterThanOrEqualTo(1500)); }); testWidgets('heart burst starts at tapped location', (tester) async { @@ -192,17 +186,21 @@ void main() { }; var elapsedMs = 0; try { + final clock = _TestClock(); final key = GlobalKey<_RemovableGestureHarnessState>(); await tester.pumpWidget( - MaterialApp(home: _RemovableGestureHarness(key: key)), + MaterialApp( + home: _RemovableGestureHarness(key: key, clock: clock), + ), ); const tapPosition = Offset(120, 220); await tester.tapAt(tapPosition); + clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(tapPosition); await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + await _pumpTestClock(tester, clock, const Duration(milliseconds: 100)); final burstFinder = find.byType(PlayerDoubleTapHeartBurst); expect(burstFinder, findsOneWidget); @@ -212,10 +210,8 @@ void main() { expect(burstFinder, findsOneWidget); elapsedMs = 100; - while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { - await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; - } + await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); + elapsedMs += 1600; expect(burstFinder, findsNothing); } finally { debugPrint = originalDebugPrint; @@ -380,7 +376,9 @@ class _HarnessState extends State<_Harness> { } class _RemovableGestureHarness extends StatefulWidget { - const _RemovableGestureHarness({super.key}); + const _RemovableGestureHarness({super.key, required this.clock}); + + final _TestClock clock; @override State<_RemovableGestureHarness> createState() => @@ -415,6 +413,7 @@ class _RemovableGestureHarnessState extends State<_RemovableGestureHarness> { gestureKey: const Key('removable-gesture'), enabled: true, doubleTapEnabled: true, + now: widget.clock.now, onSingleTap: () {}, onDoubleTapLike: (_) {}, onLongPressStart: (_, _) {}, @@ -437,3 +436,18 @@ class _TestClock { _now = _now.add(duration); } } + +Future _pumpTestClock( + WidgetTester tester, + _TestClock clock, + Duration duration, +) async { + const frameMs = 16; + var remainingMs = duration.inMilliseconds; + while (remainingMs > 0) { + final stepMs = remainingMs >= frameMs ? frameMs : remainingMs; + clock.advance(Duration(milliseconds: stepMs)); + await tester.pump(const Duration(milliseconds: frameMs)); + remainingMs -= stepMs; + } +} diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index b799123..61776c4 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -8,14 +8,19 @@ void main() { testWidgets('should_show_three_develop_hearts_when_burst_starts', ( tester, ) async { + final clock = _HeartClock(); await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst(burstId: 1, onCompleted: _noop), + body: PlayerDoubleTapHeartBurst( + burstId: 1, + now: clock.now, + onCompleted: _noop, + ), ), ), ); - await tester.pump(const Duration(milliseconds: 200)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 200)); expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); expect(_size(tester, 0), 48); @@ -42,18 +47,23 @@ void main() { testWidgets('heart burst keeps opacity then fades after 55 percent', ( tester, ) async { + final clock = _HeartClock(); await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst(burstId: 2, onCompleted: _noop), + body: PlayerDoubleTapHeartBurst( + burstId: 2, + now: clock.now, + onCompleted: _noop, + ), ), ), ); - await tester.pump(const Duration(milliseconds: 450)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 450)); expect(_opacity(tester, 0), closeTo(1, 1e-9)); - await tester.pump(const Duration(milliseconds: 750)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 750)); expect(_opacity(tester, 0), lessThan(0.5)); }); @@ -61,8 +71,9 @@ void main() { tester, ) async { const origin = Offset(300, 546); + final clock = _HeartClock(); await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( body: Stack( fit: StackFit.expand, @@ -71,6 +82,7 @@ void main() { child: PlayerDoubleTapHeartBurst( burstId: 4, origin: origin, + now: clock.now, onCompleted: _noop, ), ), @@ -93,7 +105,7 @@ void main() { lessThanOrEqualTo(1), ); - await tester.pump(const Duration(milliseconds: 750)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 750)); final midpoint = tester.getCenter(firstHeart); expect(midpoint.dy, lessThan(origin.dy)); expect((midpoint.dx - origin.dx).abs(), lessThanOrEqualTo(1)); @@ -103,26 +115,24 @@ void main() { tester, ) async { var completed = false; - var elapsedMs = 0; + final clock = _HeartClock(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: PlayerDoubleTapHeartBurst( burstId: 3, + now: clock.now, onCompleted: () => completed = true, ), ), ), ); - while (!completed && elapsedMs < 2000) { - await tester.pump(const Duration(milliseconds: 16)); - elapsedMs += 16; - } + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 1600)); expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(1584)); - expect(elapsedMs, lessThanOrEqualTo(1616)); + expect(clock.elapsedMs, greaterThanOrEqualTo(1590)); + expect(clock.elapsedMs, lessThanOrEqualTo(1600)); }); } @@ -141,3 +151,31 @@ double _opacity(WidgetTester tester, int index) { } void _noop() {} + +Future _pumpHeartTime( + WidgetTester tester, + _HeartClock clock, + Duration duration, +) async { + const frameMs = 16; + var remainingMs = duration.inMilliseconds; + while (remainingMs > 0) { + final stepMs = remainingMs >= frameMs ? frameMs : remainingMs; + clock.advance(Duration(milliseconds: stepMs)); + await tester.pump(const Duration(milliseconds: frameMs)); + remainingMs -= stepMs; + } +} + +class _HeartClock { + DateTime _now = DateTime(2026, 10, 26, 12); + + DateTime now() => _now; + + int get elapsedMs => + _now.difference(DateTime(2026, 10, 26, 12)).inMilliseconds; + + void advance(Duration duration) { + _now = _now.add(duration); + } +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 1c4a022..854d6a8 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -680,6 +680,7 @@ void main() { } await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); + await _waitForBufferedHeartFrames(tester); expect(logic.session.userWantsPlay, isTrue); expect(find.byKey(const Key('player-play-toggle')), findsNothing); @@ -699,7 +700,7 @@ void main() { AppColors.doubleTapHeartRed, ); - await tester.pump(const Duration(milliseconds: 2300)); + await _waitForHeartCompletion(tester); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(logic.session.userWantsPlay, isTrue); }); @@ -723,6 +724,7 @@ void main() { await tester.tapAt(const Offset(80, 300)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); + await _waitForBufferedHeartFrames(tester); expect(logic.session.userWantsPlay, isTrue); expect(interactions.likeTargets, hasLength(1)); @@ -1818,6 +1820,20 @@ Future _clearFromMore(WidgetTester tester) async { await tester.pumpAndSettle(); } +Future _waitForBufferedHeartFrames(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 120)), + ); + await tester.pump(const Duration(milliseconds: 120)); +} + +Future _waitForHeartCompletion(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1700)), + ); + await tester.pump(const Duration(milliseconds: 16)); +} + Future _openSeriesPlayer( WidgetTester tester, { Key cardKey = const Key('home-feed-card-city-lights-demo'), diff --git a/test/player/player_hearts_pixels_test.dart b/test/player/player_hearts_pixels_test.dart index 704086b..44180ce 100644 --- a/test/player/player_hearts_pixels_test.dart +++ b/test/player/player_hearts_pixels_test.dart @@ -13,21 +13,26 @@ void main() { var completedCalls = 0; const origin = Offset(300, 546); + final clock = _HeartClock(); await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: PlayerDoubleTapHeartBurst( - burstId: 91, - origin: origin, - onCompleted: () => completedCalls++, + home: TickerMode( + enabled: false, + child: Scaffold( + body: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: PlayerDoubleTapHeartBurst( + burstId: 91, + origin: origin, + now: clock.now, + onCompleted: () => completedCalls++, + ), ), - ), - ], + ], + ), ), ), ), @@ -52,15 +57,56 @@ void main() { lessThanOrEqualTo(1), ); - await tester.pump(const Duration(milliseconds: 400)); - final midpoint = tester.getCenter(firstHeart); - debugPrint( - '[heart-pixels] after400ms center=${midpoint.dx},${midpoint.dy}', - ); - expect(midpoint.dy, lessThan(origin.dy - 40)); - expect((midpoint.dx - origin.dx).abs(), lessThanOrEqualTo(30)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 300)); + final center300 = tester.getCenter(firstHeart); + debugPrint('[heart-pixels] t=300 center=${center300.dx},${center300.dy}'); + expect(center300.dy, lessThan(initialCenter.dy)); + expect((center300.dx - origin.dx).abs(), lessThanOrEqualTo(30)); - await tester.pump(const Duration(milliseconds: 1200)); + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 300)); + final center600 = tester.getCenter(firstHeart); + debugPrint('[heart-pixels] t=600 center=${center600.dx},${center600.dy}'); + expect(center600.dy, lessThan(center300.dy)); + expect((center600.dx - origin.dx).abs(), lessThanOrEqualTo(30)); + + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 300)); + final center900 = tester.getCenter(firstHeart); + debugPrint('[heart-pixels] t=900 center=${center900.dx},${center900.dy}'); + expect(center900.dy, lessThan(center600.dy)); + expect((center900.dx - origin.dx).abs(), lessThanOrEqualTo(30)); + expect({ + initialCenter.dy, + center300.dy, + center600.dy, + center900.dy, + }, hasLength(4)); + + await _pumpHeartTime(tester, clock, const Duration(milliseconds: 700)); expect(completedCalls, 1); }); } + +Future _pumpHeartTime( + WidgetTester tester, + _HeartClock clock, + Duration duration, +) async { + const frameMs = 16; + var remainingMs = duration.inMilliseconds; + while (remainingMs > 0) { + final stepMs = remainingMs >= frameMs ? frameMs : remainingMs; + clock.advance(Duration(milliseconds: stepMs)); + await tester.pump(const Duration(milliseconds: frameMs)); + remainingMs -= stepMs; + } +} + +class _HeartClock { + DateTime _now = DateTime(2026, 10, 26, 12); + + DateTime now() => _now; + + void advance(Duration duration) { + _now = _now.add(duration); + } +}