From 67440925eb60c3775805e9b923d337ae6a40d7dc Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:04:05 +0700 Subject: [PATCH] perf(ui): retain track history lookup snapshots Share the retained history lookup snapshot across albums, playlists, artists, home search results, and library folders. Reuse derived lookups until the track list is replaced. Test reuse with 4,000 items and invalidation after replacement, sorting, and filtering. --- lib/screens/album_screen.dart | 11 ++- lib/screens/artist_screen.dart | 2 + lib/screens/artist_screen_widgets.dart | 9 +-- lib/screens/home_tab.dart | 2 + lib/screens/home_tab_search_results.dart | 9 +-- lib/screens/library_tracks_folder_data.dart | 11 +-- lib/screens/playlist_screen.dart | 11 ++- lib/screens/track_history_snapshot.dart | 20 +++++ test/track_history_snapshot_test.dart | 82 +++++++++++++++++++++ 9 files changed, 126 insertions(+), 31 deletions(-) create mode 100644 lib/screens/track_history_snapshot.dart create mode 100644 test/track_history_snapshot_test.dart 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); + }, + ); +}