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.
This commit is contained in:
zarzet
2026-09-06 14:04:05 +07:00
parent 09c31d9dba
commit 67440925eb
9 changed files with 126 additions and 31 deletions
+5 -6
View File
@@ -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<AlbumScreen>
with
SelectionModeMixin<AlbumScreen>,
CollapsingHeaderScrollMixin<AlbumScreen> {
final _historySnapshot = TrackHistorySnapshot();
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
@@ -543,13 +545,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
ColorScheme colorScheme,
List<Track> 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)),
+2
View File
@@ -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<ArtistScreen>
with SelectionModeMixin<ArtistScreen> {
bool _isLoadingDiscography = false;
final _historySnapshot = TrackHistorySnapshot();
List<ArtistAlbum>? _albums;
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
+3 -6
View File
@@ -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();
+2
View File
@@ -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<HomeTab>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _historySnapshot = TrackHistorySnapshot();
final _urlController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
+3 -6
View File
@@ -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(
+4 -7
View File
@@ -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<CollectionTrackEntry>? _entries;
Set<String> keys = const {};
List<Track> tracks = const [];
HistoryBatchLookupRequest historyRequest = const HistoryBatchLookupRequest(
[],
);
final _history = TrackHistorySnapshot();
HistoryBatchLookupRequest get historyRequest => _history.request;
void update(List<CollectionTrackEntry> 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);
}
}
+5 -6
View File
@@ -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<PlaylistScreen>
with
SelectionModeMixin<PlaylistScreen>,
CollapsingHeaderScrollMixin<PlaylistScreen> {
final _historySnapshot = TrackHistorySnapshot();
List<Track>? _fetchedTracks;
bool _isLoading = false;
String? _error;
@@ -396,13 +398,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
);
}
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)),
+20
View File
@@ -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<Track>? _source;
HistoryBatchLookupRequest request = HistoryBatchLookupRequest.snapshot([]);
List<HistoryLookupRequest> get lookups => request.tracks;
void update(List<Track> tracks) {
if (identical(tracks, _source)) return;
_source = tracks;
request = HistoryBatchLookupRequest.snapshot(
tracks.map(historyLookupForTrack),
);
}
}
+82
View File
@@ -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<Track> {
final List<Track> 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);
},
);
}