From 76b843b2bffae88d9313c57cfb41bbad8d0ba7c5 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:54:22 +0700 Subject: [PATCH] perf(mornye): prepare overlay GPU pipeline before first opening --- .../mornye_download_picker_test.dart | 101 ++++++++++++- lib/app.dart | 11 +- lib/services/mornye_glass_warmup.dart | 135 ++++++++++++++++++ lib/widgets/mornye_glass_preparation.dart | 26 ++++ 4 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 lib/services/mornye_glass_warmup.dart create mode 100644 lib/widgets/mornye_glass_preparation.dart diff --git a/integration_test/mornye_download_picker_test.dart b/integration_test/mornye_download_picker_test.dart index 5b45955c..e0f9239e 100644 --- a/integration_test/mornye_download_picker_test.dart +++ b/integration_test/mornye_download_picker_test.dart @@ -5,17 +5,20 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/mornye_glass_warmup.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:spotiflac_android/widgets/mornye_glass_preparation.dart'; import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'performance_probe.dart'; @@ -65,8 +68,20 @@ class _Extensions extends ExtensionNotifier { void main() { const glassOverride = String.fromEnvironment('DOWNLOAD_GLASS_LEVEL'); + const brightnessOverride = String.fromEnvironment('DOWNLOAD_BRIGHTNESS'); + const preloadGlass = bool.fromEnvironment('DOWNLOAD_PRELOAD_GLASS'); + const warmupGlass = bool.fromEnvironment( + 'DOWNLOAD_WARMUP_GLASS', + defaultValue: true, + ); + const shellGlass = bool.fromEnvironment('DOWNLOAD_SHELL_GLASS'); + const traceFirstOpen = bool.fromEnvironment('DOWNLOAD_TRACE_FIRST_OPEN'); final binding = PerformanceTestBinding.ensureInitialized(); for (final brightness in Brightness.values) { + if (brightnessOverride.isNotEmpty && + brightness.name != brightnessOverride) { + continue; + } testWidgets( 'download glass, source changes and queue feedback in $brightness', (tester) async { @@ -105,7 +120,11 @@ void main() { supportedLocales: AppLocalizations.supportedLocales, builder: (_, child) => RepaintBoundary( key: capture, - child: AppScaffoldMessenger(child: child!), + child: warmupGlass + ? MornyeGlassPreparation( + child: AppScaffoldMessenger(child: child!), + ) + : AppScaffoldMessenger(child: child!), ), home: Scaffold( body: Builder( @@ -131,6 +150,32 @@ void main() { child: const Text('Open download'), ), ), + if (shellGlass) + Positioned( + left: 12, + right: 12, + bottom: 24, + child: MornyeTabBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.library_music), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.search), + label: 'Search', + ), + ], + selectedIndex: 0, + onSelected: (_) {}, + blurEnabled: true, + liquidGlass: true, + ), + ), ], ); }, @@ -139,7 +184,29 @@ void main() { ), ), ); - await probe.wait(const Duration(milliseconds: 600)); + if (preloadGlass) { + await tester.runAsync(() => LiquidGlassShaders.ensureLoaded()); + } + if (traceFirstOpen) { + await tester.runAsync(() => binding.enableTimeline()); + } + // Give both baseline and prepared processes the same idle interval; + // opening the route never waits for preparation to complete. + await probe.wait(const Duration(milliseconds: 1500)); + if (warmupGlass && + (glassOverride.isEmpty || glassOverride == 'liquid')) { + expect(MornyeGlassWarmup.isReady, isTrue); + final view = binding.platformDispatcher.views.first; + expect( + identical( + MornyeGlassWarmup.prepare(view), + MornyeGlassWarmup.prepare(view), + ), + isTrue, + reason: 'Multiple callers must share one preparation', + ); + } + final shadersLoadedBeforeOpen = LiquidGlassShaders.isLoaded; Future open() async { await tester.tap(find.text('Open download')); await probe.wait(const Duration(milliseconds: 500)); @@ -156,7 +223,12 @@ void main() { await probe.measure( 'first_open', - open, + traceFirstOpen + ? () => binding.traceAction( + open, + reportKey: 'first_open_timeline_${brightness.name}', + ) + : open, warmup: () async {}, repetitions: 1, ); @@ -166,6 +238,23 @@ void main() { warmup: () async {}, repetitions: 1, ); + if (const bool.fromEnvironment('DOWNLOAD_FIRST_OPEN_ONLY')) { + await probe.finish( + metadata: { + 'scope': + 'first opening of production picker with fixture providers', + 'brightness': brightness.name, + 'shaders_loaded_before_open': shadersLoadedBeforeOpen, + 'preload_glass': preloadGlass, + 'trace_first_open': traceFirstOpen, + 'warmup_glass': warmupGlass, + 'warmup_ready': MornyeGlassWarmup.isReady, + 'shell_glass': shellGlass, + }, + ); + await tester.pumpWidget(const SizedBox.shrink()); + return; + } await probe.measure('open_close', () async { await open(); await close(); @@ -287,6 +376,12 @@ void main() { 'scope': 'production download picker and queue feedback with fixture providers; no downloads', 'brightness': brightness.name, + 'shaders_loaded_before_open': shadersLoadedBeforeOpen, + 'preload_glass': preloadGlass, + 'trace_first_open': traceFirstOpen, + 'warmup_glass': warmupGlass, + 'warmup_ready': MornyeGlassWarmup.isReady, + 'shell_glass': shellGlass, 'pose_layers': poses, 'glass_level_override': glassOverride, 'quality_count': const int.fromEnvironment( diff --git a/lib/app.dart b/lib/app.dart index c0e63680..4041302f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/l10n/supported_locales.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_glass_preparation.dart'; String initialLocationForAppState({ required bool isFirstLaunch, @@ -214,10 +215,12 @@ class SpotiFLACApp extends ConsumerWidget { // level it sits above the router's Navigator, so the controller // never encounters it while collecting heroes. Removing the // inherited controller disables flights on the root Navigator. - child: AdaptiveUiScaler( - child: heroAnimationsEnabled - ? appContent - : HeroControllerScope.none(child: appContent), + child: MornyeGlassPreparation( + child: AdaptiveUiScaler( + child: heroAnimationsEnabled + ? appContent + : HeroControllerScope.none(child: appContent), + ), ), ); }, diff --git a/lib/services/mornye_glass_warmup.dart b/lib/services/mornye_glass_warmup.dart new file mode 100644 index 00000000..478439c1 --- /dev/null +++ b/lib/services/mornye_glass_warmup.dart @@ -0,0 +1,135 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +/// Prepares the actual backdrop pipeline, not just the fragment asset. The +/// detached scene is rasterized once and discarded without presenting a frame. +class MornyeGlassWarmup { + static Future? _pending; + static bool _ready = false; + static final _log = AppLogger('MornyeGlass'); + + @visibleForTesting + static bool get isReady => _ready; + + static Future prepare(ui.FlutterView view) { + if (!ui.ImageFilter.isShaderFilterSupported) return Future.value(); + return _pending ??= _prepare(view).catchError((Object error) { + // Preparation is optional; keep the package's existing lazy fallback. + _log.w('Glass preparation failed: $error'); + }); + } + + static Future _prepare(ui.FlutterView view) async { + // GLES needs the view's initialized graphics context. A first-frame + // callback only means painting was submitted, not that rasterization ran. + await Future.wait([ + LiquidGlassShaders.ensureLoaded(), + WidgetsBinding.instance.waitUntilFirstFrameRasterized, + ]); + // Do not compete with an entrance, theme switch or scrolling animation. + await SchedulerBinding.instance.scheduleTask( + () async { + final binding = WidgetsBinding.instance; + if (binding.lifecycleState != AppLifecycleState.resumed || + !binding.platformDispatcher.views.contains(view)) { + _pending = null; + return; + } + await _rasterize(view); + _ready = true; + }, + Priority.idle, + debugLabel: 'Prepare Mornye glass backdrop', + ); + } +} + +Future _rasterize(ui.FlutterView view) async { + const size = Size(240, 180); + final boundary = RenderRepaintBoundary(); + final pipeline = PipelineOwner(); + final focus = FocusManager(); + final owner = BuildOwner(focusManager: focus); + final renderView = RenderView( + view: view, + configuration: ViewConfiguration( + logicalConstraints: BoxConstraints.tight(size), + physicalConstraints: BoxConstraints.tight(size * view.devicePixelRatio), + devicePixelRatio: view.devicePixelRatio, + ), + child: boundary, + ); + pipeline.rootNode = renderView; + renderView.prepareInitialFrame(); + RenderObjectToWidgetElement? root; + try { + root = RenderObjectToWidgetAdapter( + container: boundary, + child: ProviderScope( + overrides: [ + mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid), + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData( + size: size, + devicePixelRatio: view.devicePixelRatio, + ), + child: Theme( + data: MornyeTheme.build(Brightness.dark), + child: const Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xff76534b), Color(0xff385763)], + ), + ), + ), + Padding( + padding: EdgeInsets.all(12), + child: MornyeGlassPanel.overlay( + child: Center(child: Text('Download')), + ), + ), + ], + ), + ), + ), + ), + ), + ).attachToRenderTree(owner); + owner.buildScope(root); + owner.finalizeTree(); + pipeline.flushLayout(); + pipeline.flushCompositingBits(); + pipeline.flushPaint(); + final image = await boundary.toImage(pixelRatio: view.devicePixelRatio); + image.dispose(); + } finally { + if (root != null) { + RenderObjectToWidgetAdapter( + container: boundary, + ).attachToRenderTree(owner, root); + owner.buildScope(root); + owner.finalizeTree(); + } + pipeline.rootNode = null; + renderView.child = null; + renderView.dispose(); + boundary.dispose(); + pipeline.dispose(); + focus.dispose(); + } +} diff --git a/lib/widgets/mornye_glass_preparation.dart b/lib/widgets/mornye_glass_preparation.dart new file mode 100644 index 00000000..b75de7dc --- /dev/null +++ b/lib/widgets/mornye_glass_preparation.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/services/mornye_glass_warmup.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// Starts the one-time preparation when the full glass theme is enabled, +/// including switching to Mornye or enabling blur after the app has started. +class MornyeGlassPreparation extends ConsumerWidget { + const MornyeGlassPreparation({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (context.isMornye && + ref.watch(mornyeGlassLevelProvider) == MornyeGlassLevel.liquid && + MornyeTheme.glassClarityOf(context) > 0.2 && + !MediaQuery.highContrastOf(context)) { + unawaited(MornyeGlassWarmup.prepare(View.of(context))); + } + return child; + } +}