fix(player): 恢复独立播放页双击点赞

This commit is contained in:
Zhou Weidong committed 2026-10-26 18:06:55 +08:00
1 parent b291044acc
commit 63296d2a92
8 files changed
+348 -145

No files matched your search

+3
View File
@@ -1228,6 +1228,9 @@ class _PlayerPageState extends State<PlayerPage>
? null
: () => unawaited(logic.leave(context)),
onTogglePlay: _togglePlayback,
onDoubleTapLike: locked
? null
: (_) => _handleDoubleTapLike(context, interactions, episode),
onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true),
onDetail: () => unawaited(logic.openDetail(context)),
+11 -128
View File
@@ -14,7 +14,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'home_feed_recommend_panel.dart';
import 'loading_progress_track.dart';
import 'player_double_tap_hearts.dart';
import 'player_tap_gesture_region.dart';
import 'seek_progress_bar.dart';
import 'scrub_preview_card.dart';
@@ -110,19 +110,8 @@ class HomeFeedOverlay extends StatefulWidget {
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
static const Duration _singleTapDelay = Duration(milliseconds: 400);
bool _scrubbing = false;
bool _wasVisibleBeforeScrub = true;
Timer? _singleTapTimer;
DateTime? _lastDownAt;
DateTime? _blockSingleTapUntil;
bool _doubleTapDown = false;
bool _likedThisPair = false;
int _downSerial = 0;
int _tapSeq = 0;
int _nextHeartBurstId = 0;
final List<({int id, Offset position})> _heartBursts = [];
@override
void didUpdateWidget(covariant HomeFeedOverlay oldWidget) {
@@ -132,13 +121,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
}
}
@override
void dispose() {
_tapSeq++;
_singleTapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
@@ -146,21 +128,16 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
child: Stack(
children: [
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) => Listener(
onPointerUp: (_) => widget.onBoostEnd(),
onPointerCancel: (_) => widget.onBoostEnd(),
onPointerDown: _handlePointerDown,
child: GestureDetector(
key: const Key('home-feed-gesture'),
behavior: HitTestBehavior.opaque,
onTapUp: _handleTapUp,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => widget.onBoostEnd(),
onLongPressCancel: widget.onBoostEnd,
),
),
child: PlayerTapGestureRegion(
gestureKey: const Key('home-feed-gesture'),
episodeId: widget.episode.id,
enabled: !_scrubbing,
doubleTapEnabled: !widget.locked,
now: widget.now,
onSingleTap: widget.onTogglePlay,
onDoubleTapLike: widget.onDoubleTapLike,
onLongPressStart: _handleLongPressStart,
onInteractionEnd: widget.onBoostEnd,
),
),
Positioned(
@@ -222,25 +199,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
onSeek: widget.onSeek,
),
),
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),
),
),
],
),
),
),
],
),
);
@@ -262,83 +220,8 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
widget.onScrubEnd?.call(wasVisible);
}
DateTime _now() => widget.now?.call() ?? DateTime.now();
void _handlePointerDown(PointerDownEvent event) {
final now = _now();
final previous = _lastDownAt;
_downSerial++;
_lastDownAt = now;
final repeated =
previous != null && now.difference(previous) <= _singleTapDelay;
if (!repeated) {
if (_singleTapBlocked(now)) return;
_doubleTapDown = false;
_likedThisPair = false;
return;
}
_doubleTapDown = true;
_blockSingleTapUntil = now.add(_singleTapDelay);
_cancelPendingSingleTap();
}
bool _singleTapBlocked(DateTime now) {
final blockedUntil = _blockSingleTapUntil;
return blockedUntil != null && now.isBefore(blockedUntil);
}
void _cancelPendingSingleTap() {
_tapSeq++;
_singleTapTimer?.cancel();
_singleTapTimer = null;
}
void _handleTapUp(TapUpDetails details) {
final now = _now();
if (_doubleTapDown || _singleTapBlocked(now)) {
_showDoubleTapLike(details.localPosition);
return;
}
final serial = _downSerial;
final tapSeq = ++_tapSeq;
_singleTapTimer?.cancel();
late final Timer pending;
pending = Timer(_singleTapDelay, () {
if (!identical(_singleTapTimer, pending)) return;
_singleTapTimer = null;
if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return;
if (_doubleTapDown || _singleTapBlocked(_now())) return;
_lastDownAt = null;
widget.onTogglePlay();
});
_singleTapTimer = pending;
}
void _showDoubleTapLike(Offset position) {
if (_likedThisPair) return;
_likedThisPair = true;
_blockSingleTapUntil = _now().add(_singleTapDelay);
_cancelPendingSingleTap();
_showHearts(position);
}
void _showHearts(Offset position) {
final id = _nextHeartBurstId++;
setState(() => _heartBursts.add((id: id, position: position)));
widget.onDoubleTapLike?.call(position);
}
void _removeHeartBurst(int id) {
if (!mounted) return;
setState(() => _heartBursts.removeWhere((burst) => burst.id == id));
}
void _handleLongPressStart(LongPressStartDetails details, double width) {
final third = width / 3;
_tapSeq++;
_cancelPendingSingleTap();
_doubleTapDown = false;
_lastDownAt = null;
final dx = details.localPosition.dx;
if (dx < third || dx > width - third) {
widget.onBoostStart();
@@ -37,6 +37,8 @@ const List<_HeartParticle> _hearts = [
class PlayerDoubleTapHeartBurst extends StatefulWidget {
const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted});
static const int particleCount = 3;
final VoidCallback onCompleted;
@override
+10 -10
View File
@@ -14,6 +14,7 @@ import '../../../l10n/app_localizations.dart';
import 'player_center_toggle.dart';
import 'interaction_rail.dart';
import 'player_overlay_models.dart';
import 'player_tap_gesture_region.dart';
import 'seek_progress_bar.dart';
part 'player_overlay_parts.dart';
@@ -56,16 +57,15 @@ class PlayerPlaybackOverlay extends StatelessWidget {
child: Stack(
children: [
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) => GestureDetector(
key: const Key('player-video-tap'),
behavior: HitTestBehavior.opaque,
onTap: actions.onTogglePlay,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => actions.onBoostEnd(),
onLongPressCancel: actions.onBoostEnd,
),
child: PlayerTapGestureRegion(
gestureKey: const Key('player-video-tap'),
episodeId: data.episode.id,
enabled: !scrubbing,
doubleTapEnabled: !data.locked,
onSingleTap: actions.onTogglePlay,
onDoubleTapLike: actions.onDoubleTapLike,
onLongPressStart: _handleLongPressStart,
onInteractionEnd: actions.onBoostEnd,
),
),
_top(context),
@@ -1,5 +1,7 @@
// Author: fruit.com.cn
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:video_player/video_player.dart';
@@ -87,6 +89,7 @@ class PlayerOverlayActions {
const PlayerOverlayActions({
required this.onBack,
required this.onTogglePlay,
this.onDoubleTapLike,
required this.onClear,
required this.onExitClear,
required this.onDetail,
@@ -111,6 +114,9 @@ class PlayerOverlayActions {
/// 切换播放状态。
final VoidCallback onTogglePlay;
/// 双击画面点赞;位置用于飘心。
final ValueChanged<Offset>? onDoubleTapLike;
/// 进入清屏。
final VoidCallback onClear;
@@ -0,0 +1,192 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'player_double_tap_hearts.dart';
typedef PlayerLongPressStartCallback =
void Function(LongPressStartDetails details, double width);
/// 播放画面共用的单击/双击仲裁层。
class PlayerTapGestureRegion extends StatefulWidget {
const PlayerTapGestureRegion({
super.key,
required this.gestureKey,
required this.episodeId,
required this.onSingleTap,
required this.onLongPressStart,
required this.onInteractionEnd,
this.onDoubleTapLike,
this.doubleTapEnabled = true,
this.enabled = true,
this.now,
});
final Key gestureKey;
final String episodeId;
final VoidCallback onSingleTap;
final ValueChanged<Offset>? onDoubleTapLike;
final PlayerLongPressStartCallback onLongPressStart;
final VoidCallback onInteractionEnd;
final bool doubleTapEnabled;
final bool enabled;
final DateTime Function()? now;
@override
State<PlayerTapGestureRegion> createState() => _PlayerTapGestureRegionState();
}
class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
static const Duration _singleTapDelay = Duration(milliseconds: 320);
Timer? _singleTapTimer;
DateTime? _lastDownAt;
DateTime? _blockSingleTapUntil;
bool _doubleTapDown = false;
bool _likedThisPair = false;
int _downSerial = 0;
int _tapSeq = 0;
int _nextHeartBurstId = 0;
final List<({int id, Offset position})> _heartBursts = [];
@override
void dispose() {
_tapSeq++;
_singleTapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: !widget.enabled,
child: LayoutBuilder(
builder: (context, constraints) => Listener(
onPointerUp: (_) => widget.onInteractionEnd(),
onPointerCancel: (_) => widget.onInteractionEnd(),
onPointerDown: _handlePointerDown,
child: Stack(
fit: StackFit.expand,
children: [
GestureDetector(
key: widget.gestureKey,
behavior: HitTestBehavior.opaque,
onTapUp: _handleTapUp,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
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),
),
),
],
),
),
),
],
),
),
),
);
}
DateTime _now() => widget.now?.call() ?? DateTime.now();
void _handlePointerDown(PointerDownEvent event) {
final now = _now();
final previous = _lastDownAt;
_downSerial++;
_lastDownAt = now;
final repeated =
previous != null && now.difference(previous) <= _singleTapDelay;
if (!repeated) {
if (_singleTapBlocked(now)) return;
_doubleTapDown = false;
_likedThisPair = false;
return;
}
_doubleTapDown = true;
_blockSingleTapUntil = now.add(_singleTapDelay);
_cancelPendingSingleTap();
}
bool _singleTapBlocked(DateTime now) {
final blockedUntil = _blockSingleTapUntil;
return blockedUntil != null && now.isBefore(blockedUntil);
}
void _cancelPendingSingleTap() {
_tapSeq++;
_singleTapTimer?.cancel();
_singleTapTimer = null;
}
void _handleTapUp(TapUpDetails details) {
final now = _now();
if (_doubleTapDown || _singleTapBlocked(now)) {
_handleDoubleTapLike(details.localPosition);
return;
}
final serial = _downSerial;
final tapSeq = ++_tapSeq;
_singleTapTimer?.cancel();
late final Timer pending;
pending = Timer(_singleTapDelay, () {
if (!identical(_singleTapTimer, pending)) return;
_singleTapTimer = null;
if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return;
if (_doubleTapDown || _singleTapBlocked(_now())) return;
_lastDownAt = null;
widget.onSingleTap();
});
_singleTapTimer = pending;
}
void _handleDoubleTapLike(Offset position) {
_blockSingleTapUntil = _now().add(_singleTapDelay);
_cancelPendingSingleTap();
if (!widget.doubleTapEnabled || _likedThisPair) return;
_likedThisPair = true;
final id = _nextHeartBurstId++;
setState(() => _heartBursts.add((id: id, position: position)));
if (kDebugMode) {
debugPrint(
'[player] phase=double-tap-like episodeId=${widget.episodeId}',
);
debugPrint(
'[player] phase=heart-burst '
'count=${PlayerDoubleTapHeartBurst.particleCount}',
);
}
widget.onDoubleTapLike?.call(position);
}
void _removeHeartBurst(int id) {
if (!mounted) return;
setState(() => _heartBursts.removeWhere((burst) => burst.id == id));
}
void _handleLongPressStart(LongPressStartDetails details, double width) {
_tapSeq++;
_cancelPendingSingleTap();
_doubleTapDown = false;
_lastDownAt = null;
widget.onLongPressStart(details, width);
}
}
+35 -2
View File
@@ -12,8 +12,8 @@ import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
testWidgets('double tap within 400ms never pauses', (tester) async {
for (final delay in [120, 250, 300, 380]) {
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>();
@@ -74,6 +74,39 @@ void main() {
await tester.pump(const Duration(milliseconds: 500));
expect(key.currentState!.pauseToggles, 2);
});
testWidgets('liked episode keeps heart feedback without liking again', (
tester,
) async {
final repository = FakeInteractionRepository();
final haptics = FakeHaptics();
final key = GlobalKey<_HarnessState>();
final clock = _TestClock();
await _pumpHarness(tester, repository, haptics, key, clock);
final center = tester.getCenter(find.byKey(const Key('home-feed-gesture')));
await tester.tapAt(center);
clock.advance(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 100));
await tester.tapAt(center);
await tester.pump();
expect(repository.likeTargets, hasLength(1));
await tester.pump(const Duration(milliseconds: 800));
clock.advance(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(center);
clock.advance(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 100));
await tester.tapAt(center);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(repository.likeTargets, hasLength(1));
expect(haptics.lightImpacts, 2);
expect(key.currentState!.pauseToggles, 0);
expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget);
});
}
Future<void> _pumpHarness(
+89 -5
View File
@@ -364,7 +364,7 @@ void main() {
// IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。
expect(fullLogic.session.userWantsPlay, isTrue);
await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(fullLogic.session.userWantsPlay, isFalse);
final indexBeforeSwipe = fullLogic.session.currentIndex;
await tester.drag(fullPlayerPage, const Offset(0, -560));
@@ -632,7 +632,7 @@ void main() {
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
await tester.tapAt(const Offset(80, 300));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.byKey(const Key('player-play-toggle')), findsOneWidget);
expect(
tester
@@ -655,6 +655,82 @@ void main() {
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
});
testWidgets('should_double_tap_like_once_without_pausing_full_player', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'test-access',
'auth.refreshToken': 'test-refresh',
'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}',
});
final interactions = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 23000),
);
await _pumpApp(tester, interactions: interactions);
await _openSeriesPlayer(tester);
final logic = _pageLogic(tester, find.byType(PlayerPage).last);
final gesture = find.byKey(const Key('player-video-tap')).hitTestable();
final position = tester.getCenter(gesture);
expect(logic.session.userWantsPlay, isTrue);
for (var index = 0; index < 4; index++) {
await tester.tapAt(position);
}
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(logic.session.userWantsPlay, isTrue);
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']);
for (var index = 0; index < 3; index++) {
expect(find.byKey(Key('double-tap-heart-$index')), findsOneWidget);
}
expect(
tester
.widget<Image>(
find.descendant(
of: find.byKey(const Key('player-rail-like')),
matching: find.byType(Image),
),
)
.color,
AppColors.doubleTapHeartRed,
);
await tester.pump(const Duration(milliseconds: 800));
expect(find.byKey(const Key('double-tap-heart-0')), findsNothing);
expect(logic.session.userWantsPlay, isTrue);
});
testWidgets('should_double_tap_like_in_clear_screen', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'test-access',
'auth.refreshToken': 'test-refresh',
'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}',
});
final interactions = FakeInteractionRepository();
await _pumpApp(tester, interactions: interactions);
await _openSeriesPlayer(tester);
final logic = _pageLogic(tester, find.byType(PlayerPage).last);
await _clearFromMore(tester);
expect(find.byKey(const Key('player-controls-bottom')), findsNothing);
await tester.tapAt(const Offset(80, 300));
await tester.tapAt(const Offset(80, 300));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(logic.session.userWantsPlay, isTrue);
expect(interactions.likeTargets, hasLength(1));
expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget);
expect(find.byKey(const Key('double-tap-heart-1')), findsOneWidget);
expect(find.byKey(const Key('double-tap-heart-2')), findsOneWidget);
});
testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', (
tester,
) async {
@@ -666,7 +742,7 @@ void main() {
expect(find.byType(PlayerCenterToggle), findsNothing);
await tester.tapAt(const Offset(80, 300));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.byType(PlayerCenterToggle), findsOneWidget);
expect(
tester
@@ -710,13 +786,13 @@ void main() {
);
await tester.tapAt(const Offset(80, 300));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget);
expect(find.byType(PlayerCenterToggle), findsNothing);
await tester.tapAt(const Offset(80, 300));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.byType(PlayerCenterToggle), findsOneWidget);
await tester.tap(find.byKey(const Key('player-bottom-clear-screen')));
@@ -1649,8 +1725,10 @@ void main() {
testWidgets('should_open_unlock_panel_with_honest_method_states', (
tester,
) async {
final interactions = FakeInteractionRepository();
await _pumpApp(
tester,
interactions: interactions,
playback: FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'city-lights-demo-1',
@@ -1663,6 +1741,12 @@ void main() {
await _openSeriesPlayer(tester);
expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget);
await tester.tapAt(const Offset(80, 300));
await tester.tapAt(const Offset(80, 300));
await tester.pump(const Duration(milliseconds: 350));
expect(find.byKey(const Key('double-tap-heart-0')), findsNothing);
expect(interactions.likeTargets, isEmpty);
await tester.tap(find.byKey(const Key('player-open-unlock')));
await tester.pumpAndSettle();