fix(player): 修正飘心Overlay生命周期
This commit is contained in:
1 parent
a7d01e0306
commit
c0f65042e6
4 files changed
+184
-28
No files matched your search
@@ -39,6 +39,7 @@ import 'widgets/exit_clear_screen_button.dart';
|
|||||||
import 'widgets/feed_pull_refresh.dart';
|
import 'widgets/feed_pull_refresh.dart';
|
||||||
import 'widgets/player_center_toggle.dart';
|
import 'widgets/player_center_toggle.dart';
|
||||||
import 'widgets/player_loading_overlay.dart';
|
import 'widgets/player_loading_overlay.dart';
|
||||||
|
import 'widgets/player_heart_overlay_scope.dart';
|
||||||
import 'widgets/player_more_sheet.dart';
|
import 'widgets/player_more_sheet.dart';
|
||||||
import 'widgets/player_overlay.dart';
|
import 'widgets/player_overlay.dart';
|
||||||
import 'widgets/player_overlay_models.dart';
|
import 'widgets/player_overlay_models.dart';
|
||||||
@@ -147,6 +148,8 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
bool _loadingRemoved = true;
|
bool _loadingRemoved = true;
|
||||||
bool _loadingFadeScheduled = false;
|
bool _loadingFadeScheduled = false;
|
||||||
bool _loggedLoadingUiVisible = false;
|
bool _loggedLoadingUiVisible = false;
|
||||||
|
final PlayerHeartOverlayController _heartOverlayController =
|
||||||
|
PlayerHeartOverlayController();
|
||||||
final Set<String> _precachedPosterUrls = {};
|
final Set<String> _precachedPosterUrls = {};
|
||||||
|
|
||||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||||
@@ -306,6 +309,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
appRouteObserver.unsubscribe(this);
|
appRouteObserver.unsubscribe(this);
|
||||||
|
_heartOverlayController.dispose();
|
||||||
_spriteAxisService
|
_spriteAxisService
|
||||||
..removeListener(_handleSpriteFrameChanged)
|
..removeListener(_handleSpriteFrameChanged)
|
||||||
..dispose();
|
..dispose();
|
||||||
@@ -701,6 +705,10 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
final scopedBody = PlayerHeartOverlayScope(
|
||||||
|
controller: _heartOverlayController,
|
||||||
|
child: body,
|
||||||
|
);
|
||||||
final player = AnnotatedRegion<SystemUiOverlayStyle>(
|
final player = AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
key: const Key('player-system-overlay'),
|
key: const Key('player-system-overlay'),
|
||||||
value: SystemUiOverlayStyle.light.copyWith(
|
value: SystemUiOverlayStyle.light.copyWith(
|
||||||
@@ -713,12 +721,12 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
child: Theme(
|
child: Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.dark(),
|
||||||
child: widget.embedded
|
child: widget.embedded
|
||||||
? body
|
? scopedBody
|
||||||
: Scaffold(
|
: Scaffold(
|
||||||
key: const Key('player-scaffold'),
|
key: const Key('player-scaffold'),
|
||||||
backgroundColor: AppColors.transparent,
|
backgroundColor: AppColors.transparent,
|
||||||
resizeToAvoidBottomInset: false,
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'player_double_tap_hearts.dart';
|
import 'player_double_tap_hearts.dart';
|
||||||
|
import 'player_heart_overlay_scope.dart';
|
||||||
|
|
||||||
typedef PlayerLongPressStartCallback =
|
typedef PlayerLongPressStartCallback =
|
||||||
void Function(LongPressStartDetails details, double width);
|
void Function(LongPressStartDetails details, double width);
|
||||||
@@ -48,18 +49,11 @@ class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
|
|||||||
int _downSerial = 0;
|
int _downSerial = 0;
|
||||||
int _tapSeq = 0;
|
int _tapSeq = 0;
|
||||||
int _nextHeartBurstId = 0;
|
int _nextHeartBurstId = 0;
|
||||||
final Map<int, OverlayEntry> _heartOverlayEntries = {};
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tapSeq++;
|
_tapSeq++;
|
||||||
_singleTapTimer?.cancel();
|
_singleTapTimer?.cancel();
|
||||||
for (final entry in _heartOverlayEntries.values.toList()) {
|
|
||||||
entry
|
|
||||||
..remove()
|
|
||||||
..dispose();
|
|
||||||
}
|
|
||||||
_heartOverlayEntries.clear();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +155,20 @@ class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
|
|||||||
? overlayBox.globalToLocal(globalPosition)
|
? overlayBox.globalToLocal(globalPosition)
|
||||||
: globalPosition;
|
: globalPosition;
|
||||||
final id = _nextHeartBurstId++;
|
final id = _nextHeartBurstId++;
|
||||||
|
final controller = PlayerHeartOverlayScope.maybeOf(context);
|
||||||
|
var removed = false;
|
||||||
late final OverlayEntry entry;
|
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(
|
entry = OverlayEntry(
|
||||||
builder: (context) => Positioned.fill(
|
builder: (context) => Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
@@ -169,26 +176,15 @@ class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
|
|||||||
key: ValueKey('heart-overlay-$id'),
|
key: ValueKey('heart-overlay-$id'),
|
||||||
burstId: id,
|
burstId: id,
|
||||||
origin: origin,
|
origin: origin,
|
||||||
onCompleted: () => _removeHeartOverlay(id),
|
onCompleted: removeEntry,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_heartOverlayEntries[id] = entry;
|
controller?.track(id, entry);
|
||||||
overlay.insert(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) {
|
void _handleLongPressStart(LongPressStartDetails details, double width) {
|
||||||
_tapSeq++;
|
_tapSeq++;
|
||||||
_cancelPendingSingleTap();
|
_cancelPendingSingleTap();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:fruitshorts/core/theme/app_theme.dart';
|
|||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.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_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 'package:fruitshorts/pages/player/widgets/player_tap_gesture_region.dart';
|
||||||
|
|
||||||
import '../support/fake_catalog.dart';
|
import '../support/fake_catalog.dart';
|
||||||
@@ -155,9 +156,7 @@ void main() {
|
|||||||
expect(elapsedMs, greaterThanOrEqualTo(1500));
|
expect(elapsedMs, greaterThanOrEqualTo(1500));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('heart burst is cleaned up when gesture region is disposed', (
|
testWidgets('heart burst starts at tapped location', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final key = GlobalKey<_HarnessState>();
|
final key = GlobalKey<_HarnessState>();
|
||||||
final clock = _TestClock();
|
final clock = _TestClock();
|
||||||
await _pumpHarness(
|
await _pumpHarness(
|
||||||
@@ -183,10 +182,62 @@ void main() {
|
|||||||
.distance,
|
.distance,
|
||||||
lessThanOrEqualTo(1),
|
lessThanOrEqualTo(1),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
testWidgets('heart burst survives gesture region disposal', (tester) async {
|
||||||
await tester.pump();
|
final logs = <String>[];
|
||||||
expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing);
|
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 {
|
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 {
|
class _TestClock {
|
||||||
DateTime _now = DateTime(2026, 10, 22, 12);
|
DateTime _now = DateTime(2026, 10, 22, 12);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user