perf(collections): isolate action and track row rebuilds

This commit is contained in:
zarzet committed 2026-10-07 03:21:51 +07:00
1 parent 996efb4f54
commit 4d7b9d97bc
4 files changed
+254 -32

No files matched your search

+20 -16
View File
@@ -874,23 +874,27 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
}
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final tracks = _tracks;
final allLoved =
tracks != null &&
tracks.isNotEmpty &&
tracks.every((t) => collectionsState.isLoved(t));
return HeaderCircleButton(
icon: allLoved ? Icons.favorite : Icons.favorite_border,
tonal: true,
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
onPressed: tracks == null || tracks.isEmpty
? null
: () => _loveAll(tracks),
return Consumer(
builder: (context, ref, _) {
ref.watch(libraryCollectionsProvider.select((state) => state.loved));
final collectionsState = ref.read(libraryCollectionsProvider);
final allLoved =
tracks != null &&
tracks.isNotEmpty &&
tracks.every((t) => collectionsState.isLoved(t));
return HeaderCircleButton(
icon: allLoved ? Icons.favorite : Icons.favorite_border,
tonal: true,
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
onPressed: tracks == null || tracks.isEmpty
? null
: () => _loveAll(tracks),
);
},
);
}
+17 -12
View File
@@ -513,18 +513,23 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
}
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final allLoved =
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
return HeaderCircleButton(
icon: allLoved ? Icons.favorite : Icons.favorite_border,
tonal: true,
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
return Consumer(
builder: (context, ref, _) {
ref.watch(libraryCollectionsProvider.select((state) => state.loved));
final collectionsState = ref.read(libraryCollectionsProvider);
final allLoved =
_tracks.isNotEmpty &&
_tracks.every((t) => collectionsState.isLoved(t));
return HeaderCircleButton(
icon: allLoved ? Icons.favorite : Icons.favorite_border,
tonal: true,
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
);
},
);
}
+2 -4
View File
@@ -47,9 +47,9 @@ class TrackListTile 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),
),
);
@@ -70,8 +70,6 @@ class TrackListTile extends ConsumerWidget {
)
: false;
final isQueued = queueItem != null;
return TrackCard(
style: TrackCardStyle.flat,
isSelectionMode: isSelectionMode,
+215
View File
@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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/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/screens/album_screen.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/track_list_tile.dart';
const _track = Track(
id: 'example-track',
name: 'Example song',
artistName: 'Example artist',
albumName: 'Example album',
duration: 180,
);
class _Settings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings();
}
class _Collections extends LibraryCollectionsNotifier {
@override
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
CollectionTrackEntry get entry => CollectionTrackEntry(
key: trackCollectionKey(_track),
track: _track,
addedAt: DateTime.utc(2026),
);
void publishWishlist() => state = state.copyWith(wishlist: [entry]);
void publishLoved() => state = state.copyWith(loved: [entry]);
}
class _Recent extends RecentAccessNotifier {
@override
RecentAccessState build() => const RecentAccessState(isLoaded: true);
@override
void recordAlbumAccess({
required String id,
required String name,
String? artistName,
String? imageUrl,
String? providerId,
}) {}
}
Widget _app(ProviderContainer container, Widget child, {bool mornye = false}) =>
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
),
);
void main() {
testWidgets(
'queue progress leaves track rows untouched but membership updates',
(tester) async {
var lookup = const DownloadQueueLookup.empty();
final snapshot = Provider<DownloadQueueLookup>((ref) => lookup);
final container = ProviderContainer(
overrides: [
settingsProvider.overrideWith(_Settings.new),
libraryCollectionsProvider.overrideWith(_Collections.new),
currentMediaItemProvider.overrideWith((ref) => Stream.value(null)),
downloadQueueLookupProvider.overrideWith(
(ref) => ref.watch(snapshot),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
_app(
container,
TrackListTile(
track: _track,
isInHistory: false,
onDownload: ({bool forceQualityPicker = false}) {},
leading: const Text('1'),
),
),
);
await tester.pumpAndSettle();
var builds = 0;
final previous = debugOnRebuildDirtyWidget;
debugOnRebuildDirtyWidget = (element, builtOnce) {
previous?.call(element, builtOnce);
if (element.widget is TrackListTile) builds++;
};
addTearDown(() => debugOnRebuildDirtyWidget = previous);
var item = DownloadItem(
id: 'example-download',
track: _track,
service: 'example-provider',
createdAt: DateTime.utc(2026),
);
Future<void> publish() async {
lookup = DownloadQueueLookup.fromItems([item]);
container.invalidate(snapshot);
await tester.pump();
}
await publish();
expect(builds, 1);
builds = 0;
for (var tick = 1; tick <= 30; tick++) {
item = item.copyWith(
progress: tick / 30,
bytesReceived: tick * 1024,
status: DownloadStatus.downloading,
);
await publish();
}
expect(builds, 0);
lookup = const DownloadQueueLookup.empty();
container.invalidate(snapshot);
await tester.pump();
expect(builds, 1);
expect(find.text('Example song'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
},
);
for (final mornye in [false, true]) {
for (final album in [false, true]) {
testWidgets('Love All isolates the $album page in Mornye=$mornye', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final collections = _Collections();
final container = ProviderContainer(
overrides: [
settingsProvider.overrideWith(_Settings.new),
libraryCollectionsProvider.overrideWith(() => collections),
recentAccessProvider.overrideWith(_Recent.new),
currentMediaItemProvider.overrideWith((ref) => Stream.value(null)),
downloadQueueLookupProvider.overrideWithValue(
const DownloadQueueLookup.empty(),
),
downloadHistoryVisibleBatchExistsProvider.overrideWith(
(ref, request) => const <String>{},
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
_app(
container,
album
? const AlbumScreen(
albumId: 'example-album',
albumName: 'Example album',
tracks: [_track],
)
: const PlaylistScreen(
playlistName: 'Example playlist',
tracks: [_track],
),
mornye: mornye,
),
);
await tester.pumpAndSettle();
var pageBuilds = 0;
var actionBuilds = 0;
final previous = debugOnRebuildDirtyWidget;
debugOnRebuildDirtyWidget = (element, builtOnce) {
previous?.call(element, builtOnce);
if (element.widget is AlbumScreen ||
element.widget is PlaylistScreen) {
pageBuilds++;
}
if (element.widget is HeaderCircleButton) actionBuilds++;
};
addTearDown(() => debugOnRebuildDirtyWidget = previous);
collections.publishWishlist();
await tester.pumpAndSettle();
expect(pageBuilds, 0);
expect(actionBuilds, 0);
collections.publishLoved();
await tester.pumpAndSettle();
expect(pageBuilds, 0);
expect(actionBuilds, greaterThan(0));
final context = tester.element(find.byType(HeaderCircleButton).first);
expect(
find.byTooltip(context.l10n.trackOptionRemoveFromLoved),
findsWidgets,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
}
}