diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 49cca34..9b3a37f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -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 bool _loadingRemoved = true; bool _loadingFadeScheduled = false; bool _loggedLoadingUiVisible = false; + final PlayerHeartOverlayController _heartOverlayController = + PlayerHeartOverlayController(); final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -306,6 +309,7 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); + _heartOverlayController.dispose(); _spriteAxisService ..removeListener(_handleSpriteFrameChanged) ..dispose(); @@ -701,6 +705,10 @@ class _PlayerPageState extends State ), ], ); + final scopedBody = PlayerHeartOverlayScope( + controller: _heartOverlayController, + child: body, + ); final player = AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( @@ -713,12 +721,12 @@ class _PlayerPageState extends State 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, ), ), ); diff --git a/lib/pages/player/widgets/player_heart_overlay_scope.dart b/lib/pages/player/widgets/player_heart_overlay_scope.dart new file mode 100644 index 0000000..f957ef1 --- /dev/null +++ b/lib/pages/player/widgets/player_heart_overlay_scope.dart @@ -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 _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() + ?.controller; + } + + @override + bool updateShouldNotify(PlayerHeartOverlayScope oldWidget) { + return controller != oldWidget.controller; + } +} diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index a001021..e6e10e4 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -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 { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final Map _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 { ? 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 { 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(); diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 861c988..03c6246 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -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 = []; + 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);