mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
perf(library): lazy-load collections and dispose queries
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user