fix: 使用点击序号防止双击误暂停
This commit is contained in:
1 parent
9ddd7fc833
commit
bbc759473c
7 files changed
+113
-61
No files matched your search
@@ -1302,3 +1302,5 @@ $ flutter test
|
|||||||
- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。
|
- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。
|
||||||
- `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。
|
- `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。
|
||||||
- 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。
|
- 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。
|
||||||
|
- 双击窗口延长至 320ms,单击 Timer 使用点击序号校验,双击后保留 350ms 播放切换保护。
|
||||||
|
- `double_tap_like_test.dart` 覆盖 120/250/300ms 双击与 500ms 单击恢复:2 项测试全部通过。
|
||||||
@@ -120,8 +120,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
bool _scrubbing = false;
|
bool _scrubbing = false;
|
||||||
double? _speedBeforeBoost;
|
double? _speedBeforeBoost;
|
||||||
bool? _chromeBeforeBoost;
|
bool? _chromeBeforeBoost;
|
||||||
bool _doubleTapHandling = false;
|
DateTime? _lastDoubleTapAt;
|
||||||
Timer? _doubleTapGuardTimer;
|
|
||||||
|
|
||||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||||
final Map<String, int> _favoriteDelta = {};
|
final Map<String, int> _favoriteDelta = {};
|
||||||
@@ -263,7 +262,6 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
appRouteObserver.unsubscribe(this);
|
appRouteObserver.unsubscribe(this);
|
||||||
_spriteAxisService.dispose();
|
_spriteAxisService.dispose();
|
||||||
_thumbnailService.dispose();
|
_thumbnailService.dispose();
|
||||||
_doubleTapGuardTimer?.cancel();
|
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -336,7 +334,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleFeedSurfaceTap() {
|
void _handleFeedSurfaceTap() {
|
||||||
if (_scrubbing || _doubleTapHandling) return;
|
if (_scrubbing || _doubleTapGuardActive) return;
|
||||||
if (!_chromeVisible) {
|
if (!_chromeVisible) {
|
||||||
_restoreFeedChrome();
|
_restoreFeedChrome();
|
||||||
return;
|
return;
|
||||||
@@ -345,20 +343,23 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _togglePlayback() {
|
void _togglePlayback() {
|
||||||
if (_doubleTapHandling) return;
|
if (_doubleTapGuardActive) return;
|
||||||
logic.session.setUserWantsPlay(!logic.session.userWantsPlay);
|
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(
|
void _handleDoubleTapLike(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
InteractionController interactions,
|
InteractionController interactions,
|
||||||
Episode episode,
|
Episode episode,
|
||||||
) {
|
) {
|
||||||
_doubleTapHandling = true;
|
_lastDoubleTapAt = DateTime.now();
|
||||||
_doubleTapGuardTimer?.cancel();
|
|
||||||
_doubleTapGuardTimer = Timer(const Duration(milliseconds: 300), () {
|
|
||||||
_doubleTapHandling = false;
|
|
||||||
});
|
|
||||||
unawaited(_toggleLike(context, interactions, episode, likeOnly: true));
|
unawaited(_toggleLike(context, interactions, episode, likeOnly: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
required this.onBoostEnd,
|
required this.onBoostEnd,
|
||||||
required this.onMore,
|
required this.onMore,
|
||||||
this.onDoubleTapLike,
|
this.onDoubleTapLike,
|
||||||
|
this.now,
|
||||||
this.likeCount,
|
this.likeCount,
|
||||||
this.favoriteCount,
|
this.favoriteCount,
|
||||||
this.commentCount,
|
this.commentCount,
|
||||||
@@ -94,6 +95,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onBoostEnd;
|
final VoidCallback onBoostEnd;
|
||||||
final VoidCallback onMore;
|
final VoidCallback onMore;
|
||||||
final ValueChanged<Offset>? onDoubleTapLike;
|
final ValueChanged<Offset>? onDoubleTapLike;
|
||||||
|
final DateTime Function()? now;
|
||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final ValueChanged<bool>? onScrubEnd;
|
final ValueChanged<bool>? onScrubEnd;
|
||||||
final ValueChanged<bool>? onScrubVisibilityChanged;
|
final ValueChanged<bool>? onScrubVisibilityChanged;
|
||||||
@@ -107,12 +109,13 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||||
static const Duration _singleTapDelay = Duration(milliseconds: 280);
|
static const Duration _singleTapDelay = Duration(milliseconds: 320);
|
||||||
|
|
||||||
bool _scrubbing = false;
|
bool _scrubbing = false;
|
||||||
bool _wasVisibleBeforeScrub = true;
|
bool _wasVisibleBeforeScrub = true;
|
||||||
Timer? _singleTapTimer;
|
Timer? _singleTapTimer;
|
||||||
DateTime? _lastTapAt;
|
DateTime? _lastTapAt;
|
||||||
|
int _tapSeq = 0;
|
||||||
int _nextHeartBurstId = 0;
|
int _nextHeartBurstId = 0;
|
||||||
final List<({int id, Offset position})> _heartBursts = [];
|
final List<({int id, Offset position})> _heartBursts = [];
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_tapSeq++;
|
||||||
_singleTapTimer?.cancel();
|
_singleTapTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -249,11 +253,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTapUp(TapUpDetails details) {
|
void _handleTapUp(TapUpDetails details) {
|
||||||
final now = DateTime.now();
|
final tapSeq = ++_tapSeq;
|
||||||
|
final now = widget.now?.call() ?? DateTime.now();
|
||||||
final lastTap = _lastTapAt;
|
final lastTap = _lastTapAt;
|
||||||
if (_singleTapTimer != null &&
|
if (lastTap != null && now.difference(lastTap) <= _singleTapDelay) {
|
||||||
lastTap != null &&
|
_tapSeq++;
|
||||||
now.difference(lastTap) <= _singleTapDelay) {
|
|
||||||
_singleTapTimer?.cancel();
|
_singleTapTimer?.cancel();
|
||||||
_singleTapTimer = null;
|
_singleTapTimer = null;
|
||||||
_lastTapAt = null;
|
_lastTapAt = null;
|
||||||
@@ -262,11 +266,22 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
}
|
}
|
||||||
_lastTapAt = now;
|
_lastTapAt = now;
|
||||||
_singleTapTimer?.cancel();
|
_singleTapTimer?.cancel();
|
||||||
_singleTapTimer = Timer(_singleTapDelay, () {
|
late final Timer pending;
|
||||||
_singleTapTimer = null;
|
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;
|
_lastTapAt = null;
|
||||||
if (mounted) widget.onTogglePlay();
|
if (mounted) widget.onTogglePlay();
|
||||||
});
|
});
|
||||||
|
_singleTapTimer = pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showHearts(Offset position) {
|
void _showHearts(Offset position) {
|
||||||
@@ -281,6 +296,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleLongPressStart(LongPressStartDetails details, double width) {
|
void _handleLongPressStart(LongPressStartDetails details, double width) {
|
||||||
|
_tapSeq++;
|
||||||
_singleTapTimer?.cancel();
|
_singleTapTimer?.cancel();
|
||||||
_singleTapTimer = null;
|
_singleTapTimer = null;
|
||||||
_lastTapAt = null;
|
_lastTapAt = null;
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
required this.videoUrl,
|
required this.videoUrl,
|
||||||
required this.theater,
|
required this.theater,
|
||||||
this.onTap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoPlayerController? controller;
|
final VideoPlayerController? controller;
|
||||||
final String videoUrl;
|
final String videoUrl;
|
||||||
final bool theater;
|
final bool theater;
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
|
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
|
||||||
@@ -69,7 +67,6 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
value: value!,
|
value: value!,
|
||||||
theater: widget.theater,
|
theater: widget.theater,
|
||||||
onTap: widget.onTap,
|
|
||||||
)
|
)
|
||||||
: const SizedBox.expand(),
|
: const SizedBox.expand(),
|
||||||
),
|
),
|
||||||
@@ -141,17 +138,15 @@ class _VideoLayer extends StatelessWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.theater,
|
required this.theater,
|
||||||
required this.onTap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoPlayerController controller;
|
final VideoPlayerController controller;
|
||||||
final VideoPlayerValue value;
|
final VideoPlayerValue value;
|
||||||
final bool theater;
|
final bool theater;
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final child = FittedBox(
|
return FittedBox(
|
||||||
fit: theater ? BoxFit.contain : BoxFit.cover,
|
fit: theater ? BoxFit.contain : BoxFit.cover,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: value.size.width,
|
width: value.size.width,
|
||||||
@@ -159,12 +154,5 @@ class _VideoLayer extends StatelessWidget {
|
|||||||
child: VideoPlayer(controller, key: ValueKey(controller)),
|
child: VideoPlayer(controller, key: ValueKey(controller)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final tap = onTap;
|
|
||||||
if (tap == null) return child;
|
|
||||||
return GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: tap,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,43 +12,67 @@ import '../support/fake_haptics.dart';
|
|||||||
import '../support/fake_interaction_repositories.dart';
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('quick double tap likes without pausing', (tester) async {
|
testWidgets('double tap within 320ms never pauses', (tester) async {
|
||||||
final repository = FakeInteractionRepository();
|
for (final delay in [120, 250, 300]) {
|
||||||
final haptics = FakeHaptics();
|
final repository = FakeInteractionRepository();
|
||||||
final key = GlobalKey<_HarnessState>();
|
final haptics = FakeHaptics();
|
||||||
await _pumpHarness(tester, repository, haptics, key);
|
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(
|
||||||
final center = tester.getCenter(gesture);
|
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);
|
expect(
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
key.currentState!.pauseToggles,
|
||||||
await tester.tapAt(center);
|
0,
|
||||||
await tester.pump();
|
reason: 'double tap with $delay ms gap paused playback',
|
||||||
await tester.pump(const Duration(milliseconds: 400));
|
);
|
||||||
|
expect(repository.likeTargets, hasLength(1));
|
||||||
expect(key.currentState!.pauseToggles, 0);
|
expect(haptics.lightImpacts, 1);
|
||||||
expect(repository.likeTargets, hasLength(1));
|
expect(
|
||||||
expect(haptics.lightImpacts, 1);
|
find.descendant(
|
||||||
expect(
|
of: find.byKey(const Key('rail-like')),
|
||||||
find.descendant(
|
matching: find.byIcon(Icons.favorite_rounded),
|
||||||
of: find.byKey(const Key('rail-like')),
|
),
|
||||||
matching: find.byIcon(Icons.favorite_rounded),
|
findsOneWidget,
|
||||||
),
|
);
|
||||||
findsOneWidget,
|
await tester.pumpAndSettle();
|
||||||
);
|
}
|
||||||
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>();
|
final key = GlobalKey<_HarnessState>();
|
||||||
await _pumpHarness(tester, FakeInteractionRepository(), FakeHaptics(), key);
|
final clock = _TestClock();
|
||||||
|
await _pumpHarness(
|
||||||
|
tester,
|
||||||
|
FakeInteractionRepository(),
|
||||||
|
FakeHaptics(),
|
||||||
|
key,
|
||||||
|
clock,
|
||||||
|
);
|
||||||
|
|
||||||
await tester.tapAt(
|
await tester.tapAt(
|
||||||
tester.getCenter(find.byKey(const Key('home-feed-gesture'))),
|
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);
|
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,
|
FakeInteractionRepository repository,
|
||||||
FakeHaptics haptics,
|
FakeHaptics haptics,
|
||||||
GlobalKey<_HarnessState> key,
|
GlobalKey<_HarnessState> key,
|
||||||
|
_TestClock clock,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
@@ -64,17 +89,28 @@ Future<void> _pumpHarness(
|
|||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
home: _Harness(key: key, repository: repository, haptics: haptics),
|
home: _Harness(
|
||||||
|
key: key,
|
||||||
|
repository: repository,
|
||||||
|
haptics: haptics,
|
||||||
|
clock: clock,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Harness extends StatefulWidget {
|
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 FakeInteractionRepository repository;
|
||||||
final FakeHaptics haptics;
|
final FakeHaptics haptics;
|
||||||
|
final _TestClock clock;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_Harness> createState() => _HarnessState();
|
State<_Harness> createState() => _HarnessState();
|
||||||
@@ -124,6 +160,7 @@ class _HarnessState extends State<_Harness> {
|
|||||||
boosting: false,
|
boosting: false,
|
||||||
onLike: () => _like(),
|
onLike: () => _like(),
|
||||||
onDoubleTapLike: (_) => _like(likeOnly: true),
|
onDoubleTapLike: (_) => _like(likeOnly: true),
|
||||||
|
now: widget.clock.now,
|
||||||
onSave: () {},
|
onSave: () {},
|
||||||
onComments: () {},
|
onComments: () {},
|
||||||
onShare: () {},
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1326,7 +1326,6 @@ class _SessionVideoSurface extends StatelessWidget {
|
|||||||
controller: video?.controller,
|
controller: video?.controller,
|
||||||
videoUrl: video?.controller.dataSource ?? '',
|
videoUrl: video?.controller.dataSource ?? '',
|
||||||
theater: false,
|
theater: false,
|
||||||
onTap: () {},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ Future<VideoPlayerController> _pumpSurface(
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
videoUrl: controller.dataSource,
|
videoUrl: controller.dataSource,
|
||||||
theater: false,
|
theater: false,
|
||||||
onTap: () {},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user