diff --git a/integration_test/mornye_artwork_performance_test.dart b/integration_test/mornye_artwork_performance_test.dart new file mode 100644 index 00000000..48f8e9ba --- /dev/null +++ b/integration_test/mornye_artwork_performance_test.dart @@ -0,0 +1,186 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; +import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; +import 'package:spotiflac_android/widgets/mornye_player_layout.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; + +import 'performance_probe.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(motionArtworkEnabled: false); +} + +class _Collections extends LibraryCollectionsNotifier { + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); +} + +void main() { + final binding = PerformanceTestBinding.ensureInitialized(); + testWidgets('Mornye player panel transitions and artwork cache', ( + tester, + ) async { + final probe = PerformanceProbe(binding, tester, suite: 'mornye-artwork'); + final directory = await tester.runAsync(getTemporaryDirectory); + final cover = File('${directory!.path}/mornye-performance-cover.png'); + await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + const bounds = Rect.fromLTWH(0, 0, 2400, 2400); + canvas.drawRect( + bounds, + Paint() + ..shader = const LinearGradient( + colors: [Colors.deepPurple, Colors.orange, Colors.cyan], + ).createShader(bounds), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(2400, 2400); + try { + final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!; + await cover.writeAsBytes(bytes.buffer.asUint8List()); + } finally { + image.dispose(); + picture.dispose(); + } + }); + addTearDown(() async { + if (await cover.exists()) await cover.delete(); + }); + final item = MediaItem( + id: 'performance-cover', + title: 'Performance track', + artist: 'Fixture artist', + duration: const Duration(minutes: 4), + artUri: cover.uri, + ); + final theme = MornyeTheme.build(Brightness.dark); + final page = ValueNotifier(0); + final metadata = PlayerMetadataController(); + final seek = PlaybackSeekPreview(); + final foreground = ValueNotifier({}); + addTearDown(page.dispose); + addTearDown(metadata.dispose); + addTearDown(seek.dispose); + addTearDown(foreground.dispose); + final keys = ( + header: GlobalKey(), + controls: GlobalKey(), + volume: GlobalKey(), + expanded: GlobalKey(), + compact: GlobalKey(), + ); + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + Map snapshot() => { + 'cache_bytes': cache.currentSizeBytes, + 'cache_count': cache.currentSize, + 'live_images': cache.liveImageCount, + 'rss_bytes': ProcessInfo.currentRss, + }; + final snapshots = >{'empty': snapshot()}; + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + libraryCollectionsProvider.overrideWith(_Collections.new), + currentMediaItemProvider.overrideWith((ref) => Stream.value(item)), + playQueueProvider.overrideWith((ref) => Stream.value([item])), + playbackStateProvider.overrideWith( + (ref) => Stream.value(PlaybackState(queueIndex: 0)), + ), + playerCollectionTrackProvider.overrideWith( + (ref, item) async => Track( + id: item.id, + name: item.title, + artistName: item.artist ?? '', + albumName: '', + duration: item.duration?.inSeconds ?? 0, + ), + ), + systemVolumeProvider.overrideWith((ref) => Stream.value(0.5)), + ], + child: MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: page, + builder: (context, value, _) => MornyePlayerLayout( + mediaItem: item, + metadata: metadata, + seekPreview: seek, + colorScheme: theme.colorScheme, + page: value, + motionArtwork: MornyePlayerArtwork(mediaItem: item), + insetArtwork: true, + artworkTopInset: 0, + artworkKeys: keys, + artworkForeground: foreground, + lyricsControlsHidden: false, + lyricsOptionsOpen: false, + lyricsBuilder: (_) => ListView.builder( + itemCount: 200, + itemBuilder: (_, index) => Padding( + padding: const EdgeInsets.all(12), + child: Text('Lyric line $index'), + ), + ), + lyricsOptions: null, + onPageChanged: (value) => page.value = value, + onTrackNavigation: (_, _) async {}, + onMoreActions: (_, _, _) async {}, + ), + ), + ), + ), + ), + ), + ); + await tester.runAsync(() => probe.wait(const Duration(seconds: 1))); + await tester.pump(); + snapshots['expanded'] = snapshot(); + await probe.measure('player_lyrics_queue_transitions', () async { + for (final value in [1, 0, 2, 0]) { + page.value = value; + await probe.wait(const Duration(milliseconds: 500)); + } + }); + snapshots['after_panels'] = snapshot(); + // Allow the queue's small thumbnail, but not a second full-size decode. + expect( + cache.currentSizeBytes, + lessThan((snapshots['expanded']!['cache_bytes']! as int) * 1.25), + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(() => probe.wait(const Duration(milliseconds: 500))); + snapshots['unmounted'] = snapshot(); + await probe.finish( + metadata: { + 'fixture': + 'Production MornyePlayerLayout, local 2400px static cover; ' + 'synthetic lyrics/queue and no playback or persistent data', + 'cache_snapshots': snapshots, + }, + ); + }); +} diff --git a/integration_test/mornye_lyrics_performance_test.dart b/integration_test/mornye_lyrics_performance_test.dart new file mode 100644 index 00000000..d7185c80 --- /dev/null +++ b/integration_test/mornye_lyrics_performance_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; +import 'package:spotiflac_android/widgets/player_lyrics.dart'; + +import 'performance_probe.dart'; + +final _lyrics = ParsedLyrics( + synced: true, + wordSynced: true, + plainText: '', + lines: List.generate(200, (index) { + final words = ['Fixture ', 'line ', '$index ', 'flows ', 'on']; + return LyricLine( + time: Duration(seconds: index * 2), + end: Duration(seconds: (index + 1) * 2), + text: words.join(), + words: [ + for (var word = 0; word < words.length; word++) + LyricWord( + time: Duration(milliseconds: index * 2000 + word * 400), + end: Duration(milliseconds: index * 2000 + (word + 1) * 400), + text: words[word], + ), + ], + ); + }), +); + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + +void main() { + final binding = PerformanceTestBinding.ensureInitialized(); + testWidgets('Mornye production word-synced lyrics and manual scrolling', ( + tester, + ) async { + final probe = PerformanceProbe(binding, tester, suite: 'mornye_lyrics'); + final playback = StreamController.broadcast(); + final seek = ValueNotifier(null); + final active = ValueNotifier(true); + final elapsed = Stopwatch(); + Timer? timer; + void report(bool playing) => playback.add( + PlaybackState( + playing: playing, + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 40) + elapsed.elapsed, + ), + ); + void stop() { + timer?.cancel(); + timer = null; + elapsed.stop(); + report(false); + } + + void start() { + elapsed.start(); + report(true); + timer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => report(true), + ); + } + + addTearDown(() async { + timer?.cancel(); + await playback.close(); + seek.dispose(); + active.dispose(); + }); + final theme = MornyeTheme.build(Brightness.dark); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + playbackStateProvider.overrideWith((ref) => playback.stream), + musicPlayerControllerProvider.overrideWith( + (ref) => throw StateError('Unexpected audio controller access'), + ), + ], + child: MaterialApp( + theme: theme, + home: Scaffold( + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: active, + builder: (_, value, _) => SyncedLyricsView( + lyrics: _lyrics, + seekPreview: seek, + colorScheme: theme.colorScheme, + isActive: value, + showPronunciation: false, + showTranslation: false, + ), + ), + ), + ), + ), + ), + ); + ScrollPosition position() => + tester.state(find.byType(Scrollable)).position; + Future reset() async { + stop(); + elapsed.reset(); + active.value = true; + seek.value = const Duration(seconds: 40); + report(false); + await probe.wait(const Duration(milliseconds: 500)); + seek.value = null; + await probe.wait(const Duration(milliseconds: 150)); + } + + await tester.runAsync(() => probe.wait(const Duration(milliseconds: 100))); + await probe.measure('lyrics_word_highlight_and_line_progression', () async { + await reset(); + final before = position().pixels; + start(); + await probe.wait(const Duration(milliseconds: 2300)); + stop(); + expect(elapsed.elapsed, greaterThan(const Duration(seconds: 2))); + expect(position().pixels, greaterThan(before + 20)); + }); + await probe.measure('lyrics_manual_scroll_during_playback', () async { + await reset(); + final before = position().pixels; + start(); + final touch = await tester.startGesture( + tester.getCenter(find.byType(ListView)), + ); + try { + await touch.moveBy(const Offset(0, -24)); + await probe.wait(const Duration(milliseconds: 50)); + await touch.moveBy(const Offset(0, -100)); + await probe.wait(const Duration(milliseconds: 100)); + final held = position().pixels; + expect(held, greaterThan(before + 30)); + await probe.wait(const Duration(milliseconds: 2300)); + expect(elapsed.elapsed, greaterThan(const Duration(seconds: 2))); + expect(position().pixels, closeTo(held, 1)); + } finally { + stop(); + await touch.up(); + } + }); + await tester.runAsync(() async { + stop(); + active.value = false; + await probe.wait(const Duration(milliseconds: 700)); + }); + await probe.measure( + 'lyrics_inactive_paused_idle', + () => probe.wait(const Duration(milliseconds: 800)), + allowIdle: true, + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(() => probe.wait(const Duration(milliseconds: 350))); + await probe.finish( + metadata: { + 'scope': + 'Production SyncedLyricsView; 200 generated five-word lines, ' + '200ms in-memory playback stream; no audio, metadata, database, ' + 'preferences or network. Active samples include a 650ms paused ' + 'seek reset to 40s. Idle is inactive and paused.', + 'lineCount': _lyrics.lines.length, + 'wordsPerLine': 5, + 'manualScrollTolerancePixels': 1, + }, + ); + }); +} diff --git a/integration_test/mornye_performance_test.dart b/integration_test/mornye_performance_test.dart new file mode 100644 index 00000000..a8f6fcd6 --- /dev/null +++ b/integration_test/mornye_performance_test.dart @@ -0,0 +1,13 @@ +import 'mornye_artwork_performance_test.dart' as artwork; +import 'mornye_lyrics_performance_test.dart' as lyrics; +import 'mornye_rendering_performance_test.dart' as rendering; +import 'mornye_screens_performance_test.dart' as screens; +import 'performance_probe.dart'; + +void main() { + PerformanceTestBinding.ensureInitialized(); + rendering.main(); + screens.main(); + artwork.main(); + lyrics.main(); +} diff --git a/integration_test/mornye_rendering_performance_test.dart b/integration_test/mornye_rendering_performance_test.dart new file mode 100644 index 00000000..d4705722 --- /dev/null +++ b/integration_test/mornye_rendering_performance_test.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.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/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/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'; + +import 'performance_probe.dart'; + +void main() { + final binding = PerformanceTestBinding.ensureInitialized(); + for (final clarity in [0.0, 0.1, 0.6, 1.0]) { + testWidgets('Mornye real rendering at clarity $clarity', (tester) async { + final probe = PerformanceProbe( + binding, + tester, + suite: 'mornye_chrome_${(clarity * 100).round()}', + ); + final fixture = (await tester.runAsync(_Fixture.create))!; + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(() => binding.endOfFrame); + await fixture.dispose(); + }); + await tester.pumpWidget(fixture.app(clarity)); + await tester.runAsync(() async { + await LiquidGlassShaders.ensureLoaded(); + await ui.FragmentProgram.fromAsset( + 'assets/shaders/mornye_selection_mask.frag', + ); + await probe.wait(const Duration(milliseconds: 600)); + }); + expect(find.byType(MornyeBottomBar), findsOneWidget); + expect(find.byType(MiniPlayer), findsOneWidget); + final player = tester.state(find.byType(MiniPlayer)); + + for (final compact in [false, true]) { + // Returning to the top expands production chrome, including during a + // programmatic scroll. Keep both scenarios above that boundary. + fixture.scroll.jumpTo(300); + fixture.chrome.value = compact; + await probe.wait(const Duration(milliseconds: 450)); + await probe.measure( + compact ? 'compact_scroll' : 'full_scroll', + () async { + await probe.scroll(fixture.scroll, distance: 1800); + await probe.scroll(fixture.scroll, distance: -1800); + }, + ); + expect(fixture.chrome.value, compact); + expect(fixture.scroll.offset, closeTo(300, 0.1)); + } + + await probe.measure('collapse_expand', () async { + fixture.chrome.value = true; + await probe.wait(const Duration(milliseconds: 450)); + fixture.chrome.expand(); + await probe.wait(const Duration(milliseconds: 450)); + }); + 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)); + } + }); + expect(fixture.activeTab.value, 0); + + if (clarity > 0.2) { + await probe.measure('held_lens_drag', () async { + final tabs = find.byType(MornyeTabBar); + final home = tester.getCenter( + find.descendant(of: tabs, matching: find.text('Home')).first, + ); + final search = tester.getCenter( + find.descendant(of: tabs, matching: find.text('Search')).first, + ); + final gesture = await tester.startGesture(home); + await probe.wait(const Duration(milliseconds: 280)); + final lens = find.byWidgetPredicate( + (widget) => widget is MornyeLiquidBackdrop && widget.interaction, + ); + if (ui.ImageFilter.isShaderFilterSupported) { + expect(tester.widget(lens).progress, 1); + } + for (var step = 1; step <= 60; step++) { + final fraction = (1 - math.cos(step / 60 * math.pi * 2)) / 2; + await gesture.moveTo(Offset.lerp(home, search, fraction)!); + await probe.wait(const Duration(milliseconds: 16)); + } + await gesture.up(); + await probe.wait(const Duration(milliseconds: 300)); + }); + expect(fixture.activeTab.value, 0); + } + + fixture.setLongTitle(true); + await probe.wait(const Duration(milliseconds: 100)); + await probe.measure('mini_player_marquee', () async { + await probe.wait(const Duration(seconds: 4)); + }); + 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); + expect(tester.takeException(), isNull); + await probe.finish( + metadata: { + 'glass_clarity': clarity, + 'glass_level': 'liquid', + 'tracks': 2000, + 'unique_local_covers': fixture.covers.length, + 'scope': + 'production Mornye chrome and MiniPlayer over a fixture grid', + }, + ); + }); + } +} + +class _BenchmarkSettings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(motionArtworkEnabled: false); +} + +class _Fixture { + _Fixture(this.directory, this.covers, this.artUri); + + final Directory directory; + final List covers; + final Uri artUri; + final chrome = MornyeChromeController(); + final scroll = ScrollController(); + final activeTab = ValueNotifier(0); + final media = StreamController.broadcast(sync: true); + + static Future<_Fixture> create() async { + final temp = await getTemporaryDirectory(); + final directory = await temp.createTemp('mornye-benchmark-art-'); + final covers = []; + for (var index = 0; index < 24; index++) { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final hue = index * 137.5 % 360; + canvas.drawRect( + const Rect.fromLTWH(0, 0, 192, 192), + Paint() + ..shader = ui.Gradient.linear(Offset.zero, const Offset(192, 192), [ + HSLColor.fromAHSL(1, hue, 0.75, 0.25).toColor(), + HSLColor.fromAHSL(1, (hue + 80) % 360, 0.85, 0.65).toColor(), + ]), + ); + for (var stripe = 0; stripe < 8; stripe++) { + canvas.drawCircle( + Offset(24.0 * stripe, (index * 13 + stripe * 23) % 192.0), + 10 + stripe * 4.0, + Paint()..color = Colors.white.withValues(alpha: 0.12), + ); + } + final picture = recorder.endRecording(); + final image = await picture.toImage(192, 192); + final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!; + covers.add( + bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes), + ); + image.dispose(); + picture.dispose(); + } + final artwork = File('${directory.path}/cover.png'); + await artwork.writeAsBytes(covers.first); + return _Fixture(directory, covers, artwork.uri); + } + + MediaItem _item(bool longTitle) => MediaItem( + id: longTitle ? 'benchmark-marquee' : 'benchmark-idle', + title: longTitle + ? 'A very long benchmark track title that keeps the production marquee moving' + : 'Track 001', + artist: longTitle + ? 'Benchmark artist with an overflowing display name' + : 'Artist', + album: 'Fixture album', + artUri: artUri, + duration: const Duration(minutes: 4), + ); + + void setLongTitle(bool value) => media.add(_item(value)); + + Widget app(double clarity) => ProviderScope( + overrides: [ + settingsProvider.overrideWith(_BenchmarkSettings.new), + currentMediaItemProvider.overrideWith((ref) async* { + yield _item(false); + yield* media.stream; + }), + playbackStateProvider.overrideWith((ref) => const Stream.empty()), + musicPlayerControllerProvider.overrideWithValue(MusicPlayerController()), + lowEndDeviceProvider.overrideWithValue(false), + backdropBlurEnabledProvider.overrideWithValue(true), + mornyeGlassLevelProvider.overrideWithValue(MornyeGlassLevel.liquid), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark, glassClarity: clarity), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + extendBody: true, + body: NotificationListener( + onNotification: chrome.handleScroll, + child: GridView.builder( + controller: scroll, + padding: const EdgeInsets.fromLTRB(16, 64, 16, 184), + itemCount: 2000, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.80, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + ), + itemBuilder: (context, index) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory( + covers[index % covers.length], + fit: BoxFit.cover, + cacheWidth: 192, + gaplessPlayback: true, + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Track ${index + 1}', + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + 'Artist ${index % 40}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: ListenableBuilder( + listenable: Listenable.merge([chrome, activeTab]), + builder: (context, child) => 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: activeTab.value, + onSelected: (index) { + activeTab.value = index; + chrome.expand(); + }, + onHome: () { + activeTab.value = 0; + chrome.expand(); + }, + onSearch: () => activeTab.value = 3, + blurEnabled: true, + ), + ), + ), + ), + ), + ), + ); + + Future dispose() async { + final imageCache = PaintingBinding.instance.imageCache; + for (final image in [ + for (final cover in covers) + ResizeImage.resizeIfNeeded(192, null, MemoryImage(cover)), + ResizeImage.resizeIfNeeded(132, null, FileImage(File.fromUri(artUri))), + ]) { + final key = await image.obtainKey(ImageConfiguration.empty); + expect(imageCache.statusForKey(key).live, isFalse); + imageCache.evict(key); + expect(imageCache.statusForKey(key).untracked, isTrue); + } + await media.close(); + chrome.dispose(); + scroll.dispose(); + activeTab.dispose(); + await directory.delete(recursive: true); + debugPrint( + 'Mornye fixture cache after cleanup: ${imageCache.currentSizeBytes} bytes', + ); + } +} diff --git a/integration_test/mornye_screens_performance_test.dart b/integration_test/mornye_screens_performance_test.dart new file mode 100644 index 00000000..e7b54d5d --- /dev/null +++ b/integration_test/mornye_screens_performance_test.dart @@ -0,0 +1,466 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/explore_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/library_browse_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/recent_access_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:spotiflac_android/providers/user_profile_provider.dart'; +import 'package:spotiflac_android/screens/home_tab.dart'; +import 'package:spotiflac_android/screens/queue_tab.dart'; +import 'package:spotiflac_android/services/album_completeness.dart'; +import 'package:spotiflac_android/services/user_profile_store.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; +import 'package:spotiflac_android/widgets/lazy_tab_view.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +import 'performance_probe.dart'; + +final _tracks = List.generate( + 2000, + (index) => Track( + id: 'fixture-track-$index', + name: 'Fixture song $index', + artistName: 'Artist ${index ~/ 100}', + albumName: 'Album ${(index ~/ 10).toString().padLeft(3, '0')}', + duration: 240, + source: 'example', + ), +); + +final _albums = List.generate( + 200, + (index) => LibraryBrowseEntry( + source: 'downloaded', + key: 'fixture-album-$index', + name: _tracks[index * 10].albumName, + artist: _tracks[index * 10].artistName, + samplePath: '', + trackCount: 10, + completeness: AlbumCompleteness( + status: ['complete', 'incomplete', 'unknown'][index % 3], + present: 10, + expected: index % 3 == 2 + ? null + : index % 3 == 1 + ? 12 + : 10, + ), + ), +); + +void main() { + final binding = PerformanceTestBinding.ensureInitialized(); + + testWidgets('Mornye production screens with deterministic 2000-track data', ( + tester, + ) async { + final probe = PerformanceProbe(binding, tester, suite: 'mornye_screens'); + final preferences = (await tester.runAsync(SharedPreferences.getInstance))!; + final settingsBefore = preferences.getString('app_settings'); + const databaseChannel = MethodChannel('com.tekartik.sqflite'); + final databaseCalls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(databaseChannel, (call) async { + databaseCalls.add(call.method); + fail('Unexpected database call: ${call.method}'); + }); + addTearDown( + () => messenger.setMockMethodCallHandler(databaseChannel, null), + ); + final index = ValueNotifier(0); + addTearDown(index.dispose); + final requests = []; + final search = _Search(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + libraryCollectionsProvider.overrideWith(_Collections.new), + extensionProvider.overrideWith(_Extensions.new), + exploreProvider.overrideWith(_Explore.new), + downloadHistoryProvider.overrideWith(_History.new), + downloadHistoryBatchExistsProvider.overrideWith( + (ref, request) async => {}, + ), + downloadQueueProvider.overrideWith(_Queue.new), + recentAccessProvider.overrideWith(_Recent.new), + trackProvider.overrideWith(() => search), + userProfileProvider.overrideWith(_Profile.new), + currentMediaItemProvider.overrideWith((ref) => Stream.value(null)), + libraryBrowseProvider.overrideWith((ref, request) async { + requests.add(request); + final rows = _albums.where((album) { + return '${album.name} ${album.artist}'.toLowerCase().contains( + request.search.toLowerCase(), + ) && + (request.completeness == null || + album.completeness!.status == + (request.completeness == incompleteAlbumFilter + ? 'incomplete' + : 'unknown')); + }).toList(); + if (request.sort == 'latest') { + return rows.reversed.take(request.limit).toList(); + } + return rows.take(request.limit).toList(); + }), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: _Tabs(index: index), + ), + ), + ); + await tester.runAsync(() => probe.wait(const Duration(seconds: 1))); + + Future select(int value) async { + FocusManager.instance.primaryFocus?.unfocus(); + index.value = value; + await probe.wait(const Duration(milliseconds: 350)); + } + + Future scrollRoundTrip() async { + final scrollable = find.byType(Scrollable).hitTestable().first; + final position = tester.state(scrollable).position; + position.jumpTo(0); + await probe.wait(const Duration(milliseconds: 100)); + await position.animateTo( + position.maxScrollExtent.clamp(0.0, 2400.0), + duration: const Duration(milliseconds: 900), + curve: Curves.linear, + ); + await position.animateTo( + 0, + duration: const Duration(milliseconds: 900), + curve: Curves.linear, + ); + await probe.wait(const Duration(milliseconds: 200)); + } + + await tester.runAsync(() async { + await select(1); + expect(find.byType(QueueTab), findsOneWidget); + await tester.tap(find.text('Albums').hitTestable().first); + await probe.wait(const Duration(milliseconds: 500)); + final position = tester + .state(find.byType(Scrollable).hitTestable().first) + .position; + for (var page = 0; page < 4; page++) { + position.jumpTo(position.maxScrollExtent); + await probe.wait(const Duration(milliseconds: 250)); + } + position.jumpTo(0); + expect(requests.last.limit, 200); + }); + await probe.measure( + 'library_album_grid_scroll_2000_tracks', + scrollRoundTrip, + warmup: scrollRoundTrip, + ); + + Future filterRoundTrip() async { + for (final label in [ + 'Incomplete albums', + 'Album completeness unknown', + 'All', + ]) { + await tester.tap(find.byTooltip('Filters').hitTestable()); + await probe.wait(const Duration(milliseconds: 350)); + await tester.tap(find.text(label).hitTestable().last); + await probe.wait(const Duration(milliseconds: 350)); + } + expect(requests.last.completeness, isNull); + } + + await probe.measure( + 'library_album_completeness_filters', + filterRoundTrip, + warmup: filterRoundTrip, + ); + await tester.runAsync(() async { + await tester.tap(find.byTooltip('Search your library').hitTestable()); + await probe.wait(const Duration(milliseconds: 400)); + FocusManager.instance.primaryFocus?.unfocus(); + }); + Future librarySearchRoundTrip() async { + final field = tester.widget( + find.byType(AppSearchField).hitTestable(), + ); + field.controller.text = 'Album 01'; + field.onChanged?.call('Album 01'); + await probe.wait(const Duration(milliseconds: 500)); + expect(requests.last.search, 'Album 01'); + await tester.tap(find.byTooltip('Clear').hitTestable()); + await probe.wait(const Duration(milliseconds: 500)); + expect(requests.last.search, isEmpty); + } + + await probe.measure( + 'library_album_search_clear', + librarySearchRoundTrip, + warmup: librarySearchRoundTrip, + ); + await tester.runAsync(() async { + Navigator.of( + tester.element(find.byType(Scrollable).hitTestable().first), + ).pop(); + await probe.wait(const Duration(milliseconds: 500)); + await select(3); + }); + await probe.measure( + 'queue_downloads_scroll_2000_tracks', + scrollRoundTrip, + warmup: scrollRoundTrip, + ); + + await tester.runAsync(() => select(2)); + Future searchRoundTrip() async { + final field = tester.widget( + find.byType(AppSearchField).hitTestable(), + ); + for (final query in ['Fixture', 'Fixture song', 'Fixture song 1']) { + field.controller.text = query; + field.onChanged?.call(query); + await probe.wait(const Duration(milliseconds: 100)); + } + await probe.wait(const Duration(milliseconds: 900)); + expect(search.resultCount, greaterThan(0)); + await tester.tap(find.byTooltip('Clear').hitTestable()); + await probe.wait(const Duration(milliseconds: 400)); + expect(field.controller.text, isEmpty); + } + + await probe.measure( + 'home_search_input_results_clear', + searchRoundTrip, + warmup: searchRoundTrip, + ); + Future tabRoundTrip() async { + for (final value in [0, 1, 2, 3, 0]) { + await tester.tap( + find.descendant( + of: find.byType(MornyeTabBar), + matching: find.text( + ['Home', 'Library', 'Search', 'Downloads'][value], + ), + ), + ); + await probe.wait(const Duration(milliseconds: 350)); + expect(index.value, value); + } + } + + await probe.measure( + 'lazy_tab_view_retained_production_tabs', + tabRoundTrip, + warmup: tabRoundTrip, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(() async { + await probe.wait(const Duration(milliseconds: 350)); + await preferences.reload(); + }); + expect(preferences.getString('app_settings'), settingsBefore); + expect(databaseCalls, isEmpty); + await probe.finish( + metadata: { + 'scope': + 'Production QueueTab, MornyeLibraryScreen, HomeTab, LazyTabView and MornyeTabBar; synthetic provider data, no artwork/network/database/IME.', + 'trackCount': _tracks.length, + 'albumCount': _albums.length, + 'queueCount': 2000, + 'searchResultLimit': 80, + 'browseRequests': requests.length, + 'maximumBrowseLimit': requests + .map((request) => request.limit) + .reduce((a, b) => a > b ? a : b), + 'searchRequests': search.requests, + 'settingsUnchanged': true, + 'databaseCalls': databaseCalls.length, + }, + ); + }); +} + +class _Tabs extends StatelessWidget { + const _Tabs({required this.index}); + final ValueNotifier index; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: index, + builder: (context, value, _) => Scaffold( + body: LazyTabView( + index: value, + preloadKeys: {const ValueKey('search')}, + children: const [ + HomeTab(key: ValueKey('home'), mode: HomeTabMode.browse), + QueueTab(key: ValueKey('library')), + HomeTab(key: ValueKey('search'), mode: HomeTabMode.search), + QueueTab(key: ValueKey('downloads'), librarySection: 'downloads'), + ], + ), + bottomNavigationBar: 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'), + NavigationDestination(icon: Icon(Icons.download), label: 'Downloads'), + ], + selectedIndex: value, + blurEnabled: true, + onSelected: (value) { + FocusManager.instance.primaryFocus?.unfocus(); + index.value = value; + }, + ), + ), + ); +} + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings( + searchProvider: 'example', + hasSearchedBefore: true, + defaultLibraryView: 'all', + ); + + @override + void setHistoryFilterMode(String mode) { + state = state.copyWith(historyFilterMode: mode); + } + + @override + void setHasSearchedBefore() { + if (!state.hasSearchedBefore) { + state = state.copyWith(hasSearchedBefore: true); + } + } + + @override + void setSearchProvider(String? provider) { + state = state.copyWith( + searchProvider: provider, + clearSearchProvider: provider == null || provider.isEmpty, + ); + } +} + +class _Collections extends LibraryCollectionsNotifier { + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); +} + +class _Extensions extends ExtensionNotifier { + @override + ExtensionState build() => const ExtensionState( + isInitialized: true, + extensions: [ + Extension( + id: 'example', + name: 'example', + displayName: 'Example', + version: '1.0.0', + description: '', + enabled: true, + status: 'loaded', + hasMetadataProvider: true, + searchBehavior: SearchBehavior(enabled: true), + ), + ], + ); +} + +class _Explore extends ExploreNotifier { + @override + ExploreState build() => const ExploreState( + sections: [ + ExploreSection( + uri: 'example:featured', + title: 'Featured albums', + items: [], + ), + ], + ); +} + +class _History extends DownloadHistoryNotifier { + @override + DownloadHistoryState build() => DownloadHistoryState(); + + @override + Future reloadFromStorage() async {} +} + +class _Queue extends DownloadQueueNotifier { + @override + DownloadQueueState build() => const DownloadQueueState().copyWith( + items: [ + for (final track in _tracks) + DownloadItem( + id: track.id, + track: track, + service: 'example', + createdAt: DateTime(2026), + ), + ], + ); +} + +class _Recent extends RecentAccessNotifier { + @override + RecentAccessState build() => const RecentAccessState(isLoaded: true); +} + +class _Profile extends UserProfileNotifier { + @override + Future build() async => const UserProfile(); +} + +class _Search extends TrackNotifier { + int requests = 0; + int get resultCount => state.tracks.length; + + @override + Future customSearch( + String extensionId, + String query, { + Map? options, + String? selectedFilter, + bool allowVerificationRetry = true, + }) async { + requests++; + state = TrackState( + hasSearchText: true, + searchExtensionId: 'example', + tracks: _tracks + .where( + (track) => track.name.toLowerCase().contains(query.toLowerCase()), + ) + .take(80) + .toList(), + ); + } +} diff --git a/integration_test/performance_driver.dart b/integration_test/performance_driver.dart new file mode 100644 index 00000000..bdb74e5c --- /dev/null +++ b/integration_test/performance_driver.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; +import 'dart:io'; + +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. +Future main() => integrationDriver( + responseDataCallback: (data) async { + final output = File( + Platform.environment['SPOTIFLAC_PERFORMANCE_REPORT'] ?? + '/tmp/spotiflac-performance.json', + ); + await output.writeAsString( + const JsonEncoder.withIndent(' ').convert(data), + ); + stdout.writeln('Performance report: ${output.path}'); + }, +); diff --git a/integration_test/performance_probe.dart b/integration_test/performance_probe.dart new file mode 100644 index 00000000..c4f01a94 --- /dev/null +++ b/integration_test/performance_probe.dart @@ -0,0 +1,230 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Live test bindings otherwise request another engine frame after every draw, +/// even when the app is idle. Service only frames requested by the framework. +class PerformanceTestBinding extends IntegrationTestWidgetsFlutterBinding { + static PerformanceTestBinding? _benchmarkInstance; + + static PerformanceTestBinding ensureInitialized() => + _benchmarkInstance ??= PerformanceTestBinding(); + + bool _requestedFrame = false; + + @override + void handleBeginFrame(Duration? rawTimeStamp) { + _requestedFrame = hasScheduledFrame || rawTimeStamp == null; + if (_requestedFrame) super.handleBeginFrame(rawTimeStamp); + } + + @override + void handleDrawFrame() { + if (_requestedFrame) super.handleDrawFrame(); + _requestedFrame = false; + } +} + +/// Device-only measurements: the engine drives frames, never a pumped clock. +class PerformanceProbe { + PerformanceProbe(this.binding, this.tester, {required this.suite}) { + final previousPolicy = binding.framePolicy; + binding.framePolicy = + LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive; + addTearDown(() => binding.framePolicy = previousPolicy); + } + + final IntegrationTestWidgetsFlutterBinding binding; + final WidgetTester tester; + final String suite; + final List> _scenarios = []; + static const _budgetUs = 1000000 / 60; + + /// Also safe outside [measure]: integration bindings use real timers. + Future wait(Duration duration) => Future.delayed(duration); + + Future scroll( + ScrollController controller, { + required double distance, + Duration duration = const Duration(milliseconds: 900), + }) async { + final position = controller.position; + await controller.animateTo( + (position.pixels + distance).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ), + duration: duration, + curve: Curves.linear, + ); + await wait(const Duration(milliseconds: 150)); + } + + Future measure( + String name, + Future Function() action, { + Future Function()? warmup, + int repetitions = 3, + bool allowIdle = false, + }) async { + assert(repetitions > 0); + final runs = >[]; + final allFrames = []; + await tester.runAsync(() async { + await (warmup ?? action)(); + await wait(const Duration(milliseconds: 300)); + for (var repetition = 0; repetition < repetitions; repetition++) { + final frames = []; + void collect(List batch) => frames.addAll(batch); + binding.addTimingsCallback(collect); + final startUs = binding.currentSystemFrameTimeStamp.inMicroseconds; + final rssBefore = ProcessInfo.currentRss; + final elapsed = Stopwatch()..start(); + late int endUs; + late int rssAfter; + try { + await action(); + elapsed.stop(); + endUs = binding.currentSystemFrameTimeStamp.inMicroseconds; + rssAfter = ProcessInfo.currentRss; + // Engine timing callbacks can arrive in batches once per second. + // Exclude the drain's frames using the recorded vsync boundaries. + await wait(const Duration(milliseconds: 1100)); + } finally { + binding.removeTimingsCallback(collect); + } + frames.removeWhere((frame) { + final vsync = frame.timestampInMicroseconds(ui.FramePhase.vsyncStart); + return vsync <= startUs || vsync > endUs; + }); + if (!allowIdle && frames.isEmpty) { + fail('$name produced no real engine FrameTiming samples'); + } + allFrames.addAll(frames); + runs.add({ + 'repetition': repetition + 1, + 'elapsed_ms': elapsed.elapsedMicroseconds / 1000, + 'rss_before_bytes': rssBefore, + 'rss_after_bytes': rssAfter, + 'rss_delta_bytes': rssAfter - rssBefore, + ..._summarize(frames), + 'samples_us': [ + for (final frame in frames) + [ + frame.buildDuration.inMicroseconds, + frame.rasterDuration.inMicroseconds, + frame.totalSpan.inMicroseconds, + ], + ], + }); + } + }); + expect(tester.takeException(), isNull, reason: name); + expect(runs, hasLength(repetitions), reason: name); + final scenario = { + 'name': name, + 'runs': runs, + 'summary': _summarize(allFrames), + }; + _scenarios.add(scenario); + debugPrint( + 'PERFORMANCE_SCENARIO: ${jsonEncode({'name': name, ..._summarize(allFrames), 'rss_delta_bytes': runs.map((run) => run['rss_delta_bytes']).toList()})}', + ); + } + + Map _summarize(List frames) { + final build = frames.map((frame) => frame.buildDuration.inMicroseconds); + final raster = frames.map((frame) => frame.rasterDuration.inMicroseconds); + final total = frames.map((frame) => frame.totalSpan.inMicroseconds); + final missed = frames + .where( + (frame) => + math.max( + frame.buildDuration.inMicroseconds, + frame.rasterDuration.inMicroseconds, + ) > + _budgetUs, + ) + .length; + return { + 'frames': frames.length, + 'build_ms': _percentiles(build), + 'raster_ms': _percentiles(raster), + 'total_span_ms': _percentiles(total), + 'over_16_67ms_frames': missed, + 'over_16_67ms_percent': frames.isEmpty ? 0 : missed * 100 / frames.length, + 'build_over_16_67ms_frames': build + .where((time) => time > _budgetUs) + .length, + 'raster_over_16_67ms_frames': raster + .where((time) => time > _budgetUs) + .length, + 'total_over_16_67ms_frames': total + .where((time) => time > _budgetUs) + .length, + }; + } + + Map _percentiles(Iterable values) { + final sorted = values.toList()..sort(); + double? at(double fraction) => sorted.isEmpty + ? null + : sorted[math.max(0, (sorted.length * fraction).ceil() - 1)] / 1000; + return { + 'p50': at(0.50), + 'p90': at(0.90), + 'p95': at(0.95), + 'p99': at(0.99), + 'max': at(1), + }; + } + + Future finish({Map metadata = const {}}) async { + final report = { + 'suite': suite, + 'utc': DateTime.now().toUtc().toIso8601String(), + 'platform': Platform.operatingSystem, + 'mode': kProfileMode + ? 'profile' + : kDebugMode + ? 'debug' + : 'release', + 'frame_policy': binding.framePolicy.name, + 'framework_requested_frames_only': binding is PerformanceTestBinding, + 'budget_hz': 60, + 'budget_ms': _budgetUs / 1000, + 'budget_basis': 'fixed 60 Hz reference, independent of display refresh', + 'over_budget_definition': + 'max(buildDuration, rasterDuration) > fixed budget', + 'timing_scope': 'engine FrameTiming phases, not displayed FPS', + 'sample_columns': ['build', 'raster', 'total_span'], + 'refresh_hz': tester.view.display.refreshRate, + 'physical_width': tester.view.physicalSize.width, + 'physical_height': tester.view.physicalSize.height, + 'device_pixel_ratio': tester.view.devicePixelRatio, + 'shader_filters_supported': ui.ImageFilter.isShaderFilterSupported, + 'image_cache_bytes': PaintingBinding.instance.imageCache.currentSizeBytes, + 'rss_bytes': ProcessInfo.currentRss, + 'max_rss_bytes': ProcessInfo.maxRss, + ...metadata, + 'scenarios': _scenarios, + }; + final directory = await getTemporaryDirectory(); + final file = File('${directory.path}/$suite-performance.json'); + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(report), + ); + binding.reportData ??= {}; + binding.reportData![suite] = report; + binding.reportData!['${suite}_report_path'] = file.path; + debugPrint('PERFORMANCE_REPORT: ${file.path}'); + return file; + } +}