refactor: migrate local library from in-memory list to database-backed pagination

Replace the full in-memory List<LocalLibraryItem> in LocalLibraryState
with a lightweight lookup index (ISRCs, matchKeys, filePathById) and
database-backed FutureProvider.family pagination providers.

Database changes:
- Add library schema v7 with normalized lookup columns (track_name_norm,
  artist_name_norm, album_name_norm, album_artist_norm, match_key,
  album_key) and corresponding indexes
- Backfill normalized columns on migration from v6
- Add getPage, getPageCount, getAlbumPage, getAlbumCount, getLookupIndex,
  getCoverPaths, getByFilePath, findFirstByTrackAndArtist DB methods

Provider changes:
- LocalLibraryState no longer holds items list; uses totalCount and
  loadedIndexVersion for change tracking
- Deprecate synchronous getByIsrc/findByTrackAndArtist (return null);
  add async findExistingAsync, getByIsrcAsync, getById on notifier
- Add localLibraryPageProvider, localLibraryAlbumPageProvider,
  localLibraryAllItemsProvider family providers for paginated access
- Add localLibraryCoverProvider and localLibraryFirstCoverProvider
  for async cover path resolution from DB

Screen migrations:
- album/artist/playlist screens use findExistingAsync for playback
- library_tracks_folder_screen uses async cover providers and
  existsInLibrary for local library indicator
- queue_tab watches localLibraryAllItemsProvider instead of state.items
- library_settings_page uses state.totalCount
- playback_provider uses findExistingAsync

Track metadata screen:
- Replace pushReplacement navigation with in-place state swap using
  AnimatedSwitcher for smooth cross-fade transitions on track swipe
- Add metadataLoadGeneration counter to prevent stale async callbacks
- Reset all transient state (lyrics, cover, file check) on track change
This commit is contained in:
zarzet 2026-05-06 03:15:30 +07:00
parent d24435dbc2
commit 149cdc782d
11 changed files with 1128 additions and 333 deletions

View file

@ -18,7 +18,6 @@ const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
final _prefs = SharedPreferences.getInstance(); final _prefs = SharedPreferences.getInstance();
class LocalLibraryState { class LocalLibraryState {
final List<LocalLibraryItem> items;
final bool isScanning; final bool isScanning;
final bool scanIsFinalizing; final bool scanIsFinalizing;
final double scanProgress; final double scanProgress;
@ -27,14 +26,15 @@ class LocalLibraryState {
final int scannedFiles; final int scannedFiles;
final int scanErrorCount; final int scanErrorCount;
final bool scanWasCancelled; final bool scanWasCancelled;
final int totalCount;
final int loadedIndexVersion;
final DateTime? lastScannedAt; final DateTime? lastScannedAt;
final int excludedDownloadedCount; final int excludedDownloadedCount;
final Set<String> _trackKeySet; final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc; final Set<String> _isrcSet;
final Map<String, LocalLibraryItem> _byTrackKey; final Map<String, String> _filePathById;
LocalLibraryState({ LocalLibraryState({
this.items = const [],
this.isScanning = false, this.isScanning = false,
this.scanIsFinalizing = false, this.scanIsFinalizing = false,
this.scanProgress = 0, this.scanProgress = 0,
@ -43,36 +43,30 @@ class LocalLibraryState {
this.scannedFiles = 0, this.scannedFiles = 0,
this.scanErrorCount = 0, this.scanErrorCount = 0,
this.scanWasCancelled = false, this.scanWasCancelled = false,
this.totalCount = 0,
this.loadedIndexVersion = 0,
this.lastScannedAt, this.lastScannedAt,
this.excludedDownloadedCount = 0, this.excludedDownloadedCount = 0,
Set<String>? trackKeySet, Set<String>? trackKeySet,
Map<String, LocalLibraryItem>? byIsrc, Set<String>? isrcSet,
Map<String, LocalLibraryItem>? byTrackKey, Map<String, String>? filePathById,
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(), }) : _trackKeySet = trackKeySet ?? const <String>{},
_byIsrc = _isrcSet = isrcSet ?? const <String>{},
byIsrc ?? _filePathById = filePathById ?? const <String, String>{};
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)));
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc); @Deprecated(
'LocalLibraryState no longer owns full track rows. Use DB-backed page providers.',
)
List<LocalLibraryItem> get items => const <LocalLibraryItem>[];
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
bool hasTrack(String trackName, String artistName) { bool hasTrack(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; final key = LibraryDatabase.matchKeyFor(trackName, artistName);
return _trackKeySet.contains(key); return _trackKeySet.contains(key);
} }
LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc]; String? filePathForId(String id) => _filePathById[id];
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return _byTrackKey[key];
}
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) { bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) { if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
@ -85,7 +79,6 @@ class LocalLibraryState {
} }
LocalLibraryState copyWith({ LocalLibraryState copyWith({
List<LocalLibraryItem>? items,
bool? isScanning, bool? isScanning,
bool? scanIsFinalizing, bool? scanIsFinalizing,
double? scanProgress, double? scanProgress,
@ -94,14 +87,15 @@ class LocalLibraryState {
int? scannedFiles, int? scannedFiles,
int? scanErrorCount, int? scanErrorCount,
bool? scanWasCancelled, bool? scanWasCancelled,
int? totalCount,
int? loadedIndexVersion,
DateTime? lastScannedAt, DateTime? lastScannedAt,
int? excludedDownloadedCount, int? excludedDownloadedCount,
Set<String>? trackKeySet,
Set<String>? isrcSet,
Map<String, String>? filePathById,
}) { }) {
final nextItems = items ?? this.items;
final keepDerivedIndex = identical(nextItems, this.items);
return LocalLibraryState( return LocalLibraryState(
items: nextItems,
isScanning: isScanning ?? this.isScanning, isScanning: isScanning ?? this.isScanning,
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing, scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
scanProgress: scanProgress ?? this.scanProgress, scanProgress: scanProgress ?? this.scanProgress,
@ -110,12 +104,14 @@ class LocalLibraryState {
scannedFiles: scannedFiles ?? this.scannedFiles, scannedFiles: scannedFiles ?? this.scannedFiles,
scanErrorCount: scanErrorCount ?? this.scanErrorCount, scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
totalCount: totalCount ?? this.totalCount,
loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion,
lastScannedAt: lastScannedAt ?? this.lastScannedAt, lastScannedAt: lastScannedAt ?? this.lastScannedAt,
excludedDownloadedCount: excludedDownloadedCount:
excludedDownloadedCount ?? this.excludedDownloadedCount, excludedDownloadedCount ?? this.excludedDownloadedCount,
trackKeySet: keepDerivedIndex ? _trackKeySet : null, trackKeySet: trackKeySet ?? _trackKeySet,
byIsrc: keepDerivedIndex ? _byIsrc : null, isrcSet: isrcSet ?? _isrcSet,
byTrackKey: keepDerivedIndex ? _byTrackKey : null, filePathById: filePathById ?? _filePathById,
); );
} }
} }
@ -169,12 +165,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_isLoaded = true; _isLoaded = true;
try { try {
final dbItemsFuture = _db.getAll(); final countFuture = _db.getCount();
final indexFuture = _db.getLookupIndex();
final prefsFuture = _prefs; final prefsFuture = _prefs;
final jsonList = await dbItemsFuture; final count = await countFuture;
final items = jsonList final lookupIndex = await indexFuture;
.map((e) => LocalLibraryItem.fromJson(e))
.toList(growable: false);
DateTime? lastScannedAt; DateTime? lastScannedAt;
var excludedDownloadedCount = 0; var excludedDownloadedCount = 0;
@ -188,12 +183,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
state = state.copyWith( state = state.copyWith(
items: items, totalCount: count,
loadedIndexVersion: state.loadedIndexVersion + 1,
lastScannedAt: lastScannedAt, lastScannedAt: lastScannedAt,
excludedDownloadedCount: excludedDownloadedCount, excludedDownloadedCount: excludedDownloadedCount,
trackKeySet: lookupIndex.matchKeys,
isrcSet: lookupIndex.isrcs,
filePathById: lookupIndex.filePathById,
); );
_log.i( _log.i(
'Loaded ${items.length} items from library database, lastScannedAt: ' 'Loaded local library summary: $count items, lastScannedAt: '
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount', '$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
); );
_hasLoadedFromDatabase = true; _hasLoadedFromDatabase = true;
@ -212,6 +211,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _ensureLoadedFromDatabase(); await _ensureLoadedFromDatabase();
} }
Future<void> _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<String> downloadedPathKeys) { bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) { if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false; return false;
@ -225,31 +245,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return false; return false;
} }
Future<Map<String, LocalLibraryItem>> _currentItemsByPathForIncrementalScan(
Map<String, int> existingFiles,
) async {
await _ensureLoadedFromDatabase();
final loadedItems = state.items;
if (loadedItems.isNotEmpty || existingFiles.isEmpty) {
return <String, LocalLibraryItem>{
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 <String, LocalLibraryItem>{
for (final item in existingJson.map(LocalLibraryItem.fromJson))
item.filePath: item,
};
}
Future<void> startScan( Future<void> startScan(
String folderPath, { String folderPath, {
bool forceFullScan = false, bool forceFullScan = false,
@ -376,7 +371,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
await _db.replaceAll(items.map((e) => e.toJson()).toList()); await _db.replaceAll(items.map((e) => e.toJson()).toList());
final persistedItems = [...items]..sort(_compareLibraryItems);
final now = DateTime.now(); final now = DateTime.now();
try { try {
@ -388,8 +382,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
} }
await _refreshSummaryFromStorage(
lastScannedAt: now,
excludedDownloadedCount: skippedDownloads,
);
state = state.copyWith( state = state.copyWith(
items: persistedItems,
isScanning: false, isScanning: false,
scanIsFinalizing: false, scanIsFinalizing: false,
scanProgress: 100, scanProgress: 100,
@ -397,14 +394,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads, excludedDownloadedCount: skippedDownloads,
); );
await _pruneLibraryCoverCache(persistedItems); await _pruneLibraryCoverCache();
_log.i( _log.i(
'Full scan complete: ${persistedItems.length} tracks found, ' 'Full scan complete: ${state.totalCount} tracks found, '
'$skippedDownloads already in downloads', '$skippedDownloads already in downloads',
); );
await _showScanCompleteNotification( await _showScanCompleteNotification(
totalTracks: persistedItems.length, totalTracks: state.totalCount,
excludedDownloadedCount: skippedDownloads, excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount, errorCount: state.scanErrorCount,
); );
@ -497,17 +494,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
); );
final currentByPath = await _currentItemsByPathForIncrementalScan( final existingPaths = existingFiles.keys.toList(growable: false);
existingFiles,
);
final existingDownloadedPaths = <String>[]; final existingDownloadedPaths = <String>[];
currentByPath.removeWhere((path, _) { for (final path in existingPaths) {
final shouldExclude = _isDownloadedPath(path, downloadedPathKeys); if (_isDownloadedPath(path, downloadedPathKeys)) {
if (shouldExclude) {
existingDownloadedPaths.add(path); existingDownloadedPaths.add(path);
} }
return shouldExclude; }
});
if (existingDownloadedPaths.isNotEmpty) { if (existingDownloadedPaths.isNotEmpty) {
final removed = await _db.deleteByPaths(existingDownloadedPaths); final removed = await _db.deleteByPaths(existingDownloadedPaths);
_log.i( _log.i(
@ -527,7 +520,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
final item = LocalLibraryItem.fromJson(map); final item = LocalLibraryItem.fromJson(map);
updatedItems.add(item); updatedItems.add(item);
currentByPath[item.filePath] = item;
} }
if (updatedItems.isNotEmpty) { if (updatedItems.isNotEmpty) {
await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList()); await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
@ -542,15 +534,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (deletedPaths.isNotEmpty) { if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths); final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
currentByPath.remove(path);
}
_log.i('Deleted $deleteCount items from database'); _log.i('Deleted $deleteCount items from database');
} }
final items = currentByPath.values.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now(); final now = DateTime.now();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@ -561,8 +547,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
} }
await _refreshSummaryFromStorage(
lastScannedAt: now,
excludedDownloadedCount: skippedDownloads,
);
state = state.copyWith( state = state.copyWith(
items: items,
isScanning: false, isScanning: false,
scanIsFinalizing: false, scanIsFinalizing: false,
scanProgress: 100, scanProgress: 100,
@ -572,12 +561,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
_log.i( _log.i(
'Incremental scan complete: ${items.length} total tracks ' 'Incremental scan complete: ${state.totalCount} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, ' '(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)', '${deletedPaths.length} removed, $skippedDownloads already in downloads)',
); );
await _showScanCompleteNotification( await _showScanCompleteNotification(
totalTracks: items.length, totalTracks: state.totalCount,
excludedDownloadedCount: skippedDownloads, excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount, errorCount: state.scanErrorCount,
); );
@ -893,7 +882,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final removed = await _db.cleanupMissingFiles(); final removed = await _db.cleanupMissingFiles();
if (removed > 0) { if (removed > 0) {
await reloadFromStorage(); await _refreshSummaryFromStorage();
} }
return removed; return removed;
} finally { } finally {
@ -914,11 +903,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.w('Failed to clear lastScannedAt: $e'); _log.w('Failed to clear lastScannedAt: $e');
} }
state = LocalLibraryState(); state = LocalLibraryState(loadedIndexVersion: state.loadedIndexVersion + 1);
_log.i('Library cleared'); _log.i('Library cleared');
} }
Future<void> _pruneLibraryCoverCache(Iterable<LocalLibraryItem> items) async { Future<void> _pruneLibraryCoverCache() async {
try { try {
final appSupportDir = await getApplicationSupportDirectory(); final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
@ -926,11 +915,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return; return;
} }
final referencedCoverPaths = items final referencedCoverPaths = <String>{};
.map((item) => item.coverPath) var offset = 0;
.whereType<String>() const pageSize = 500;
.where((path) => path.isNotEmpty) while (true) {
.toSet(); 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; var deletedCount = 0;
await for (final entity in libraryCoverDir.list( await for (final entity in libraryCoverDir.list(
@ -960,9 +954,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> removeItem(String id) async { Future<void> removeItem(String id) async {
await _db.delete(id); await _db.delete(id);
state = state.copyWith( await _refreshSummaryFromStorage();
items: state.items.where((item) => item.id != id).toList(),
);
} }
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) { bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
@ -973,21 +965,40 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
} }
LocalLibraryItem? getByIsrc(String isrc) { Future<LocalLibraryItem?> getById(String id) async {
return state.getByIsrc(isrc); final json = await _db.getById(id);
return json == null ? null : LocalLibraryItem.fromJson(json);
} }
LocalLibraryItem? findExisting({ Future<LocalLibraryItem?> getByIsrcAsync(String isrc) async {
final json = await _db.getByIsrc(isrc);
return json == null ? null : LocalLibraryItem.fromJson(json);
}
Future<LocalLibraryItem?> findByTrackAndArtistAsync(
String trackName,
String artistName,
) async {
final json = await _db.findFirstByTrackAndArtist(trackName, artistName);
return json == null ? null : LocalLibraryItem.fromJson(json);
}
Future<LocalLibraryItem?> findExistingAsync({
String? id,
String? isrc, String? isrc,
String? trackName, String? trackName,
String? artistName, String? artistName,
}) { }) async {
if (id != null && id.isNotEmpty) {
final byId = await getById(id);
if (byId != null) return byId;
}
if (isrc != null && isrc.isNotEmpty) { if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc); final byIsrc = await getByIsrcAsync(isrc);
if (byIsrc != null) return byIsrc; if (byIsrc != null) return byIsrc;
} }
if (trackName != null && artistName != null) { if (trackName != null && artistName != null) {
return state.findByTrackAndArtist(trackName, artistName); return findByTrackAndArtistAsync(trackName, artistName);
} }
return null; return null;
} }
@ -1003,23 +1014,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return await _db.getCount(); 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<Map<String, int>> _backfillLegacyFileModTimes({ Future<Map<String, int>> _backfillLegacyFileModTimes({
required bool isSaf, required bool isSaf,
required Map<String, int> existingFiles, required Map<String, int> existingFiles,
@ -1097,3 +1091,239 @@ final localLibraryProvider =
NotifierProvider<LocalLibraryNotifier, LocalLibraryState>( NotifierProvider<LocalLibraryNotifier, LocalLibraryState>(
LocalLibraryNotifier.new, LocalLibraryNotifier.new,
); );
final localLibrarySummaryProvider = Provider<LocalLibraryState>((ref) {
return ref.watch(localLibraryProvider);
});
class LocalLibraryLookup {
final LibraryDatabase _db;
const LocalLibraryLookup(this._db);
Future<LocalLibraryItem?> byId(String id) async {
final json = await _db.getById(id);
return json == null ? null : LocalLibraryItem.fromJson(json);
}
Future<LocalLibraryItem?> byIsrc(String isrc) async {
final json = await _db.getByIsrc(isrc);
return json == null ? null : LocalLibraryItem.fromJson(json);
}
Future<LocalLibraryItem?> byTrackAndArtist(
String trackName,
String artistName,
) async {
final json = await _db.findFirstByTrackAndArtist(trackName, artistName);
return json == null ? null : LocalLibraryItem.fromJson(json);
}
Future<LocalLibraryItem?> 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<LocalLibraryLookup>((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<LocalLibraryCoverRequest> 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<String, dynamic>? json) {
final coverPath = json?['coverPath'] as String?;
final trimmed = coverPath?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
final localLibraryCoverProvider =
FutureProvider.family<String?, LocalLibraryCoverRequest>((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<String?, LocalLibraryCoverBatchRequest>((
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<List<LocalLibraryItem>, 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<int, LocalLibraryPageRequest>((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<LocalLibraryAlbumGroup>,
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<int, LocalLibraryAlbumPageRequest>((
ref,
request,
) async {
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
return LibraryDatabase.instance.getAlbumCount(
filterMode: request.filterMode,
searchQuery: request.searchQuery,
);
});
final localLibraryAllItemsProvider = FutureProvider<List<LocalLibraryItem>>((
ref,
) async {
ref.watch(localLibraryProvider.select((state) => state.loadedIndexVersion));
const pageSize = 500;
final items = <LocalLibraryItem>[];
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;
});

View file

@ -76,11 +76,10 @@ class PlaybackController extends Notifier<PlaybackState> {
} }
Future<String?> _resolveTrackPath(Track track) async { Future<String?> _resolveTrackPath(Track track) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier); final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final localItem = _findLocalLibraryItemForTrack(track, localState); final localItem = await _findLocalLibraryItemForTrack(track);
if (localItem != null && await fileExists(localItem.filePath)) { if (localItem != null && await fileExists(localItem.filePath)) {
return localItem.filePath; return localItem.filePath;
} }
@ -96,28 +95,23 @@ class PlaybackController extends Notifier<PlaybackState> {
return null; return null;
} }
LocalLibraryItem? _findLocalLibraryItemForTrack( Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
Track track,
LocalLibraryState localState,
) {
final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
if (isLocalSource) { if (isLocalSource) {
for (final item in localState.items) { final byId = await ref
if (item.id == track.id) { .read(localLibraryProvider.notifier)
return item; .getById(track.id);
} if (byId != null) return byId;
}
} }
final isrc = track.isrc?.trim(); final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) { return ref
final byIsrc = localState.getByIsrc(isrc); .read(localLibraryProvider.notifier)
if (byIsrc != null) { .findExistingAsync(
return byIsrc; isrc: isrc,
} trackName: track.name,
} artistName: track.artistName,
);
return localState.findByTrackAndArtist(track.name, track.artistName);
} }
DownloadHistoryItem? _findDownloadHistoryItemForTrack( DownloadHistoryItem? _findDownloadHistoryItemForTrack(

View file

@ -1085,7 +1085,6 @@ class _AlbumTrackItem extends ConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
) async { ) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier); final historyNotifier = ref.read(downloadHistoryProvider.notifier);
@ -1119,13 +1118,13 @@ class _AlbumTrackItem extends ConsumerWidget {
historyNotifier.removeFromHistory(historyItem.id); historyNotifier.removeFromHistory(historyItem.id);
} }
var localItem = (isrc != null && isrc.isNotEmpty) final localItem = await ref
? localState.getByIsrc(isrc) .read(localLibraryProvider.notifier)
: null; .findExistingAsync(
localItem ??= localState.findByTrackAndArtist( isrc: isrc,
track.name, trackName: track.name,
track.artistName, artistName: track.artistName,
); );
if (localItem != null && await fileExists(localItem.filePath)) { if (localItem != null && await fileExists(localItem.filePath)) {
await ref await ref

View file

@ -1600,7 +1600,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} }
Future<bool> _playLocalIfAvailable(Track track) async { Future<bool> _playLocalIfAvailable(Track track) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier); final historyNotifier = ref.read(downloadHistoryProvider.notifier);
@ -1634,13 +1633,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
historyNotifier.removeFromHistory(historyItem.id); historyNotifier.removeFromHistory(historyItem.id);
} }
var localItem = (isrc != null && isrc.isNotEmpty) final localItem = await ref
? localState.getByIsrc(isrc) .read(localLibraryProvider.notifier)
: null; .findExistingAsync(
localItem ??= localState.findByTrackAndArtist( isrc: isrc,
track.name, trackName: track.name,
track.artistName, artistName: track.artistName,
); );
if (localItem != null && await fileExists(localItem.filePath)) { if (localItem != null && await fileExists(localItem.filePath)) {
await ref await ref

View file

@ -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/library_collections_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/local_library_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/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@ -79,45 +78,20 @@ class _LibraryTracksFolderScreenState
}; };
} }
String? _resolveEntryCoverUrl( String? _resolveRawEntryCoverUrl(CollectionTrackEntry entry) {
CollectionTrackEntry entry,
LocalLibraryState localState,
) {
final rawCover = entry.track.coverUrl?.trim(); final rawCover = entry.track.coverUrl?.trim();
if (rawCover != null && if (rawCover != null &&
rawCover.isNotEmpty && rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) { !rawCover.startsWith('content://')) {
return rawCover; 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; return null;
} }
/// Find the first available cover URL from entries. /// Find the first available cover URL from entries.
String? _firstCoverUrl( String? _firstRawCoverUrl(List<CollectionTrackEntry> entries) {
List<CollectionTrackEntry> entries,
LocalLibraryState localState,
) {
for (final entry in entries) { for (final entry in entries) {
final cover = _resolveEntryCoverUrl(entry, localState); final cover = _resolveRawEntryCoverUrl(entry);
if (cover != null && cover.isNotEmpty) { if (cover != null && cover.isNotEmpty) {
return cover; return cover;
} }
@ -212,6 +186,22 @@ class _LibraryTracksFolderScreenState
); );
} }
LocalLibraryCoverBatchRequest _coverBatchRequest(
List<CollectionTrackEntry> 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<CollectionTrackEntry> entries) { void _downloadSelected(List<CollectionTrackEntry> entries) {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider); final extensionState = ref.read(extensionProvider);
@ -255,8 +245,7 @@ class _LibraryTracksFolderScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
ref.watch(localLibraryProvider.select((s) => s.items)); ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion));
final localState = ref.read(localLibraryProvider);
final List<CollectionTrackEntry> entries; final List<CollectionTrackEntry> entries;
switch (widget.mode) { switch (widget.mode) {
@ -337,14 +326,7 @@ class _LibraryTracksFolderScreenState
CustomScrollView( CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
_buildAppBar( _buildAppBar(context, colorScheme, title, entries, playlist),
context,
colorScheme,
title,
entries,
playlist,
localState,
),
if (entries.isEmpty) if (entries.isEmpty)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
@ -366,7 +348,6 @@ class _LibraryTracksFolderScreenState
entry: entry, entry: entry,
mode: widget.mode, mode: widget.mode,
playlistId: widget.playlistId, playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks, folderTracks: folderTracks,
isSelectionMode: _isSelectionMode, isSelectionMode: _isSelectionMode,
isSelected: isSelected, isSelected: isSelected,
@ -602,13 +583,21 @@ class _LibraryTracksFolderScreenState
String title, String title,
List<CollectionTrackEntry> entries, List<CollectionTrackEntry> entries,
UserPlaylistCollection? playlist, UserPlaylistCollection? playlist,
LocalLibraryState localState,
) { ) {
final expandedHeight = _calculateExpandedHeight(context); final expandedHeight = _calculateExpandedHeight(context);
final customCoverPath = playlist?.coverImagePath; final customCoverPath = playlist?.coverImagePath;
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved; final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist; 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 = final hasCustomCover =
customCoverPath != null && customCoverPath.isNotEmpty; customCoverPath != null && customCoverPath.isNotEmpty;
final hasCoverUrl = coverUrl != null; final hasCoverUrl = coverUrl != null;
@ -1069,7 +1058,6 @@ class _CollectionTrackTile extends ConsumerWidget {
final CollectionTrackEntry entry; final CollectionTrackEntry entry;
final LibraryTracksFolderMode mode; final LibraryTracksFolderMode mode;
final String? playlistId; final String? playlistId;
final LocalLibraryState localLibraryState;
final List<Track> folderTracks; final List<Track> folderTracks;
final bool isSelectionMode; final bool isSelectionMode;
final bool isSelected; final bool isSelected;
@ -1080,7 +1068,6 @@ class _CollectionTrackTile extends ConsumerWidget {
required this.entry, required this.entry,
required this.mode, required this.mode,
required this.playlistId, required this.playlistId,
required this.localLibraryState,
required this.folderTracks, required this.folderTracks,
this.isSelectionMode = false, this.isSelectionMode = false,
this.isSelected = false, this.isSelected = false,
@ -1092,7 +1079,21 @@ class _CollectionTrackTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final track = entry.track; final track = entry.track;
final colorScheme = Theme.of(context).colorScheme; 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 // Fine-grained provider watches only this tile rebuilds when its own
// history / local-library entry changes. // history / local-library entry changes.
@ -1113,26 +1114,21 @@ class _CollectionTrackTile extends ConsumerWidget {
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
), ),
); );
final localItem = showLocalLibraryIndicator final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch( ? ref.watch(
localLibraryProvider.select((state) { localLibraryProvider.select((state) {
final isrc = track.isrc?.trim(); final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) { return state.existsInLibrary(
final byIsrc = state.getByIsrc(isrc); isrc: isrc,
if (byIsrc != null) return byIsrc; trackName: track.name,
} artistName: track.artistName,
return state.findByTrackAndArtist(track.name, track.artistName); );
}), }),
) )
: null; : false;
final isInHistory = historyItem != null; final isInHistory = historyItem != null;
final isInLocalLibrary = localItem != null; final heroTag = historyItem != null ? 'cover_${historyItem.id}' : null;
final heroTag = historyItem != null
? 'cover_${historyItem.id}'
: localItem != null
? 'cover_lib_${localItem.id}'
: null;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@ -1245,7 +1241,7 @@ class _CollectionTrackTile extends ConsumerWidget {
), ),
trailing: isSelectionMode trailing: isSelectionMode
? null ? null
: historyItem != null || localItem != null : historyItem != null || isInLocalLibrary
? IconButton( ? IconButton(
tooltip: context.l10n.tooltipPlay, tooltip: context.l10n.tooltipPlay,
onPressed: () { onPressed: () {
@ -1275,28 +1271,13 @@ class _CollectionTrackTile extends ConsumerWidget {
); );
} }
String? _resolveCoverUrl(Track track) { String? _resolveRawCoverUrl(Track track) {
final rawCover = track.coverUrl?.trim(); final rawCover = track.coverUrl?.trim();
if (rawCover != null && if (rawCover != null &&
rawCover.isNotEmpty && rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) { !rawCover.startsWith('content://')) {
return rawCover; 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; return null;
} }
@ -1418,13 +1399,14 @@ class _CollectionTrackTile extends ConsumerWidget {
return; return;
} }
final localState = ref.read(localLibraryProvider); final localItem = await ref
LocalLibraryItem? localItem; .read(localLibraryProvider.notifier)
if (track.isrc != null && track.isrc!.isNotEmpty) { .findExistingAsync(
localItem = localState.getByIsrc(track.isrc!); isrc: track.isrc,
} trackName: track.name,
artistName: track.artistName,
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); );
if (!context.mounted) return;
if (localItem != null) { if (localItem != null) {
await Navigator.of(context).push( await Navigator.of(context).push(

View file

@ -942,7 +942,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
) async { ) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier); final historyNotifier = ref.read(downloadHistoryProvider.notifier);
@ -976,13 +975,13 @@ class _PlaylistTrackItem extends ConsumerWidget {
historyNotifier.removeFromHistory(historyItem.id); historyNotifier.removeFromHistory(historyItem.id);
} }
var localItem = (isrc != null && isrc.isNotEmpty) final localItem = await ref
? localState.getByIsrc(isrc) .read(localLibraryProvider.notifier)
: null; .findExistingAsync(
localItem ??= localState.findByTrackAndArtist( isrc: isrc,
track.name, trackName: track.name,
track.artistName, artistName: track.artistName,
); );
if (localItem != null && await fileExists(localItem.filePath)) { if (localItem != null && await fileExists(localItem.filePath)) {
await ref await ref

View file

@ -2369,7 +2369,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
settingsProvider.select((s) => s.localLibraryEnabled), settingsProvider.select((s) => s.localLibraryEnabled),
); );
final localLibraryItems = localLibraryEnabled final localLibraryItems = localLibraryEnabled
? ref.watch(localLibraryProvider.select((s) => s.items)) ? ref
.watch(localLibraryAllItemsProvider)
.maybeWhen(
data: (items) => items,
orElse: () => const <LocalLibraryItem>[],
)
: const <LocalLibraryItem>[]; : const <LocalLibraryItem>[];
// Watch with selector on key fields to reduce unnecessary rebuilds. // Watch with selector on key fields to reduce unnecessary rebuilds.
// LibraryCollectionsState doesn't implement == so watching without // LibraryCollectionsState doesn't implement == so watching without

View file

@ -1106,7 +1106,12 @@ final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) {
settingsProvider.select((s) => s.localLibraryEnabled), settingsProvider.select((s) => s.localLibraryEnabled),
); );
final localItems = localLibraryEnabled final localItems = localLibraryEnabled
? ref.watch(localLibraryProvider.select((s) => s.items)) ? ref
.watch(localLibraryAllItemsProvider)
.maybeWhen(
data: (items) => items,
orElse: () => const <LocalLibraryItem>[],
)
: const <LocalLibraryItem>[]; : const <LocalLibraryItem>[];
return _buildQueueHistoryStats(historyItems, localItems); return _buildQueueHistoryStats(historyItems, localItems);
}); });

View file

@ -374,7 +374,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: _LibraryHeroCard( child: _LibraryHeroCard(
itemCount: libraryState.items.length, itemCount: libraryState.totalCount,
excludedDownloadedCount: libraryState.excludedDownloadedCount, excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning, isScanning: libraryState.isScanning,
scanIsFinalizing: libraryState.scanIsFinalizing, scanIsFinalizing: libraryState.scanIsFinalizing,
@ -547,25 +547,23 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
), ),
], ],
Opacity( Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5, opacity: libraryState.totalCount > 0 ? 1.0 : 0.5,
child: SettingsItem( child: SettingsItem(
icon: Icons.cleaning_services_outlined, icon: Icons.cleaning_services_outlined,
title: context.l10n.libraryCleanupMissingFiles, title: context.l10n.libraryCleanupMissingFiles,
subtitle: context.l10n.libraryCleanupMissingFilesSubtitle, subtitle: context.l10n.libraryCleanupMissingFilesSubtitle,
onTap: libraryState.items.isNotEmpty onTap: libraryState.totalCount > 0
? _cleanupMissingFiles ? _cleanupMissingFiles
: null, : null,
), ),
), ),
Opacity( Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5, opacity: libraryState.totalCount > 0 ? 1.0 : 0.5,
child: SettingsItem( child: SettingsItem(
icon: Icons.delete_outline, icon: Icons.delete_outline,
title: context.l10n.libraryClear, title: context.l10n.libraryClear,
subtitle: context.l10n.libraryClearSubtitle, subtitle: context.l10n.libraryClearSubtitle,
onTap: libraryState.items.isNotEmpty onTap: libraryState.totalCount > 0 ? _clearLibrary : null,
? _clearLibrary
: null,
showDivider: false, showDivider: false,
), ),
), ),

View file

@ -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/mime_utils.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_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'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
part 'track_metadata_edit_sheet.dart'; part 'track_metadata_edit_sheet.dart';
@ -101,6 +100,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _hasMetadataChanges = false; bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false; bool _hasLoadedResolvedAudioMetadata = false;
bool _isTrackSwipeNavigationInFlight = false; bool _isTrackSwipeNavigationInFlight = false;
int _metadataLoadGeneration = 0;
int _metadataTransitionDirection = 0;
late DownloadHistoryItem? _currentDownloadItem;
late LocalLibraryItem? _currentLocalLibraryItem;
late int? _currentNavigationIndex;
Map<String, dynamic>? _editedMetadata; Map<String, dynamic>? _editedMetadata;
String? _embeddedCoverPreviewPath; String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@ -226,6 +230,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentDownloadItem = widget.item;
_currentLocalLibraryItem = widget.localItem;
_currentNavigationIndex = widget.navigationIndex;
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_checkFile(); _checkFile();
} }
@ -253,6 +260,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Future<void> _checkFile() async { Future<void> _checkFile() async {
final generation = _metadataLoadGeneration;
final filePath = cleanFilePath; final filePath = cleanFilePath;
bool exists = false; bool exists = false;
@ -266,6 +274,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (_) {} } catch (_) {}
if (mounted && if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
(exists != _fileExists || size != _fileSize || !_hasCheckedFile)) { (exists != _fileExists || size != _fileSize || !_hasCheckedFile)) {
setState(() { setState(() {
_fileExists = exists; _fileExists = exists;
@ -274,21 +284,35 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}); });
} }
if (mounted && exists && _lyrics == null && !_lyricsLoading) { if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
_lyrics == null &&
!_lyricsLoading) {
_checkEmbeddedLyrics(); _checkEmbeddedLyrics();
} }
if (mounted && if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists && exists &&
!_isCueVirtualTrack && !_isCueVirtualTrack &&
!_hasLoadedResolvedAudioMetadata) { !_hasLoadedResolvedAudioMetadata) {
unawaited(_refreshResolvedAudioMetadataFromFile()); unawaited(_refreshResolvedAudioMetadataFromFile());
} }
if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) { if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
exists &&
!_hasPath(_embeddedCoverPreviewPath)) {
final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid( final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid(
_coverCacheKey, _coverCacheKey,
cleanFilePath, filePath,
); );
if (_hasPath(cachedPath)) { if (mounted &&
generation == _metadataLoadGeneration &&
filePath == cleanFilePath &&
_hasPath(cachedPath)) {
setState(() => _embeddedCoverPreviewPath = cachedPath); setState(() => _embeddedCoverPreviewPath = cachedPath);
} }
} }
@ -318,6 +342,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Future<void> _refreshResolvedAudioMetadataFromFile() async { Future<void> _refreshResolvedAudioMetadataFromFile() async {
final generation = _metadataLoadGeneration;
final sourcePath = cleanFilePath;
if ((_isLocalItem && _localLibraryItem == null) || if ((_isLocalItem && _localLibraryItem == null) ||
(!_isLocalItem && _downloadItem == null) || (!_isLocalItem && _downloadItem == null) ||
_isCueVirtualTrack || _isCueVirtualTrack ||
@ -328,7 +354,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_hasLoadedResolvedAudioMetadata = true; _hasLoadedResolvedAudioMetadata = true;
try { try {
final metadata = await PlatformBridge.readFileMetadata(cleanFilePath); final metadata = await PlatformBridge.readFileMetadata(sourcePath);
if (!mounted ||
generation != _metadataLoadGeneration ||
sourcePath != cleanFilePath) {
return;
}
if (metadata['error'] != null) { if (metadata['error'] != null) {
return; return;
} }
@ -463,6 +494,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Future<void> _refreshEmbeddedCoverPreview({bool force = false}) async { Future<void> _refreshEmbeddedCoverPreview({bool force = false}) async {
final generation = _metadataLoadGeneration;
final cacheKey = _coverCacheKey; final cacheKey = _coverCacheKey;
final sourcePath = cleanFilePath; final sourcePath = cleanFilePath;
if (!force) { if (!force) {
@ -471,7 +503,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
sourcePath, sourcePath,
); );
if (_hasPath(cachedPath)) { if (_hasPath(cachedPath)) {
if (mounted && _embeddedCoverPreviewPath != cachedPath) { if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath &&
_embeddedCoverPreviewPath != cachedPath) {
setState(() => _embeddedCoverPreviewPath = cachedPath); setState(() => _embeddedCoverPreviewPath = cachedPath);
} }
return; return;
@ -483,7 +518,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!_fileExists) { if (!_fileExists) {
await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey);
await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath); await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath);
if (mounted) { if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
setState(() => _embeddedCoverPreviewPath = null); setState(() => _embeddedCoverPreviewPath = null);
} }
return; return;
@ -511,7 +548,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (_) {} } catch (_) {}
final oldPreviewPath = _embeddedCoverPreviewPath; final oldPreviewPath = _embeddedCoverPreviewPath;
if (!mounted) { if (!mounted ||
generation != _metadataLoadGeneration ||
sourcePath != cleanFilePath) {
if (newPreviewPath != null) { if (newPreviewPath != null) {
await _cleanupTempFileAndParentIfNotCached(newPreviewPath); await _cleanupTempFileAndParentIfNotCached(newPreviewPath);
} }
@ -524,16 +563,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
bool get _isLocalItem => widget.localItem != null; bool get _isLocalItem => _currentLocalLibraryItem != null;
DownloadHistoryItem? get _downloadItem => widget.item; DownloadHistoryItem? get _downloadItem => _currentDownloadItem;
LocalLibraryItem? get _localLibraryItem => widget.localItem; LocalLibraryItem? get _localLibraryItem => _currentLocalLibraryItem;
bool get _hasHistoryNavigation => bool get _hasHistoryNavigation =>
widget.historyNavigationItems != null && widget.navigationIndex != null; widget.historyNavigationItems != null && widget.navigationIndex != null;
bool get _hasLocalNavigation => bool get _hasLocalNavigation =>
widget.localNavigationItems != null && widget.navigationIndex != null; widget.localNavigationItems != null && widget.navigationIndex != null;
bool get _hasTrackSwipeNavigation => bool get _hasTrackSwipeNavigation =>
_hasHistoryNavigation || _hasLocalNavigation; _hasHistoryNavigation || _hasLocalNavigation;
int? get _navigationIndex => widget.navigationIndex; int? get _navigationIndex => _currentNavigationIndex;
int get _navigationLength => int get _navigationLength =>
widget.historyNavigationItems?.length ?? widget.historyNavigationItems?.length ??
widget.localNavigationItems?.length ?? widget.localNavigationItems?.length ??
@ -869,28 +908,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (targetIndex < 0 || targetIndex >= _navigationLength) return; if (targetIndex < 0 || targetIndex >= _navigationLength) return;
_isTrackSwipeNavigationInFlight = true; _isTrackSwipeNavigationInFlight = true;
await Navigator.of(context).pushReplacement<bool, bool>( final oldPreviewPath = _embeddedCoverPreviewPath;
adjacentHorizontalPageRoute<bool>(
page: _buildSiblingTrackScreen(targetIndex),
fromRight: offset > 0,
),
result: _hasMetadataChanges ? true : null,
);
}
TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) { try {
if (_hasHistoryNavigation) { setState(() {
return TrackMetadataScreen( _metadataLoadGeneration++;
item: widget.historyNavigationItems![targetIndex], _metadataTransitionDirection = offset > 0 ? 1 : -1;
historyNavigationItems: widget.historyNavigationItems, _currentNavigationIndex = targetIndex;
navigationIndex: 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 @override
@ -973,39 +1034,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: _buildAnimatedTrackContent(context, ref, colorScheme),
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),
],
),
),
), ),
], ],
), ),
@ -1013,6 +1042,80 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
); );
} }
Widget _buildAnimatedTrackContent(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final currentKey = ValueKey<String>('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: <Widget>[...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<Offset>(
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( Widget _buildHeaderBackground(
BuildContext context, BuildContext context,
ColorScheme colorScheme, ColorScheme colorScheme,
@ -1944,6 +2047,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
/// Called automatically when the screen opens. /// Called automatically when the screen opens.
Future<void> _checkEmbeddedLyrics() async { Future<void> _checkEmbeddedLyrics() async {
if (_lyricsLoading || !_fileExists) return; if (_lyricsLoading || !_fileExists) return;
final generation = _metadataLoadGeneration;
final sourcePath = cleanFilePath;
if (!mounted) return;
setState(() { setState(() {
_lyricsLoading = true; _lyricsLoading = true;
@ -1958,7 +2064,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'', '',
trackName, trackName,
artistName, artistName,
filePath: cleanFilePath, filePath: sourcePath,
durationMs: 0, durationMs: 0,
).timeout( ).timeout(
const Duration(seconds: 5), const Duration(seconds: 5),
@ -1968,7 +2074,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
final embeddedSource = embeddedResult['source']?.toString() ?? ''; final embeddedSource = embeddedResult['source']?.toString() ?? '';
if (mounted) { if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
if (embeddedLyrics.isNotEmpty) { if (embeddedLyrics.isNotEmpty) {
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
setState(() { setState(() {
@ -1989,7 +2097,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
setState(() { setState(() {
_lyricsLoading = false; _lyricsLoading = false;
_embeddedLyricsChecked = true; _embeddedLyricsChecked = true;

View file

@ -117,9 +117,113 @@ class LocalLibraryItem {
); );
String get matchKey => String get matchKey =>
'${trackName.toLowerCase()}|${artistName.toLowerCase()}'; '${LibraryDatabase.normalizeLookupText(trackName)}|${LibraryDatabase.normalizeLookupText(artistName)}';
String get albumKey => 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<String, dynamic> 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<String> isrcs;
final Set<String> matchKeys;
final Map<String, String> filePathById;
const LocalLibraryLookupIndex({
this.isrcs = const <String>{},
this.matchKeys = const <String>{},
this.filePathById = const <String, String>{},
});
} }
class LibraryDatabase { class LibraryDatabase {
@ -142,7 +246,7 @@ class LibraryDatabase {
return await openDatabase( return await openDatabase(
path, path,
version: 6, version: 7,
onConfigure: (db) async { onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL'); await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL'); await db.execute('PRAGMA synchronous = NORMAL');
@ -180,7 +284,13 @@ class LibraryDatabase {
composer TEXT, composer TEXT,
label TEXT, label TEXT,
copyright 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( await db.execute(
'CREATE INDEX idx_library_file_path ON library(file_path)', 'CREATE INDEX idx_library_file_path ON library(file_path)',
); );
await _createNormalizedIndexes(db);
_log.i('Library database schema created with indexes'); _log.i('Library database schema created with indexes');
} }
@ -228,10 +339,124 @@ class LibraryDatabase {
await db.execute('ALTER TABLE library ADD COLUMN composer TEXT'); await db.execute('ALTER TABLE library ADD COLUMN composer TEXT');
_log.i('Added total_tracks/total_discs/composer columns'); _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<void> _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<void> _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<void> _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<String, dynamic> _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<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) { Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return { final row = {
'id': json['id'], 'id': json['id'],
'track_name': json['trackName'], 'track_name': json['trackName'],
'artist_name': json['artistName'], 'artist_name': json['artistName'],
@ -257,6 +482,15 @@ class LibraryDatabase {
'copyright': json['copyright'], 'copyright': json['copyright'],
'format': json['format'], '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<String, dynamic> _dbRowToJson(Map<String, dynamic> row) { Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
@ -346,6 +580,173 @@ class LibraryDatabase {
return rows.map(_dbRowToJson).toList(); return rows.map(_dbRowToJson).toList();
} }
Future<List<Map<String, dynamic>>> getPage(
LocalLibraryPageRequest request,
) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<int> getPageCount(LocalLibraryPageRequest request) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<List<LocalLibraryAlbumGroup>> getAlbumPage({
int limit = 100,
int offset = 0,
LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums,
LocalLibrarySortMode sortMode = LocalLibrarySortMode.album,
String? searchQuery,
}) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<int> getAlbumCount({
LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums,
String? searchQuery,
}) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<String> where,
List<Object?> 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<String> where,
List<Object?> 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<Map<String, dynamic>?> getById(String id) async { Future<Map<String, dynamic>?> getById(String id) async {
final db = await database; final db = await database;
final rows = await db.query( final rows = await db.query(
@ -370,6 +771,18 @@ class LibraryDatabase {
return _dbRowToJson(rows.first); return _dbRowToJson(rows.first);
} }
Future<Map<String, dynamic>?> 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<bool> existsByIsrc(String isrc) async { Future<bool> existsByIsrc(String isrc) async {
final db = await database; final db = await database;
final result = await db.rawQuery( final result = await db.rawQuery(
@ -386,12 +799,28 @@ class LibraryDatabase {
final db = await database; final db = await database;
final rows = await db.query( final rows = await db.query(
'library', 'library',
where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?', where: 'match_key = ?',
whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()], whereArgs: [matchKeyFor(trackName, artistName)],
); );
return rows.map(_dbRowToJson).toList(); return rows.map(_dbRowToJson).toList();
} }
Future<Map<String, dynamic>?> 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<Map<String, dynamic>?> findExisting({ Future<Map<String, dynamic>?> findExisting({
String? isrc, String? isrc,
String? trackName, String? trackName,
@ -421,11 +850,56 @@ class LibraryDatabase {
Future<Set<String>> getAllTrackKeys() async { Future<Set<String>> getAllTrackKeys() async {
final db = await database; final db = await database;
final rows = await db.rawQuery( 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(); return rows.map((r) => r['match_key'] as String).toSet();
} }
Future<LocalLibraryLookupIndex> getLookupIndex() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT id, file_path, isrc, match_key FROM library',
);
final isrcs = <String>{};
final matchKeys = <String>{};
final filePathById = <String, String>{};
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<String>.unmodifiable(isrcs),
matchKeys: Set<String>.unmodifiable(matchKeys),
filePathById: Map<String, String>.unmodifiable(filePathById),
);
}
Future<List<String>> 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<String>()
.toList(growable: false);
}
Future<void> deleteByPath(String filePath) async { Future<void> deleteByPath(String filePath) async {
final db = await database; final db = await database;
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]); await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);