diff --git a/integration_test/mornye_glass_visual_test.dart b/integration_test/mornye_glass_visual_test.dart new file mode 100644 index 00000000..978dc8ff --- /dev/null +++ b/integration_test/mornye_glass_visual_test.dart @@ -0,0 +1,313 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:audio_service/audio_service.dart'; +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:integration_test/integration_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/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/music_player_runtime.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_liquid_backdrop.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(motionArtworkEnabled: false); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Android Mornye glass visual poses and attached layers', ( + tester, + ) async { + expect(Platform.isAndroid, isTrue); + expect(ui.ImageFilter.isShaderFilterSupported, isTrue); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + final directory = (await tester.runAsync(() async { + return (await getTemporaryDirectory()).createTemp('mornye-glass-visual-'); + }))!; + final cover = File('${directory.path}/cover.png'); + await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + const _Pattern().paint(Canvas(recorder), const Size(192, 192)); + final picture = recorder.endRecording(); + final image = await picture.toImage(192, 192); + try { + final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!; + await cover.writeAsBytes(bytes.buffer.asUint8List()); + } finally { + image.dispose(); + picture.dispose(); + } + }); + final chrome = MornyeChromeController(); + final selected = ValueNotifier(0); + final runtime = MusicPlayerRuntime(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await runtime.dispose(); + await ResizeImage.resizeIfNeeded(132, null, FileImage(cover)).evict(); + chrome.dispose(); + selected.dispose(); + await directory.delete(recursive: true); + }); + final item = MediaItem( + id: 'glass-visual-fixture', + title: 'Color study', + artist: 'Fixture artist', + duration: const Duration(minutes: 4), + artUri: cover.uri, + ); + final poses = {}; + Future wait(int milliseconds) async { + await tester.runAsync( + () => Future.delayed(Duration(milliseconds: milliseconds)), + ); + } + + CurvedAnimation fold() { + final builder = tester.widget( + find + .descendant( + of: find.byType(MornyeBottomBar), + matching: find.byWidgetPredicate( + (widget) => + widget is AnimatedBuilder && + widget.animation is CurvedAnimation, + ), + ) + .first, + ); + return builder.animation as CurvedAnimation; + } + + Future capture(String name) async { + await tester.pump(); + // Android can acquire the previously displayed image before the new + // Flutter frame reaches its image view. Prime that view, then capture the + // frozen pose; discard the priming image from the exported report. + await wait(250); + await tester.runAsync(() => binding.takeScreenshot('settle_$name')); + (binding.reportData!['screenshots']! as List).removeLast(); + final histogram = {}; + void visit(Layer layer) { + histogram.update( + layer.runtimeType.toString(), + (count) => count + 1, + ifAbsent: () => 1, + ); + if (layer is ContainerLayer) { + for ( + var child = layer.firstChild; + child != null; + child = child.nextSibling + ) { + visit(child); + } + } + } + + // Profile builds need the retained layer, since debugLayer is debug-only. + // ignore: invalid_use_of_protected_member + visit(tester.binding.renderViews.first.layer!); + poses[name] = { + 'collapseProgress': fold().value, + 'controllerProgress': fold().parent.value, + 'shaderBackdrops': histogram['_ImpellerShaderBackdropLayer'] ?? 0, + 'stockBlurLayers': histogram['BackdropFilterLayer'] ?? 0, + 'batchBlurLayers': histogram['LiquidGlassBatchBackdropLayer'] ?? 0, + 'attachedLayerTypes': histogram, + }; + await tester.runAsync(() => binding.takeScreenshot(name)); + } + + Future halfway(bool collapsed) async { + chrome.value = collapsed; + await tester.pump(); + (fold().parent as AnimationController) + ..stop() + ..value = 0.5; + await tester.pump(); + } + + for (final clarity in [0.6, 1.0]) { + chrome.expand(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + currentMediaItemProvider.overrideWith((ref) => Stream.value(item)), + playbackStateProvider.overrideWith( + (ref) => Stream.value(PlaybackState()), + ), + musicPlayerControllerProvider.overrideWithValue( + MusicPlayerController(runtime: runtime), + ), + mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark, glassClarity: clarity), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + extendBody: true, + body: const CustomPaint( + painter: _Pattern(), + child: SizedBox.expand(), + ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: ListenableBuilder( + listenable: Listenable.merge([chrome, selected]), + builder: (_, _) => MornyeBottomBar( + collapsed: chrome.value, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.music_note), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.grid_view), + label: 'Repo', + ), + NavigationDestination( + icon: Icon(Icons.search), + label: 'Search', + ), + ], + selectedIndex: selected.value, + onSelected: (value) => selected.value = value, + onHome: () => selected.value = 0, + onSearch: () => selected.value = 3, + blurEnabled: true, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.runAsync(() async { + await LiquidGlassShaders.ensureLoaded(); + await ui.FragmentProgram.fromAsset( + 'assets/shaders/mornye_selection_mask.frag', + ); + }); + await wait(600); + if (clarity == 0.6) { + await binding.convertFlutterSurfaceToImage(); + await tester.pump(); + } + expect(find.byType(MiniPlayer), findsOneWidget); + final player = tester.state(find.byType(MiniPlayer)); + final prefix = 'clarity_${(clarity * 100).round()}'; + await capture('${prefix}_expanded'); + await halfway(true); + await capture('${prefix}_folding_half'); + (fold().parent as AnimationController).forward(); + await wait(450); + await capture('${prefix}_compact'); + await halfway(false); + await capture('${prefix}_reopening_half'); + (fold().parent as AnimationController).reverse(); + await wait(450); + Offset tab(String label) => tester.getCenter( + find + .descendant( + of: find.byType(MornyeTabBar), + matching: find.text(label), + ) + .hitTestable(), + ); + final home = tab('Home'); + final search = tab('Search'); + final touch = await tester.startGesture(home); + try { + await wait(320); + await touch.moveTo(Offset.lerp(home, search, 0.5)!); + await wait(100); + final lens = find.byWidgetPredicate( + (widget) => widget is MornyeLiquidBackdrop && widget.interaction, + ); + for ( + var attempt = 0; + tester.widget(lens).progress < 1 && + attempt < 20; + attempt++ + ) { + await wait(100); + } + expect(tester.widget(lens).progress, 1); + await capture('${prefix}_held_halfway'); + } finally { + await touch.cancel(); + } + await wait(300); + expect(tester.state(find.byType(MiniPlayer)), same(player)); + expect(tester.takeException(), isNull); + } + binding.reportData!['mornye_glass_visual'] = { + 'scope': + 'Production MornyeBottomBar/MiniPlayer over generated stripes/grid; ' + 'paused in-memory playback and settings, no persistent data or network.', + 'captureSurface': + 'Android converted Flutter image view; visual-only, no timings.', + 'halfPose': + 'Production animation controller stopped at linear progress 0.5.', + 'physicalSize': [ + tester.view.physicalSize.width, + tester.view.physicalSize.height, + ], + 'devicePixelRatio': tester.view.devicePixelRatio, + 'poses': poses, + }; + }); +} + +class _Pattern extends CustomPainter { + const _Pattern(); + + @override + void paint(Canvas canvas, Size size) { + const colors = [Colors.deepPurple, Colors.cyan, Colors.amber, Colors.pink]; + canvas.drawRect( + Offset.zero & size, + Paint()..color = const Color(0xff102038), + ); + for (var y = 0.0; y < size.height; y += 48) { + for (var x = 0.0; x < size.width; x += 48) { + final color = colors[((x + y) / 48).toInt() % colors.length]; + canvas.drawRect( + Rect.fromLTWH(x, y, 47, 47), + Paint()..color = color.withValues(alpha: 0.65), + ); + canvas.drawRect( + Rect.fromLTWH(x, y + 16, 47, 4), + Paint()..color = Colors.white.withValues(alpha: 0.5), + ); + } + } + } + + @override + bool shouldRepaint(_Pattern oldDelegate) => false; +} diff --git a/integration_test/mornye_rendering_performance_test.dart b/integration_test/mornye_rendering_performance_test.dart index d4705722..e64ebb98 100644 --- a/integration_test/mornye_rendering_performance_test.dart +++ b/integration_test/mornye_rendering_performance_test.dart @@ -25,7 +25,8 @@ import 'performance_probe.dart'; void main() { final binding = PerformanceTestBinding.ensureInitialized(); - for (final clarity in [0.0, 0.1, 0.6, 1.0]) { + const focused = bool.fromEnvironment('MORNYE_GLASS_FOCUS'); + for (final clarity in focused ? [0.6, 1.0] : [0.0, 0.1, 0.6, 1.0]) { testWidgets('Mornye real rendering at clarity $clarity', (tester) async { final probe = PerformanceProbe( binding, @@ -76,16 +77,18 @@ void main() { expect(tester.state(find.byType(MiniPlayer)), same(player)); expect(fixture.chrome.value, isFalse); - await probe.measure('tab_changes', () async { - for (final label in ['Library', 'Repo', 'Search', 'Home']) { - final tab = find.descendant( - of: find.byType(MornyeTabBar), - matching: find.text(label), - ); - await tester.tapAt(tester.getCenter(tab.first)); - await probe.wait(const Duration(milliseconds: 300)); - } - }); + if (!focused) { + await probe.measure('tab_changes', () async { + for (final label in ['Library', 'Repo', 'Search', 'Home']) { + final tab = find.descendant( + of: find.byType(MornyeTabBar), + matching: find.text(label), + ); + await tester.tapAt(tester.getCenter(tab.first)); + await probe.wait(const Duration(milliseconds: 300)); + } + }); + } expect(fixture.activeTab.value, 0); if (clarity > 0.2) { @@ -103,6 +106,15 @@ void main() { (widget) => widget is MornyeLiquidBackdrop && widget.interaction, ); if (ui.ImageFilter.isShaderFilterSupported) { + // Await the final painted pose even if the emulator delays a frame. + for ( + var attempt = 0; + tester.widget(lens).progress < 1 && + attempt < 20; + attempt++ + ) { + await probe.wait(const Duration(milliseconds: 100)); + } expect(tester.widget(lens).progress, 1); } for (var step = 1; step <= 60; step++) { @@ -123,14 +135,17 @@ void main() { }); fixture.setLongTitle(false); await probe.wait(const Duration(milliseconds: 400)); - await probe.measure('short_title_idle', () async { - await probe.wait(const Duration(milliseconds: 1500)); - }, allowIdle: true); + if (!focused) { + await probe.measure('short_title_idle', () async { + await probe.wait(const Duration(milliseconds: 1500)); + }, allowIdle: true); + } expect(tester.takeException(), isNull); await probe.finish( metadata: { 'glass_clarity': clarity, 'glass_level': 'liquid', + 'focused': focused, 'tracks': 2000, 'unique_local_covers': fixture.covers.length, 'scope': diff --git a/integration_test/performance_driver.dart b/integration_test/performance_driver.dart index bdb74e5c..66cf38d9 100644 --- a/integration_test/performance_driver.dart +++ b/integration_test/performance_driver.dart @@ -6,13 +6,31 @@ import 'package:integration_test/integration_test_driver.dart'; /// Run on dedicated emulators with --keep-app-running: `flutter drive` otherwise /// uninstalls the app when it finishes. Use --profile on Android and --debug on /// iOS simulators. SPOTIFLAC_PERFORMANCE_REPORT selects the host JSON output; -/// the aggregate target is integration_test/mornye_performance_test.dart. +/// screenshots are PNGs beside that report. The aggregate target is +/// integration_test/mornye_performance_test.dart. Future main() => integrationDriver( responseDataCallback: (data) async { final output = File( Platform.environment['SPOTIFLAC_PERFORMANCE_REPORT'] ?? '/tmp/spotiflac-performance.json', ); + await output.parent.create(recursive: true); + final screenshots = data?['screenshots']; + if (screenshots is List) { + final prefix = output.path.replaceFirst(RegExp(r'\.json$'), ''); + for (final entry in screenshots) { + final shot = entry as Map; + final name = (shot['screenshotName'] as String).replaceAll( + RegExp(r'[^a-zA-Z0-9_-]'), + '_', + ); + final bytes = (shot.remove('bytes') as List).cast(); + final png = File('$prefix-$name.png'); + await png.writeAsBytes(bytes); + shot.addAll({'file': png.path, 'byte_count': bytes.length}); + stdout.writeln('Screenshot: ${png.path}'); + } + } await output.writeAsString( const JsonEncoder.withIndent(' ').convert(data), );