mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
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:
parent
2b47537bb5
commit
38a8b715f8
13 changed files with 927 additions and 352 deletions
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
12
lib/utils/image_cache_utils.dart
Normal file
12
lib/utils/image_cache_utils.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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://') &&
|
||||
|
|
|
|||
Loading…
Reference in a new issue