diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 2739e2c..39d0c4d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1302,3 +1302,5 @@ $ flutter test - 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。 - `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。 - 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。 +- 双击窗口延长至 320ms,单击 Timer 使用点击序号校验,双击后保留 350ms 播放切换保护。 +- `double_tap_like_test.dart` 覆盖 120/250/300ms 双击与 500ms 单击恢复:2 项测试全部通过。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 894cd70..12df1b8 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -120,8 +120,7 @@ class _PlayerPageState extends State bool _scrubbing = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; - bool _doubleTapHandling = false; - Timer? _doubleTapGuardTimer; + DateTime? _lastDoubleTapAt; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -263,7 +262,6 @@ class _PlayerPageState extends State appRouteObserver.unsubscribe(this); _spriteAxisService.dispose(); _thumbnailService.dispose(); - _doubleTapGuardTimer?.cancel(); logic.dispose(); super.dispose(); } @@ -336,7 +334,7 @@ class _PlayerPageState extends State } void _handleFeedSurfaceTap() { - if (_scrubbing || _doubleTapHandling) return; + if (_scrubbing || _doubleTapGuardActive) return; if (!_chromeVisible) { _restoreFeedChrome(); return; @@ -345,20 +343,23 @@ class _PlayerPageState extends State } void _togglePlayback() { - if (_doubleTapHandling) return; + if (_doubleTapGuardActive) return; logic.session.setUserWantsPlay(!logic.session.userWantsPlay); } + bool get _doubleTapGuardActive { + final lastDoubleTapAt = _lastDoubleTapAt; + return lastDoubleTapAt != null && + DateTime.now().difference(lastDoubleTapAt) < + const Duration(milliseconds: 350); + } + void _handleDoubleTapLike( BuildContext context, InteractionController interactions, Episode episode, ) { - _doubleTapHandling = true; - _doubleTapGuardTimer?.cancel(); - _doubleTapGuardTimer = Timer(const Duration(milliseconds: 300), () { - _doubleTapHandling = false; - }); + _lastDoubleTapAt = DateTime.now(); unawaited(_toggleLike(context, interactions, episode, likeOnly: true)); } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index cf182a2..2c028cb 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -56,6 +56,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onBoostEnd, required this.onMore, this.onDoubleTapLike, + this.now, this.likeCount, this.favoriteCount, this.commentCount, @@ -94,6 +95,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onBoostEnd; final VoidCallback onMore; final ValueChanged? onDoubleTapLike; + final DateTime Function()? now; final VoidCallback? onScrubStart; final ValueChanged? onScrubEnd; final ValueChanged? onScrubVisibilityChanged; @@ -107,12 +109,13 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { - static const Duration _singleTapDelay = Duration(milliseconds: 280); + static const Duration _singleTapDelay = Duration(milliseconds: 320); bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; Timer? _singleTapTimer; DateTime? _lastTapAt; + int _tapSeq = 0; int _nextHeartBurstId = 0; final List<({int id, Offset position})> _heartBursts = []; @@ -126,6 +129,7 @@ class _HomeFeedOverlayState extends State { @override void dispose() { + _tapSeq++; _singleTapTimer?.cancel(); super.dispose(); } @@ -249,11 +253,11 @@ class _HomeFeedOverlayState extends State { } void _handleTapUp(TapUpDetails details) { - final now = DateTime.now(); + final tapSeq = ++_tapSeq; + final now = widget.now?.call() ?? DateTime.now(); final lastTap = _lastTapAt; - if (_singleTapTimer != null && - lastTap != null && - now.difference(lastTap) <= _singleTapDelay) { + if (lastTap != null && now.difference(lastTap) <= _singleTapDelay) { + _tapSeq++; _singleTapTimer?.cancel(); _singleTapTimer = null; _lastTapAt = null; @@ -262,11 +266,22 @@ class _HomeFeedOverlayState extends State { } _lastTapAt = now; _singleTapTimer?.cancel(); - _singleTapTimer = Timer(_singleTapDelay, () { - _singleTapTimer = null; + late final Timer pending; + pending = Timer(_singleTapDelay, () { + if (identical(_singleTapTimer, pending)) { + _singleTapTimer = null; + } + if (tapSeq != _tapSeq) return; + final latestTap = _lastTapAt; + if (latestTap != null && + (widget.now?.call() ?? DateTime.now()).difference(latestTap) < + _singleTapDelay) { + return; + } _lastTapAt = null; if (mounted) widget.onTogglePlay(); }); + _singleTapTimer = pending; } void _showHearts(Offset position) { @@ -281,6 +296,7 @@ class _HomeFeedOverlayState extends State { } void _handleLongPressStart(LongPressStartDetails details, double width) { + _tapSeq++; _singleTapTimer?.cancel(); _singleTapTimer = null; _lastTapAt = null; diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 670ccce..513dda7 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -17,13 +17,11 @@ class PlayerVideoSurface extends StatefulWidget { required this.controller, required this.videoUrl, required this.theater, - this.onTap, }); final VideoPlayerController? controller; final String videoUrl; final bool theater; - final VoidCallback? onTap; @override State createState() => _PlayerVideoSurfaceState(); @@ -69,7 +67,6 @@ class _PlayerVideoSurfaceState extends State { controller: controller, value: value!, theater: widget.theater, - onTap: widget.onTap, ) : const SizedBox.expand(), ), @@ -141,17 +138,15 @@ class _VideoLayer extends StatelessWidget { required this.controller, required this.value, required this.theater, - required this.onTap, }); final VideoPlayerController controller; final VideoPlayerValue value; final bool theater; - final VoidCallback? onTap; @override Widget build(BuildContext context) { - final child = FittedBox( + return FittedBox( fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( width: value.size.width, @@ -159,12 +154,5 @@ class _VideoLayer extends StatelessWidget { child: VideoPlayer(controller, key: ValueKey(controller)), ), ); - final tap = onTap; - if (tap == null) return child; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: tap, - child: child, - ); } } diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 3a44f04..b1f5ad6 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -12,43 +12,67 @@ 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); + 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>(); + final clock = _TestClock(); + await _pumpHarness(tester, repository, haptics, key, clock); - final gesture = find.byKey(const Key('home-feed-gesture')); - final center = tester.getCenter(gesture); + final center = tester.getCenter( + find.byKey(const Key('home-feed-gesture')), + ); + await tester.tapAt(center); + clock.advance(Duration(milliseconds: delay)); + await tester.pump(Duration(milliseconds: delay)); + await tester.tapAt(center); + await tester.pump(); + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); - await tester.tapAt(center); - await tester.pump(const Duration(milliseconds: 100)); - await tester.tapAt(center); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - - 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, - ); - await tester.pumpAndSettle(); + expect( + key.currentState!.pauseToggles, + 0, + reason: 'double tap with $delay ms gap paused playback', + ); + 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, + ); + await tester.pumpAndSettle(); + } }); - testWidgets('single tap toggles playback after 300ms', (tester) async { + testWidgets('taps 500ms apart toggle playback twice', (tester) async { final key = GlobalKey<_HarnessState>(); - await _pumpHarness(tester, FakeInteractionRepository(), FakeHaptics(), key); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + key, + clock, + ); await tester.tapAt( tester.getCenter(find.byKey(const Key('home-feed-gesture'))), ); - await tester.pump(const Duration(milliseconds: 400)); + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); expect(key.currentState!.pauseToggles, 1); + + await tester.tapAt( + tester.getCenter(find.byKey(const Key('home-feed-gesture'))), + ); + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect(key.currentState!.pauseToggles, 2); }); } @@ -57,6 +81,7 @@ Future _pumpHarness( FakeInteractionRepository repository, FakeHaptics haptics, GlobalKey<_HarnessState> key, + _TestClock clock, ) async { await tester.pumpWidget( MaterialApp( @@ -64,17 +89,28 @@ Future _pumpHarness( locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - home: _Harness(key: key, repository: repository, haptics: haptics), + home: _Harness( + key: key, + repository: repository, + haptics: haptics, + clock: clock, + ), ), ); await tester.pumpAndSettle(); } class _Harness extends StatefulWidget { - const _Harness({super.key, required this.repository, required this.haptics}); + const _Harness({ + super.key, + required this.repository, + required this.haptics, + required this.clock, + }); final FakeInteractionRepository repository; final FakeHaptics haptics; + final _TestClock clock; @override State<_Harness> createState() => _HarnessState(); @@ -124,6 +160,7 @@ class _HarnessState extends State<_Harness> { boosting: false, onLike: () => _like(), onDoubleTapLike: (_) => _like(likeOnly: true), + now: widget.clock.now, onSave: () {}, onComments: () {}, onShare: () {}, @@ -139,3 +176,13 @@ class _HarnessState extends State<_Harness> { ); } } + +class _TestClock { + DateTime _now = DateTime(2026, 10, 22, 12); + + DateTime now() => _now; + + void advance(Duration duration) { + _now = _now.add(duration); + } +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index a600ce7..4aadab2 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -1326,7 +1326,6 @@ class _SessionVideoSurface extends StatelessWidget { controller: video?.controller, videoUrl: video?.controller.dataSource ?? '', theater: false, - onTap: () {}, ), ); }, diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 7ab4f3c..d9357bf 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -87,7 +87,6 @@ Future _pumpSurface( controller: controller, videoUrl: controller.dataSource, theater: false, - onTap: () {}, ), ), ),