mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-28 20:32:15 +02:00
perf(search): reduce list rendering and provider rebuilds
This commit is contained in:
+26
-22
@@ -765,22 +765,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
);
|
||||
|
||||
final hasExploreContent = ref.watch(
|
||||
exploreProvider.select((s) => s.sections.isNotEmpty),
|
||||
);
|
||||
final exploreLoading = ref.watch(
|
||||
exploreProvider.select((s) => s.isLoading),
|
||||
);
|
||||
final hasHomeFeedExtension = ref.watch(
|
||||
extensionProvider.select(
|
||||
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
|
||||
),
|
||||
);
|
||||
final homeFeedDisabled = ref.watch(
|
||||
settingsProvider.select(
|
||||
(s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff,
|
||||
),
|
||||
);
|
||||
final hasExploreContent =
|
||||
_showsHome &&
|
||||
ref.watch(exploreProvider.select((s) => s.sections.isNotEmpty));
|
||||
final exploreLoading =
|
||||
_showsHome && ref.watch(exploreProvider.select((s) => s.isLoading));
|
||||
final hasHomeFeedExtension =
|
||||
_showsHome &&
|
||||
ref.watch(
|
||||
extensionProvider.select(
|
||||
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
|
||||
),
|
||||
);
|
||||
final homeFeedDisabled =
|
||||
_showsHome &&
|
||||
ref.watch(
|
||||
settingsProvider.select(
|
||||
(s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff,
|
||||
),
|
||||
);
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final searchText = _showsSearch ? _urlController.text.trim() : '';
|
||||
@@ -804,9 +807,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_showsSearch &&
|
||||
ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
||||
final screenHeight = MediaQuery.sizeOf(context).height;
|
||||
final hasHistoryItems = ref.watch(
|
||||
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
|
||||
);
|
||||
final hasHistoryItems =
|
||||
_showsHome &&
|
||||
ref.watch(
|
||||
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
|
||||
);
|
||||
|
||||
final recentModeRequested =
|
||||
widget.mode == HomeTabMode.search ||
|
||||
@@ -982,9 +987,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final recentAccessView = ref.watch(
|
||||
recentAccessViewProvider,
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: _buildRecentAccess(recentAccessView, colorScheme),
|
||||
);
|
||||
return _buildRecentAccess(recentAccessView, colorScheme);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1113,6 +1116,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: _buildSearchResults(
|
||||
resultsRef: ref,
|
||||
tracks: tracks,
|
||||
isLoading: isLoading,
|
||||
error: error,
|
||||
|
||||
@@ -2,12 +2,43 @@ part of 'home_tab.dart';
|
||||
|
||||
extension _HomeTabRecentUI on _HomeTabState {
|
||||
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildRecentAccessHeader(view, colorScheme)),
|
||||
if (view.uniqueItems.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding:
|
||||
const EdgeInsets.fromLTRB(16, 0, 16, 8) +
|
||||
EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: view.uniqueItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = view.uniqueItems[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('${item.type.name}:${item.id}'),
|
||||
child: _buildRecentAccessItem(
|
||||
item,
|
||||
colorScheme,
|
||||
view.downloadFilePathByRecentKey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecentAccessHeader(
|
||||
_RecentAccessView view,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final uniqueItems = view.uniqueItems;
|
||||
final hasHiddenDownloads = view.hasHiddenDownloads;
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.fromLTRB(16, 8, 16, 8) +
|
||||
EdgeInsets.fromLTRB(16, 8, 16, uniqueItems.isEmpty ? 8 : 0) +
|
||||
EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -99,14 +130,6 @@ extension _HomeTabRecentUI on _HomeTabState {
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...uniqueItems.map(
|
||||
(item) => _buildRecentAccessItem(
|
||||
item,
|
||||
colorScheme,
|
||||
view.downloadFilePathByRecentKey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -299,6 +299,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
|
||||
}
|
||||
|
||||
List<Widget> _buildSearchResults({
|
||||
required WidgetRef resultsRef,
|
||||
required List<Track> tracks,
|
||||
required bool isLoading,
|
||||
required String? error,
|
||||
@@ -418,7 +419,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
|
||||
if (sortedTracks.isNotEmpty) {
|
||||
_historySnapshot.update(sortedTracks);
|
||||
final historyLookups = _historySnapshot.lookups;
|
||||
final existingHistoryKeys = ref.watch(
|
||||
final existingHistoryKeys = resultsRef.watch(
|
||||
downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request),
|
||||
);
|
||||
slivers.addAll(
|
||||
@@ -540,6 +541,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: MornyeGlassPanel.overlay(
|
||||
blurEnabled: false,
|
||||
radius: 24,
|
||||
firstInGroup: isFirst,
|
||||
lastInGroup: isLast,
|
||||
|
||||
@@ -219,9 +219,9 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final queueItem = ref.watch(
|
||||
final isQueued = ref.watch(
|
||||
downloadQueueLookupProvider.select(
|
||||
(lookup) => lookup.byTrackId[track.id],
|
||||
(lookup) => lookup.byTrackId.containsKey(track.id),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -249,7 +249,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
thumbHeight = thumbSize.$2;
|
||||
}
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final hasCover = track.coverUrl?.isNotEmpty == true;
|
||||
|
||||
return Column(
|
||||
|
||||
@@ -131,6 +131,7 @@ class MornyeGlassPanel extends ConsumerWidget {
|
||||
this.strongTint = false,
|
||||
this.tintOpacity,
|
||||
this.liquidGlass = false,
|
||||
this.blurEnabled = true,
|
||||
});
|
||||
|
||||
/// Shared glass material for confirmation dialogs and floating sheets.
|
||||
@@ -142,6 +143,7 @@ class MornyeGlassPanel extends ConsumerWidget {
|
||||
this.lastInGroup = true,
|
||||
this.tintOpacity = 0.78,
|
||||
this.liquidGlass = false,
|
||||
this.blurEnabled = true,
|
||||
}) : strongTint = false;
|
||||
|
||||
final Widget child;
|
||||
@@ -152,9 +154,13 @@ class MornyeGlassPanel extends ConsumerWidget {
|
||||
final double? tintOpacity;
|
||||
final bool liquidGlass;
|
||||
|
||||
/// Disable backdrop sampling for surfaces that scroll over a plain page.
|
||||
final bool blurEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final blur =
|
||||
blurEnabled &&
|
||||
!MediaQuery.highContrastOf(context) &&
|
||||
(!ref.watch(lowEndDeviceProvider) ||
|
||||
ref.watch(backdropBlurEnabledProvider));
|
||||
|
||||
+154
-15
@@ -4,14 +4,17 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.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/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/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_search_field.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
@@ -124,6 +127,128 @@ void main() {
|
||||
expect(find.text('Found artist'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final (brightness, itemType) in [
|
||||
for (final brightness in Brightness.values)
|
||||
for (final itemType in ['artist', 'track']) (brightness, itemType),
|
||||
]) {
|
||||
testWidgets(
|
||||
'Search keeps field glass and scrolls $itemType results without blur in $brightness',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(430, 650);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final search = _Search(resultCount: 120, itemType: itemType);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
extensionProvider.overrideWith(_Extensions.new),
|
||||
exploreProvider.overrideWith(_Explore.new),
|
||||
downloadHistoryProvider.overrideWith(_History.new),
|
||||
downloadHistoryBatchExistsProvider.overrideWith(
|
||||
(ref, request) async => <String>{},
|
||||
),
|
||||
downloadQueueLookupProvider.overrideWith(
|
||||
(ref) => DownloadQueueState().lookup,
|
||||
),
|
||||
currentMediaItemProvider.overrideWith(
|
||||
(ref) => Stream.value(null),
|
||||
),
|
||||
recentAccessProvider.overrideWith(_Recent.new),
|
||||
trackProvider.overrideWith(() => search),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(brightness),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const HomeTab(mode: HomeTabMode.search),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppSearchField),
|
||||
matching: find.byType(BackdropFilter),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(find.byType(TextField), 'Example');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Found $itemType 0'), findsOneWidget);
|
||||
expect(find.text('Found $itemType 119'), findsNothing);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.ancestor(
|
||||
of: find.text('Found $itemType 0'),
|
||||
matching: find.byType(MornyeGlassPanel),
|
||||
),
|
||||
matching: find.byType(BackdropFilter),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Found $itemType 119'),
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Found $itemType 119').hitTestable(), findsOneWidget);
|
||||
expect(search._requests, 1);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.ancestor(
|
||||
of: find.text('Found $itemType 119'),
|
||||
matching: find.byType(MornyeGlassPanel),
|
||||
),
|
||||
matching: find.byType(BackdropFilter),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('Search builds recent items as they enter the viewport', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(430, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
extensionProvider.overrideWith(_Extensions.new),
|
||||
exploreProvider.overrideWith(_Explore.new),
|
||||
downloadHistoryProvider.overrideWith(_History.new),
|
||||
recentAccessProvider.overrideWith(() => _Recent(itemCount: 10)),
|
||||
trackProvider.overrideWith(_Search.new),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const HomeTab(mode: HomeTabMode.search),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recent artist 0'), findsOneWidget);
|
||||
expect(find.text('Recent artist 9'), findsNothing);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Recent artist 9'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recent artist 9').hitTestable(), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
class _Tabs extends StatefulWidget {
|
||||
@@ -211,25 +336,38 @@ class _History extends DownloadHistoryNotifier {
|
||||
}
|
||||
|
||||
class _Recent extends RecentAccessNotifier {
|
||||
_Recent({this.itemCount = 1});
|
||||
|
||||
final int itemCount;
|
||||
|
||||
@override
|
||||
RecentAccessState build() => RecentAccessState(
|
||||
isLoaded: true,
|
||||
items: [
|
||||
RecentAccessItem(
|
||||
id: 'recent',
|
||||
name: 'Recently visited artist',
|
||||
items: List.generate(
|
||||
itemCount,
|
||||
(index) => RecentAccessItem(
|
||||
id: 'recent-$index',
|
||||
name: itemCount == 1
|
||||
? 'Recently visited artist'
|
||||
: 'Recent artist $index',
|
||||
type: RecentAccessType.artist,
|
||||
accessedAt: DateTime(2026),
|
||||
accessedAt: DateTime(2026, 1, itemCount - index),
|
||||
providerId: 'example',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Search extends TrackNotifier {
|
||||
_Search({bool failFirst = false}) : _failFirst = failFirst;
|
||||
_Search({
|
||||
bool failFirst = false,
|
||||
this.resultCount = 1,
|
||||
this.itemType = 'artist',
|
||||
}) : _failFirst = failFirst;
|
||||
|
||||
final bool _failFirst;
|
||||
final int resultCount;
|
||||
final String itemType;
|
||||
int _requests = 0;
|
||||
|
||||
@override
|
||||
@@ -248,20 +386,21 @@ class _Search extends TrackNotifier {
|
||||
);
|
||||
return;
|
||||
}
|
||||
state = const TrackState(
|
||||
state = TrackState(
|
||||
hasSearchText: true,
|
||||
searchExtensionId: 'example',
|
||||
tracks: [
|
||||
Track(
|
||||
id: 'artist',
|
||||
name: 'Found artist',
|
||||
tracks: List.generate(
|
||||
resultCount,
|
||||
(index) => Track(
|
||||
id: '$itemType-$index',
|
||||
name: resultCount == 1 ? 'Found $itemType' : 'Found $itemType $index',
|
||||
artistName: '',
|
||||
albumName: '',
|
||||
duration: 0,
|
||||
itemType: 'artist',
|
||||
itemType: itemType,
|
||||
source: 'example',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user