perf(player): suspend covered pages and streamline transitions

This commit is contained in:
zarzet committed 2026-09-30 23:40:04 +07:00
1 parent 1e246138c8
commit b11e925ee4
4 files changed
+138 -36

No files matched your search

+36 -31
View File
@@ -62,6 +62,16 @@ class _MainShellState extends ConsumerState<MainShell>
final GlobalKey _pageViewKey = GlobalKey();
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
// Transitions update their layers directly; no shell subtree rebuilds per
// animation frame.
late final CurvedAnimation _tabJumpOpacity = CurvedAnimation(
parent: _tabJumpTransitionController,
curve: Curves.easeOutCubic,
);
late final Animation<double> _tabJumpScale = Tween<double>(
begin: 0.985,
end: 1,
).animate(_tabJumpOpacity);
bool _hasCheckedUpdate = false;
bool _hasCheckedAppAnnouncement = false;
bool _initialSafRepairComplete = false;
@@ -534,6 +544,7 @@ class _MainShellState extends ConsumerState<MainShell>
ShellNavigationService.unregisterTabSelectionHandler(this);
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpOpacity.dispose();
_tabJumpTransitionController.dispose();
_mornyeChrome.dispose();
super.dispose();
@@ -946,41 +957,35 @@ class _MainShellState extends ConsumerState<MainShell>
final pageView = KeyedSubtree(
key: _pageViewKey,
child: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
// TickerMode mutes animations and lets visibility-aware widgets
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
// kept-alive pages otherwise keep running offscreen.
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (canMinimizeChrome && index == _currentIndex) {
return _mornyeChrome.handleScroll(notification);
}
return false;
},
child: tabs[index],
child: FadeTransition(
opacity: _tabJumpOpacity,
child: ScaleTransition(
scale: _tabJumpScale,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
// TickerMode mutes animations and lets visibility-aware widgets
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
// kept-alive pages otherwise keep running offscreen.
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: TickerMode(
enabled: index == _currentIndex,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (canMinimizeChrome && index == _currentIndex) {
return _mornyeChrome.handleScroll(notification);
}
return false;
},
child: tabs[index],
),
),
),
),
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
),
);
+27 -4
View File
@@ -101,10 +101,30 @@ class NowPlayingRoute extends PageRoute<void> {
bool _interactiveTransition = false;
int _dragGeneration = 0;
// Keep the previous page painted where a drag exposes it. The player itself
// fills the screen, including the status bar and bottom safe area.
// The player paints an opaque surface over the whole screen, including the
// status bar and bottom safe area. Once settled, the framework therefore
// keeps the pages below offstage: their glass, marquees and tickers stop
// compositing. Every drag or dismissal moves the animation off `completed`,
// which makes this route translucent again before the page is exposed.
@override
bool get opaque => false;
bool get opaque => true;
/// Overlay size when the pages below were last laid out, recorded while this
/// route hides them. A rotation while hidden leaves their geometry stale.
Size? _coveredOverlaySize;
Size? get _overlaySize {
final box = navigator?.overlay?.context.findRenderObject();
return box is RenderBox && box.hasSize ? box.size : null;
}
@override
void install() {
super.install();
animation!.addStatusListener((status) {
_coveredOverlaySize = status.isCompleted ? _overlaySize : null;
});
}
@override
Color? get barrierColor => null;
@@ -175,7 +195,10 @@ class NowPlayingRoute extends PageRoute<void> {
context.isMornye &&
!MediaQuery.disableAnimationsOf(context) &&
(controller?.value ?? 0) > 0) {
final target = miniPlayerGeometry?.call();
final coveredSize = _coveredOverlaySize;
final target = coveredSize == null || coveredSize == _overlaySize
? miniPlayerGeometry?.call()
: null;
if (target != null && !target.surface.isEmpty) {
final size = MediaQuery.sizeOf(context);
_dismissStartValue = controller!.value;
+2 -1
View File
@@ -155,7 +155,8 @@ class _ShimmerLoadingState extends State<ShimmerLoading>
child: child,
);
},
child: widget.child,
// The mask moves every frame; the skeleton beneath it does not.
child: RepaintBoundary(child: widget.child),
);
}
}
+73
View File
@@ -60,6 +60,79 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('settled player keeps the page below offstage until dragged', (
tester,
) async {
const below = Key('below-player');
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigator,
home: const SizedBox.expand(key: below),
),
);
final route = _TestPlayerRoute();
navigator.currentState!.push(route);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Translucent while it slides in.
expect(find.byKey(below), findsOneWidget);
await tester.pumpAndSettle();
expect(find.byKey(below), findsNothing);
expect(find.byKey(below, skipOffstage: false), findsOneWidget);
route.startDrag();
route.updateDrag(
DragUpdateDetails(
globalPosition: const Offset(0, 120),
delta: const Offset(0, 120),
primaryDelta: 120,
),
600,
);
await tester.pump();
expect(find.byKey(below), findsOneWidget);
route.cancelDrag();
await tester.pumpAndSettle();
expect(find.byKey(below), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('rotation while covered discards stale mini-player bounds', (
tester,
) async {
tester.view.physicalSize = const Size(393, 852);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigator,
theme: MornyeTheme.build(Brightness.dark),
home: const SizedBox(),
),
);
navigator.currentState!.push(
_TestPlayerRoute(
miniPlayerGeometry: () => (
surface: const Rect.fromLTWH(20, 700, 353, 52),
artwork: const Rect.fromLTWH(28, 704, 44, 44),
),
),
);
await tester.pumpAndSettle();
// The page below is not laid out while hidden, so its mini player still
// reports portrait bounds after the window turns landscape.
tester.view.physicalSize = const Size(852, 393);
await tester.pump();
navigator.currentState!.pop();
await tester.pump();
expect(find.byKey(const ValueKey('player-minimize-surface')), findsNothing);
await tester.pumpAndSettle();
expect(find.byKey(_sheet), findsNothing);
expect(tester.takeException(), isNull);
});
for (final brightness in Brightness.values) {
testWidgets(
'player fills safe areas and reveals the page only when dragged in $brightness',