diff --git a/lib/screens/library_tracks_folder_data.dart b/lib/screens/library_tracks_folder_data.dart new file mode 100644 index 00000000..c100172c --- /dev/null +++ b/lib/screens/library_tracks_folder_data.dart @@ -0,0 +1,25 @@ +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/history_database.dart'; + +/// Collection providers replace their track list when its contents change. +/// Keep derived data across selection/layout rebuilds of the same snapshot. +class LibraryTracksFolderData { + List? _entries; + Set keys = const {}; + List tracks = const []; + HistoryBatchLookupRequest historyRequest = const HistoryBatchLookupRequest( + [], + ); + + void update(List entries) { + if (identical(entries, _entries)) return; + _entries = entries; + keys = Set.unmodifiable(entries.map((entry) => entry.key)); + tracks = List.unmodifiable(entries.map((entry) => entry.track)); + historyRequest = HistoryBatchLookupRequest.snapshot( + tracks.map(historyLookupForTrack), + ); + } +} diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 63e9d063..9e628a9e 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -28,6 +28,7 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_data.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; @@ -67,6 +68,7 @@ class _LibraryTracksFolderScreenState SelectionModeMixin, CollapsingHeaderScrollMixin { UserPlaylistCollection? playlist; + final _folderData = LibraryTracksFolderData(); @override void initState() { @@ -272,7 +274,8 @@ class _LibraryTracksFolderScreenState break; } - pruneSelection(entries.map((e) => e.key).toSet()); + _folderData.update(entries); + pruneSelection(_folderData.keys); final title = switch (widget.mode) { LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, @@ -297,16 +300,10 @@ class _LibraryTracksFolderScreenState LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylistEmptySubtitle, }; - final folderTracks = entries - .map((entry) => entry.track) - .toList(growable: false); - final historyLookups = folderTracks - .map(historyLookupForTrack) - .toList(growable: false); + final folderTracks = _folderData.tracks; + final historyLookups = _folderData.historyRequest.tracks; final existingHistoryKeys = ref.watch( - downloadHistoryVisibleBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ), + downloadHistoryVisibleBatchExistsProvider(_folderData.historyRequest), ); final bottomPadding = MediaQuery.paddingOf(context).bottom; diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index dde59422..b019dc78 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -48,6 +48,12 @@ class HistoryBatchLookupRequest { const HistoryBatchLookupRequest(this.tracks); + /// An immutable request whose hash is computed only once. Use for lists + /// retained across widget builds; the legacy constructor remains available. + factory HistoryBatchLookupRequest.snapshot( + Iterable tracks, + ) = _HistoryBatchLookupSnapshot; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -65,6 +71,23 @@ class HistoryBatchLookupRequest { int get hashCode => Object.hashAll(tracks); } +class _HistoryBatchLookupSnapshot extends HistoryBatchLookupRequest { + _HistoryBatchLookupSnapshot(Iterable tracks) + : super(List.unmodifiable(tracks)); + + late final int _hash = Object.hashAll(tracks); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HistoryBatchLookupRequest && + hashCode == other.hashCode && + super == other; + + @override + int get hashCode => _hash; +} + class HistoryDatabase { // The FTS table is a derived, optional index and is initialized lazily after // the existing schema migration. Keep this contract at v13 because the diff --git a/test/library_tracks_folder_data_test.dart b/test/library_tracks_folder_data_test.dart new file mode 100644 index 00000000..c1e82c98 --- /dev/null +++ b/test/library_tracks_folder_data_test.dart @@ -0,0 +1,65 @@ +import 'dart:collection'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_data.dart'; +import 'package:spotiflac_android/services/history_database.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _ReadCountingList extends ListBase { + _ReadCountingList(this.values); + final List values; + int reads = 0; + @override + int get length => values.length; + @override + set length(int value) => throw UnsupportedError('immutable'); + @override + T operator [](int index) { + reads++; + return values[index]; + } + + @override + void operator []=(int index, T value) => throw UnsupportedError('immutable'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('unchanged collection snapshot does no additional full-list reads', () { + final entries = _ReadCountingList( + List.generate( + 4000, + (index) => CollectionTrackEntry( + key: '$index', + addedAt: DateTime.utc(2026), + track: Track( + id: '$index', + name: 'Song $index', + artistName: 'Artist', + albumName: 'Album', + duration: 1, + ), + ), + ), + ); + final data = LibraryTracksFolderData()..update(entries); + final request = data.historyRequest; + final reads = entries.reads; + final hash = request.hashCode; + for (var i = 0; i < 100; i++) { + data.update(entries); + expect(identical(data.historyRequest, request), isTrue); + expect(data.historyRequest.hashCode, hash); + } + expect(entries.reads, reads); + expect(data.keys.length, 4000); + expect(() => data.tracks.clear(), throwsUnsupportedError); + expect(() => request.tracks.clear(), throwsUnsupportedError); + expect(request, HistoryBatchLookupRequest(request.tracks)); + expect(hash, HistoryBatchLookupRequest(request.tracks).hashCode); + data.update(entries.take(2).toList()); + expect(data.keys, {'0', '1'}); + expect(data.tracks.length, 2); + expect(identical(data.historyRequest, request), isFalse); + }); +}