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
This commit is contained in:
zarzet 2026-04-03 22:31:04 +07:00
parent 2b47537bb5
commit 38a8b715f8
13 changed files with 927 additions and 352 deletions

View file

@ -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 == "" {

View file

@ -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<ExploreItem> items) {
return true;
}
List<Map<String, Object?>> _normalizeExploreSectionsPayload(
dynamic rawSections,
) {
if (rawSections is! List) return const [];
final sections = <Map<String, Object?>>[];
for (final rawSection in rawSections) {
if (rawSection is! Map) continue;
final section = Map<Object?, Object?>.from(rawSection);
final rawItems = section['items'];
final items = <Map<String, Object?>>[];
if (rawItems is List) {
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
items.add(Map<String, Object?>.from(rawItem));
}
}
sections.add({
'uri': section['uri']?.toString() ?? '',
'title': section['title']?.toString() ?? '',
'items': items,
});
}
return sections;
}
List<Map<String, Object?>> _decodeExploreCacheSections(String rawCache) {
final decoded = jsonDecode(rawCache);
if (decoded is! Map) return const [];
return _normalizeExploreSectionsPayload(decoded['sections']);
}
String _encodeExploreCacheSections(List<Map<String, Object?>> sections) {
return jsonEncode({'sections': sections});
}
List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
List<Map<String, Object?>> normalizedSections,
) {
return normalizedSections
.map(
(section) =>
ExploreSection.fromJson(Map<String, dynamic>.from(section)),
)
.toList(growable: false);
}
class ExploreNotifier extends Notifier<ExploreState> {
static const _cacheKey = 'explore_home_feed_cache';
static const _cacheTsKey = 'explore_home_feed_ts';
@ -179,11 +226,13 @@ class ExploreNotifier extends Notifier<ExploreState> {
final cachedTs = prefs.getInt(_cacheTsKey);
if (cached == null || cached.isEmpty) return;
final data = jsonDecode(cached) as Map<String, dynamic>;
final sectionsData = data['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.toList();
final normalizedSections = await compute(
_decodeExploreCacheSections,
cached,
);
final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
);
if (sections.isEmpty) return;
@ -202,13 +251,18 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
Future<void> _saveToCache(List<ExploreSection> sections) async {
Future<void> _saveToCache(
List<Map<String, Object?>> 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<ExploreState> {
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.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<ExploreState> {
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());

View file

@ -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<String> trackKeys;
PlaylistPickerSummaryRequest._(this.trackKeys);
factory PlaylistPickerSummaryRequest.fromTracks(Iterable<Track> 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<CollectionTrackEntry> wishlist;
final List<CollectionTrackEntry> loved;
@ -280,6 +329,10 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
Future<void>? _loadFuture;
void _invalidatePlaylistPickerSummaries() {
ref.invalidate(libraryPlaylistPickerSummariesProvider);
}
@override
LibraryCollectionsState build() {
_loadFuture = _load();
@ -494,6 +547,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
updatedAt: now.toIso8601String(),
);
state = state.copyWith(playlists: [playlist, ...state.playlists]);
_invalidatePlaylistPickerSummaries();
return id;
}
@ -513,6 +567,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
_replacePlaylistById(playlistId, (playlist) {
return playlist.copyWith(name: trimmed, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
Future<void> deletePlaylist(String playlistId) async {
@ -523,6 +578,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
await _db.deletePlaylist(playlistId);
final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex);
state = state.copyWith(playlists: updatedPlaylists);
_invalidatePlaylistPickerSummaries();
}
Future<bool> addTrackToPlaylist(String playlistId, Track track) async {
@ -550,6 +606,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
);
});
if (!changed) return false;
_invalidatePlaylistPickerSummaries();
return true;
}
@ -615,6 +672,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
_invalidatePlaylistPickerSummaries();
return PlaylistAddBatchResult(
addedCount: entriesToAdd.length,
alreadyInPlaylistCount: alreadyInPlaylistCount,
@ -642,6 +700,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
if (nextTracks.length == playlist.tracks.length) return playlist;
return playlist.copyWith(tracks: nextTracks, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
Future<Directory> _playlistCoversDir() async {
@ -678,6 +737,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
if (playlist.coverImagePath == destPath) return playlist;
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
Future<void> removePlaylistCover(String playlistId) async {
@ -703,6 +763,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
if (playlist.coverImagePath == null) return playlist;
return playlist.copyWith(coverImagePath: () => null, updatedAt: now);
});
_invalidatePlaylistPickerSummaries();
}
}
@ -710,3 +771,27 @@ final libraryCollectionsProvider =
NotifierProvider<LibraryCollectionsNotifier, LibraryCollectionsState>(
LibraryCollectionsNotifier.new,
);
final libraryPlaylistPickerSummariesProvider =
FutureProvider.family<
List<PlaylistPickerSummary>,
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);
});

View file

@ -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<AlbumScreen> {
(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<AlbumScreen> {
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),

View file

@ -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<DownloadedAlbumScreen> {
(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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),

View file

@ -258,6 +258,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
List<Track>? _searchBucketsSourceTracks;
_SearchResultBuckets? _searchBucketsCache;
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
List<SearchArtist>? _sortedArtistsSource;
_SearchSortOption? _sortedArtistsMode;
List<SearchArtist>? _sortedArtistsCache;
List<SearchAlbum>? _sortedAlbumsSource;
_SearchSortOption? _sortedAlbumsMode;
List<SearchAlbum>? _sortedAlbumsCache;
List<SearchPlaylist>? _sortedPlaylistsSource;
_SearchSortOption? _sortedPlaylistsMode;
List<SearchPlaylist>? _sortedPlaylistsCache;
List<Track>? _sortedTracksSource;
List<int>? _sortedTrackIndexesSource;
_SearchSortOption? _sortedTracksMode;
List<Track>? _sortedTracksCache;
List<int>? _sortedTrackIndexesCache;
double _responsiveScale({
required BuildContext context,
@ -476,6 +490,23 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey;
_searchSortOption = _SearchSortOption.defaultOrder;
_invalidateSearchSortCaches();
final isBuiltInProvider =
searchProvider != null &&
@ -1405,10 +1437,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
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<HomeTab>
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<HomeTab>
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<HomeTab>
return sorted;
}
List<SearchArtist>? _sortSearchArtists(List<SearchArtist>? artists) {
if (artists == null ||
artists.isEmpty ||
_searchSortOption == _SearchSortOption.defaultOrder) {
return artists;
}
if (identical(artists, _sortedArtistsSource) &&
_sortedArtistsMode == _searchSortOption &&
_sortedArtistsCache != null) {
return _sortedArtistsCache;
}
final sorted = _applySortToList<SearchArtist>(
artists,
(a) => a.name,
(a) => a.name,
(a) => 0,
(a) => null,
);
_sortedArtistsSource = artists;
_sortedArtistsMode = _searchSortOption;
_sortedArtistsCache = sorted;
return sorted;
}
List<SearchAlbum>? _sortSearchAlbums(List<SearchAlbum>? albums) {
if (albums == null ||
albums.isEmpty ||
_searchSortOption == _SearchSortOption.defaultOrder) {
return albums;
}
if (identical(albums, _sortedAlbumsSource) &&
_sortedAlbumsMode == _searchSortOption &&
_sortedAlbumsCache != null) {
return _sortedAlbumsCache;
}
final sorted = _applySortToList<SearchAlbum>(
albums,
(a) => a.name,
(a) => a.artists,
(a) => 0,
(a) => a.releaseDate,
);
_sortedAlbumsSource = albums;
_sortedAlbumsMode = _searchSortOption;
_sortedAlbumsCache = sorted;
return sorted;
}
List<SearchPlaylist>? _sortSearchPlaylists(List<SearchPlaylist>? playlists) {
if (playlists == null ||
playlists.isEmpty ||
_searchSortOption == _SearchSortOption.defaultOrder) {
return playlists;
}
if (identical(playlists, _sortedPlaylistsSource) &&
_sortedPlaylistsMode == _searchSortOption &&
_sortedPlaylistsCache != null) {
return _sortedPlaylistsCache;
}
final sorted = _applySortToList<SearchPlaylist>(
playlists,
(p) => p.name,
(p) => p.owner,
(p) => 0,
(p) => null,
);
_sortedPlaylistsSource = playlists;
_sortedPlaylistsMode = _searchSortOption;
_sortedPlaylistsCache = sorted;
return sorted;
}
({List<Track> tracks, List<int> indexes}) _sortTrackResults(
List<Track> tracks,
List<int> 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<Widget> _buildSearchResults({
required List<Track> tracks,
required List<SearchArtist>? searchArtists,
@ -2603,58 +2663,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
final playlistItems = buckets.playlistItems;
final artistItems = buckets.artistItems;
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
? _applySortToList<SearchArtist>(
searchArtists,
(a) => a.name,
(a) => a.name,
(a) => 0,
(a) => null,
)
: searchArtists;
final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty
? _applySortToList<SearchAlbum>(
searchAlbums,
(a) => a.name,
(a) => a.artists,
(a) => 0,
(a) => a.releaseDate,
)
: searchAlbums;
final sortedPlaylists =
searchPlaylists != null && searchPlaylists.isNotEmpty
? _applySortToList<SearchPlaylist>(
searchPlaylists,
(p) => p.name,
(p) => p.owner,
(p) => 0,
(p) => null,
)
: searchPlaylists;
List<Track> sortedTracks;
List<int> 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 = <Widget>[
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;

View file

@ -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<LocalAlbumScreen> {
(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<LocalAlbumScreen> {
Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)

View file

@ -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<PlaylistScreen> {
(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<PlaylistScreen> {
CachedNetworkImage(
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),

View file

@ -359,6 +359,24 @@ class _QueueGroupedAlbumFilterRequest {
);
}
class _QueueHistoryStatsMemoEntry {
final List<DownloadHistoryItem> historyItems;
final List<LocalLibraryItem> 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<DownloadHistoryItem> items, [
List<LocalLibraryItem> localItems = const [],
]) {
final memo = _queueHistoryStatsMemo;
if (memo != null &&
identical(memo.historyItems, items) &&
identical(memo.localItems, localItems)) {
return memo.stats;
}
final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
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 = <String>{};
for (final item in items) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
@ -620,8 +643,10 @@ _HistoryStats _buildQueueHistoryStats(
final localAlbumCounts = <String, int>{};
final localAlbumMap = <String, List<LocalLibraryItem>>{};
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<QueueTab> {
List<UnifiedLibraryItem> _cachedUnifiedLocal = const [];
List<DownloadHistoryItem>? _cachedDownloadedPathKeysSource;
Set<String> _cachedDownloadedPathKeys = const <String>{};
final Map<String, List<String>> _localPathMatchKeysCache = {};
List<LocalLibraryItem>? _cachedLocalSinglesSource;
Map<String, int>? _cachedLocalSinglesAlbumCountsSource;
List<LocalLibraryItem> _cachedLocalSingles = const [];
final Map<String, _FilterContentData> _filterContentDataCache = {};
List<DownloadHistoryItem>? _filterCacheAllHistoryItems;
_HistoryStats? _filterCacheHistoryStats;
@ -1264,9 +1299,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
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<QueueTab> {
return _cachedDownloadedPathKeys;
}
List<String> _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<LocalLibraryItem> _localSingleItems(
List<LocalLibraryItem> items,
Map<String, int> 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<LocalLibraryItem> _filterLocalItems(
List<LocalLibraryItem> items,
String query,
@ -3617,12 +3682,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
final dedupedUnifiedLocal = <UnifiedLibraryItem>[];
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);

View file

@ -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<TrackMetadataScreen> {
);
}
int? _readLocalFileModTimeMsSync(String path) {
Future<String?> _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<void> _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<void> _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<String?> _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<TrackMetadataScreen> {
@override
void dispose() {
_cleanupTempFileAndParentSyncIfNotCached(_embeddedCoverPreviewPath);
unawaited(_cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath));
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
@ -251,7 +257,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
unawaited(_refreshResolvedAudioMetadataFromFile());
}
if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) {
final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid(
final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid(
_coverCacheKey,
cleanFilePath,
);
@ -374,32 +380,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
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<void> _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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
);
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
? 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<void> _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;

View file

@ -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<List<PlaylistPickerSummaryRow>> loadPlaylistPickerSummaries(
List<String> 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 = <String, int>{};
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 = <String, String?>{};
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<void> upsertWishlistEntry({
required String trackKey,
required String trackJson,

View file

@ -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);
}

View file

@ -19,9 +19,9 @@ Future<void> showAddTrackToPlaylistSheet(
Future<void> showAddTracksToPlaylistSheet(
BuildContext context,
WidgetRef ref,
List<Track> tracks,
{String? playlistNamePrefill}
) async {
List<Track> tracks, {
String? playlistNamePrefill,
}) async {
if (tracks.isEmpty) return;
if (!context.mounted) return;
@ -32,7 +32,10 @@ Future<void> 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<Track> 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<String> _selectedPlaylistIds = {};
final Set<String> _initialDisabledIds = {};
bool _initialized = false;
final Set<String> _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<PlaylistPickerSummary> playlists) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds);
final effectiveDisabledIds = <String>{
..._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 = <String>[];
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 = <String>{
..._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<String?> _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async {
Future<String?> _promptPlaylistName(
BuildContext context,
String? playlistNamePrefill,
) async {
final controller = TextEditingController(text: playlistNamePrefill);
final formKey = GlobalKey<FormState>();
@ -275,7 +315,7 @@ Future<String?> _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://') &&