diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 83dc0a06..a6753f5e 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -18,7 +18,6 @@ const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count'; final _prefs = SharedPreferences.getInstance(); class LocalLibraryState { - final List items; final bool isScanning; final bool scanIsFinalizing; final double scanProgress; @@ -27,14 +26,15 @@ class LocalLibraryState { final int scannedFiles; final int scanErrorCount; final bool scanWasCancelled; + final int totalCount; + final int loadedIndexVersion; final DateTime? lastScannedAt; final int excludedDownloadedCount; final Set _trackKeySet; - final Map _byIsrc; - final Map _byTrackKey; + final Set _isrcSet; + final Map _filePathById; LocalLibraryState({ - this.items = const [], this.isScanning = false, this.scanIsFinalizing = false, this.scanProgress = 0, @@ -43,36 +43,30 @@ class LocalLibraryState { this.scannedFiles = 0, this.scanErrorCount = 0, this.scanWasCancelled = false, + this.totalCount = 0, + this.loadedIndexVersion = 0, this.lastScannedAt, this.excludedDownloadedCount = 0, Set? trackKeySet, - Map? byIsrc, - Map? byTrackKey, - }) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(), - _byIsrc = - byIsrc ?? - Map.fromEntries( - items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => MapEntry(item.isrc!, item)), - ), - _byTrackKey = - byTrackKey ?? - Map.fromEntries(items.map((item) => MapEntry(item.matchKey, item))); + Set? isrcSet, + Map? filePathById, + }) : _trackKeySet = trackKeySet ?? const {}, + _isrcSet = isrcSet ?? const {}, + _filePathById = filePathById ?? const {}; - bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc); + @Deprecated( + 'LocalLibraryState no longer owns full track rows. Use DB-backed page providers.', + ) + List get items => const []; + + bool hasIsrc(String isrc) => _isrcSet.contains(isrc); bool hasTrack(String trackName, String artistName) { - final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; + final key = LibraryDatabase.matchKeyFor(trackName, artistName); return _trackKeySet.contains(key); } - LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc]; - - LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) { - final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; - return _byTrackKey[key]; - } + String? filePathForId(String id) => _filePathById[id]; bool existsInLibrary({String? isrc, String? trackName, String? artistName}) { if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) { @@ -85,7 +79,6 @@ class LocalLibraryState { } LocalLibraryState copyWith({ - List? items, bool? isScanning, bool? scanIsFinalizing, double? scanProgress, @@ -94,14 +87,15 @@ class LocalLibraryState { int? scannedFiles, int? scanErrorCount, bool? scanWasCancelled, + int? totalCount, + int? loadedIndexVersion, DateTime? lastScannedAt, int? excludedDownloadedCount, + Set? trackKeySet, + Set? isrcSet, + Map? filePathById, }) { - final nextItems = items ?? this.items; - final keepDerivedIndex = identical(nextItems, this.items); - return LocalLibraryState( - items: nextItems, isScanning: isScanning ?? this.isScanning, scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing, scanProgress: scanProgress ?? this.scanProgress, @@ -110,12 +104,14 @@ class LocalLibraryState { scannedFiles: scannedFiles ?? this.scannedFiles, scanErrorCount: scanErrorCount ?? this.scanErrorCount, scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, + totalCount: totalCount ?? this.totalCount, + loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion, lastScannedAt: lastScannedAt ?? this.lastScannedAt, excludedDownloadedCount: excludedDownloadedCount ?? this.excludedDownloadedCount, - trackKeySet: keepDerivedIndex ? _trackKeySet : null, - byIsrc: keepDerivedIndex ? _byIsrc : null, - byTrackKey: keepDerivedIndex ? _byTrackKey : null, + trackKeySet: trackKeySet ?? _trackKeySet, + isrcSet: isrcSet ?? _isrcSet, + filePathById: filePathById ?? _filePathById, ); } } @@ -169,12 +165,11 @@ class LocalLibraryNotifier extends Notifier { _isLoaded = true; try { - final dbItemsFuture = _db.getAll(); + final countFuture = _db.getCount(); + final indexFuture = _db.getLookupIndex(); final prefsFuture = _prefs; - final jsonList = await dbItemsFuture; - final items = jsonList - .map((e) => LocalLibraryItem.fromJson(e)) - .toList(growable: false); + final count = await countFuture; + final lookupIndex = await indexFuture; DateTime? lastScannedAt; var excludedDownloadedCount = 0; @@ -188,12 +183,16 @@ class LocalLibraryNotifier extends Notifier { } state = state.copyWith( - items: items, + totalCount: count, + loadedIndexVersion: state.loadedIndexVersion + 1, lastScannedAt: lastScannedAt, excludedDownloadedCount: excludedDownloadedCount, + trackKeySet: lookupIndex.matchKeys, + isrcSet: lookupIndex.isrcs, + filePathById: lookupIndex.filePathById, ); _log.i( - 'Loaded ${items.length} items from library database, lastScannedAt: ' + 'Loaded local library summary: $count items, lastScannedAt: ' '$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount', ); _hasLoadedFromDatabase = true; @@ -212,6 +211,27 @@ class LocalLibraryNotifier extends Notifier { await _ensureLoadedFromDatabase(); } + Future _refreshSummaryFromStorage({ + DateTime? lastScannedAt, + int? excludedDownloadedCount, + }) async { + final countFuture = _db.getCount(); + final indexFuture = _db.getLookupIndex(); + final count = await countFuture; + final index = await indexFuture; + state = state.copyWith( + totalCount: count, + loadedIndexVersion: state.loadedIndexVersion + 1, + lastScannedAt: lastScannedAt, + excludedDownloadedCount: excludedDownloadedCount, + trackKeySet: index.matchKeys, + isrcSet: index.isrcs, + filePathById: index.filePathById, + ); + _hasLoadedFromDatabase = true; + _isLoaded = true; + } + bool _isDownloadedPath(String? filePath, Set downloadedPathKeys) { if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) { return false; @@ -225,31 +245,6 @@ class LocalLibraryNotifier extends Notifier { return false; } - Future> _currentItemsByPathForIncrementalScan( - Map existingFiles, - ) async { - await _ensureLoadedFromDatabase(); - - final loadedItems = state.items; - if (loadedItems.isNotEmpty || existingFiles.isEmpty) { - return { - for (final item in loadedItems) item.filePath: item, - }; - } - - // Rare fallback: if provider state failed to warm while the database has - // rows, preserve correctness instead of applying a diff to an empty base. - _log.w( - 'Library state is empty while database has ${existingFiles.length} files; ' - 'loading incremental scan baseline from database', - ); - final existingJson = await _db.getAll(); - return { - for (final item in existingJson.map(LocalLibraryItem.fromJson)) - item.filePath: item, - }; - } - Future startScan( String folderPath, { bool forceFullScan = false, @@ -376,7 +371,6 @@ class LocalLibraryNotifier extends Notifier { } await _db.replaceAll(items.map((e) => e.toJson()).toList()); - final persistedItems = [...items]..sort(_compareLibraryItems); final now = DateTime.now(); try { @@ -388,8 +382,11 @@ class LocalLibraryNotifier extends Notifier { _log.w('Failed to save lastScannedAt: $e'); } + await _refreshSummaryFromStorage( + lastScannedAt: now, + excludedDownloadedCount: skippedDownloads, + ); state = state.copyWith( - items: persistedItems, isScanning: false, scanIsFinalizing: false, scanProgress: 100, @@ -397,14 +394,14 @@ class LocalLibraryNotifier extends Notifier { scanWasCancelled: false, excludedDownloadedCount: skippedDownloads, ); - await _pruneLibraryCoverCache(persistedItems); + await _pruneLibraryCoverCache(); _log.i( - 'Full scan complete: ${persistedItems.length} tracks found, ' + 'Full scan complete: ${state.totalCount} tracks found, ' '$skippedDownloads already in downloads', ); await _showScanCompleteNotification( - totalTracks: persistedItems.length, + totalTracks: state.totalCount, excludedDownloadedCount: skippedDownloads, errorCount: state.scanErrorCount, ); @@ -497,17 +494,13 @@ class LocalLibraryNotifier extends Notifier { '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', ); - final currentByPath = await _currentItemsByPathForIncrementalScan( - existingFiles, - ); + final existingPaths = existingFiles.keys.toList(growable: false); final existingDownloadedPaths = []; - currentByPath.removeWhere((path, _) { - final shouldExclude = _isDownloadedPath(path, downloadedPathKeys); - if (shouldExclude) { + for (final path in existingPaths) { + if (_isDownloadedPath(path, downloadedPathKeys)) { existingDownloadedPaths.add(path); } - return shouldExclude; - }); + } if (existingDownloadedPaths.isNotEmpty) { final removed = await _db.deleteByPaths(existingDownloadedPaths); _log.i( @@ -527,7 +520,6 @@ class LocalLibraryNotifier extends Notifier { } final item = LocalLibraryItem.fromJson(map); updatedItems.add(item); - currentByPath[item.filePath] = item; } if (updatedItems.isNotEmpty) { await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList()); @@ -542,15 +534,9 @@ class LocalLibraryNotifier extends Notifier { if (deletedPaths.isNotEmpty) { final deleteCount = await _db.deleteByPaths(deletedPaths); - for (final path in deletedPaths) { - currentByPath.remove(path); - } _log.i('Deleted $deleteCount items from database'); } - final items = currentByPath.values.toList(growable: false) - ..sort(_compareLibraryItems); - final now = DateTime.now(); try { final prefs = await SharedPreferences.getInstance(); @@ -561,8 +547,11 @@ class LocalLibraryNotifier extends Notifier { _log.w('Failed to save lastScannedAt: $e'); } + await _refreshSummaryFromStorage( + lastScannedAt: now, + excludedDownloadedCount: skippedDownloads, + ); state = state.copyWith( - items: items, isScanning: false, scanIsFinalizing: false, scanProgress: 100, @@ -572,12 +561,12 @@ class LocalLibraryNotifier extends Notifier { ); _log.i( - 'Incremental scan complete: ${items.length} total tracks ' + 'Incremental scan complete: ${state.totalCount} total tracks ' '(${scannedList.length} new/updated, $skippedCount unchanged, ' '${deletedPaths.length} removed, $skippedDownloads already in downloads)', ); await _showScanCompleteNotification( - totalTracks: items.length, + totalTracks: state.totalCount, excludedDownloadedCount: skippedDownloads, errorCount: state.scanErrorCount, ); @@ -893,7 +882,7 @@ class LocalLibraryNotifier extends Notifier { try { final removed = await _db.cleanupMissingFiles(); if (removed > 0) { - await reloadFromStorage(); + await _refreshSummaryFromStorage(); } return removed; } finally { @@ -914,11 +903,11 @@ class LocalLibraryNotifier extends Notifier { _log.w('Failed to clear lastScannedAt: $e'); } - state = LocalLibraryState(); + state = LocalLibraryState(loadedIndexVersion: state.loadedIndexVersion + 1); _log.i('Library cleared'); } - Future _pruneLibraryCoverCache(Iterable items) async { + Future _pruneLibraryCoverCache() async { try { final appSupportDir = await getApplicationSupportDirectory(); final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); @@ -926,11 +915,16 @@ class LocalLibraryNotifier extends Notifier { return; } - final referencedCoverPaths = items - .map((item) => item.coverPath) - .whereType() - .where((path) => path.isNotEmpty) - .toSet(); + final referencedCoverPaths = {}; + var offset = 0; + const pageSize = 500; + while (true) { + final page = await _db.getCoverPaths(limit: pageSize, offset: offset); + if (page.isEmpty) break; + referencedCoverPaths.addAll(page); + if (page.length < pageSize) break; + offset += pageSize; + } var deletedCount = 0; await for (final entity in libraryCoverDir.list( @@ -960,9 +954,7 @@ class LocalLibraryNotifier extends Notifier { Future removeItem(String id) async { await _db.delete(id); - state = state.copyWith( - items: state.items.where((item) => item.id != id).toList(), - ); + await _refreshSummaryFromStorage(); } bool existsInLibrary({String? isrc, String? trackName, String? artistName}) { @@ -973,21 +965,40 @@ class LocalLibraryNotifier extends Notifier { ); } - LocalLibraryItem? getByIsrc(String isrc) { - return state.getByIsrc(isrc); + Future getById(String id) async { + final json = await _db.getById(id); + return json == null ? null : LocalLibraryItem.fromJson(json); } - LocalLibraryItem? findExisting({ + Future getByIsrcAsync(String isrc) async { + final json = await _db.getByIsrc(isrc); + return json == null ? null : LocalLibraryItem.fromJson(json); + } + + Future findByTrackAndArtistAsync( + String trackName, + String artistName, + ) async { + final json = await _db.findFirstByTrackAndArtist(trackName, artistName); + return json == null ? null : LocalLibraryItem.fromJson(json); + } + + Future findExistingAsync({ + String? id, String? isrc, String? trackName, String? artistName, - }) { + }) async { + if (id != null && id.isNotEmpty) { + final byId = await getById(id); + if (byId != null) return byId; + } if (isrc != null && isrc.isNotEmpty) { - final byIsrc = state.getByIsrc(isrc); + final byIsrc = await getByIsrcAsync(isrc); if (byIsrc != null) return byIsrc; } if (trackName != null && artistName != null) { - return state.findByTrackAndArtist(trackName, artistName); + return findByTrackAndArtistAsync(trackName, artistName); } return null; } @@ -1003,23 +1014,6 @@ class LocalLibraryNotifier extends Notifier { return await _db.getCount(); } - int _compareLibraryItems(LocalLibraryItem a, LocalLibraryItem b) { - final artistA = (a.albumArtist ?? a.artistName).toLowerCase(); - final artistB = (b.albumArtist ?? b.artistName).toLowerCase(); - final artistCompare = artistA.compareTo(artistB); - if (artistCompare != 0) return artistCompare; - - final albumCompare = a.albumName.toLowerCase().compareTo( - b.albumName.toLowerCase(), - ); - if (albumCompare != 0) return albumCompare; - - final discCompare = (a.discNumber ?? 0).compareTo(b.discNumber ?? 0); - if (discCompare != 0) return discCompare; - - return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0); - } - Future> _backfillLegacyFileModTimes({ required bool isSaf, required Map existingFiles, @@ -1097,3 +1091,239 @@ final localLibraryProvider = NotifierProvider( LocalLibraryNotifier.new, ); + +final localLibrarySummaryProvider = Provider((ref) { + return ref.watch(localLibraryProvider); +}); + +class LocalLibraryLookup { + final LibraryDatabase _db; + + const LocalLibraryLookup(this._db); + + Future byId(String id) async { + final json = await _db.getById(id); + return json == null ? null : LocalLibraryItem.fromJson(json); + } + + Future byIsrc(String isrc) async { + final json = await _db.getByIsrc(isrc); + return json == null ? null : LocalLibraryItem.fromJson(json); + } + + Future byTrackAndArtist( + String trackName, + String artistName, + ) async { + final json = await _db.findFirstByTrackAndArtist(trackName, artistName); + return json == null ? null : LocalLibraryItem.fromJson(json); + } + + Future existing({ + String? id, + String? isrc, + String? trackName, + String? artistName, + }) async { + if (id != null && id.isNotEmpty) { + final item = await byId(id); + if (item != null) return item; + } + if (isrc != null && isrc.isNotEmpty) { + final item = await byIsrc(isrc); + if (item != null) return item; + } + if (trackName != null && artistName != null) { + return byTrackAndArtist(trackName, artistName); + } + return null; + } +} + +final localLibraryLookupProvider = Provider((ref) { + ref.watch(localLibraryProvider.select((state) => state.loadedIndexVersion)); + return LocalLibraryLookup(LibraryDatabase.instance); +}); + +class LocalLibraryCoverRequest { + final String? isrc; + final String trackName; + final String artistName; + + const LocalLibraryCoverRequest({ + this.isrc, + required this.trackName, + required this.artistName, + }); + + @override + bool operator ==(Object other) { + return other is LocalLibraryCoverRequest && + other.isrc == isrc && + other.trackName == trackName && + other.artistName == artistName; + } + + @override + int get hashCode => Object.hash(isrc, trackName, artistName); +} + +class LocalLibraryCoverBatchRequest { + final List tracks; + + const LocalLibraryCoverBatchRequest(this.tracks); + + @override + bool operator ==(Object other) { + if (other is! LocalLibraryCoverBatchRequest) return false; + if (other.tracks.length != tracks.length) return false; + for (var i = 0; i < tracks.length; i++) { + if (other.tracks[i] != tracks[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll(tracks); +} + +String? _nonEmptyCoverPath(Map? json) { + final coverPath = json?['coverPath'] as String?; + final trimmed = coverPath?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} + +final localLibraryCoverProvider = + FutureProvider.family((ref, request) { + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + return LibraryDatabase.instance + .findExisting( + isrc: request.isrc, + trackName: request.trackName, + artistName: request.artistName, + ) + .then(_nonEmptyCoverPath); + }); + +final localLibraryFirstCoverProvider = + FutureProvider.family(( + ref, + request, + ) async { + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + for (final track in request.tracks) { + final cover = _nonEmptyCoverPath( + await LibraryDatabase.instance.findExisting( + isrc: track.isrc, + trackName: track.trackName, + artistName: track.artistName, + ), + ); + if (cover != null) return cover; + } + return null; + }); + +final localLibraryPageProvider = + FutureProvider.family, LocalLibraryPageRequest>(( + ref, + request, + ) async { + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + final rows = await LibraryDatabase.instance.getPage(request); + return rows.map(LocalLibraryItem.fromJson).toList(growable: false); + }); + +final localLibraryPageCountProvider = + FutureProvider.family((ref, request) async { + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + return LibraryDatabase.instance.getPageCount(request); + }); + +class LocalLibraryAlbumPageRequest { + final int limit; + final int offset; + final LocalLibraryFilterMode filterMode; + final LocalLibrarySortMode sortMode; + final String? searchQuery; + + const LocalLibraryAlbumPageRequest({ + this.limit = 100, + this.offset = 0, + this.filterMode = LocalLibraryFilterMode.albums, + this.sortMode = LocalLibrarySortMode.album, + this.searchQuery, + }); + + @override + bool operator ==(Object other) { + return other is LocalLibraryAlbumPageRequest && + other.limit == limit && + other.offset == offset && + other.filterMode == filterMode && + other.sortMode == sortMode && + other.searchQuery == searchQuery; + } + + @override + int get hashCode => + Object.hash(limit, offset, filterMode, sortMode, searchQuery); +} + +final localLibraryAlbumPageProvider = + FutureProvider.family< + List, + LocalLibraryAlbumPageRequest + >((ref, request) async { + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + return LibraryDatabase.instance.getAlbumPage( + limit: request.limit, + offset: request.offset, + filterMode: request.filterMode, + sortMode: request.sortMode, + searchQuery: request.searchQuery, + ); + }); + +final localLibraryAlbumCountProvider = + FutureProvider.family(( + ref, + request, + ) async { + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + return LibraryDatabase.instance.getAlbumCount( + filterMode: request.filterMode, + searchQuery: request.searchQuery, + ); + }); + +final localLibraryAllItemsProvider = FutureProvider>(( + ref, +) async { + ref.watch(localLibraryProvider.select((state) => state.loadedIndexVersion)); + const pageSize = 500; + final items = []; + var offset = 0; + while (true) { + final rows = await LibraryDatabase.instance.getPage( + const LocalLibraryPageRequest(limit: pageSize).copyWithOffset(offset), + ); + if (rows.isEmpty) break; + items.addAll(rows.map(LocalLibraryItem.fromJson)); + if (rows.length < pageSize) break; + offset += pageSize; + } + return items; +}); diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index bcaa14c0..1e639beb 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -76,11 +76,10 @@ class PlaybackController extends Notifier { } Future _resolveTrackPath(Track track) async { - final localState = ref.read(localLibraryProvider); final historyState = ref.read(downloadHistoryProvider); final historyNotifier = ref.read(downloadHistoryProvider.notifier); - final localItem = _findLocalLibraryItemForTrack(track, localState); + final localItem = await _findLocalLibraryItemForTrack(track); if (localItem != null && await fileExists(localItem.filePath)) { return localItem.filePath; } @@ -96,28 +95,23 @@ class PlaybackController extends Notifier { return null; } - LocalLibraryItem? _findLocalLibraryItemForTrack( - Track track, - LocalLibraryState localState, - ) { + Future _findLocalLibraryItemForTrack(Track track) async { final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; if (isLocalSource) { - for (final item in localState.items) { - if (item.id == track.id) { - return item; - } - } + final byId = await ref + .read(localLibraryProvider.notifier) + .getById(track.id); + if (byId != null) return byId; } final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - final byIsrc = localState.getByIsrc(isrc); - if (byIsrc != null) { - return byIsrc; - } - } - - return localState.findByTrackAndArtist(track.name, track.artistName); + return ref + .read(localLibraryProvider.notifier) + .findExistingAsync( + isrc: isrc, + trackName: track.name, + artistName: track.artistName, + ); } DownloadHistoryItem? _findDownloadHistoryItemForTrack( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 251b183d..1cf33f86 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1085,7 +1085,6 @@ class _AlbumTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final localState = ref.read(localLibraryProvider); final historyState = ref.read(downloadHistoryProvider); final historyNotifier = ref.read(downloadHistoryProvider.notifier); @@ -1119,13 +1118,13 @@ class _AlbumTrackItem extends ConsumerWidget { historyNotifier.removeFromHistory(historyItem.id); } - var localItem = (isrc != null && isrc.isNotEmpty) - ? localState.getByIsrc(isrc) - : null; - localItem ??= localState.findByTrackAndArtist( - track.name, - track.artistName, - ); + final localItem = await ref + .read(localLibraryProvider.notifier) + .findExistingAsync( + isrc: isrc, + trackName: track.name, + artistName: track.artistName, + ); if (localItem != null && await fileExists(localItem.filePath)) { await ref diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 90b06fc1..ea840181 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1600,7 +1600,6 @@ class _ArtistScreenState extends ConsumerState { } Future _playLocalIfAvailable(Track track) async { - final localState = ref.read(localLibraryProvider); final historyState = ref.read(downloadHistoryProvider); final historyNotifier = ref.read(downloadHistoryProvider.notifier); @@ -1634,13 +1633,13 @@ class _ArtistScreenState extends ConsumerState { historyNotifier.removeFromHistory(historyItem.id); } - var localItem = (isrc != null && isrc.isNotEmpty) - ? localState.getByIsrc(isrc) - : null; - localItem ??= localState.findByTrackAndArtist( - track.name, - track.artistName, - ); + final localItem = await ref + .read(localLibraryProvider.notifier) + .findExistingAsync( + isrc: isrc, + trackName: track.name, + artistName: track.artistName, + ); if (localItem != null && await fileExists(localItem.filePath)) { await ref diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index fc45b73c..eceaaea2 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -12,7 +12,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; -import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -79,45 +78,20 @@ class _LibraryTracksFolderScreenState }; } - String? _resolveEntryCoverUrl( - CollectionTrackEntry entry, - LocalLibraryState localState, - ) { + String? _resolveRawEntryCoverUrl(CollectionTrackEntry entry) { final rawCover = entry.track.coverUrl?.trim(); if (rawCover != null && rawCover.isNotEmpty && !rawCover.startsWith('content://')) { return rawCover; } - - final isrc = entry.track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - final byIsrc = localState.getByIsrc(isrc); - final localCover = byIsrc?.coverPath?.trim(); - if (localCover != null && localCover.isNotEmpty) { - return localCover; - } - } - - final byTrack = localState.findByTrackAndArtist( - entry.track.name, - entry.track.artistName, - ); - final localCover = byTrack?.coverPath?.trim(); - if (localCover != null && localCover.isNotEmpty) { - return localCover; - } - return null; } /// Find the first available cover URL from entries. - String? _firstCoverUrl( - List entries, - LocalLibraryState localState, - ) { + String? _firstRawCoverUrl(List entries) { for (final entry in entries) { - final cover = _resolveEntryCoverUrl(entry, localState); + final cover = _resolveRawEntryCoverUrl(entry); if (cover != null && cover.isNotEmpty) { return cover; } @@ -212,6 +186,22 @@ class _LibraryTracksFolderScreenState ); } + LocalLibraryCoverBatchRequest _coverBatchRequest( + List entries, + ) { + return LocalLibraryCoverBatchRequest( + entries + .map( + (entry) => LocalLibraryCoverRequest( + isrc: entry.track.isrc?.trim(), + trackName: entry.track.name, + artistName: entry.track.artistName, + ), + ) + .toList(growable: false), + ); + } + void _downloadSelected(List entries) { final settings = ref.read(settingsProvider); final extensionState = ref.read(extensionProvider); @@ -255,8 +245,7 @@ class _LibraryTracksFolderScreenState @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - ref.watch(localLibraryProvider.select((s) => s.items)); - final localState = ref.read(localLibraryProvider); + ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion)); final List entries; switch (widget.mode) { @@ -337,14 +326,7 @@ class _LibraryTracksFolderScreenState CustomScrollView( controller: _scrollController, slivers: [ - _buildAppBar( - context, - colorScheme, - title, - entries, - playlist, - localState, - ), + _buildAppBar(context, colorScheme, title, entries, playlist), if (entries.isEmpty) SliverFillRemaining( hasScrollBody: false, @@ -366,7 +348,6 @@ class _LibraryTracksFolderScreenState entry: entry, mode: widget.mode, playlistId: widget.playlistId, - localLibraryState: localState, folderTracks: folderTracks, isSelectionMode: _isSelectionMode, isSelected: isSelected, @@ -602,13 +583,21 @@ class _LibraryTracksFolderScreenState String title, List entries, UserPlaylistCollection? playlist, - LocalLibraryState localState, ) { final expandedHeight = _calculateExpandedHeight(context); final customCoverPath = playlist?.coverImagePath; final isLovedMode = widget.mode == LibraryTracksFolderMode.loved; final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist; - final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState); + final rawCoverUrl = isLovedMode ? null : _firstRawCoverUrl(entries); + final localCoverUrl = + rawCoverUrl == null && !isLovedMode && entries.isNotEmpty + ? ref + .watch( + localLibraryFirstCoverProvider(_coverBatchRequest(entries)), + ) + .maybeWhen(data: (cover) => cover, orElse: () => null) + : null; + final coverUrl = rawCoverUrl ?? localCoverUrl; final hasCustomCover = customCoverPath != null && customCoverPath.isNotEmpty; final hasCoverUrl = coverUrl != null; @@ -1069,7 +1058,6 @@ class _CollectionTrackTile extends ConsumerWidget { final CollectionTrackEntry entry; final LibraryTracksFolderMode mode; final String? playlistId; - final LocalLibraryState localLibraryState; final List folderTracks; final bool isSelectionMode; final bool isSelected; @@ -1080,7 +1068,6 @@ class _CollectionTrackTile extends ConsumerWidget { required this.entry, required this.mode, required this.playlistId, - required this.localLibraryState, required this.folderTracks, this.isSelectionMode = false, this.isSelected = false, @@ -1092,7 +1079,21 @@ class _CollectionTrackTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; - final effectiveCoverUrl = _resolveCoverUrl(track); + final rawCoverUrl = _resolveRawCoverUrl(track); + final localCoverUrl = rawCoverUrl == null + ? ref + .watch( + localLibraryCoverProvider( + LocalLibraryCoverRequest( + isrc: track.isrc?.trim(), + trackName: track.name, + artistName: track.artistName, + ), + ), + ) + .maybeWhen(data: (cover) => cover, orElse: () => null) + : null; + final effectiveCoverUrl = rawCoverUrl ?? localCoverUrl; // Fine-grained provider watches – only this tile rebuilds when its own // history / local-library entry changes. @@ -1113,26 +1114,21 @@ class _CollectionTrackTile extends ConsumerWidget { (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, ), ); - final localItem = showLocalLibraryIndicator + final isInLocalLibrary = showLocalLibraryIndicator ? ref.watch( localLibraryProvider.select((state) { final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - final byIsrc = state.getByIsrc(isrc); - if (byIsrc != null) return byIsrc; - } - return state.findByTrackAndArtist(track.name, track.artistName); + return state.existsInLibrary( + isrc: isrc, + trackName: track.name, + artistName: track.artistName, + ); }), ) - : null; + : false; final isInHistory = historyItem != null; - final isInLocalLibrary = localItem != null; - final heroTag = historyItem != null - ? 'cover_${historyItem.id}' - : localItem != null - ? 'cover_lib_${localItem.id}' - : null; + final heroTag = historyItem != null ? 'cover_${historyItem.id}' : null; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1245,7 +1241,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), trailing: isSelectionMode ? null - : historyItem != null || localItem != null + : historyItem != null || isInLocalLibrary ? IconButton( tooltip: context.l10n.tooltipPlay, onPressed: () { @@ -1275,28 +1271,13 @@ class _CollectionTrackTile extends ConsumerWidget { ); } - String? _resolveCoverUrl(Track track) { + String? _resolveRawCoverUrl(Track track) { final rawCover = track.coverUrl?.trim(); if (rawCover != null && rawCover.isNotEmpty && !rawCover.startsWith('content://')) { return rawCover; } - - final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - final byIsrc = localLibraryState.getByIsrc(isrc); - final localCover = byIsrc?.coverPath?.trim(); - if (localCover != null && localCover.isNotEmpty) return localCover; - } - - final byTrack = localLibraryState.findByTrackAndArtist( - track.name, - track.artistName, - ); - final localCover = byTrack?.coverPath?.trim(); - if (localCover != null && localCover.isNotEmpty) return localCover; - return null; } @@ -1418,13 +1399,14 @@ class _CollectionTrackTile extends ConsumerWidget { return; } - final localState = ref.read(localLibraryProvider); - LocalLibraryItem? localItem; - if (track.isrc != null && track.isrc!.isNotEmpty) { - localItem = localState.getByIsrc(track.isrc!); - } - - localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); + final localItem = await ref + .read(localLibraryProvider.notifier) + .findExistingAsync( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ); + if (!context.mounted) return; if (localItem != null) { await Navigator.of(context).push( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index fcf3ec02..7336e6ef 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -942,7 +942,6 @@ class _PlaylistTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final localState = ref.read(localLibraryProvider); final historyState = ref.read(downloadHistoryProvider); final historyNotifier = ref.read(downloadHistoryProvider.notifier); @@ -976,13 +975,13 @@ class _PlaylistTrackItem extends ConsumerWidget { historyNotifier.removeFromHistory(historyItem.id); } - var localItem = (isrc != null && isrc.isNotEmpty) - ? localState.getByIsrc(isrc) - : null; - localItem ??= localState.findByTrackAndArtist( - track.name, - track.artistName, - ); + final localItem = await ref + .read(localLibraryProvider.notifier) + .findExistingAsync( + isrc: isrc, + trackName: track.name, + artistName: track.artistName, + ); if (localItem != null && await fileExists(localItem.filePath)) { await ref diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 3335a4bf..ebca6f6e 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2369,7 +2369,12 @@ class _QueueTabState extends ConsumerState { settingsProvider.select((s) => s.localLibraryEnabled), ); final localLibraryItems = localLibraryEnabled - ? ref.watch(localLibraryProvider.select((s) => s.items)) + ? ref + .watch(localLibraryAllItemsProvider) + .maybeWhen( + data: (items) => items, + orElse: () => const [], + ) : const []; // Watch with selector on key fields to reduce unnecessary rebuilds. // LibraryCollectionsState doesn't implement == so watching without diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index 63268d3b..2c999df3 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -1106,7 +1106,12 @@ final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) { settingsProvider.select((s) => s.localLibraryEnabled), ); final localItems = localLibraryEnabled - ? ref.watch(localLibraryProvider.select((s) => s.items)) + ? ref + .watch(localLibraryAllItemsProvider) + .maybeWhen( + data: (items) => items, + orElse: () => const [], + ) : const []; return _buildQueueHistoryStats(historyItems, localItems); }); diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 4a10db76..be108efa 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -374,7 +374,7 @@ class _LibrarySettingsPageState extends ConsumerState { SliverToBoxAdapter( child: _LibraryHeroCard( - itemCount: libraryState.items.length, + itemCount: libraryState.totalCount, excludedDownloadedCount: libraryState.excludedDownloadedCount, isScanning: libraryState.isScanning, scanIsFinalizing: libraryState.scanIsFinalizing, @@ -547,25 +547,23 @@ class _LibrarySettingsPageState extends ConsumerState { ), ], Opacity( - opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5, + opacity: libraryState.totalCount > 0 ? 1.0 : 0.5, child: SettingsItem( icon: Icons.cleaning_services_outlined, title: context.l10n.libraryCleanupMissingFiles, subtitle: context.l10n.libraryCleanupMissingFilesSubtitle, - onTap: libraryState.items.isNotEmpty + onTap: libraryState.totalCount > 0 ? _cleanupMissingFiles : null, ), ), Opacity( - opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5, + opacity: libraryState.totalCount > 0 ? 1.0 : 0.5, child: SettingsItem( icon: Icons.delete_outline, title: context.l10n.libraryClear, subtitle: context.l10n.libraryClearSubtitle, - onTap: libraryState.items.isNotEmpty - ? _clearLibrary - : null, + onTap: libraryState.totalCount > 0 ? _clearLibrary : null, showDivider: false, ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 2f1b2fb7..54b6c70f 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -24,7 +24,6 @@ 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/animation_utils.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; part 'track_metadata_edit_sheet.dart'; @@ -101,6 +100,11 @@ class _TrackMetadataScreenState extends ConsumerState { bool _hasMetadataChanges = false; bool _hasLoadedResolvedAudioMetadata = false; bool _isTrackSwipeNavigationInFlight = false; + int _metadataLoadGeneration = 0; + int _metadataTransitionDirection = 0; + late DownloadHistoryItem? _currentDownloadItem; + late LocalLibraryItem? _currentLocalLibraryItem; + late int? _currentNavigationIndex; Map? _editedMetadata; String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); @@ -226,6 +230,9 @@ class _TrackMetadataScreenState extends ConsumerState { @override void initState() { super.initState(); + _currentDownloadItem = widget.item; + _currentLocalLibraryItem = widget.localItem; + _currentNavigationIndex = widget.navigationIndex; _scrollController.addListener(_onScroll); _checkFile(); } @@ -253,6 +260,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _checkFile() async { + final generation = _metadataLoadGeneration; final filePath = cleanFilePath; bool exists = false; @@ -266,6 +274,8 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} if (mounted && + generation == _metadataLoadGeneration && + filePath == cleanFilePath && (exists != _fileExists || size != _fileSize || !_hasCheckedFile)) { setState(() { _fileExists = exists; @@ -274,21 +284,35 @@ class _TrackMetadataScreenState extends ConsumerState { }); } - if (mounted && exists && _lyrics == null && !_lyricsLoading) { + if (mounted && + generation == _metadataLoadGeneration && + filePath == cleanFilePath && + exists && + _lyrics == null && + !_lyricsLoading) { _checkEmbeddedLyrics(); } if (mounted && + generation == _metadataLoadGeneration && + filePath == cleanFilePath && exists && !_isCueVirtualTrack && !_hasLoadedResolvedAudioMetadata) { unawaited(_refreshResolvedAudioMetadataFromFile()); } - if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) { + if (mounted && + generation == _metadataLoadGeneration && + filePath == cleanFilePath && + exists && + !_hasPath(_embeddedCoverPreviewPath)) { final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid( _coverCacheKey, - cleanFilePath, + filePath, ); - if (_hasPath(cachedPath)) { + if (mounted && + generation == _metadataLoadGeneration && + filePath == cleanFilePath && + _hasPath(cachedPath)) { setState(() => _embeddedCoverPreviewPath = cachedPath); } } @@ -318,6 +342,8 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _refreshResolvedAudioMetadataFromFile() async { + final generation = _metadataLoadGeneration; + final sourcePath = cleanFilePath; if ((_isLocalItem && _localLibraryItem == null) || (!_isLocalItem && _downloadItem == null) || _isCueVirtualTrack || @@ -328,7 +354,12 @@ class _TrackMetadataScreenState extends ConsumerState { _hasLoadedResolvedAudioMetadata = true; try { - final metadata = await PlatformBridge.readFileMetadata(cleanFilePath); + final metadata = await PlatformBridge.readFileMetadata(sourcePath); + if (!mounted || + generation != _metadataLoadGeneration || + sourcePath != cleanFilePath) { + return; + } if (metadata['error'] != null) { return; } @@ -463,6 +494,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _refreshEmbeddedCoverPreview({bool force = false}) async { + final generation = _metadataLoadGeneration; final cacheKey = _coverCacheKey; final sourcePath = cleanFilePath; if (!force) { @@ -471,7 +503,10 @@ class _TrackMetadataScreenState extends ConsumerState { sourcePath, ); if (_hasPath(cachedPath)) { - if (mounted && _embeddedCoverPreviewPath != cachedPath) { + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath && + _embeddedCoverPreviewPath != cachedPath) { setState(() => _embeddedCoverPreviewPath = cachedPath); } return; @@ -483,7 +518,9 @@ class _TrackMetadataScreenState extends ConsumerState { if (!_fileExists) { await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath); - if (mounted) { + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { setState(() => _embeddedCoverPreviewPath = null); } return; @@ -511,7 +548,9 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} final oldPreviewPath = _embeddedCoverPreviewPath; - if (!mounted) { + if (!mounted || + generation != _metadataLoadGeneration || + sourcePath != cleanFilePath) { if (newPreviewPath != null) { await _cleanupTempFileAndParentIfNotCached(newPreviewPath); } @@ -524,16 +563,16 @@ class _TrackMetadataScreenState extends ConsumerState { } } - bool get _isLocalItem => widget.localItem != null; - DownloadHistoryItem? get _downloadItem => widget.item; - LocalLibraryItem? get _localLibraryItem => widget.localItem; + bool get _isLocalItem => _currentLocalLibraryItem != null; + DownloadHistoryItem? get _downloadItem => _currentDownloadItem; + LocalLibraryItem? get _localLibraryItem => _currentLocalLibraryItem; bool get _hasHistoryNavigation => widget.historyNavigationItems != null && widget.navigationIndex != null; bool get _hasLocalNavigation => widget.localNavigationItems != null && widget.navigationIndex != null; bool get _hasTrackSwipeNavigation => _hasHistoryNavigation || _hasLocalNavigation; - int? get _navigationIndex => widget.navigationIndex; + int? get _navigationIndex => _currentNavigationIndex; int get _navigationLength => widget.historyNavigationItems?.length ?? widget.localNavigationItems?.length ?? @@ -869,28 +908,50 @@ class _TrackMetadataScreenState extends ConsumerState { if (targetIndex < 0 || targetIndex >= _navigationLength) return; _isTrackSwipeNavigationInFlight = true; - await Navigator.of(context).pushReplacement( - adjacentHorizontalPageRoute( - page: _buildSiblingTrackScreen(targetIndex), - fromRight: offset > 0, - ), - result: _hasMetadataChanges ? true : null, - ); - } + final oldPreviewPath = _embeddedCoverPreviewPath; - TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) { - if (_hasHistoryNavigation) { - return TrackMetadataScreen( - item: widget.historyNavigationItems![targetIndex], - historyNavigationItems: widget.historyNavigationItems, - navigationIndex: targetIndex, - ); + try { + setState(() { + _metadataLoadGeneration++; + _metadataTransitionDirection = offset > 0 ? 1 : -1; + _currentNavigationIndex = targetIndex; + if (_hasHistoryNavigation) { + _currentDownloadItem = widget.historyNavigationItems![targetIndex]; + _currentLocalLibraryItem = null; + } else { + _currentDownloadItem = null; + _currentLocalLibraryItem = widget.localNavigationItems![targetIndex]; + } + _fileExists = false; + _hasCheckedFile = false; + _fileSize = null; + _lyrics = null; + _rawLyrics = null; + _lyricsLoading = false; + _lyricsError = null; + _lyricsSource = null; + _showTitleInAppBar = false; + _lyricsEmbedded = false; + _isInstrumental = false; + _embeddedLyricsChecked = false; + _hasLoadedResolvedAudioMetadata = false; + _editedMetadata = null; + _embeddedCoverPreviewPath = null; + }); + + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + + if (oldPreviewPath != null) { + unawaited(_cleanupTempFileAndParentIfNotCached(oldPreviewPath)); + } + await _checkFile(); + } finally { + if (mounted) { + _isTrackSwipeNavigationInFlight = false; + } } - return TrackMetadataScreen( - localItem: widget.localNavigationItems![targetIndex], - localNavigationItems: widget.localNavigationItems, - navigationIndex: targetIndex, - ); } @override @@ -973,39 +1034,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildMetadataCard(context, colorScheme, _fileSize), - - const SizedBox(height: 16), - - _buildFileInfoCard( - context, - colorScheme, - _fileExists, - _fileSize, - ), - - const SizedBox(height: 16), - - _buildLyricsCard(context, colorScheme), - - if (_fileExists) ...[ - const SizedBox(height: 16), - AudioAnalysisCard(filePath: _filePath), - ], - - const SizedBox(height: 24), - - _buildActionButtons(context, ref, colorScheme, _fileExists), - - const SizedBox(height: 32), - ], - ), - ), + child: _buildAnimatedTrackContent(context, ref, colorScheme), ), ], ), @@ -1013,6 +1042,80 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + Widget _buildAnimatedTrackContent( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) { + final currentKey = ValueKey('metadata_content_$_itemId'); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 240), + reverseDuration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [...previousChildren, ?currentChild], + ); + }, + transitionBuilder: (child, animation) { + if (_metadataTransitionDirection == 0) { + return child; + } + final isIncoming = child.key == currentKey; + final direction = _metadataTransitionDirection.toDouble(); + final begin = Offset( + isIncoming ? 0.18 * direction : -0.18 * direction, + 0, + ); + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return ClipRect( + child: SlideTransition( + position: Tween( + begin: begin, + end: Offset.zero, + ).animate(curved), + child: FadeTransition(opacity: animation, child: child), + ), + ); + }, + child: Padding( + key: currentKey, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMetadataCard(context, colorScheme, _fileSize), + + const SizedBox(height: 16), + + _buildFileInfoCard(context, colorScheme, _fileExists, _fileSize), + + const SizedBox(height: 16), + + _buildLyricsCard(context, colorScheme), + + if (_fileExists) ...[ + const SizedBox(height: 16), + AudioAnalysisCard(filePath: _filePath), + ], + + const SizedBox(height: 24), + + _buildActionButtons(context, ref, colorScheme, _fileExists), + + const SizedBox(height: 32), + ], + ), + ), + ); + } + Widget _buildHeaderBackground( BuildContext context, ColorScheme colorScheme, @@ -1944,6 +2047,9 @@ class _TrackMetadataScreenState extends ConsumerState { /// Called automatically when the screen opens. Future _checkEmbeddedLyrics() async { if (_lyricsLoading || !_fileExists) return; + final generation = _metadataLoadGeneration; + final sourcePath = cleanFilePath; + if (!mounted) return; setState(() { _lyricsLoading = true; @@ -1958,7 +2064,7 @@ class _TrackMetadataScreenState extends ConsumerState { '', trackName, artistName, - filePath: cleanFilePath, + filePath: sourcePath, durationMs: 0, ).timeout( const Duration(seconds: 5), @@ -1968,7 +2074,9 @@ class _TrackMetadataScreenState extends ConsumerState { final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; final embeddedSource = embeddedResult['source']?.toString() ?? ''; - if (mounted) { + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { if (embeddedLyrics.isNotEmpty) { final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); setState(() { @@ -1989,7 +2097,9 @@ class _TrackMetadataScreenState extends ConsumerState { } } } catch (e) { - if (mounted) { + if (mounted && + generation == _metadataLoadGeneration && + sourcePath == cleanFilePath) { setState(() { _lyricsLoading = false; _embeddedLyricsChecked = true; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 7bc50794..6f3702e8 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -117,9 +117,113 @@ class LocalLibraryItem { ); String get matchKey => - '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; + '${LibraryDatabase.normalizeLookupText(trackName)}|${LibraryDatabase.normalizeLookupText(artistName)}'; String get albumKey => - '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}'; + '${LibraryDatabase.normalizeLookupText(albumName)}|${LibraryDatabase.normalizeLookupText(albumArtist ?? artistName)}'; +} + +enum LocalLibrarySortMode { album, title, artist, latest, quality } + +enum LocalLibraryFilterMode { all, albums, singles } + +class LocalLibraryPageRequest { + final int limit; + final int offset; + final LocalLibrarySortMode sortMode; + final LocalLibraryFilterMode filterMode; + final String? searchQuery; + final String? format; + + const LocalLibraryPageRequest({ + this.limit = 100, + this.offset = 0, + this.sortMode = LocalLibrarySortMode.album, + this.filterMode = LocalLibraryFilterMode.all, + this.searchQuery, + this.format, + }); + + LocalLibraryPageRequest copyWithOffset(int nextOffset) { + return LocalLibraryPageRequest( + limit: limit, + offset: nextOffset, + sortMode: sortMode, + filterMode: filterMode, + searchQuery: searchQuery, + format: format, + ); + } + + @override + bool operator ==(Object other) { + return other is LocalLibraryPageRequest && + other.limit == limit && + other.offset == offset && + other.sortMode == sortMode && + other.filterMode == filterMode && + other.searchQuery == searchQuery && + other.format == format; + } + + @override + int get hashCode => + Object.hash(limit, offset, sortMode, filterMode, searchQuery, format); +} + +class LocalLibraryAlbumGroup { + final String albumKey; + final String albumName; + final String artistName; + final String? coverPath; + final int trackCount; + final int? maxBitDepth; + final int? maxSampleRate; + final int? maxBitrate; + final String? format; + final String? releaseDate; + final String? genre; + + const LocalLibraryAlbumGroup({ + required this.albumKey, + required this.albumName, + required this.artistName, + this.coverPath, + required this.trackCount, + this.maxBitDepth, + this.maxSampleRate, + this.maxBitrate, + this.format, + this.releaseDate, + this.genre, + }); + + factory LocalLibraryAlbumGroup.fromDbRow(Map row) { + return LocalLibraryAlbumGroup( + albumKey: row['album_key'] as String, + albumName: row['album_name'] as String? ?? '', + artistName: row['artist_name'] as String? ?? '', + coverPath: row['cover_path'] as String?, + trackCount: (row['track_count'] as num?)?.toInt() ?? 0, + maxBitDepth: (row['max_bit_depth'] as num?)?.toInt(), + maxSampleRate: (row['max_sample_rate'] as num?)?.toInt(), + maxBitrate: (row['max_bitrate'] as num?)?.toInt(), + format: row['format'] as String?, + releaseDate: row['release_date'] as String?, + genre: row['genre'] as String?, + ); + } +} + +class LocalLibraryLookupIndex { + final Set isrcs; + final Set matchKeys; + final Map filePathById; + + const LocalLibraryLookupIndex({ + this.isrcs = const {}, + this.matchKeys = const {}, + this.filePathById = const {}, + }); } class LibraryDatabase { @@ -142,7 +246,7 @@ class LibraryDatabase { return await openDatabase( path, - version: 6, + version: 7, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -180,7 +284,13 @@ class LibraryDatabase { composer TEXT, label TEXT, copyright TEXT, - format TEXT + format TEXT, + track_name_norm TEXT, + artist_name_norm TEXT, + album_name_norm TEXT, + album_artist_norm TEXT, + match_key TEXT, + album_key TEXT ) '''); @@ -194,6 +304,7 @@ class LibraryDatabase { await db.execute( 'CREATE INDEX idx_library_file_path ON library(file_path)', ); + await _createNormalizedIndexes(db); _log.i('Library database schema created with indexes'); } @@ -228,10 +339,124 @@ class LibraryDatabase { await db.execute('ALTER TABLE library ADD COLUMN composer TEXT'); _log.i('Added total_tracks/total_discs/composer columns'); } + + if (oldVersion < 7) { + await _addColumnIfMissing(db, 'library', 'track_name_norm', 'TEXT'); + await _addColumnIfMissing(db, 'library', 'artist_name_norm', 'TEXT'); + await _addColumnIfMissing(db, 'library', 'album_name_norm', 'TEXT'); + await _addColumnIfMissing(db, 'library', 'album_artist_norm', 'TEXT'); + await _addColumnIfMissing(db, 'library', 'match_key', 'TEXT'); + await _addColumnIfMissing(db, 'library', 'album_key', 'TEXT'); + await _backfillNormalizedColumns(db); + await _createNormalizedIndexes(db); + _log.i('Added normalized local library lookup columns'); + } + } + + static String normalizeLookupText(String? value) { + return (value ?? '').trim().toLowerCase(); + } + + static String matchKeyFor(String trackName, String artistName) { + return '${normalizeLookupText(trackName)}|${normalizeLookupText(artistName)}'; + } + + static String albumKeyFor( + String albumName, + String? albumArtist, + String artistName, + ) { + return '${normalizeLookupText(albumName)}|${normalizeLookupText(albumArtist ?? artistName)}'; + } + + Future _addColumnIfMissing( + Database db, + String table, + String column, + String type, + ) async { + final info = await db.rawQuery('PRAGMA table_info($table)'); + final exists = info.any((row) => row['name'] == column); + if (!exists) { + await db.execute('ALTER TABLE $table ADD COLUMN $column $type'); + } + } + + Future _createNormalizedIndexes(DatabaseExecutor db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_match_key ON library(match_key)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_album_key ON library(album_key)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_track_norm ON library(track_name_norm)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_artist_norm ON library(artist_name_norm)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_album_norm ON library(album_name_norm)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_scanned_at ON library(scanned_at)', + ); + } + + Future _backfillNormalizedColumns(Database db) async { + final rows = await db.query( + 'library', + columns: [ + 'id', + 'track_name', + 'artist_name', + 'album_name', + 'album_artist', + ], + ); + final batch = db.batch(); + for (final row in rows) { + final trackName = row['track_name'] as String? ?? ''; + final artistName = row['artist_name'] as String? ?? ''; + final albumName = row['album_name'] as String? ?? ''; + final albumArtist = row['album_artist'] as String?; + batch.update( + 'library', + _normalizedColumns( + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist, + ), + where: 'id = ?', + whereArgs: [row['id']], + ); + } + await batch.commit(noResult: true); + } + + Map _normalizedColumns({ + required String trackName, + required String artistName, + required String albumName, + required String? albumArtist, + }) { + final trackNorm = normalizeLookupText(trackName); + final artistNorm = normalizeLookupText(artistName); + final albumNorm = normalizeLookupText(albumName); + final albumArtistNorm = normalizeLookupText(albumArtist ?? artistName); + return { + 'track_name_norm': trackNorm, + 'artist_name_norm': artistNorm, + 'album_name_norm': albumNorm, + 'album_artist_norm': albumArtistNorm, + 'match_key': '$trackNorm|$artistNorm', + 'album_key': '$albumNorm|$albumArtistNorm', + }; } Map _jsonToDbRow(Map json) { - return { + final row = { 'id': json['id'], 'track_name': json['trackName'], 'artist_name': json['artistName'], @@ -257,6 +482,15 @@ class LibraryDatabase { 'copyright': json['copyright'], 'format': json['format'], }; + row.addAll( + _normalizedColumns( + trackName: json['trackName'] as String? ?? '', + artistName: json['artistName'] as String? ?? '', + albumName: json['albumName'] as String? ?? '', + albumArtist: json['albumArtist'] as String?, + ), + ); + return row; } Map _dbRowToJson(Map row) { @@ -346,6 +580,173 @@ class LibraryDatabase { return rows.map(_dbRowToJson).toList(); } + Future>> getPage( + LocalLibraryPageRequest request, + ) async { + final db = await database; + final where = []; + final whereArgs = []; + _appendPageFilters(where, whereArgs, request); + + final rows = await db.query( + 'library', + where: where.isEmpty ? null : where.join(' AND '), + whereArgs: whereArgs, + orderBy: _orderByForSort(request.sortMode), + limit: request.limit, + offset: request.offset, + ); + return rows.map(_dbRowToJson).toList(growable: false); + } + + Future getPageCount(LocalLibraryPageRequest request) async { + final db = await database; + final where = []; + final whereArgs = []; + _appendPageFilters(where, whereArgs, request); + final rows = await db.rawQuery( + 'SELECT COUNT(*) AS count FROM library' + '${where.isEmpty ? '' : ' WHERE ${where.join(' AND ')}'}', + whereArgs, + ); + return Sqflite.firstIntValue(rows) ?? 0; + } + + Future> getAlbumPage({ + int limit = 100, + int offset = 0, + LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums, + LocalLibrarySortMode sortMode = LocalLibrarySortMode.album, + String? searchQuery, + }) async { + final db = await database; + final where = []; + final whereArgs = []; + _appendSearchFilter(where, whereArgs, searchQuery); + final having = switch (filterMode) { + LocalLibraryFilterMode.singles => 'COUNT(*) = 1', + LocalLibraryFilterMode.albums => 'COUNT(*) > 1', + LocalLibraryFilterMode.all => null, + }; + final rows = await db.rawQuery( + ''' + SELECT + album_key, + MIN(album_name) AS album_name, + COALESCE(NULLIF(MIN(album_artist), ''), MIN(artist_name)) AS artist_name, + MAX(CASE WHEN cover_path IS NOT NULL AND cover_path != '' THEN cover_path END) AS cover_path, + COUNT(*) AS track_count, + MAX(bit_depth) AS max_bit_depth, + MAX(sample_rate) AS max_sample_rate, + MAX(bitrate) AS max_bitrate, + MAX(format) AS format, + MAX(release_date) AS release_date, + MAX(genre) AS genre + FROM library + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + GROUP BY album_key + ${having == null ? '' : 'HAVING $having'} + ORDER BY ${_albumOrderByForSort(sortMode)} + LIMIT ? OFFSET ? + ''', + [...whereArgs, limit, offset], + ); + return rows.map(LocalLibraryAlbumGroup.fromDbRow).toList(growable: false); + } + + Future getAlbumCount({ + LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums, + String? searchQuery, + }) async { + final db = await database; + final where = []; + final whereArgs = []; + _appendSearchFilter(where, whereArgs, searchQuery); + final having = switch (filterMode) { + LocalLibraryFilterMode.singles => 'COUNT(*) = 1', + LocalLibraryFilterMode.albums => 'COUNT(*) > 1', + LocalLibraryFilterMode.all => null, + }; + final rows = await db.rawQuery(''' + SELECT COUNT(*) AS count FROM ( + SELECT album_key + FROM library + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + GROUP BY album_key + ${having == null ? '' : 'HAVING $having'} + ) + ''', whereArgs); + return Sqflite.firstIntValue(rows) ?? 0; + } + + void _appendPageFilters( + List where, + List whereArgs, + LocalLibraryPageRequest request, + ) { + _appendSearchFilter(where, whereArgs, request.searchQuery); + final normalizedFormat = request.format?.trim().toLowerCase(); + if (normalizedFormat != null && normalizedFormat.isNotEmpty) { + where.add('LOWER(format) = ?'); + whereArgs.add(normalizedFormat); + } + switch (request.filterMode) { + case LocalLibraryFilterMode.all: + break; + case LocalLibraryFilterMode.albums: + where.add( + 'album_key IN (SELECT album_key FROM library GROUP BY album_key HAVING COUNT(*) > 1)', + ); + break; + case LocalLibraryFilterMode.singles: + where.add( + 'album_key IN (SELECT album_key FROM library GROUP BY album_key HAVING COUNT(*) = 1)', + ); + break; + } + } + + void _appendSearchFilter( + List where, + List whereArgs, + String? searchQuery, + ) { + final query = normalizeLookupText(searchQuery); + if (query.isEmpty) return; + final like = '%$query%'; + where.add( + '(track_name_norm LIKE ? OR artist_name_norm LIKE ? OR album_name_norm LIKE ? OR album_artist_norm LIKE ?)', + ); + whereArgs.addAll([like, like, like, like]); + } + + String _orderByForSort(LocalLibrarySortMode sortMode) { + return switch (sortMode) { + LocalLibrarySortMode.title => + 'track_name_norm, artist_name_norm, album_name_norm, disc_number, track_number', + LocalLibrarySortMode.artist => + 'artist_name_norm, album_name_norm, disc_number, track_number, track_name_norm', + LocalLibrarySortMode.latest => + 'scanned_at DESC, album_artist_norm, album_name_norm, disc_number, track_number', + LocalLibrarySortMode.quality => + 'COALESCE(bit_depth, 0) DESC, COALESCE(sample_rate, 0) DESC, COALESCE(bitrate, 0) DESC, album_artist_norm, album_name_norm, disc_number, track_number', + LocalLibrarySortMode.album => + 'album_artist_norm, album_name_norm, COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name_norm', + }; + } + + String _albumOrderByForSort(LocalLibrarySortMode sortMode) { + return switch (sortMode) { + LocalLibrarySortMode.latest => + 'MAX(scanned_at) DESC, artist_name, album_name', + LocalLibrarySortMode.quality => + 'MAX(COALESCE(bit_depth, 0)) DESC, MAX(COALESCE(sample_rate, 0)) DESC, MAX(COALESCE(bitrate, 0)) DESC, artist_name, album_name', + LocalLibrarySortMode.title => 'album_name, artist_name', + LocalLibrarySortMode.artist || + LocalLibrarySortMode.album => 'artist_name, album_name', + }; + } + Future?> getById(String id) async { final db = await database; final rows = await db.query( @@ -370,6 +771,18 @@ class LibraryDatabase { return _dbRowToJson(rows.first); } + Future?> getByFilePath(String filePath) async { + final db = await database; + final rows = await db.query( + 'library', + where: 'file_path = ?', + whereArgs: [filePath], + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + Future existsByIsrc(String isrc) async { final db = await database; final result = await db.rawQuery( @@ -386,12 +799,28 @@ class LibraryDatabase { final db = await database; final rows = await db.query( 'library', - where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?', - whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()], + where: 'match_key = ?', + whereArgs: [matchKeyFor(trackName, artistName)], ); return rows.map(_dbRowToJson).toList(); } + Future?> findFirstByTrackAndArtist( + String trackName, + String artistName, + ) async { + final db = await database; + final rows = await db.query( + 'library', + where: 'match_key = ?', + whereArgs: [matchKeyFor(trackName, artistName)], + orderBy: _orderByForSort(LocalLibrarySortMode.album), + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + Future?> findExisting({ String? isrc, String? trackName, @@ -421,11 +850,56 @@ class LibraryDatabase { Future> getAllTrackKeys() async { final db = await database; final rows = await db.rawQuery( - 'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library', + 'SELECT match_key FROM library WHERE match_key IS NOT NULL AND match_key != ""', ); return rows.map((r) => r['match_key'] as String).toSet(); } + Future getLookupIndex() async { + final db = await database; + final rows = await db.rawQuery( + 'SELECT id, file_path, isrc, match_key FROM library', + ); + final isrcs = {}; + final matchKeys = {}; + final filePathById = {}; + for (final row in rows) { + final id = row['id'] as String?; + final filePath = row['file_path'] as String?; + if (id != null && id.isNotEmpty && filePath != null) { + filePathById[id] = filePath; + } + final isrc = row['isrc'] as String?; + if (isrc != null && isrc.isNotEmpty) { + isrcs.add(isrc); + } + final matchKey = row['match_key'] as String?; + if (matchKey != null && matchKey.isNotEmpty) { + matchKeys.add(matchKey); + } + } + return LocalLibraryLookupIndex( + isrcs: Set.unmodifiable(isrcs), + matchKeys: Set.unmodifiable(matchKeys), + filePathById: Map.unmodifiable(filePathById), + ); + } + + Future> getCoverPaths({int? limit, int? offset}) async { + final db = await database; + final rows = await db.query( + 'library', + columns: ['cover_path'], + where: 'cover_path IS NOT NULL AND cover_path != ""', + limit: limit, + offset: offset, + ); + return rows + .map((row) => row['cover_path'] as String?) + .whereType() + .toList(growable: false); + } + Future deleteByPath(String filePath) async { final db = await database; await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);