From 030f44a444ccd0696b20582f1f8f940111914aa0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 3 Apr 2026 22:31:04 +0700 Subject: [PATCH] perf: reduce UI jank via memoization, compute isolates, SQL-backed playlist picker, and viewport-aware image caching - Move explore JSON decode/encode to compute() isolate to avoid blocking main thread - Memoize search sort results (artists/albums/playlists/tracks) in HomeTab; invalidate on new query - Extract _DownloadedOrRemoteCover StatefulWidget with proper embedded-cover lifecycle management - Replace O(playlists x tracks) in-memory playlist picker check with SQL loadPlaylistPickerSummaries query - Add FutureProvider.family (libraryPlaylistPickerSummariesProvider) invalidated on all playlist mutations - Memoize _buildQueueHistoryStats, localPathMatchKeys, and localSingleItems in QueueTab - Add coverCacheWidthForViewport util; apply memCacheWidth/cacheWidth based on real DPR across all album/playlist/track screens - Convert sync file ops in TrackMetadataScreen to async; use mtime+size as validation token - Fetch Deezer album nb_tracks in parallel via fetchAlbumTrackCounts --- go_backend/deezer.go | 61 +++ lib/providers/explore_provider.dart | 85 +++- .../library_collections_provider.dart | 85 ++++ lib/screens/album_screen.dart | 3 + lib/screens/downloaded_album_screen.dart | 6 + lib/screens/home_tab.dart | 436 ++++++++++++------ lib/screens/local_album_screen.dart | 5 + lib/screens/playlist_screen.dart | 3 + lib/screens/queue_tab.dart | 110 ++++- lib/screens/track_metadata_screen.dart | 116 ++--- .../library_collections_database.dart | 135 ++++++ lib/utils/image_cache_utils.dart | 12 + lib/widgets/playlist_picker_sheet.dart | 222 +++++---- 13 files changed, 927 insertions(+), 352 deletions(-) create mode 100644 lib/utils/image_cache_utils.dart diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 846f243b..dbd02ea2 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -748,6 +748,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR Artists: artist.Name, }) } + + // The Deezer /artist/{id}/albums endpoint does not return nb_tracks. + // Fetch track counts in parallel from individual /album/{id} endpoints. + c.fetchAlbumTrackCounts(ctx, albums) } result := &ArtistResponsePayload{ @@ -767,6 +771,63 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR return result, nil } +// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using +// individual /album/{id} calls, since the /artist/{id}/albums endpoint does +// not include this field. Albums whose track count is already known (non-zero) +// are skipped. +func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) { + // Find albums that need track counts + type indexedID struct { + idx int + albumID string + } + var toFetch []indexedID + for i, a := range albums { + if a.TotalTracks == 0 { + rawID := strings.TrimPrefix(a.ID, "deezer:") + if rawID != "" { + toFetch = append(toFetch, indexedID{idx: i, albumID: rawID}) + } + } + } + if len(toFetch) == 0 { + return + } + + const maxParallel = 10 + sem := make(chan struct{}, maxParallel) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, item := range toFetch { + wg.Add(1) + go func(it indexedID) { + defer wg.Done() + + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + + albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID) + var resp struct { + NbTracks int `json:"nb_tracks"` + } + if err := c.getJSON(ctx, albumURL, &resp); err != nil { + return + } + + mu.Lock() + albums[it.idx].TotalTracks = resp.NbTracks + mu.Unlock() + }(item) + } + + wg.Wait() +} + func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:")) if normalizedArtistID == "" { diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 37c2ef28..c0abd0d0 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -162,6 +163,52 @@ bool _isYTMusicQuickPicksItems(List items) { return true; } +List> _normalizeExploreSectionsPayload( + dynamic rawSections, +) { + if (rawSections is! List) return const []; + final sections = >[]; + for (final rawSection in rawSections) { + if (rawSection is! Map) continue; + final section = Map.from(rawSection); + final rawItems = section['items']; + final items = >[]; + if (rawItems is List) { + for (final rawItem in rawItems) { + if (rawItem is! Map) continue; + items.add(Map.from(rawItem)); + } + } + sections.add({ + 'uri': section['uri']?.toString() ?? '', + 'title': section['title']?.toString() ?? '', + 'items': items, + }); + } + return sections; +} + +List> _decodeExploreCacheSections(String rawCache) { + final decoded = jsonDecode(rawCache); + if (decoded is! Map) return const []; + return _normalizeExploreSectionsPayload(decoded['sections']); +} + +String _encodeExploreCacheSections(List> sections) { + return jsonEncode({'sections': sections}); +} + +List _buildExploreSectionsFromNormalizedPayload( + List> normalizedSections, +) { + return normalizedSections + .map( + (section) => + ExploreSection.fromJson(Map.from(section)), + ) + .toList(growable: false); +} + class ExploreNotifier extends Notifier { static const _cacheKey = 'explore_home_feed_cache'; static const _cacheTsKey = 'explore_home_feed_ts'; @@ -179,11 +226,13 @@ class ExploreNotifier extends Notifier { final cachedTs = prefs.getInt(_cacheTsKey); if (cached == null || cached.isEmpty) return; - final data = jsonDecode(cached) as Map; - final sectionsData = data['sections'] as List? ?? []; - final sections = sectionsData - .map((s) => ExploreSection.fromJson(s as Map)) - .toList(); + final normalizedSections = await compute( + _decodeExploreCacheSections, + cached, + ); + final sections = _buildExploreSectionsFromNormalizedPayload( + normalizedSections, + ); if (sections.isEmpty) return; @@ -202,13 +251,18 @@ class ExploreNotifier extends Notifier { } } - Future _saveToCache(List sections) async { + Future _saveToCache( + List> normalizedSections, + ) async { try { final prefs = await SharedPreferences.getInstance(); - final data = {'sections': sections.map((s) => s.toJson()).toList()}; - await prefs.setString(_cacheKey, jsonEncode(data)); + final encoded = await compute( + _encodeExploreCacheSections, + normalizedSections, + ); + await prefs.setString(_cacheKey, encoded); await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); - _log.d('Saved ${sections.length} explore sections to cache'); + _log.d('Saved ${normalizedSections.length} explore sections to cache'); } catch (e) { _log.w('Failed to save explore cache: $e'); } @@ -290,10 +344,13 @@ class ExploreNotifier extends Notifier { final greeting = result['greeting'] as String?; final sectionsData = result['sections'] as List? ?? []; - - final sections = sectionsData - .map((s) => ExploreSection.fromJson(s as Map)) - .toList(); + final normalizedSections = await compute( + _normalizeExploreSectionsPayload, + sectionsData, + ); + final sections = _buildExploreSectionsFromNormalizedPayload( + normalizedSections, + ); _log.i('Fetched ${sections.length} sections'); @@ -314,7 +371,7 @@ class ExploreNotifier extends Notifier { lastFetched: DateTime.now(), ); - _saveToCache(sections); + _saveToCache(normalizedSections); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); state = state.copyWith(isLoading: false, error: e.toString()); diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index d6fece8e..6b344228 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -127,6 +128,54 @@ class UserPlaylistCollection { } } +class PlaylistPickerSummary { + final String id; + final String name; + final String? coverImagePath; + final String? previewCover; + final DateTime createdAt; + final DateTime updatedAt; + final int trackCount; + final bool containsAllRequestedTracks; + + const PlaylistPickerSummary({ + required this.id, + required this.name, + this.coverImagePath, + this.previewCover, + required this.createdAt, + required this.updatedAt, + required this.trackCount, + required this.containsAllRequestedTracks, + }); +} + +class PlaylistPickerSummaryRequest { + final List trackKeys; + + PlaylistPickerSummaryRequest._(this.trackKeys); + + factory PlaylistPickerSummaryRequest.fromTracks(Iterable tracks) { + final keys = + tracks + .map(trackCollectionKey) + .where((key) => key.trim().isNotEmpty) + .toSet() + .toList(growable: false) + ..sort(); + return PlaylistPickerSummaryRequest._(keys); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaylistPickerSummaryRequest && + listEquals(trackKeys, other.trackKeys); + + @override + int get hashCode => Object.hashAll(trackKeys); +} + class LibraryCollectionsState { final List wishlist; final List loved; @@ -280,6 +329,10 @@ class LibraryCollectionsNotifier extends Notifier { final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; Future? _loadFuture; + void _invalidatePlaylistPickerSummaries() { + ref.invalidate(libraryPlaylistPickerSummariesProvider); + } + @override LibraryCollectionsState build() { _loadFuture = _load(); @@ -494,6 +547,7 @@ class LibraryCollectionsNotifier extends Notifier { updatedAt: now.toIso8601String(), ); state = state.copyWith(playlists: [playlist, ...state.playlists]); + _invalidatePlaylistPickerSummaries(); return id; } @@ -513,6 +567,7 @@ class LibraryCollectionsNotifier extends Notifier { _replacePlaylistById(playlistId, (playlist) { return playlist.copyWith(name: trimmed, updatedAt: now); }); + _invalidatePlaylistPickerSummaries(); } Future deletePlaylist(String playlistId) async { @@ -523,6 +578,7 @@ class LibraryCollectionsNotifier extends Notifier { await _db.deletePlaylist(playlistId); final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex); state = state.copyWith(playlists: updatedPlaylists); + _invalidatePlaylistPickerSummaries(); } Future addTrackToPlaylist(String playlistId, Track track) async { @@ -550,6 +606,7 @@ class LibraryCollectionsNotifier extends Notifier { ); }); if (!changed) return false; + _invalidatePlaylistPickerSummaries(); return true; } @@ -615,6 +672,7 @@ class LibraryCollectionsNotifier extends Notifier { alreadyInPlaylistCount: alreadyInPlaylistCount, ); } + _invalidatePlaylistPickerSummaries(); return PlaylistAddBatchResult( addedCount: entriesToAdd.length, alreadyInPlaylistCount: alreadyInPlaylistCount, @@ -642,6 +700,7 @@ class LibraryCollectionsNotifier extends Notifier { if (nextTracks.length == playlist.tracks.length) return playlist; return playlist.copyWith(tracks: nextTracks, updatedAt: now); }); + _invalidatePlaylistPickerSummaries(); } Future _playlistCoversDir() async { @@ -678,6 +737,7 @@ class LibraryCollectionsNotifier extends Notifier { if (playlist.coverImagePath == destPath) return playlist; return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now); }); + _invalidatePlaylistPickerSummaries(); } Future removePlaylistCover(String playlistId) async { @@ -703,6 +763,7 @@ class LibraryCollectionsNotifier extends Notifier { if (playlist.coverImagePath == null) return playlist; return playlist.copyWith(coverImagePath: () => null, updatedAt: now); }); + _invalidatePlaylistPickerSummaries(); } } @@ -710,3 +771,27 @@ final libraryCollectionsProvider = NotifierProvider( LibraryCollectionsNotifier.new, ); + +final libraryPlaylistPickerSummariesProvider = + FutureProvider.family< + List, + PlaylistPickerSummaryRequest + >((ref, request) async { + final db = LibraryCollectionsDatabase.instance; + await db.migrateFromSharedPreferences(); + final rows = await db.loadPlaylistPickerSummaries(request.trackKeys); + return rows + .map( + (row) => PlaylistPickerSummary( + id: row.id, + name: row.name, + coverImagePath: row.coverImagePath, + previewCover: row.previewCover, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + trackCount: row.trackCount, + containsAllRequestedTracks: row.containsAllRequestedTracks, + ), + ) + .toList(growable: false); + }); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 1d55a3d6..9957c399 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -390,6 +391,7 @@ class _AlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final cacheWidth = coverCacheWidthForViewport(context); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, @@ -401,6 +403,7 @@ class _AlbumScreenState extends ConsumerState { imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index d44f5453..5a320b39 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; @@ -462,6 +463,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final cacheWidth = coverCacheWidthForViewport(context); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, @@ -472,6 +474,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { Image.file( File(embeddedCoverPath), fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) @@ -480,6 +485,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 27f9a905..805ad025 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -258,6 +258,20 @@ class _HomeTabState extends ConsumerState List? _searchBucketsSourceTracks; _SearchResultBuckets? _searchBucketsCache; _SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder; + List? _sortedArtistsSource; + _SearchSortOption? _sortedArtistsMode; + List? _sortedArtistsCache; + List? _sortedAlbumsSource; + _SearchSortOption? _sortedAlbumsMode; + List? _sortedAlbumsCache; + List? _sortedPlaylistsSource; + _SearchSortOption? _sortedPlaylistsMode; + List? _sortedPlaylistsCache; + List? _sortedTracksSource; + List? _sortedTrackIndexesSource; + _SearchSortOption? _sortedTracksMode; + List? _sortedTracksCache; + List? _sortedTrackIndexesCache; double _responsiveScale({ required BuildContext context, @@ -476,6 +490,23 @@ class _HomeTabState extends ConsumerState return buckets; } + void _invalidateSearchSortCaches() { + _sortedArtistsSource = null; + _sortedArtistsMode = null; + _sortedArtistsCache = null; + _sortedAlbumsSource = null; + _sortedAlbumsMode = null; + _sortedAlbumsCache = null; + _sortedPlaylistsSource = null; + _sortedPlaylistsMode = null; + _sortedPlaylistsCache = null; + _sortedTracksSource = null; + _sortedTrackIndexesSource = null; + _sortedTracksMode = null; + _sortedTracksCache = null; + _sortedTrackIndexesCache = null; + } + void _onSearchFocusChanged() { if (mounted) { setState(() {}); @@ -577,6 +608,7 @@ class _HomeTabState extends ConsumerState if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; _searchSortOption = _SearchSortOption.defaultOrder; + _invalidateSearchSortCaches(); final isBuiltInProvider = searchProvider != null && @@ -1405,10 +1437,6 @@ class _HomeTabState extends ConsumerState itemCount: itemCount, itemBuilder: (context, index) { final item = items[index]; - final embeddedCoverPath = DownloadedEmbeddedCoverResolver.resolve( - item.filePath, - onChanged: _onEmbeddedCoverChanged, - ); return KeyedSubtree( key: ValueKey(item.id), child: Semantics( @@ -1421,48 +1449,15 @@ class _HomeTabState extends ConsumerState margin: const EdgeInsets.only(right: 12), child: Column( children: [ - ClipRRect( + _DownloadedOrRemoteCover( + downloadedFilePath: item.filePath, + imageUrl: item.coverUrl, + width: coverSize, + height: coverSize, borderRadius: BorderRadius.circular(12), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - width: coverSize, - height: coverSize, - fit: BoxFit.cover, - cacheWidth: (coverSize * 2).round(), - cacheHeight: (coverSize * 2).round(), - errorBuilder: (_, _, _) => Container( - width: coverSize, - height: coverSize, - color: - colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), - ) - : item.coverUrl != null - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: coverSize, - height: coverSize, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).round(), - memCacheHeight: (coverSize * 2).round(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: coverSize, - height: coverSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), + fallbackIcon: Icons.music_note, + fallbackIconSize: 32, + colorScheme: colorScheme, ), const SizedBox(height: 6), Text( @@ -1995,12 +1990,6 @@ class _HomeTabState extends ConsumerState IconData typeIcon; String typeLabel; final isDownloaded = item.providerId == 'download'; - final embeddedCoverPath = isDownloaded - ? DownloadedEmbeddedCoverResolver.resolve( - downloadFilePathByRecentKey['${item.type.name}:${item.id}'], - onChanged: _onEmbeddedCoverChanged, - ) - : null; switch (item.type) { case RecentAccessType.artist: @@ -2026,55 +2015,18 @@ class _HomeTabState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( children: [ - ClipRRect( + _DownloadedOrRemoteCover( + downloadedFilePath: isDownloaded + ? downloadFilePathByRecentKey['${item.type.name}:${item.id}'] + : null, + imageUrl: item.imageUrl, + width: 56, + height: 56, borderRadius: BorderRadius.circular( item.type == RecentAccessType.artist ? 28 : 4, ), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - width: 56, - height: 56, - fit: BoxFit.cover, - cacheWidth: 112, - cacheHeight: 112, - errorBuilder: (context, error, stackTrace) => Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - typeIcon, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : item.imageUrl != null && item.imageUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: item.imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - cacheManager: CoverCacheManager.instance, - errorWidget: (context, url, error) => Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - typeIcon, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - typeIcon, - color: colorScheme.onSurfaceVariant, - ), - ), + fallbackIcon: typeIcon, + colorScheme: colorScheme, ), const SizedBox(width: 12), Expanded( @@ -2570,6 +2522,114 @@ class _HomeTabState extends ConsumerState return sorted; } + List? _sortSearchArtists(List? artists) { + if (artists == null || + artists.isEmpty || + _searchSortOption == _SearchSortOption.defaultOrder) { + return artists; + } + if (identical(artists, _sortedArtistsSource) && + _sortedArtistsMode == _searchSortOption && + _sortedArtistsCache != null) { + return _sortedArtistsCache; + } + final sorted = _applySortToList( + artists, + (a) => a.name, + (a) => a.name, + (a) => 0, + (a) => null, + ); + _sortedArtistsSource = artists; + _sortedArtistsMode = _searchSortOption; + _sortedArtistsCache = sorted; + return sorted; + } + + List? _sortSearchAlbums(List? albums) { + if (albums == null || + albums.isEmpty || + _searchSortOption == _SearchSortOption.defaultOrder) { + return albums; + } + if (identical(albums, _sortedAlbumsSource) && + _sortedAlbumsMode == _searchSortOption && + _sortedAlbumsCache != null) { + return _sortedAlbumsCache; + } + final sorted = _applySortToList( + albums, + (a) => a.name, + (a) => a.artists, + (a) => 0, + (a) => a.releaseDate, + ); + _sortedAlbumsSource = albums; + _sortedAlbumsMode = _searchSortOption; + _sortedAlbumsCache = sorted; + return sorted; + } + + List? _sortSearchPlaylists(List? playlists) { + if (playlists == null || + playlists.isEmpty || + _searchSortOption == _SearchSortOption.defaultOrder) { + return playlists; + } + if (identical(playlists, _sortedPlaylistsSource) && + _sortedPlaylistsMode == _searchSortOption && + _sortedPlaylistsCache != null) { + return _sortedPlaylistsCache; + } + final sorted = _applySortToList( + playlists, + (p) => p.name, + (p) => p.owner, + (p) => 0, + (p) => null, + ); + _sortedPlaylistsSource = playlists; + _sortedPlaylistsMode = _searchSortOption; + _sortedPlaylistsCache = sorted; + return sorted; + } + + ({List tracks, List indexes}) _sortTrackResults( + List tracks, + List indexes, + ) { + if (tracks.isEmpty || _searchSortOption == _SearchSortOption.defaultOrder) { + return (tracks: tracks, indexes: indexes); + } + if (identical(tracks, _sortedTracksSource) && + identical(indexes, _sortedTrackIndexesSource) && + _sortedTracksMode == _searchSortOption && + _sortedTracksCache != null && + _sortedTrackIndexesCache != null) { + return (tracks: _sortedTracksCache!, indexes: _sortedTrackIndexesCache!); + } + final paired = List.generate( + tracks.length, + (i) => (tracks[i], indexes[i]), + growable: false, + ); + final sortedPairs = _applySortToList<(Track, int)>( + paired, + (p) => p.$1.name, + (p) => p.$1.artistName, + (p) => p.$1.duration, + (p) => p.$1.releaseDate, + ); + final sortedTracks = sortedPairs.map((p) => p.$1).toList(growable: false); + final sortedIndexes = sortedPairs.map((p) => p.$2).toList(growable: false); + _sortedTracksSource = tracks; + _sortedTrackIndexesSource = indexes; + _sortedTracksMode = _searchSortOption; + _sortedTracksCache = sortedTracks; + _sortedTrackIndexesCache = sortedIndexes; + return (tracks: sortedTracks, indexes: sortedIndexes); + } + List _buildSearchResults({ required List tracks, required List? searchArtists, @@ -2603,58 +2663,12 @@ class _HomeTabState extends ConsumerState final playlistItems = buckets.playlistItems; final artistItems = buckets.artistItems; - final sortedArtists = searchArtists != null && searchArtists.isNotEmpty - ? _applySortToList( - searchArtists, - (a) => a.name, - (a) => a.name, - (a) => 0, - (a) => null, - ) - : searchArtists; - - final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty - ? _applySortToList( - searchAlbums, - (a) => a.name, - (a) => a.artists, - (a) => 0, - (a) => a.releaseDate, - ) - : searchAlbums; - - final sortedPlaylists = - searchPlaylists != null && searchPlaylists.isNotEmpty - ? _applySortToList( - searchPlaylists, - (p) => p.name, - (p) => p.owner, - (p) => 0, - (p) => null, - ) - : searchPlaylists; - - List sortedTracks; - List sortedTrackIndexes; - if (realTracks.isNotEmpty && - _searchSortOption != _SearchSortOption.defaultOrder) { - final paired = List.generate( - realTracks.length, - (i) => (realTracks[i], realTrackIndexes[i]), - ); - final sortedPairs = _applySortToList<(Track, int)>( - paired, - (p) => p.$1.name, - (p) => p.$1.artistName, - (p) => p.$1.duration, - (p) => p.$1.releaseDate, - ); - sortedTracks = sortedPairs.map((p) => p.$1).toList(); - sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList(); - } else { - sortedTracks = realTracks; - sortedTrackIndexes = realTrackIndexes; - } + final sortedArtists = _sortSearchArtists(searchArtists); + final sortedAlbums = _sortSearchAlbums(searchAlbums); + final sortedPlaylists = _sortSearchPlaylists(searchPlaylists); + final sortedTrackResults = _sortTrackResults(realTracks, realTrackIndexes); + final sortedTracks = sortedTrackResults.tracks; + final sortedTrackIndexes = sortedTrackResults.indexes; final slivers = [ if (error != null) @@ -3689,9 +3703,7 @@ class _TrackItemWithStatus extends ConsumerWidget { Divider( height: 1, thickness: 1, - indent: - thumbWidth + - 24, + indent: thumbWidth + 24, endIndent: 12, color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), @@ -4161,6 +4173,126 @@ class _SearchPlaylistItemWidget extends StatelessWidget { } } +class _DownloadedOrRemoteCover extends StatefulWidget { + final String? downloadedFilePath; + final String? imageUrl; + final double width; + final double height; + final BorderRadius borderRadius; + final IconData fallbackIcon; + final double fallbackIconSize; + final ColorScheme colorScheme; + + const _DownloadedOrRemoteCover({ + required this.downloadedFilePath, + required this.imageUrl, + required this.width, + required this.height, + required this.borderRadius, + required this.fallbackIcon, + required this.colorScheme, + this.fallbackIconSize = 24, + }); + + @override + State<_DownloadedOrRemoteCover> createState() => + _DownloadedOrRemoteCoverState(); +} + +class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> { + String? _embeddedCoverPath; + bool _refreshScheduled = false; + + @override + void initState() { + super.initState(); + _embeddedCoverPath = _resolveEmbeddedCoverPath(); + } + + @override + void didUpdateWidget(covariant _DownloadedOrRemoteCover oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.downloadedFilePath != widget.downloadedFilePath || + oldWidget.imageUrl != widget.imageUrl) { + final nextPath = _resolveEmbeddedCoverPath(); + if (nextPath != _embeddedCoverPath) { + setState(() => _embeddedCoverPath = nextPath); + } + } + } + + String? _resolveEmbeddedCoverPath() { + final filePath = widget.downloadedFilePath; + if (filePath == null || filePath.isEmpty) return null; + return DownloadedEmbeddedCoverResolver.resolve( + filePath, + onChanged: _onEmbeddedCoverChanged, + ); + } + + void _onEmbeddedCoverChanged() { + if (!mounted || _refreshScheduled) return; + _refreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _refreshScheduled = false; + if (!mounted) return; + final nextPath = _resolveEmbeddedCoverPath(); + if (nextPath != _embeddedCoverPath) { + setState(() => _embeddedCoverPath = nextPath); + } + }); + } + + Widget _fallback() { + return Container( + width: widget.width, + height: widget.height, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + widget.fallbackIcon, + color: widget.colorScheme.onSurfaceVariant, + size: widget.fallbackIconSize, + ), + ); + } + + @override + Widget build(BuildContext context) { + final cacheWidth = (widget.width * 2).round(); + final cacheHeight = (widget.height * 2).round(); + + Widget child; + if (_embeddedCoverPath != null) { + child = Image.file( + File(_embeddedCoverPath!), + width: widget.width, + height: widget.height, + fit: BoxFit.cover, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => _fallback(), + ); + } else if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) { + child = CachedNetworkImage( + imageUrl: widget.imageUrl!, + width: widget.width, + height: widget.height, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + memCacheHeight: cacheHeight, + cacheManager: CoverCacheManager.instance, + errorWidget: (_, _, _) => _fallback(), + ); + } else { + child = _fallback(); + } + + return ClipRRect(borderRadius: widget.borderRadius, child: child); + } +} + class ExtensionAlbumScreen extends ConsumerStatefulWidget { final String extensionId; final String albumId; diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e6b07663..2a0aefa2 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -336,6 +337,7 @@ class _LocalAlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final cacheWidth = coverCacheWidthForViewport(context); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, @@ -346,6 +348,9 @@ class _LocalAlbumScreenState extends ConsumerState { Image.file( File(widget.coverPath!), fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 8646b268..3f6a3015 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; @@ -242,6 +243,7 @@ class _PlaylistScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final cacheWidth = coverCacheWidthForViewport(context); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, @@ -252,6 +254,7 @@ class _PlaylistScreenState extends ConsumerState { CachedNetworkImage( imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 6481f579..a6b4b409 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -359,6 +359,24 @@ class _QueueGroupedAlbumFilterRequest { ); } +class _QueueHistoryStatsMemoEntry { + final List historyItems; + final List localItems; + final _HistoryStats stats; + + const _QueueHistoryStatsMemoEntry({ + required this.historyItems, + required this.localItems, + required this.stats, + }); +} + +_QueueHistoryStatsMemoEntry? _queueHistoryStatsMemo; + +String _queueHistoryAlbumKey(String albumName, String artistName) { + return '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; +} + String _queueFileExtLower(String filePath) { final slashIndex = filePath.lastIndexOf('/'); final dotIndex = filePath.lastIndexOf('.'); @@ -558,21 +576,31 @@ _HistoryStats _buildQueueHistoryStats( List items, [ List localItems = const [], ]) { + final memo = _queueHistoryStatsMemo; + if (memo != null && + identical(memo.historyItems, items) && + identical(memo.localItems, localItems)) { + return memo.stats; + } + final albumCounts = {}; final albumMap = >{}; for (final item in items) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + final key = _queueHistoryAlbumKey( + item.albumName, + item.albumArtist ?? item.artistName, + ); albumCounts[key] = (albumCounts[key] ?? 0) + 1; albumMap.putIfAbsent(key, () => []).add(item); } var singleTracks = 0; - for (final item in items) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - if ((albumCounts[key] ?? 0) <= 1) { - singleTracks++; + var albumCount = 0; + for (final count in albumCounts.values) { + if (count > 1) { + albumCount++; + } else { + singleTracks += count; } } @@ -600,11 +628,6 @@ _HistoryStats _buildQueueHistoryStats( }); groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - var albumCount = 0; - for (final count in albumCounts.values) { - if (count > 1) albumCount++; - } - final downloadedPathKeys = {}; for (final item in items) { downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); @@ -620,8 +643,10 @@ _HistoryStats _buildQueueHistoryStats( final localAlbumCounts = {}; final localAlbumMap = >{}; for (final item in dedupedLocalItems) { - final key = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + final key = _queueHistoryAlbumKey( + item.albumName, + item.albumArtist ?? item.artistName, + ); localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; localAlbumMap.putIfAbsent(key, () => []).add(item); } @@ -664,7 +689,7 @@ _HistoryStats _buildQueueHistoryStats( }); groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); - return _HistoryStats( + final stats = _HistoryStats( albumCounts: albumCounts, localAlbumCounts: localAlbumCounts, groupedAlbums: groupedAlbums, @@ -674,6 +699,12 @@ _HistoryStats _buildQueueHistoryStats( localAlbumCount: localAlbumCount, localSingleTracks: localSingleTracks, ); + _queueHistoryStatsMemo = _QueueHistoryStatsMemoEntry( + historyItems: items, + localItems: localItems, + stats: stats, + ); + return stats; } List<_GroupedAlbum> _queueFilterGroupedAlbums( @@ -1121,6 +1152,10 @@ class _QueueTabState extends ConsumerState { List _cachedUnifiedLocal = const []; List? _cachedDownloadedPathKeysSource; Set _cachedDownloadedPathKeys = const {}; + final Map> _localPathMatchKeysCache = {}; + List? _cachedLocalSinglesSource; + Map? _cachedLocalSinglesAlbumCountsSource; + List _cachedLocalSingles = const []; final Map _filterContentDataCache = {}; List? _filterCacheAllHistoryItems; _HistoryStats? _filterCacheHistoryStats; @@ -1264,9 +1299,13 @@ class _QueueTabState extends ConsumerState { } if (localChanged) { _localSearchIndexCache.clear(); + _localPathMatchKeysCache.clear(); _localFilterItemsCache = null; _localFilterQueryCache = ''; _filteredLocalItemsCache = const []; + _cachedLocalSinglesSource = null; + _cachedLocalSinglesAlbumCountsSource = null; + _cachedLocalSingles = const []; _cachedUnifiedLocalSource = null; _cachedUnifiedLocal = const []; } @@ -1356,6 +1395,32 @@ class _QueueTabState extends ConsumerState { return _cachedDownloadedPathKeys; } + List _localPathMatchKeys(LocalLibraryItem item) { + final cached = _localPathMatchKeysCache[item.id]; + if (cached != null) return cached; + final keys = buildPathMatchKeys(item.filePath).toList(growable: false); + _localPathMatchKeysCache[item.id] = keys; + return keys; + } + + List _localSingleItems( + List items, + Map localAlbumCounts, + ) { + if (identical(items, _cachedLocalSinglesSource) && + identical(localAlbumCounts, _cachedLocalSinglesAlbumCountsSource)) { + return _cachedLocalSingles; + } + + final singles = items + .where((item) => (localAlbumCounts[item.albumKey] ?? 0) == 1) + .toList(growable: false); + _cachedLocalSinglesSource = items; + _cachedLocalSinglesAlbumCountsSource = localAlbumCounts; + _cachedLocalSingles = singles; + return singles; + } + List _filterLocalItems( List items, String query, @@ -3617,12 +3682,10 @@ class _QueueTabState extends ConsumerState { if (filterMode == 'all') { localItemsForMerge = _filterLocalItems(localLibraryItems, query); } else { - final localSingles = localLibraryItems - .where((item) { - final count = localAlbumCounts[item.albumKey] ?? 0; - return count == 1; - }) - .toList(growable: false); + final localSingles = _localSingleItems( + localLibraryItems, + localAlbumCounts, + ); localItemsForMerge = _filterLocalItems(localSingles, query); } @@ -3631,7 +3694,10 @@ class _QueueTabState extends ConsumerState { final dedupedUnifiedLocal = []; for (final item in unifiedLocal) { - final localPathKeys = buildPathMatchKeys(item.filePath); + final localSource = item.localItem; + final localPathKeys = localSource != null + ? _localPathMatchKeys(localSource) + : buildPathMatchKeys(item.filePath); final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains); if (!overlapsDownloaded) { dedupedUnifiedLocal.add(item); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index dd3024ad..02334b2e 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -22,6 +22,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; +import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; @@ -29,11 +30,11 @@ final _log = AppLogger('TrackMetadata'); class _EmbeddedCoverPreviewCacheEntry { final String previewPath; - final int? fileModTime; + final String? sourceValidationToken; const _EmbeddedCoverPreviewCacheEntry({ required this.previewPath, - this.fileModTime, + this.sourceValidationToken, }); } @@ -119,70 +120,75 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - int? _readLocalFileModTimeMsSync(String path) { + Future _readLocalFileValidationToken(String path) async { if (path.isEmpty || isContentUri(path) || _isVolatileSafTempPath(path)) { return null; } try { - return File(path).statSync().modified.millisecondsSinceEpoch; + final stat = await fileStat(path); + if (stat == null) return null; + return '${stat.modified?.millisecondsSinceEpoch ?? 0}:${stat.size ?? 0}'; } catch (_) { return null; } } - void _cacheEmbeddedCoverPreview( + Future _cacheEmbeddedCoverPreview( String cacheKey, String sourcePath, String previewPath, - ) { - final fileModTime = _readLocalFileModTimeMsSync(sourcePath); + ) async { + final sourceValidationToken = await _readLocalFileValidationToken( + sourcePath, + ); final existing = _embeddedCoverPreviewCache[cacheKey]; _embeddedCoverPreviewCache[cacheKey] = _EmbeddedCoverPreviewCacheEntry( previewPath: previewPath, - fileModTime: fileModTime, + sourceValidationToken: sourceValidationToken, ); if (existing != null && existing.previewPath != previewPath) { - _cleanupTempFileAndParentSyncIfNotCached(existing.previewPath); + await _cleanupTempFileAndParentIfNotCached(existing.previewPath); } while (_embeddedCoverPreviewCache.length > _maxCoverPreviewCacheEntries) { final oldestKey = _embeddedCoverPreviewCache.keys.first; final removed = _embeddedCoverPreviewCache.remove(oldestKey); if (removed != null) { - _cleanupTempFileAndParentSyncIfNotCached(removed.previewPath); + await _cleanupTempFileAndParentIfNotCached(removed.previewPath); } } } - void _invalidateEmbeddedCoverPreviewCacheForPath(String cacheKey) { + Future _invalidateEmbeddedCoverPreviewCacheForPath( + String cacheKey, + ) async { if (cacheKey.isEmpty) return; final removed = _embeddedCoverPreviewCache.remove(cacheKey); if (removed != null) { - _cleanupTempFileAndParentSyncIfNotCached(removed.previewPath); + await _cleanupTempFileAndParentIfNotCached(removed.previewPath); } } - String? _getCachedEmbeddedCoverPreviewPathIfValid( + Future _getCachedEmbeddedCoverPreviewPathIfValid( String cacheKey, String sourcePath, - ) { + ) async { if (cacheKey.isEmpty) return null; final cached = _embeddedCoverPreviewCache[cacheKey]; if (cached == null) return null; - final previewFile = File(cached.previewPath); - if (!previewFile.existsSync()) { + if (!await fileExists(cached.previewPath)) { _embeddedCoverPreviewCache.remove(cacheKey); return null; } if (!isContentUri(sourcePath) && !_isVolatileSafTempPath(sourcePath)) { - final currentModTime = _readLocalFileModTimeMsSync(sourcePath); - if (currentModTime != null && - cached.fileModTime != null && - currentModTime != cached.fileModTime) { + final currentToken = await _readLocalFileValidationToken(sourcePath); + if (currentToken != null && + cached.sourceValidationToken != null && + currentToken != cached.sourceValidationToken) { _embeddedCoverPreviewCache.remove(cacheKey); - _cleanupTempFileAndParentSyncIfNotCached(cached.previewPath); + await _cleanupTempFileAndParentIfNotCached(cached.previewPath); return null; } } @@ -199,7 +205,7 @@ class _TrackMetadataScreenState extends ConsumerState { @override void dispose() { - _cleanupTempFileAndParentSyncIfNotCached(_embeddedCoverPreviewPath); + unawaited(_cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath)); _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); @@ -251,7 +257,7 @@ class _TrackMetadataScreenState extends ConsumerState { unawaited(_refreshResolvedAudioMetadataFromFile()); } if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) { - final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid( + final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid( _coverCacheKey, cleanFilePath, ); @@ -374,32 +380,11 @@ class _TrackMetadataScreenState extends ConsumerState { } } - void _cleanupTempFileAndParentSync(String? path) { - if (!_hasPath(path)) return; - final file = File(path!); - try { - if (file.existsSync()) { - file.deleteSync(); - } - } catch (_) {} - try { - final dir = file.parent; - if (dir.existsSync()) { - dir.deleteSync(recursive: true); - } - } catch (_) {} - } - - void _cleanupTempFileAndParentSyncIfNotCached(String? path) { - if (_isCacheTrackedPath(path)) return; - _cleanupTempFileAndParentSync(path); - } - Future _refreshEmbeddedCoverPreview({bool force = false}) async { final cacheKey = _coverCacheKey; final sourcePath = cleanFilePath; if (!force) { - final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid( + final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid( cacheKey, sourcePath, ); @@ -414,7 +399,7 @@ class _TrackMetadataScreenState extends ConsumerState { String? newPreviewPath; try { if (!_fileExists) { - _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath); if (mounted) { setState(() => _embeddedCoverPreviewPath = null); @@ -422,7 +407,7 @@ class _TrackMetadataScreenState extends ConsumerState { return; } if (force) { - _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); } final tempDir = await Directory.systemTemp.createTemp( 'track_cover_preview_', @@ -435,7 +420,7 @@ class _TrackMetadataScreenState extends ConsumerState { ); if (result['error'] == null && await File(outputPath).exists()) { newPreviewPath = outputPath; - _cacheEmbeddedCoverPreview(cacheKey, sourcePath, outputPath); + await _cacheEmbeddedCoverPreview(cacheKey, sourcePath, outputPath); } else { try { await tempDir.delete(recursive: true); @@ -880,16 +865,21 @@ class _TrackMetadataScreenState extends ConsumerState { double expandedHeight, bool showContent, ) { + final cacheWidth = coverCacheWidthForViewport(context); final coverChild = _hasPath(_embeddedCoverPreviewPath) ? Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) : _coverUrl != null ? CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), @@ -898,6 +888,9 @@ class _TrackMetadataScreenState extends ConsumerState { ? Image.file( File(_localCoverPath!), fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) : Container( @@ -4392,29 +4385,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } catch (_) {} } - void _cleanupSelectedCoverTempSync() { - final dirPath = _selectedCoverTempDir; - _selectedCoverPath = null; - _selectedCoverTempDir = null; - _selectedCoverName = null; - if (dirPath == null || dirPath.isEmpty) return; - try { - final dir = Directory(dirPath); - if (dir.existsSync()) { - dir.deleteSync(recursive: true); - } - } catch (_) {} - } - - void _cleanupCurrentCoverTempSync() { + Future _cleanupCurrentCoverTemp() async { final dirPath = _currentCoverTempDir; _currentCoverPath = null; _currentCoverTempDir = null; if (dirPath == null || dirPath.isEmpty) return; try { final dir = Directory(dirPath); - if (dir.existsSync()) { - dir.deleteSync(recursive: true); + if (await dir.exists()) { + await dir.delete(recursive: true); } } catch (_) {} } @@ -5132,8 +5111,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { @override void dispose() { - _cleanupSelectedCoverTempSync(); - _cleanupCurrentCoverTempSync(); + unawaited(_cleanupSelectedCoverTemp()); + unawaited(_cleanupCurrentCoverTemp()); _titleCtrl.dispose(); _artistCtrl.dispose(); _albumCtrl.dispose(); @@ -5276,8 +5255,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { await tempDir.delete(recursive: true); } catch (_) {} } - } catch (_) { - } + } catch (_) {} } String? ffmpegResult; diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index db813114..d6af1c05 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -33,6 +33,28 @@ class LibraryCollectionsSnapshot { }); } +class PlaylistPickerSummaryRow { + final String id; + final String name; + final String? coverImagePath; + final String? previewCover; + final DateTime createdAt; + final DateTime updatedAt; + final int trackCount; + final bool containsAllRequestedTracks; + + const PlaylistPickerSummaryRow({ + required this.id, + required this.name, + this.coverImagePath, + this.previewCover, + required this.createdAt, + required this.updatedAt, + required this.trackCount, + required this.containsAllRequestedTracks, + }); +} + class LibraryCollectionsDatabase { static final LibraryCollectionsDatabase instance = LibraryCollectionsDatabase._init(); @@ -251,6 +273,119 @@ class LibraryCollectionsDatabase { ); } + Future> loadPlaylistPickerSummaries( + List requestedTrackKeys, + ) async { + final db = await database; + final uniqueTrackKeys = requestedTrackKeys + .where((key) => key.trim().isNotEmpty) + .toSet() + .toList(growable: false); + + final playlistRows = await db.rawQuery(''' + SELECT + p.id, + p.name, + p.cover_image_path, + p.created_at, + p.updated_at, + COUNT(pt.track_key) AS track_count + FROM $_tablePlaylists p + LEFT JOIN $_tablePlaylistTracks pt ON pt.playlist_id = p.id + GROUP BY p.id + ORDER BY p.created_at DESC, p.rowid DESC + '''); + + final matchedCountsByPlaylistId = {}; + if (uniqueTrackKeys.isNotEmpty) { + final placeholders = List.filled(uniqueTrackKeys.length, '?').join(', '); + final matchedRows = await db.rawQuery(''' + SELECT playlist_id, COUNT(*) AS matched_count + FROM $_tablePlaylistTracks + WHERE track_key IN ($placeholders) + GROUP BY playlist_id + ''', uniqueTrackKeys); + for (final row in matchedRows) { + final playlistId = row['playlist_id']?.toString(); + if (playlistId == null || playlistId.isEmpty) continue; + matchedCountsByPlaylistId[playlistId] = + (row['matched_count'] as num?)?.toInt() ?? 0; + } + } + + final playlistIdsNeedingPreview = playlistRows + .where((row) { + final coverPath = row['cover_image_path']?.toString(); + return coverPath == null || coverPath.isEmpty; + }) + .map((row) => row['id']?.toString() ?? '') + .where((id) => id.isNotEmpty) + .toList(growable: false); + + final previewCoverByPlaylistId = {}; + if (playlistIdsNeedingPreview.isNotEmpty) { + final placeholders = List.filled( + playlistIdsNeedingPreview.length, + '?', + ).join(', '); + final previewRows = await db.rawQuery(''' + SELECT outer_tracks.playlist_id, outer_tracks.track_json + FROM $_tablePlaylistTracks outer_tracks + WHERE outer_tracks.playlist_id IN ($placeholders) + AND outer_tracks.rowid = ( + SELECT inner_tracks.rowid + FROM $_tablePlaylistTracks inner_tracks + WHERE inner_tracks.playlist_id = outer_tracks.playlist_id + ORDER BY inner_tracks.added_at DESC, inner_tracks.rowid DESC + LIMIT 1 + ) + ''', playlistIdsNeedingPreview); + + for (final row in previewRows) { + final playlistId = row['playlist_id']?.toString(); + final trackJson = row['track_json'] as String?; + if (playlistId == null || + playlistId.isEmpty || + trackJson == null || + trackJson.isEmpty) { + continue; + } + try { + final decoded = jsonDecode(trackJson); + if (decoded is! Map) continue; + final coverUrl = decoded['coverUrl']?.toString(); + if (coverUrl != null && coverUrl.isNotEmpty) { + previewCoverByPlaylistId[playlistId] = coverUrl; + } + } catch (_) {} + } + } + + return playlistRows + .map((row) { + final id = row['id']?.toString() ?? ''; + final createdAt = + DateTime.tryParse(row['created_at']?.toString() ?? '') ?? + DateTime.now(); + final updatedAt = + DateTime.tryParse(row['updated_at']?.toString() ?? '') ?? + createdAt; + return PlaylistPickerSummaryRow( + id: id, + name: row['name']?.toString() ?? '', + coverImagePath: row['cover_image_path'] as String?, + previewCover: previewCoverByPlaylistId[id], + createdAt: createdAt, + updatedAt: updatedAt, + trackCount: (row['track_count'] as num?)?.toInt() ?? 0, + containsAllRequestedTracks: + uniqueTrackKeys.isNotEmpty && + matchedCountsByPlaylistId[id] == uniqueTrackKeys.length, + ); + }) + .toList(growable: false); + } + Future upsertWishlistEntry({ required String trackKey, required String trackJson, diff --git a/lib/utils/image_cache_utils.dart b/lib/utils/image_cache_utils.dart new file mode 100644 index 00000000..b6c6757e --- /dev/null +++ b/lib/utils/image_cache_utils.dart @@ -0,0 +1,12 @@ +import 'package:flutter/widgets.dart'; + +int coverCacheWidthForViewport( + BuildContext context, { + double widthMultiplier = 1.0, + int min = 320, + int max = 2048, +}) { + final dpr = MediaQuery.devicePixelRatioOf(context); + final logicalWidth = MediaQuery.sizeOf(context).width * widthMultiplier; + return (logicalWidth * dpr).round().clamp(min, max); +} diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 238b5350..30499b11 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -19,9 +19,9 @@ Future showAddTrackToPlaylistSheet( Future showAddTracksToPlaylistSheet( BuildContext context, WidgetRef ref, - List tracks, - {String? playlistNamePrefill} -) async { + List tracks, { + String? playlistNamePrefill, +}) async { if (tracks.isEmpty) return; if (!context.mounted) return; @@ -32,7 +32,10 @@ Future showAddTracksToPlaylistSheet( showDragHandle: true, isScrollControlled: true, builder: (sheetContext) { - return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill); + return _PlaylistPickerSheetContent( + tracks: tracks, + playlistNamePrefill: playlistNamePrefill, + ); }, ); } @@ -41,7 +44,10 @@ class _PlaylistPickerSheetContent extends ConsumerStatefulWidget { final List tracks; final String? playlistNamePrefill; - const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill}); + const _PlaylistPickerSheetContent({ + required this.tracks, + this.playlistNamePrefill, + }); @override ConsumerState<_PlaylistPickerSheetContent> createState() => @@ -50,37 +56,33 @@ class _PlaylistPickerSheetContent extends ConsumerStatefulWidget { class _PlaylistPickerSheetContentState extends ConsumerState<_PlaylistPickerSheetContent> { + late final PlaylistPickerSummaryRequest _summaryRequest; final Set _selectedPlaylistIds = {}; - final Set _initialDisabledIds = {}; - bool _initialized = false; + final Set _committedPlaylistIds = {}; @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (!_initialized) { - final playlists = ref.read(libraryCollectionsProvider).playlists; - for (final playlist in playlists) { - final alreadyInPlaylist = - widget.tracks.every((t) => playlist.containsTrack(t)); - if (alreadyInPlaylist) { - _initialDisabledIds.add(playlist.id); - _selectedPlaylistIds.add(playlist.id); - } - } - _initialized = true; - } + void initState() { + super.initState(); + _summaryRequest = PlaylistPickerSummaryRequest.fromTracks(widget.tracks); } - void _handleDone() async { + void _handleDone(List playlists) async { final notifier = ref.read(libraryCollectionsProvider.notifier); - final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final effectiveDisabledIds = { + ..._committedPlaylistIds, + for (final playlist in playlists) + if (playlist.containsAllRequestedTracks) playlist.id, + }; + final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds); + final playlistNamesById = { + for (final playlist in playlists) playlist.id: playlist.name, + }; final addedNames = []; for (final playlistId in idsToAdd) { - final playlist = - ref.read(libraryCollectionsProvider).playlistById(playlistId); - if (playlist != null) { - addedNames.add(playlist.name); + final playlistName = playlistNamesById[playlistId]; + if (playlistName != null && playlistName.isNotEmpty) { + addedNames.add(playlistName); } await notifier.addTracksToPlaylist(playlistId, widget.tracks); } @@ -89,20 +91,19 @@ class _PlaylistPickerSheetContentState Navigator.of(context).pop(); if (addedNames.isNotEmpty) { - final name = - addedNames.length == 1 ? addedNames.first : addedNames.join(', '); + final name = addedNames.length == 1 + ? addedNames.first + : addedNames.join(', '); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.collectionAddedToPlaylist(name)), - ), + SnackBar(content: Text(context.l10n.collectionAddedToPlaylist(name))), ); } } @override Widget build(BuildContext context) { - final playlists = ref.watch( - libraryCollectionsProvider.select((state) => state.playlists), + final playlistSummariesValue = ref.watch( + libraryPlaylistPickerSummariesProvider(_summaryRequest), ); final notifier = ref.read(libraryCollectionsProvider.notifier); @@ -115,7 +116,13 @@ class _PlaylistPickerSheetContentState '${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}'; } - final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final resolvedPlaylists = playlistSummariesValue.asData?.value ?? const []; + final effectiveDisabledIds = { + ..._committedPlaylistIds, + for (final playlist in resolvedPlaylists) + if (playlist.containsAllRequestedTracks) playlist.id, + }; + final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds); final hasNewSelections = idsToAdd.isNotEmpty; return SafeArea( @@ -132,74 +139,104 @@ class _PlaylistPickerSheetContentState leading: const Icon(Icons.add_circle_outline), title: Text(context.l10n.collectionCreatePlaylist), onTap: () async { - final name = await _promptPlaylistName(context, widget.playlistNamePrefill); + final name = await _promptPlaylistName( + context, + widget.playlistNamePrefill, + ); if (name == null || name.trim().isEmpty || !context.mounted) { return; } final playlistId = await notifier.createPlaylist(name.trim()); await notifier.addTracksToPlaylist(playlistId, widget.tracks); setState(() { - _initialDisabledIds.add(playlistId); - _selectedPlaylistIds.add(playlistId); + _committedPlaylistIds.add(playlistId); + _selectedPlaylistIds.remove(playlistId); }); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.collectionAddedToPlaylist(name.trim())), + content: Text( + context.l10n.collectionAddedToPlaylist(name.trim()), + ), ), ); }, ), - if (playlists.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Text( - context.l10n.collectionNoPlaylistsYet, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ) - else - Flexible( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 320), - child: ListView.builder( - shrinkWrap: true, - itemCount: playlists.length, - itemBuilder: (context, index) { - final playlist = playlists[index]; - final isAlreadyIn = _initialDisabledIds.contains(playlist.id); - final isSelected = _selectedPlaylistIds.contains(playlist.id); - - return ListTile( - leading: _PlaylistPickerThumbnail( - playlist: playlist, - isSelected: isSelected, - ), - title: Text(playlist.name), - subtitle: Text( - context.l10n.collectionPlaylistTracks( - playlist.tracks.length, + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: playlistSummariesValue.when( + data: (playlists) { + if (playlists.isEmpty) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - enabled: !isAlreadyIn, - onTap: !isAlreadyIn - ? () { - setState(() { - if (isSelected) { - _selectedPlaylistIds.remove(playlist.id); - } else { - _selectedPlaylistIds.add(playlist.id); - } - }); - } - : null, ); - }, + } + return ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + final isAlreadyIn = effectiveDisabledIds.contains( + playlist.id, + ); + final isSelected = + _selectedPlaylistIds.contains(playlist.id) || + isAlreadyIn; + + return ListTile( + leading: _PlaylistPickerThumbnail( + playlist: playlist, + isSelected: isSelected, + ), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.trackCount, + ), + ), + enabled: !isAlreadyIn, + onTap: !isAlreadyIn + ? () { + setState(() { + if (_selectedPlaylistIds.contains( + playlist.id, + )) { + _selectedPlaylistIds.remove(playlist.id); + } else { + _selectedPlaylistIds.add(playlist.id); + } + }); + } + : null, + ); + }, + ); + }, + loading: () => const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + error: (_, _) => Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), ), ), + ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), @@ -208,7 +245,7 @@ class _PlaylistPickerSheetContentState child: FilledButton( onPressed: () { if (hasNewSelections) { - _handleDone(); + _handleDone(resolvedPlaylists); } else { Navigator.of(context).pop(); } @@ -223,7 +260,10 @@ class _PlaylistPickerSheetContentState } } -Future _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async { +Future _promptPlaylistName( + BuildContext context, + String? playlistNamePrefill, +) async { final controller = TextEditingController(text: playlistNamePrefill); final formKey = GlobalKey(); @@ -275,7 +315,7 @@ Future _promptPlaylistName(BuildContext context, String? playlistNamePr } class _PlaylistPickerThumbnail extends StatelessWidget { - final UserPlaylistCollection playlist; + final PlaylistPickerSummary playlist; final bool isSelected; const _PlaylistPickerThumbnail({ @@ -341,15 +381,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget { ); } - String? firstCoverUrl; - for (final entry in playlist.tracks) { - final coverUrl = entry.track.coverUrl; - if (coverUrl != null && coverUrl.isNotEmpty) { - firstCoverUrl = coverUrl; - break; - } - } - + final firstCoverUrl = playlist.previewCover; if (firstCoverUrl != null) { final isLocalPath = !firstCoverUrl.startsWith('http://') &&