fix(player): 修正飘心Overlay生命周期

This commit is contained in:
Zhou Weidong committed 2026-10-26 19:00:37 +08:00
1 parent a7d01e0306
commit c0f65042e6
4 files changed
+184 -28

No files matched your search

+10 -2
View File
@@ -39,6 +39,7 @@ import 'widgets/exit_clear_screen_button.dart';
import 'widgets/feed_pull_refresh.dart';
import 'widgets/player_center_toggle.dart';
import 'widgets/player_loading_overlay.dart';
import 'widgets/player_heart_overlay_scope.dart';
import 'widgets/player_more_sheet.dart';
import 'widgets/player_overlay.dart';
import 'widgets/player_overlay_models.dart';
@@ -147,6 +148,8 @@ class _PlayerPageState extends State<PlayerPage>
bool _loadingRemoved = true;
bool _loadingFadeScheduled = false;
bool _loggedLoadingUiVisible = false;
final PlayerHeartOverlayController _heartOverlayController =
PlayerHeartOverlayController();
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
@@ -306,6 +309,7 @@ class _PlayerPageState extends State<PlayerPage>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
appRouteObserver.unsubscribe(this);
_heartOverlayController.dispose();
_spriteAxisService
..removeListener(_handleSpriteFrameChanged)
..dispose();
@@ -701,6 +705,10 @@ class _PlayerPageState extends State<PlayerPage>
),
],
);
final scopedBody = PlayerHeartOverlayScope(
controller: _heartOverlayController,
child: body,
);
final player = AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('player-system-overlay'),
value: SystemUiOverlayStyle.light.copyWith(
@@ -713,12 +721,12 @@ class _PlayerPageState extends State<PlayerPage>
child: Theme(
data: AppTheme.dark(),
child: widget.embedded
? body
? scopedBody
: Scaffold(
key: const Key('player-scaffold'),
backgroundColor: AppColors.transparent,
resizeToAvoidBottomInset: false,
body: body,
body: scopedBody,
),
),
);
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// Tracks heart overlays for the lifetime of one player page.
class PlayerHeartOverlayController {
final Map<int, OverlayEntry> _entries = {};
void track(int id, OverlayEntry entry) {
_entries[id] = entry;
}
void untrack(int id) {
_entries.remove(id);
}
void dispose() {
for (final entry in _entries.entries.toList()) {
if (entry.value.mounted) entry.value.remove();
entry.value.dispose();
if (kDebugMode) {
debugPrint(
'[player] phase=heart-burst-entry-removed '
'id=${entry.key} reason=page-dispose',
);
}
}
_entries.clear();
}
}
class PlayerHeartOverlayScope extends InheritedWidget {
const PlayerHeartOverlayScope({
super.key,
required this.controller,
required super.child,
});
final PlayerHeartOverlayController controller;
static PlayerHeartOverlayController? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<PlayerHeartOverlayScope>()
?.controller;
}
@override
bool updateShouldNotify(PlayerHeartOverlayScope oldWidget) {
return controller != oldWidget.controller;
}
}
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'player_double_tap_hearts.dart';
import 'player_heart_overlay_scope.dart';
typedef PlayerLongPressStartCallback =
void Function(LongPressStartDetails details, double width);
@@ -48,18 +49,11 @@ class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
int _downSerial = 0;
int _tapSeq = 0;
int _nextHeartBurstId = 0;
final Map<int, OverlayEntry> _heartOverlayEntries = {};
@override
void dispose() {
_tapSeq++;
_singleTapTimer?.cancel();
for (final entry in _heartOverlayEntries.values.toList()) {
entry
..remove()
..dispose();
}
_heartOverlayEntries.clear();
super.dispose();
}
@@ -161,7 +155,20 @@ class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
? overlayBox.globalToLocal(globalPosition)
: globalPosition;
final id = _nextHeartBurstId++;
final controller = PlayerHeartOverlayScope.maybeOf(context);
var removed = false;
late final OverlayEntry entry;
void removeEntry() {
if (removed) return;
removed = true;
controller?.untrack(id);
if (entry.mounted) entry.remove();
entry.dispose();
if (kDebugMode) {
debugPrint('[player] phase=heart-burst-entry-removed id=$id');
}
}
entry = OverlayEntry(
builder: (context) => Positioned.fill(
child: IgnorePointer(
@@ -169,26 +176,15 @@ class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
key: ValueKey('heart-overlay-$id'),
burstId: id,
origin: origin,
onCompleted: () => _removeHeartOverlay(id),
onCompleted: removeEntry,
),
),
),
);
_heartOverlayEntries[id] = entry;
controller?.track(id, entry);
overlay.insert(entry);
}
void _removeHeartOverlay(int id) {
final entry = _heartOverlayEntries.remove(id);
if (entry == null) return;
entry
..remove()
..dispose();
if (kDebugMode) {
debugPrint('[player] phase=heart-burst-entry-removed id=$id');
}
}
void _handleLongPressStart(LongPressStartDetails details, double width) {
_tapSeq++;
_cancelPendingSingleTap();
+106 -6
View File
@@ -7,6 +7,7 @@ import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart';
import 'package:fruitshorts/pages/player/widgets/player_heart_overlay_scope.dart';
import 'package:fruitshorts/pages/player/widgets/player_tap_gesture_region.dart';
import '../support/fake_catalog.dart';
@@ -155,9 +156,7 @@ void main() {
expect(elapsedMs, greaterThanOrEqualTo(1500));
});
testWidgets('heart burst is cleaned up when gesture region is disposed', (
tester,
) async {
testWidgets('heart burst starts at tapped location', (tester) async {
final key = GlobalKey<_HarnessState>();
final clock = _TestClock();
await _pumpHarness(
@@ -183,10 +182,62 @@ void main() {
.distance,
lessThanOrEqualTo(1),
);
});
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing);
testWidgets('heart burst survives gesture region disposal', (tester) async {
final logs = <String>[];
final originalDebugPrint = debugPrint;
debugPrint = (message, {wrapWidth}) {
if (message != null) logs.add(message);
};
var elapsedMs = 0;
try {
final key = GlobalKey<_RemovableGestureHarnessState>();
await tester.pumpWidget(
MaterialApp(home: _RemovableGestureHarness(key: key)),
);
const tapPosition = Offset(120, 220);
await tester.tapAt(tapPosition);
await tester.pump(const Duration(milliseconds: 100));
await tester.tapAt(tapPosition);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final burstFinder = find.byType(PlayerDoubleTapHeartBurst);
expect(burstFinder, findsOneWidget);
key.currentState!.removeGesture();
await tester.pump();
expect(find.byKey(const Key('removable-tap-gesture')), findsNothing);
expect(burstFinder, findsOneWidget);
elapsedMs = 100;
while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) {
await tester.pump(const Duration(milliseconds: 16));
elapsedMs += 16;
}
expect(burstFinder, findsNothing);
} finally {
debugPrint = originalDebugPrint;
}
expect(elapsedMs, greaterThanOrEqualTo(1500));
expect(
logs.any(
(line) =>
line.contains('phase=heart-burst-complete') &&
line.contains('configuredMs=1590'),
),
isTrue,
);
expect(
logs.any(
(line) =>
line.contains('phase=heart-burst-dispose') &&
line.contains('completed=1'),
),
isTrue,
);
});
testWidgets('multiple heart bursts run independently', (tester) async {
@@ -328,6 +379,55 @@ class _HarnessState extends State<_Harness> {
}
}
class _RemovableGestureHarness extends StatefulWidget {
const _RemovableGestureHarness({super.key});
@override
State<_RemovableGestureHarness> createState() =>
_RemovableGestureHarnessState();
}
class _RemovableGestureHarnessState extends State<_RemovableGestureHarness> {
final PlayerHeartOverlayController _heartController =
PlayerHeartOverlayController();
bool _showGesture = true;
void removeGesture() => setState(() => _showGesture = false);
@override
void dispose() {
_heartController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PlayerHeartOverlayScope(
controller: _heartController,
child: Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
if (_showGesture)
Positioned.fill(
child: PlayerTapGestureRegion(
key: const Key('removable-tap-gesture'),
gestureKey: const Key('removable-gesture'),
enabled: true,
doubleTapEnabled: true,
onSingleTap: () {},
onDoubleTapLike: (_) {},
onLongPressStart: (_, _) {},
onInteractionEnd: () {},
),
),
],
),
),
);
}
}
class _TestClock {
DateTime _now = DateTime(2026, 10, 22, 12);