diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 46ade16c..cfc82f0a 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -78,6 +79,7 @@ class _AlbumScreenState extends ConsumerState with SelectionModeMixin, CollapsingHeaderScrollMixin { + final _historySnapshot = TrackHistorySnapshot(); List? _tracks; bool _isLoading = false; String? _error; @@ -543,13 +545,10 @@ class _AlbumScreenState extends ConsumerState ColorScheme colorScheme, List tracks, ) { - final historyLookups = tracks - .map(historyLookupForTrack) - .toList(growable: false); + _historySnapshot.update(tracks); + final historyLookups = _historySnapshot.lookups; final existingHistoryKeys = ref.watch( - downloadHistoryVisibleBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ), + downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request), ); return SliverPadding( padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 205da292..677d0928 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; @@ -120,6 +121,7 @@ class ArtistScreen extends ConsumerStatefulWidget { class _ArtistScreenState extends ConsumerState with SelectionModeMixin { bool _isLoadingDiscography = false; + final _historySnapshot = TrackHistorySnapshot(); List? _albums; List? _releases; List? _topTracks; diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index aab2a865..ab5d4914 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -311,13 +311,10 @@ extension _ArtistScreenSections on _ArtistScreenState { } final tracks = _topTracks!; - final historyLookups = tracks - .map(historyLookupForTrack) - .toList(growable: false); + _historySnapshot.update(tracks); + final historyLookups = _historySnapshot.lookups; final existingHistoryKeys = ref.watch( - downloadHistoryVisibleBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ), + downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request), ); const tracksPerPage = 5; final pageCount = (tracks.length / tracksPerPage).ceil(); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 2b8b3c35..8b38b91d 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -60,6 +61,7 @@ class HomeTab extends ConsumerStatefulWidget { class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { + final _historySnapshot = TrackHistorySnapshot(); final _urlController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); String? _lastSearchQuery; diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index ea317489..7302d145 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -403,13 +403,10 @@ extension _HomeTabSearchResultsUI on _HomeTabState { } if (sortedTracks.isNotEmpty) { - final historyLookups = sortedTracks - .map(historyLookupForTrack) - .toList(growable: false); + _historySnapshot.update(sortedTracks); + final historyLookups = _historySnapshot.lookups; final existingHistoryKeys = ref.watch( - downloadHistoryVisibleBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ), + downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request), ); slivers.addAll( _buildVirtualizedResultSection( diff --git a/lib/screens/library_tracks_folder_data.dart b/lib/screens/library_tracks_folder_data.dart index c100172c..04979719 100644 --- a/lib/screens/library_tracks_folder_data.dart +++ b/lib/screens/library_tracks_folder_data.dart @@ -1,5 +1,5 @@ import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/services/history_database.dart'; @@ -9,17 +9,14 @@ class LibraryTracksFolderData { List? _entries; Set keys = const {}; List tracks = const []; - HistoryBatchLookupRequest historyRequest = const HistoryBatchLookupRequest( - [], - ); + final _history = TrackHistorySnapshot(); + HistoryBatchLookupRequest get historyRequest => _history.request; 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), - ); + _history.update(tracks); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 03d645cb..a353fd65 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; @@ -54,6 +55,7 @@ class _PlaylistScreenState extends ConsumerState with SelectionModeMixin, CollapsingHeaderScrollMixin { + final _historySnapshot = TrackHistorySnapshot(); List? _fetchedTracks; bool _isLoading = false; String? _error; @@ -396,13 +398,10 @@ class _PlaylistScreenState extends ConsumerState ); } - final historyLookups = _tracks - .map(historyLookupForTrack) - .toList(growable: false); + _historySnapshot.update(_tracks); + final historyLookups = _historySnapshot.lookups; final existingHistoryKeys = ref.watch( - downloadHistoryVisibleBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ), + downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request), ); return SliverPadding( padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), diff --git a/lib/screens/track_history_snapshot.dart b/lib/screens/track_history_snapshot.dart new file mode 100644 index 00000000..4800cb89 --- /dev/null +++ b/lib/screens/track_history_snapshot.dart @@ -0,0 +1,20 @@ +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/services/history_database.dart'; + +/// Retains derived lookups while the immutable source list is unchanged. +/// Callers replace their list for metadata, order, or filter changes. +class TrackHistorySnapshot { + List? _source; + HistoryBatchLookupRequest request = HistoryBatchLookupRequest.snapshot([]); + + List get lookups => request.tracks; + + void update(List tracks) { + if (identical(tracks, _source)) return; + _source = tracks; + request = HistoryBatchLookupRequest.snapshot( + tracks.map(historyLookupForTrack), + ); + } +} diff --git a/test/track_history_snapshot_test.dart b/test/track_history_snapshot_test.dart new file mode 100644 index 00000000..a59a952e --- /dev/null +++ b/test/track_history_snapshot_test.dart @@ -0,0 +1,82 @@ +import 'dart:collection'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/screens/track_history_snapshot.dart'; + +class _CountingTracks extends ListBase { + final List values; + int reads = 0; + _CountingTracks(this.values); + @override + int get length => values.length; + @override + set length(int value) => throw UnsupportedError('immutable'); + @override + Track operator [](int index) { + reads++; + return values[index]; + } + + @override + void operator []=(int index, Track value) => + throw UnsupportedError('immutable'); +} + +void main() { + test('retains lookups without scanning an unchanged 4000-track snapshot', () { + final tracks = _CountingTracks( + List.generate( + 4000, + (index) => Track( + id: '$index', + name: 'Song $index', + artistName: 'Artist', + albumName: 'Album', + duration: 1, + ), + ), + ); + final data = TrackHistorySnapshot()..update(tracks); + final request = data.request; + final hash = request.hashCode; + final reads = tracks.reads; + for (var i = 0; i < 100; i++) { + data.update(tracks); + expect(identical(data.request, request), isTrue); + expect(data.request.hashCode, hash); + } + expect(tracks.reads, reads); + expect(() => data.lookups.clear(), throwsUnsupportedError); + }); + + test( + 'replacement metadata, sorting, filtering, and empty lists invalidate', + () { + const first = Track( + id: '1', + name: 'First', + artistName: 'A', + albumName: 'Album', + duration: 1, + ); + const second = Track( + id: '2', + name: 'Second', + artistName: 'A', + albumName: 'Album', + duration: 1, + ); + final data = TrackHistorySnapshot()..update([first, second]); + final original = data.request; + data.update([first.copyWith(name: 'Edited'), second]); + expect(data.request, isNot(original)); + expect(data.lookups.first.trackName, 'Edited'); + data.update([second, first]); + expect(data.lookups.map((item) => item.spotifyId), ['2', '1']); + data.update([second]); + expect(data.lookups.single.spotifyId, '2'); + data.update([]); + expect(data.lookups, isEmpty); + }, + ); +}