fix: 使用点击序号防止双击误暂停

This commit is contained in:
Zhou Weidong committed 2026-10-22 22:39:21 +08:00
1 parent 9ddd7fc833
commit bbc759473c
7 files changed
+113 -61

No files matched your search

+2
View File
@@ -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 项测试全部通过。
+11 -10
View File
@@ -120,8 +120,7 @@ class _PlayerPageState extends State<PlayerPage>
bool _scrubbing = false;
double? _speedBeforeBoost;
bool? _chromeBeforeBoost;
bool _doubleTapHandling = false;
Timer? _doubleTapGuardTimer;
DateTime? _lastDoubleTapAt;
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
final Map<String, int> _favoriteDelta = {};
@@ -263,7 +262,6 @@ class _PlayerPageState extends State<PlayerPage>
appRouteObserver.unsubscribe(this);
_spriteAxisService.dispose();
_thumbnailService.dispose();
_doubleTapGuardTimer?.cancel();
logic.dispose();
super.dispose();
}
@@ -336,7 +334,7 @@ class _PlayerPageState extends State<PlayerPage>
}
void _handleFeedSurfaceTap() {
if (_scrubbing || _doubleTapHandling) return;
if (_scrubbing || _doubleTapGuardActive) return;
if (!_chromeVisible) {
_restoreFeedChrome();
return;
@@ -345,20 +343,23 @@ class _PlayerPageState extends State<PlayerPage>
}
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));
}
@@ -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<Offset>? onDoubleTapLike;
final DateTime Function()? now;
final VoidCallback? onScrubStart;
final ValueChanged<bool>? onScrubEnd;
final ValueChanged<bool>? onScrubVisibilityChanged;
@@ -107,12 +109,13 @@ class HomeFeedOverlay extends StatefulWidget {
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
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<HomeFeedOverlay> {
@override
void dispose() {
_tapSeq++;
_singleTapTimer?.cancel();
super.dispose();
}
@@ -249,11 +253,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
}
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<HomeFeedOverlay> {
}
_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<HomeFeedOverlay> {
}
void _handleLongPressStart(LongPressStartDetails details, double width) {
_tapSeq++;
_singleTapTimer?.cancel();
_singleTapTimer = null;
_lastTapAt = null;
@@ -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<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
@@ -69,7 +67,6 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
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,
);
}
}
+76 -29
View File
@@ -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<void> _pumpHarness(
FakeInteractionRepository repository,
FakeHaptics haptics,
GlobalKey<_HarnessState> key,
_TestClock clock,
) async {
await tester.pumpWidget(
MaterialApp(
@@ -64,17 +89,28 @@ Future<void> _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);
}
}
-1
View File
@@ -1326,7 +1326,6 @@ class _SessionVideoSurface extends StatelessWidget {
controller: video?.controller,
videoUrl: video?.controller.dataSource ?? '',
theater: false,
onTap: () {},
),
);
},
@@ -87,7 +87,6 @@ Future<VideoPlayerController> _pumpSurface(
controller: controller,
videoUrl: controller.dataSource,
theater: false,
onTap: () {},
),
),
),