diff --git a/lib/main.dart b/lib/main.dart index 1ab6b9b7..3c2ef29c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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? _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), diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index 73518c4d..690a5d1a 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -677,8 +677,7 @@ class DownloadHistoryNotifier extends Notifier { ) async { final normalized = filePath.trim(); if (normalized.isEmpty) return; - final stored = - prefs.getStringList(_audioProbeFailedPathsKey) ?? []; + final stored = prefs.getStringList(_audioProbeFailedPathsKey) ?? []; 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, - DownloadHistoryPageRequest - >((ref, request) async { +final downloadHistoryPageProvider = FutureProvider.autoDispose + .family, DownloadHistoryPageRequest>(( + ref, + request, + ) async { ref.watch( downloadHistoryProvider.select((state) => state.loadedIndexVersion), ); @@ -1574,19 +1573,16 @@ HistoryLookupRequest historyLookupForTrack(Track track) { ); } -final downloadHistoryExistsProvider = - FutureProvider.family((ref, request) async { +final downloadHistoryExistsProvider = FutureProvider.autoDispose + .family((ref, request) async { ref.watch( downloadHistoryProvider.select((state) => state.loadedIndexVersion), ); return HistoryDatabase.instance.existsTrack(request); }); -final downloadHistoryBatchExistsProvider = - FutureProvider.family, HistoryBatchLookupRequest>(( - ref, - request, - ) async { +final downloadHistoryBatchExistsProvider = FutureProvider.autoDispose + .family, 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, - DownloadedAlbumTracksRequest - >((ref, request) async { +final downloadedAlbumTracksProvider = FutureProvider.autoDispose + .family, 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); }); - diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index a0628b90..02da2248 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -118,6 +118,8 @@ class UserPlaylistCollection { final DateTime createdAt; final DateTime updatedAt; final List tracks; + final String? previewCover; + final bool tracksLoaded; final Set _trackKeys; UserPlaylistCollection({ @@ -127,6 +129,8 @@ class UserPlaylistCollection { required this.createdAt, required this.updatedAt, required this.tracks, + this.previewCover, + this.tracksLoaded = true, Set? trackKeys, }) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet(); @@ -137,6 +141,8 @@ class UserPlaylistCollection { DateTime? createdAt, DateTime? updatedAt, List? 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 toJson() => { 'id': id, 'name': name, @@ -409,9 +421,7 @@ class LibraryCollectionsState { Set _buildPlaylistTrackKeys(List playlists) { final keys = {}; 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 { final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; Future? _loadFuture; + final Map> _playlistLoadFutures = {}; void _invalidatePlaylistPickerSummaries() { ref.invalidate(libraryPlaylistPickerSummariesProvider); @@ -469,13 +480,13 @@ class LibraryCollectionsNotifier extends Notifier { } } - final tracksByPlaylist = >{}; + final trackKeysByPlaylist = >{}; 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 = []; @@ -488,6 +499,16 @@ class LibraryCollectionsNotifier extends Notifier { 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 { coverImagePath: row['cover_image_path'] as String?, createdAt: createdAt, updatedAt: updatedAt, - tracks: tracksByPlaylist[id] ?? const [], + tracks: const [], + previewCover: previewCover, + tracksLoaded: false, + trackKeys: trackKeysByPlaylist[id], ), ); } @@ -518,6 +542,38 @@ class LibraryCollectionsNotifier extends Notifier { await (_loadFuture ?? _load()); } + Future 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() + .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 ensurePlaylistsLoaded(Iterable playlistIds) async { + await Future.wait(playlistIds.toSet().map(ensurePlaylistLoaded)); + } + CollectionTrackEntry? _parseTrackEntryRow(Map row) { final key = row['track_key'] as String?; final trackJson = row['track_json'] as String?; @@ -785,11 +841,14 @@ class LibraryCollectionsNotifier extends Notifier { Future 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 { Iterable 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 { 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 { /// favorite artists) for a backup, ensuring data is loaded first. Future> 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, - PlaylistPickerSummaryRequest - >((ref, request) async { +final libraryPlaylistPickerSummariesProvider = FutureProvider.autoDispose + .family, PlaylistPickerSummaryRequest>(( + ref, + request, + ) async { final db = LibraryCollectionsDatabase.instance; await db.migrateFromSharedPreferences(); final rows = await db.loadPlaylistPickerSummaries(request.trackKeys); diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 5e89c9cf..5d4af06c 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -1021,8 +1021,8 @@ String? _nonEmptyCoverPath(Map? json) { return trimmed == null || trimmed.isEmpty ? null : trimmed; } -final localLibraryCoverProvider = - FutureProvider.family((ref, request) { +final localLibraryCoverProvider = FutureProvider.autoDispose + .family((ref, request) { ref.watch( localLibraryProvider.select((state) => state.loadedIndexVersion), ); @@ -1035,11 +1035,8 @@ final localLibraryCoverProvider = .then(_nonEmptyCoverPath); }); -final localLibraryFirstCoverProvider = - FutureProvider.family(( - ref, - request, - ) async { +final localLibraryFirstCoverProvider = FutureProvider.autoDispose + .family((ref, request) async { ref.watch( localLibraryProvider.select((state) => state.loadedIndexVersion), ); @@ -1056,8 +1053,8 @@ final localLibraryFirstCoverProvider = return null; }); -final localLibraryPageProvider = - FutureProvider.family, LocalLibraryPageRequest>(( +final localLibraryPageProvider = FutureProvider.autoDispose + .family, LocalLibraryPageRequest>(( ref, request, ) async { @@ -1068,8 +1065,8 @@ final localLibraryPageProvider = return rows.map(LocalLibraryItem.fromJson).toList(growable: false); }); -final localLibraryPageCountProvider = - FutureProvider.family((ref, request) async { +final localLibraryPageCountProvider = FutureProvider.autoDispose + .family((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, - LocalLibraryAlbumPageRequest - >((ref, request) async { +final localLibraryAlbumPageProvider = FutureProvider.autoDispose + .family, LocalLibraryAlbumPageRequest>(( + ref, + request, + ) async { ref.watch( localLibraryProvider.select((state) => state.loadedIndexVersion), ); @@ -1123,11 +1120,8 @@ final localLibraryAlbumPageProvider = ); }); -final localLibraryAlbumCountProvider = - FutureProvider.family(( - ref, - request, - ) async { +final localLibraryAlbumCountProvider = FutureProvider.autoDispose + .family((ref, request) async { ref.watch( localLibraryProvider.select((state) => state.loadedIndexVersion), ); diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 7145479b..75749b0c 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -58,6 +58,19 @@ class _LibraryTracksFolderScreenState CollapsingHeaderScrollMixin { 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, diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 1cd2d97b..40ff276c 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -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 { ); } } - } diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index b67c92fb..6cd7facb 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -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(( +final _queueLibraryCountsProvider = FutureProvider.autoDispose + .family(( ref, request, ) async { diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart index fe2df733..f8a5d8af 100644 --- a/lib/screens/queue_tab_navigation.dart +++ b/lib/screens/queue_tab_navigation.dart @@ -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 diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 815c1d99..7f26f79f 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -189,13 +189,13 @@ extension _QueueTabSelectionActions on _QueueTabState { Future _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( 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); diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index 28eea531..62185833 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -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>> loadPlaylistTracks( + String playlistId, + ) async { + final db = await database; + return db.query( + _tablePlaylistTracks, + where: 'playlist_id = ?', + whereArgs: [playlistId], + orderBy: 'added_at ASC, rowid ASC', + ); + } + Future> loadPlaylistPickerSummaries( List requestedTrackKeys, ) async { diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index f7cd3496..486eecbe 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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(