From b11e925ee42f5dc4483342dc6b1c40e7684078c0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:40:04 +0700 Subject: [PATCH] perf(player): suspend covered pages and streamline transitions --- lib/screens/main_shell.dart | 67 ++++++++++++++------------ lib/screens/now_playing_screen.dart | 31 ++++++++++-- lib/widgets/animation_utils.dart | 3 +- test/now_playing_route_test.dart | 73 +++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 36 deletions(-) diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 34f4939d..adfba991 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -62,6 +62,16 @@ class _MainShellState extends ConsumerState 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 _tabJumpScale = Tween( + begin: 0.985, + end: 1, + ).animate(_tabJumpOpacity); bool _hasCheckedUpdate = false; bool _hasCheckedAppAnnouncement = false; bool _initialSafRepairComplete = false; @@ -534,6 +544,7 @@ class _MainShellState extends ConsumerState ShellNavigationService.unregisterTabSelectionHandler(this); _shareSubscription?.cancel(); _pageController.dispose(); + _tabJumpOpacity.dispose(); _tabJumpTransitionController.dispose(); _mornyeChrome.dispose(); super.dispose(); @@ -946,41 +957,35 @@ class _MainShellState extends ConsumerState 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( - 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( + 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), - ); - }, ), ); diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index acaf739e..35e32eb2 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -101,10 +101,30 @@ class NowPlayingRoute extends PageRoute { 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 { 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; diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 1b2434a0..c4124f4c 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -155,7 +155,8 @@ class _ShimmerLoadingState extends State child: child, ); }, - child: widget.child, + // The mask moves every frame; the skeleton beneath it does not. + child: RepaintBoundary(child: widget.child), ); } } diff --git a/test/now_playing_route_test.dart b/test/now_playing_route_test.dart index 17005353..a350c46a 100644 --- a/test/now_playing_route_test.dart +++ b/test/now_playing_route_test.dart @@ -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(); + 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(); + 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',