perf(library): lazy-load collections and dispose queries

This commit is contained in:
zarzet
2026-07-15 21:32:43 +07:00
parent 7f1c3e60db
commit 17dbcc1338
11 changed files with 217 additions and 85 deletions
-8
View File
@@ -8,7 +8,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -125,7 +124,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
with WidgetsBindingObserver {
ProviderSubscription<bool>? _localLibraryEnabledSub;
Timer? _downloadHistoryWarmupTimer;
Timer? _libraryCollectionsWarmupTimer;
Timer? _localLibraryWarmupTimer;
bool _localLibraryWarmupScheduled = false;
bool _autoScanTriggeredOnLaunch = false;
@@ -147,7 +145,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
WidgetsBinding.instance.removeObserver(this);
_localLibraryEnabledSub?.close();
_downloadHistoryWarmupTimer?.cancel();
_libraryCollectionsWarmupTimer?.cancel();
_localLibraryWarmupTimer?.cancel();
super.dispose();
}
@@ -188,11 +185,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
const Duration(milliseconds: 400),
() => ref.read(downloadHistoryProvider),
);
_libraryCollectionsWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 900),
() => ref.read(libraryCollectionsProvider),
);
_maybeScheduleLocalLibraryWarmup(
ref.read(
settingsProvider.select((settings) => settings.localLibraryEnabled),
+15 -20
View File
@@ -677,8 +677,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
) async {
final normalized = filePath.trim();
if (normalized.isEmpty) return;
final stored =
prefs.getStringList(_audioProbeFailedPathsKey) ?? <String>[];
final stored = prefs.getStringList(_audioProbeFailedPathsKey) ?? <String>[];
if (stored.contains(normalized)) return;
stored.add(normalized);
while (stored.length > _audioProbeFailedPathsMax) {
@@ -1528,11 +1527,11 @@ class DownloadHistoryPageRequest {
int get hashCode => Object.hash(limit, offset);
}
final downloadHistoryPageProvider =
FutureProvider.family<
List<DownloadHistoryItem>,
DownloadHistoryPageRequest
>((ref, request) async {
final downloadHistoryPageProvider = FutureProvider.autoDispose
.family<List<DownloadHistoryItem>, DownloadHistoryPageRequest>((
ref,
request,
) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
@@ -1574,19 +1573,16 @@ HistoryLookupRequest historyLookupForTrack(Track track) {
);
}
final downloadHistoryExistsProvider =
FutureProvider.family<bool, HistoryLookupRequest>((ref, request) async {
final downloadHistoryExistsProvider = FutureProvider.autoDispose
.family<bool, HistoryLookupRequest>((ref, request) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
return HistoryDatabase.instance.existsTrack(request);
});
final downloadHistoryBatchExistsProvider =
FutureProvider.family<Set<String>, HistoryBatchLookupRequest>((
ref,
request,
) async {
final downloadHistoryBatchExistsProvider = FutureProvider.autoDispose
.family<Set<String>, HistoryBatchLookupRequest>((ref, request) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
@@ -1612,11 +1608,11 @@ class DownloadedAlbumTracksRequest {
int get hashCode => Object.hash(albumName, artistName);
}
final downloadedAlbumTracksProvider =
FutureProvider.family<
List<DownloadHistoryItem>,
DownloadedAlbumTracksRequest
>((ref, request) async {
final downloadedAlbumTracksProvider = FutureProvider.autoDispose
.family<List<DownloadHistoryItem>, DownloadedAlbumTracksRequest>((
ref,
request,
) async {
ref.watch(
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
);
@@ -1626,4 +1622,3 @@ final downloadedAlbumTracksProvider =
);
return rows.map(DownloadHistoryItem.fromJson).toList(growable: false);
});
+79 -15
View File
@@ -118,6 +118,8 @@ class UserPlaylistCollection {
final DateTime createdAt;
final DateTime updatedAt;
final List<CollectionTrackEntry> tracks;
final String? previewCover;
final bool tracksLoaded;
final Set<String> _trackKeys;
UserPlaylistCollection({
@@ -127,6 +129,8 @@ class UserPlaylistCollection {
required this.createdAt,
required this.updatedAt,
required this.tracks,
this.previewCover,
this.tracksLoaded = true,
Set<String>? trackKeys,
}) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet();
@@ -137,6 +141,8 @@ class UserPlaylistCollection {
DateTime? createdAt,
DateTime? updatedAt,
List<CollectionTrackEntry>? tracks,
String? previewCover,
bool? tracksLoaded,
}) {
final nextTracks = tracks ?? this.tracks;
final keepTrackIndex = identical(nextTracks, this.tracks);
@@ -149,6 +155,10 @@ class UserPlaylistCollection {
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
tracks: nextTracks,
previewCover: previewCover ?? this.previewCover,
tracksLoaded:
tracksLoaded ??
(identical(nextTracks, this.tracks) ? this.tracksLoaded : true),
trackKeys: keepTrackIndex ? _trackKeys : null,
);
}
@@ -162,6 +172,8 @@ class UserPlaylistCollection {
return _trackKeys.contains(trackKey);
}
int get trackCount => _trackKeys.length;
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
@@ -409,9 +421,7 @@ class LibraryCollectionsState {
Set<String> _buildPlaylistTrackKeys(List<UserPlaylistCollection> playlists) {
final keys = <String>{};
for (final playlist in playlists) {
for (final entry in playlist.tracks) {
keys.add(entry.key);
}
keys.addAll(playlist._trackKeys);
}
return keys;
}
@@ -429,6 +439,7 @@ class PlaylistAddBatchResult {
class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
Future<void>? _loadFuture;
final Map<String, Future<void>> _playlistLoadFutures = {};
void _invalidatePlaylistPickerSummaries() {
ref.invalidate(libraryPlaylistPickerSummariesProvider);
@@ -469,13 +480,13 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
}
}
final tracksByPlaylist = <String, List<CollectionTrackEntry>>{};
final trackKeysByPlaylist = <String, Set<String>>{};
for (final row in snapshot.playlistTrackRows) {
final playlistId = row['playlist_id'] as String?;
if (playlistId == null || playlistId.isEmpty) continue;
final parsed = _parseTrackEntryRow(row);
if (parsed == null) continue;
tracksByPlaylist.putIfAbsent(playlistId, () => []).add(parsed);
final trackKey = row['track_key'] as String?;
if (trackKey == null || trackKey.isEmpty) continue;
trackKeysByPlaylist.putIfAbsent(playlistId, () => {}).add(trackKey);
}
final playlists = <UserPlaylistCollection>[];
@@ -488,6 +499,16 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final createdAt =
DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
String? previewCover;
final previewTrackJson = row['preview_track_json'] as String?;
if (previewTrackJson != null && previewTrackJson.isNotEmpty) {
try {
final decoded = jsonDecode(previewTrackJson);
if (decoded is Map) {
previewCover = decoded['coverUrl']?.toString();
}
} catch (_) {}
}
playlists.add(
UserPlaylistCollection(
@@ -496,7 +517,10 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
coverImagePath: row['cover_image_path'] as String?,
createdAt: createdAt,
updatedAt: updatedAt,
tracks: tracksByPlaylist[id] ?? const <CollectionTrackEntry>[],
tracks: const <CollectionTrackEntry>[],
previewCover: previewCover,
tracksLoaded: false,
trackKeys: trackKeysByPlaylist[id],
),
);
}
@@ -518,6 +542,38 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
await (_loadFuture ?? _load());
}
Future<void> ensurePlaylistLoaded(String playlistId) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.tracksLoaded) return;
final pending = _playlistLoadFutures[playlistId];
if (pending != null) return pending;
final load = () async {
final rows = await _db.loadPlaylistTracks(playlistId);
final tracks = rows
.map(_parseTrackEntryRow)
.whereType<CollectionTrackEntry>()
.toList(growable: false);
_replacePlaylistById(
playlistId,
(current) => current.copyWith(tracks: tracks, tracksLoaded: true),
);
}();
_playlistLoadFutures[playlistId] = load;
try {
await load;
} finally {
if (identical(_playlistLoadFutures[playlistId], load)) {
_playlistLoadFutures.remove(playlistId);
}
}
}
Future<void> ensurePlaylistsLoaded(Iterable<String> playlistIds) async {
await Future.wait(playlistIds.toSet().map(ensurePlaylistLoaded));
}
CollectionTrackEntry? _parseTrackEntryRow(Map<String, dynamic> row) {
final key = row['track_key'] as String?;
final trackJson = row['track_json'] as String?;
@@ -785,11 +841,14 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
Future<bool> addTrackToPlaylist(String playlistId, Track track) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
var playlist = state.playlistById(playlistId);
if (playlist == null) return false;
final key = trackCollectionKey(track);
if (playlist.containsTrackKey(key)) return false;
await ensurePlaylistLoaded(playlistId);
playlist = state.playlistById(playlistId);
if (playlist == null) return false;
final now = DateTime.now();
final entry = CollectionTrackEntry(key: key, track: track, addedAt: now);
@@ -817,6 +876,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
Iterable<Track> tracks,
) async {
await _ensureLoaded();
await ensurePlaylistLoaded(playlistId);
final playlist = state.playlistById(playlistId);
if (playlist == null) {
return const PlaylistAddBatchResult(
@@ -887,8 +947,11 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
String trackKey,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
var playlist = state.playlistById(playlistId);
if (playlist == null || !playlist.containsTrackKey(trackKey)) return;
await ensurePlaylistLoaded(playlistId);
playlist = state.playlistById(playlistId);
if (playlist == null) return;
final now = DateTime.now();
await _db.deletePlaylistTrack(
@@ -973,6 +1036,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
/// favorite artists) for a backup, ensuring data is loaded first.
Future<Map<String, dynamic>> exportCollections() async {
await _ensureLoaded();
await ensurePlaylistsLoaded(state.playlists.map((playlist) => playlist.id));
return state.toJson();
}
@@ -1059,11 +1123,11 @@ final libraryCollectionsProvider =
LibraryCollectionsNotifier.new,
);
final libraryPlaylistPickerSummariesProvider =
FutureProvider.family<
List<PlaylistPickerSummary>,
PlaylistPickerSummaryRequest
>((ref, request) async {
final libraryPlaylistPickerSummariesProvider = FutureProvider.autoDispose
.family<List<PlaylistPickerSummary>, PlaylistPickerSummaryRequest>((
ref,
request,
) async {
final db = LibraryCollectionsDatabase.instance;
await db.migrateFromSharedPreferences();
final rows = await db.loadPlaylistPickerSummaries(request.trackKeys);
+15 -21
View File
@@ -1021,8 +1021,8 @@ String? _nonEmptyCoverPath(Map<String, dynamic>? json) {
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
final localLibraryCoverProvider =
FutureProvider.family<String?, LocalLibraryCoverRequest>((ref, request) {
final localLibraryCoverProvider = FutureProvider.autoDispose
.family<String?, LocalLibraryCoverRequest>((ref, request) {
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
@@ -1035,11 +1035,8 @@ final localLibraryCoverProvider =
.then(_nonEmptyCoverPath);
});
final localLibraryFirstCoverProvider =
FutureProvider.family<String?, LocalLibraryCoverBatchRequest>((
ref,
request,
) async {
final localLibraryFirstCoverProvider = FutureProvider.autoDispose
.family<String?, LocalLibraryCoverBatchRequest>((ref, request) async {
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
@@ -1056,8 +1053,8 @@ final localLibraryFirstCoverProvider =
return null;
});
final localLibraryPageProvider =
FutureProvider.family<List<LocalLibraryItem>, LocalLibraryPageRequest>((
final localLibraryPageProvider = FutureProvider.autoDispose
.family<List<LocalLibraryItem>, LocalLibraryPageRequest>((
ref,
request,
) async {
@@ -1068,8 +1065,8 @@ final localLibraryPageProvider =
return rows.map(LocalLibraryItem.fromJson).toList(growable: false);
});
final localLibraryPageCountProvider =
FutureProvider.family<int, LocalLibraryPageRequest>((ref, request) async {
final localLibraryPageCountProvider = FutureProvider.autoDispose
.family<int, LocalLibraryPageRequest>((ref, request) async {
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
@@ -1106,11 +1103,11 @@ class LocalLibraryAlbumPageRequest {
Object.hash(limit, offset, filterMode, sortMode, searchQuery);
}
final localLibraryAlbumPageProvider =
FutureProvider.family<
List<LocalLibraryAlbumGroup>,
LocalLibraryAlbumPageRequest
>((ref, request) async {
final localLibraryAlbumPageProvider = FutureProvider.autoDispose
.family<List<LocalLibraryAlbumGroup>, LocalLibraryAlbumPageRequest>((
ref,
request,
) async {
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
@@ -1123,11 +1120,8 @@ final localLibraryAlbumPageProvider =
);
});
final localLibraryAlbumCountProvider =
FutureProvider.family<int, LocalLibraryAlbumPageRequest>((
ref,
request,
) async {
final localLibraryAlbumCountProvider = FutureProvider.autoDispose
.family<int, LocalLibraryAlbumPageRequest>((ref, request) async {
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
@@ -58,6 +58,19 @@ class _LibraryTracksFolderScreenState
CollapsingHeaderScrollMixin<LibraryTracksFolderScreen> {
UserPlaylistCollection? playlist;
@override
void initState() {
super.initState();
final playlistId = widget.playlistId;
if (widget.mode == LibraryTracksFolderMode.playlist && playlistId != null) {
Future.microtask(
() => ref
.read(libraryCollectionsProvider.notifier)
.ensurePlaylistLoaded(playlistId),
);
}
}
IconData _modeIcon() {
return switch (widget.mode) {
LibraryTracksFolderMode.wishlist => Icons.bookmark,
+2 -3
View File
@@ -482,7 +482,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
colorScheme,
),
title: playlist.name,
count: playlist.tracks.length,
count: playlist.trackCount,
onTap: _isPlaylistSelectionMode
? () => _togglePlaylistSelection(playlist.id)
: () => _openPlaylistById(playlist.id),
@@ -630,7 +630,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
),
title: playlist.name,
subtitle:
'${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}',
'${playlist.trackCount} ${playlist.trackCount == 1 ? 'track' : 'tracks'}',
onTap: _isPlaylistSelectionMode
? () => _togglePlaylistSelection(playlist.id)
: () => _openPlaylistById(playlist.id),
@@ -646,5 +646,4 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
);
}
}
}
+4 -4
View File
@@ -262,8 +262,8 @@ class _QueueLibraryPageData {
}
}
final _queueLibraryPageProvider =
FutureProvider.family<_QueueLibraryPageData, _QueueLibraryPageRequest>((
final _queueLibraryPageProvider = FutureProvider.autoDispose
.family<_QueueLibraryPageData, _QueueLibraryPageRequest>((
ref,
request,
) async {
@@ -338,8 +338,8 @@ final _queueLibraryPageProvider =
);
});
final _queueLibraryCountsProvider =
FutureProvider.family<QueueLibraryCounts, _QueueLibraryCountsRequest>((
final _queueLibraryCountsProvider = FutureProvider.autoDispose
.family<QueueLibraryCounts, _QueueLibraryCountsRequest>((
ref,
request,
) async {
+8 -4
View File
@@ -382,10 +382,14 @@ extension _QueueTabNavigation on _QueueTabState {
);
}
final firstCoverUrl = playlist.tracks
.where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty)
.map((e) => e.track.coverUrl!)
.firstOrNull;
final firstCoverUrl =
playlist.tracks
.where(
(e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty,
)
.map((e) => e.track.coverUrl!)
.firstOrNull ??
playlist.previewCover;
if (firstCoverUrl != null) {
// Guard against local file paths that may have been stored as coverUrl
+14 -2
View File
@@ -189,13 +189,13 @@ extension _QueueTabSelectionActions on _QueueTabState {
Future<void> _downloadAllSelectedPlaylists(BuildContext context) async {
final collectionsState = ref.read(libraryCollectionsProvider);
final selectedPlaylists = collectionsState.playlists
var selectedPlaylists = collectionsState.playlists
.where((p) => _selectedPlaylistIds.contains(p.id))
.toList();
final totalTracks = selectedPlaylists.fold<int>(
0,
(sum, p) => sum + p.tracks.length,
(sum, p) => sum + p.trackCount,
);
if (totalTracks == 0) {
@@ -230,6 +230,18 @@ extension _QueueTabSelectionActions on _QueueTabState {
if (confirmed != true || !context.mounted) return;
await ref
.read(libraryCollectionsProvider.notifier)
.ensurePlaylistsLoaded(
selectedPlaylists.map((playlist) => playlist.id),
);
if (!context.mounted) return;
selectedPlaylists = ref
.read(libraryCollectionsProvider)
.playlists
.where((playlist) => _selectedPlaylistIds.contains(playlist.id))
.toList(growable: false);
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final queueNotifier = ref.read(downloadQueueProvider.notifier);
+27 -8
View File
@@ -263,16 +263,23 @@ class LibraryCollectionsDatabase {
_tableLoved,
orderBy: 'added_at DESC, rowid DESC',
);
final playlistRows = await db.query(
_tablePlaylists,
orderBy: 'created_at DESC, rowid DESC',
);
final playlistRows = await db.rawQuery('''
SELECT
p.*,
CASE WHEN p.cover_image_path IS NULL OR p.cover_image_path = '' THEN (
SELECT pt.track_json
FROM $_tablePlaylistTracks pt
WHERE pt.playlist_id = p.id
ORDER BY pt.added_at ASC, pt.rowid ASC
LIMIT 1
) END AS preview_track_json
FROM $_tablePlaylists p
ORDER BY p.created_at DESC, p.rowid DESC
''');
final playlistTrackRows = await db.query(
_tablePlaylistTracks,
// Playlists keep playlist order: batch imports insert rows in playlist
// order and later additions append, so insertion order IS the order
// (unlike wishlist/loved, which show newest first).
orderBy: 'playlist_id ASC, added_at ASC, rowid ASC',
columns: ['playlist_id', 'track_key'],
orderBy: 'playlist_id ASC, rowid ASC',
);
final favoriteArtistRows = await db.query(
_tableFavoriteArtists,
@@ -288,6 +295,18 @@ class LibraryCollectionsDatabase {
);
}
Future<List<Map<String, dynamic>>> loadPlaylistTracks(
String playlistId,
) async {
final db = await database;
return db.query(
_tablePlaylistTracks,
where: 'playlist_id = ?',
whereArgs: [playlistId],
orderBy: 'added_at ASC, rowid ASC',
);
}
Future<List<PlaylistPickerSummaryRow>> loadPlaylistPickerSummaries(
List<String> requestedTrackKeys,
) async {
+40
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
@@ -13,6 +14,45 @@ import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
void main() {
group('Library collections', () {
test('keeps playlist membership without eagerly loading track JSON', () {
final playlist = UserPlaylistCollection(
id: 'playlist-1',
name: 'Playlist',
createdAt: DateTime.utc(2026),
updatedAt: DateTime.utc(2026),
tracks: const [],
previewCover: 'https://example.test/cover.jpg',
tracksLoaded: false,
trackKeys: {'track:a', 'track:b'},
);
expect(playlist.tracks, isEmpty);
expect(playlist.tracksLoaded, isFalse);
expect(playlist.trackCount, 2);
expect(playlist.containsTrackKey('track:a'), isTrue);
expect(playlist.previewCover, 'https://example.test/cover.jpg');
final loaded = playlist.copyWith(
tracks: [
CollectionTrackEntry(
key: 'track:a',
track: const Track(
id: 'a',
name: 'A',
artistName: 'Artist',
albumName: 'Album',
duration: 1000,
),
addedAt: DateTime.utc(2026),
),
],
);
expect(loaded.tracksLoaded, isTrue);
expect(loaded.trackCount, 1);
});
});
group('Track', () {
test('exposes collection, source, and quality flags', () {
const album = Track(