mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 06:09:39 +02:00
perf(player): suspend covered pages and streamline transitions
This commit is contained in:
1 parent
1e246138c8
commit
b11e925ee4
4 files changed
+138
-36
No files matched your search
+36
-31
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user