mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
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:
parent
d24435dbc2
commit
149cdc782d
11 changed files with 1128 additions and 333 deletions
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue