mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
refactor(library): split local_library_provider.dart into source, scan and cover parts
The provider file mixed source management, the scan pipeline and the cover lookup providers. Move the private notifier methods into extensions in two parts (_LocalLibrarySources, _LocalLibraryScan) and the cover request types and providers into a third part. The moves are verbatim except for one reference to the static heartbeat constant, which an extension must qualify with the class name. Public notifier methods, which test subclasses override, stay on the class in the library file.
This commit is contained in:
1 parent
de142abae6
commit
ff7afa5b3c
4 files changed
+1224
-1207
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
||||
part of 'local_library_provider.dart';
|
||||
|
||||
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.autoDispose
|
||||
.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.autoDispose
|
||||
.family<String?, LocalLibraryCoverBatchRequest>((ref, request) async {
|
||||
ref.watch(
|
||||
localLibraryProvider.select((state) => state.loadedIndexVersion),
|
||||
);
|
||||
final rows = await LibraryDatabase.instance.findExistingBatch([
|
||||
for (final track in request.tracks)
|
||||
LocalLibraryBatchLookupRequest(
|
||||
isrc: track.isrc,
|
||||
trackName: track.trackName,
|
||||
artistName: track.artistName,
|
||||
),
|
||||
]);
|
||||
for (final row in rows) {
|
||||
final cover = _nonEmptyCoverPath(row);
|
||||
if (cover != null) return cover;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,851 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
||||
part of 'local_library_provider.dart';
|
||||
|
||||
/// Scan execution, progress polling, pause handling and scan notifications.
|
||||
extension _LocalLibraryScan on LocalLibraryNotifier {
|
||||
Future<({int inserted, int skipped})?> _replaceFromFullScanStream({
|
||||
required String sourceId,
|
||||
required String folderPath,
|
||||
required bool isSaf,
|
||||
required bool forceFullScan,
|
||||
}) async {
|
||||
if (_scanCancelRequested) return null;
|
||||
final scanFile = folderPath.startsWith('network://')
|
||||
? await NetworkLibraryScanner().scan(
|
||||
folderPath,
|
||||
checkpoint: () async {
|
||||
while (_scanPauseRequested &&
|
||||
!_scanCancelRequested &&
|
||||
ref.mounted) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
if (_scanCancelRequested || !ref.mounted) {
|
||||
throw StateError('Network library scan cancelled');
|
||||
}
|
||||
},
|
||||
onProgress: (processed, errors, name) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
scannedFiles: processed,
|
||||
scanErrorCount: errors,
|
||||
scanCurrentFile: name,
|
||||
);
|
||||
if (_shouldShowScanProgressNotification(
|
||||
progress: 0,
|
||||
totalFiles: 0,
|
||||
isComplete: false,
|
||||
)) {
|
||||
unawaited(
|
||||
_showScanProgressNotification(
|
||||
progress: 0,
|
||||
scannedFiles: processed,
|
||||
totalFiles: 0,
|
||||
currentFile: name,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: isSaf
|
||||
? await PlatformBridge.scanSafTreeToNDJSONFile(
|
||||
folderPath,
|
||||
forceFullScan: forceFullScan,
|
||||
isCancelled: () => _scanCancelRequested,
|
||||
)
|
||||
: await PlatformBridge.scanLibraryFolderToNDJSONFile(
|
||||
folderPath,
|
||||
forceFullScan: forceFullScan,
|
||||
isCancelled: () => _scanCancelRequested,
|
||||
);
|
||||
var ingested = false;
|
||||
try {
|
||||
if (_scanCancelRequested) return null;
|
||||
await _endPauseForFinalization();
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
scanCurrentFile: null,
|
||||
scannedFiles: scanFile.expectedCount,
|
||||
scanErrorCount: scanFile.errorCount,
|
||||
);
|
||||
final result = await _db.replaceSourceScanFile(
|
||||
sourceId,
|
||||
scanFile,
|
||||
requestId: _nativeLibraryRequestId ?? 'desktop-library-scan',
|
||||
isCancelled: () => _scanCancelRequested || !ref.mounted,
|
||||
);
|
||||
_log.i(
|
||||
'Stream-ingested ${result.inserted}/${scanFile.expectedCount} scan rows '
|
||||
'(${result.skipped} downloads excluded)',
|
||||
);
|
||||
ingested = true;
|
||||
return result;
|
||||
} finally {
|
||||
if (ingested || folderPath.startsWith('network://')) {
|
||||
await scanFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runScan(
|
||||
String folderPath, {
|
||||
required bool forceFullScan,
|
||||
required String? iosBookmark,
|
||||
required String? sourceId,
|
||||
}) async {
|
||||
var activeSourceId = sourceId;
|
||||
IosSecurityScopedAccess? securityAccess;
|
||||
try {
|
||||
if (activeSourceId == null) {
|
||||
final existingSource = await _db.getSourceByPath(folderPath);
|
||||
if (!ref.mounted || _scanCancelRequested) return;
|
||||
activeSourceId = existingSource?.id;
|
||||
if (activeSourceId == null) {
|
||||
final source = await addSource(
|
||||
path: folderPath,
|
||||
displayName: _displayNameForPath(folderPath),
|
||||
bookmark: iosBookmark,
|
||||
isRemovable: _looksLikeRemovablePath(folderPath),
|
||||
);
|
||||
activeSourceId = source.id;
|
||||
}
|
||||
}
|
||||
|
||||
final available = await _isPathAvailable(
|
||||
folderPath,
|
||||
bookmark: iosBookmark,
|
||||
);
|
||||
if (!ref.mounted || _scanCancelRequested) return;
|
||||
if (!available) {
|
||||
_sourceVersion++;
|
||||
await _db.updateSourceState(activeSourceId, available: false);
|
||||
await _refreshSummaryFromStorage();
|
||||
return;
|
||||
}
|
||||
if (await _sourceForScan(activeSourceId) == null ||
|
||||
!ref.mounted ||
|
||||
_scanCancelRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A pause left behind by an interrupted Dart session would hold the new
|
||||
// native scan forever; clear it before the pause control becomes visible.
|
||||
await _releaseNativeScanPause();
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId);
|
||||
} catch (e) {
|
||||
_log.w('Failed to persist active library scan marker: $e');
|
||||
}
|
||||
if (!ref.mounted || _scanCancelRequested) return;
|
||||
_log.i(
|
||||
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
|
||||
);
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanIsFinalizing: false,
|
||||
scanIsPaused: false,
|
||||
scanProgress: 0,
|
||||
scanCurrentFile: null,
|
||||
scanTotalFiles: 0,
|
||||
scannedFiles: 0,
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
scanningSourceId: activeSourceId,
|
||||
);
|
||||
_resetScanNotificationTracking();
|
||||
// Hold before the first progress notification so Android shows a single
|
||||
// foreground-service notification; released in the final cleanup below.
|
||||
await _notificationService.beginLibraryScanWork();
|
||||
if (_shouldShowScanProgressNotification(
|
||||
progress: 0,
|
||||
totalFiles: 0,
|
||||
isComplete: false,
|
||||
)) {
|
||||
await _showScanProgressNotification(
|
||||
progress: 0,
|
||||
scannedFiles: 0,
|
||||
totalFiles: 0,
|
||||
currentFile: null,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final coverCacheDir = '${appSupportDir.path}/library_covers';
|
||||
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
|
||||
_log.i('Cover cache directory set to: $coverCacheDir');
|
||||
} catch (e) {
|
||||
_log.w('Failed to set cover cache directory: $e');
|
||||
}
|
||||
if (!ref.mounted || _scanCancelRequested) return;
|
||||
|
||||
if (!folderPath.startsWith('network://')) _startProgressPolling();
|
||||
|
||||
String? resolvedPath;
|
||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||
securityAccess = await PlatformBridge.startAccessingIosBookmark(
|
||||
iosBookmark,
|
||||
);
|
||||
resolvedPath = securityAccess?.path;
|
||||
if (securityAccess != null) {
|
||||
_log.i('Started iOS security-scoped access: $resolvedPath');
|
||||
} else {
|
||||
_log.w(
|
||||
'Failed to start iOS security-scoped access, '
|
||||
'falling back to original path',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!ref.mounted || _scanCancelRequested) return;
|
||||
final effectiveFolderPath = resolvedPath ?? folderPath;
|
||||
|
||||
final isSaf = isContentUri(effectiveFolderPath);
|
||||
|
||||
final useStreamingFullScan =
|
||||
effectiveFolderPath.startsWith('network://') ||
|
||||
forceFullScan ||
|
||||
await _db.getSourceCount(activeSourceId) == 0;
|
||||
if (useStreamingFullScan) {
|
||||
if (await _sourceForScan(activeSourceId) == null ||
|
||||
!ref.mounted ||
|
||||
_scanCancelRequested) {
|
||||
return;
|
||||
}
|
||||
final scanResult = await _replaceFromFullScanStream(
|
||||
sourceId: activeSourceId,
|
||||
folderPath: effectiveFolderPath,
|
||||
isSaf: isSaf,
|
||||
forceFullScan: forceFullScan,
|
||||
);
|
||||
if (!ref.mounted) return;
|
||||
if (scanResult == null || _scanCancelRequested) {
|
||||
if (scanResult != null) {
|
||||
await _refreshSummaryFromStorage(
|
||||
excludedDownloadedCount: scanResult.skipped,
|
||||
);
|
||||
}
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
scanCurrentFile: null,
|
||||
);
|
||||
|
||||
final skippedDownloads = scanResult.skipped;
|
||||
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
await _finishScan(
|
||||
sourceId: activeSourceId,
|
||||
skippedDownloads: skippedDownloads,
|
||||
pruneCoverCache: true,
|
||||
summary: () =>
|
||||
'Full scan complete: ${state.totalCount} tracks found, '
|
||||
'$skippedDownloads already in downloads, '
|
||||
'${state.scanErrorCount} errors',
|
||||
);
|
||||
} else {
|
||||
final nativeIngestion = PlatformBridge.supportsCoreBackend;
|
||||
final existingFiles = nativeIngestion
|
||||
? const <String, int>{}
|
||||
: await _db.getFileModTimes(sourceId: activeSourceId);
|
||||
if (!nativeIngestion) {
|
||||
_log.i(
|
||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||
);
|
||||
final backfilledModTimes = await _backfillLegacyFileModTimes(
|
||||
isSaf: isSaf,
|
||||
existingFiles: existingFiles,
|
||||
);
|
||||
if (backfilledModTimes.isNotEmpty) {
|
||||
await _db.updateFileModTimes(backfilledModTimes);
|
||||
existingFiles.addAll(backfilledModTimes);
|
||||
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
|
||||
}
|
||||
}
|
||||
|
||||
final snapshotPath = nativeIngestion
|
||||
? await _db.writeNativeFileModTimesSnapshot(
|
||||
activeSourceId,
|
||||
requestId: _nativeLibraryRequestId!,
|
||||
isCancelled: () => _scanCancelRequested || !ref.mounted,
|
||||
)
|
||||
: null;
|
||||
|
||||
Map<String, dynamic> result;
|
||||
try {
|
||||
if (await _sourceForScan(activeSourceId) == null ||
|
||||
_scanCancelRequested ||
|
||||
!ref.mounted) {
|
||||
return;
|
||||
}
|
||||
if (nativeIngestion) {
|
||||
result = await _db.scanIncrementalNative(
|
||||
activeSourceId,
|
||||
effectiveFolderPath,
|
||||
snapshotPath!,
|
||||
isSaf: isSaf,
|
||||
requestId: _nativeLibraryRequestId!,
|
||||
isCancelled: () => _scanCancelRequested || !ref.mounted,
|
||||
);
|
||||
} else if (isSaf) {
|
||||
result = await PlatformBridge.scanSafTreeIncremental(
|
||||
effectiveFolderPath,
|
||||
existingFiles,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.scanLibraryFolderIncremental(
|
||||
effectiveFolderPath,
|
||||
existingFiles,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (snapshotPath != null) {
|
||||
try {
|
||||
await File(snapshotPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ref.mounted) return;
|
||||
if (_scanCancelRequested || result['cancelled'] == true) {
|
||||
if (result['committed'] == true) {
|
||||
await _refreshSummaryFromStorage(
|
||||
excludedDownloadedCount: (result['skipped'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
await _endPauseForFinalization();
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
scanCurrentFile: null,
|
||||
);
|
||||
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
[];
|
||||
final deletedPaths =
|
||||
(result['removedUris'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
(result['deletedPaths'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[];
|
||||
final skippedCount = result['skippedCount'] as int? ?? 0;
|
||||
final totalFiles = result['totalFiles'] as int? ?? 0;
|
||||
final scannedCount = nativeIngestion
|
||||
? (result['scanned_count'] as num).toInt()
|
||||
: scannedList.length;
|
||||
final deletedCount = nativeIngestion
|
||||
? (result['deleted_count'] as num).toInt()
|
||||
: deletedPaths.length;
|
||||
if (nativeIngestion && result['committed'] != true) {
|
||||
throw StateError('Native Library scan did not commit its index');
|
||||
}
|
||||
|
||||
_log.i(
|
||||
'Incremental result: $scannedCount scanned, '
|
||||
'$skippedCount skipped, $deletedCount deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
final removedDownloaded = nativeIngestion
|
||||
? 0
|
||||
: await _db.deleteDownloadedRowsForSource(activeSourceId);
|
||||
if (removedDownloaded > 0) {
|
||||
_log.i(
|
||||
'Removed $removedDownloaded downloaded tracks already present in '
|
||||
'the local Library index',
|
||||
);
|
||||
}
|
||||
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
var skippedDownloads = nativeIngestion
|
||||
? (result['skipped'] as num).toInt()
|
||||
: removedDownloaded;
|
||||
if (!nativeIngestion && scannedList.isNotEmpty) {
|
||||
for (final json in scannedList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
final item = LocalLibraryItem.fromJson(map);
|
||||
updatedItems.add(item);
|
||||
}
|
||||
if (updatedItems.isNotEmpty) {
|
||||
final upsertResult = await _db.upsertBatchExcludingHistory(
|
||||
updatedItems.map((e) => e.toJson()).toList(),
|
||||
sourceId: activeSourceId,
|
||||
);
|
||||
skippedDownloads += upsertResult.skipped;
|
||||
_log.i('Upserted ${upsertResult.upserted} items');
|
||||
}
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i(
|
||||
'Skipped $skippedDownloads files already in download history',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!nativeIngestion && deletedPaths.isNotEmpty) {
|
||||
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
await _finishScan(
|
||||
sourceId: activeSourceId,
|
||||
skippedDownloads: skippedDownloads,
|
||||
pruneCoverCache: false,
|
||||
summary: () =>
|
||||
'Incremental scan complete: ${state.totalCount} total tracks '
|
||||
'($scannedCount new/updated, $skippedCount unchanged, '
|
||||
'$deletedCount removed, $skippedDownloads already in downloads, '
|
||||
'${state.scanErrorCount} errors)',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
if (!ref.mounted) return;
|
||||
if (_scanCancelRequested) {
|
||||
_log.i('Library scan cancelled');
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
if (activeSourceId != null) {
|
||||
await _db.updateSourceState(
|
||||
activeSourceId,
|
||||
lastScanError: e.toString(),
|
||||
);
|
||||
await _refreshSummaryFromStorage();
|
||||
}
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
await _showScanFailedNotification(e.toString());
|
||||
} finally {
|
||||
await _notificationService.endBackgroundWork(
|
||||
NotificationService.libraryScanWorkKind,
|
||||
);
|
||||
if (securityAccess != null) {
|
||||
await PlatformBridge.stopAccessingIosBookmark(securityAccess);
|
||||
_log.i('Stopped iOS security-scoped access');
|
||||
}
|
||||
_stopProgressPolling();
|
||||
if (_scanPauseRequested) {
|
||||
_scanPauseRequested = false;
|
||||
await _releaseNativeScanPause();
|
||||
}
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
if (prefs.getString(localLibraryActiveScanSourceKey) ==
|
||||
activeSourceId) {
|
||||
await prefs.remove(localLibraryActiveScanSourceKey);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear active library scan marker: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared completion tail of a successful full or incremental scan.
|
||||
/// [summary] is evaluated after the final state update so it reads the
|
||||
/// refreshed totals.
|
||||
Future<void> _finishScan({
|
||||
required String sourceId,
|
||||
required int skippedDownloads,
|
||||
required bool pruneCoverCache,
|
||||
required String Function() summary,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
_sourceVersion++;
|
||||
await _db.updateSourceState(
|
||||
sourceId,
|
||||
available: true,
|
||||
lastSeenAt: now,
|
||||
lastScannedAt: now,
|
||||
clearLastScanError: true,
|
||||
);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
await _refreshSummaryFromStorage(
|
||||
lastScannedAt: now,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
);
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
);
|
||||
if (pruneCoverCache) await _pruneLibraryCoverCache();
|
||||
|
||||
final message = summary();
|
||||
if (state.scanErrorCount > 0) {
|
||||
_log.e(message);
|
||||
} else {
|
||||
_log.i(message);
|
||||
}
|
||||
await _showScanCompleteNotification(
|
||||
totalTracks: state.totalCount,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
}
|
||||
|
||||
void _startProgressPolling() {
|
||||
final useStream = Platform.isAndroid || Platform.isIOS;
|
||||
_progressPoller.start(useStream: useStream);
|
||||
if (useStream) {
|
||||
Future<void>.microtask(
|
||||
() => _progressPoller.pollOnce(
|
||||
(e) => _log.w('Initial library scan progress fetch failed: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
|
||||
// A newly subscribed native stream may still contain the previous scan's
|
||||
// terminal snapshot (including the initial idle snapshot). Only the scan
|
||||
// method's result can start finalization; never stop polling on that replay.
|
||||
if (_scanCancelRequested ||
|
||||
!state.isScanning ||
|
||||
state.scanIsFinalizing ||
|
||||
progress['is_complete'] == true) {
|
||||
return;
|
||||
}
|
||||
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final displayProgress = normalizedProgress >= 100.0
|
||||
? 99.0
|
||||
: normalizedProgress;
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
|
||||
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
|
||||
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != displayProgress ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
state.scanErrorCount != errorCount;
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: displayProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
}
|
||||
|
||||
// Files already in flight still finish after a pause; count them, but
|
||||
// keep the paused notice instead of reposting progress.
|
||||
if (!state.scanIsPaused &&
|
||||
_shouldShowScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
totalFiles: totalFiles,
|
||||
isComplete: false,
|
||||
)) {
|
||||
await _showScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: currentFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressPoller.stop();
|
||||
_resetScanNotificationTracking();
|
||||
}
|
||||
|
||||
void _resetScanNotificationTracking() {
|
||||
_lastScanNotificationPercent = -1;
|
||||
_lastScanNotificationTotalFiles = -1;
|
||||
_lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
|
||||
bool _shouldShowScanProgressNotification({
|
||||
required double progress,
|
||||
required int totalFiles,
|
||||
required bool isComplete,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final percent = progress.round().clamp(0, 100);
|
||||
final percentChanged = percent != _lastScanNotificationPercent;
|
||||
final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles;
|
||||
final heartbeatDue =
|
||||
now.difference(_lastScanNotificationAt) >=
|
||||
LocalLibraryNotifier._scanNotificationHeartbeat;
|
||||
|
||||
if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastScanNotificationPercent = percent;
|
||||
_lastScanNotificationTotalFiles = totalFiles;
|
||||
_lastScanNotificationAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get _isScanningNetwork => state.sources.any(
|
||||
(source) =>
|
||||
source.id == state.scanningSourceId &&
|
||||
source.path.startsWith('network://'),
|
||||
);
|
||||
|
||||
Future<void> _releaseNativeScanPause() async {
|
||||
try {
|
||||
await PlatformBridge.resumeLibraryScan();
|
||||
} catch (e) {
|
||||
_log.w('Failed to release library scan pause: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// The native scan can finish while paused when the pause lands after its
|
||||
/// last file. Ingestion then runs normally, so restore background work.
|
||||
Future<void> _endPauseForFinalization() async {
|
||||
if (!_scanPauseRequested) return;
|
||||
_scanPauseRequested = false;
|
||||
state = state.copyWith(scanIsPaused: false);
|
||||
await _notificationService.beginLibraryScanWork();
|
||||
await _cancelScanPausedNotification();
|
||||
await _releaseNativeScanPause();
|
||||
}
|
||||
|
||||
Future<void> _showScanPausedNotification(int scannedFiles) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanPaused(
|
||||
scannedFiles: scannedFiles,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan paused notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelScanPausedNotification() async {
|
||||
try {
|
||||
await _notificationService.cancelLibraryScanNotification();
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear scan paused notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanProgressNotification({
|
||||
required double progress,
|
||||
required int scannedFiles,
|
||||
required int totalFiles,
|
||||
required String? currentFile,
|
||||
}) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanProgress(
|
||||
progress: progress,
|
||||
scannedFiles: scannedFiles,
|
||||
totalFiles: totalFiles,
|
||||
currentFile: _shortenFileForNotification(currentFile),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan progress notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanCompleteNotification({
|
||||
required int totalTracks,
|
||||
required int excludedDownloadedCount,
|
||||
required int errorCount,
|
||||
}) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanComplete(
|
||||
totalTracks: totalTracks,
|
||||
excludedDownloadedCount: excludedDownloadedCount,
|
||||
errorCount: errorCount,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan complete notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanFailedNotification(String message) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanFailed(message);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan failure notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanCancelledNotification() async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanCancelled();
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan cancelled notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String? _shortenFileForNotification(String? path) {
|
||||
final raw = path?.trim() ?? '';
|
||||
if (raw.isEmpty) return null;
|
||||
|
||||
var decoded = raw;
|
||||
try {
|
||||
decoded = Uri.decodeFull(raw);
|
||||
} catch (_) {}
|
||||
|
||||
final slashIdx = decoded.lastIndexOf('/');
|
||||
final backslashIdx = decoded.lastIndexOf('\\');
|
||||
final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
|
||||
if (cut >= 0 && cut < decoded.length - 1) {
|
||||
return decoded.substring(cut + 1);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Future<void> _pruneLibraryCoverCache() async {
|
||||
try {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
||||
if (!await libraryCoverDir.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ref.mounted || _scanCancelRequested) return;
|
||||
final libraryDatabase = await _db.database;
|
||||
if (!ref.mounted || _scanCancelRequested) return;
|
||||
|
||||
final deletedCount = await pruneUnreferencedLibraryCovers(
|
||||
libraryCoverDir,
|
||||
const {},
|
||||
libraryDatabase: libraryDatabase,
|
||||
requestId: _nativeLibraryRequestId,
|
||||
canPrune: () => ref.mounted && !_scanCancelRequested,
|
||||
// Avoid removing covers being written by a background producer before
|
||||
// its Library transaction publishes the reference. The scan guard is
|
||||
// still held here; the source sqflite transaction holds the writer
|
||||
// barrier while Rust reads its detached, private cover projection.
|
||||
minimumAge: const Duration(minutes: 1),
|
||||
);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
_log.i('Pruned $deletedCount stale library cover cache files');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed pruning library cover cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, int>> _backfillLegacyFileModTimes({
|
||||
required bool isSaf,
|
||||
required Map<String, int> existingFiles,
|
||||
}) async {
|
||||
final legacyPaths = existingFiles.entries
|
||||
// Negative timestamps deliberately force a metadata-version rescan;
|
||||
// only a missing timestamp (zero) may be filled from the filesystem.
|
||||
.where((entry) => entry.value == 0)
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
if (legacyPaths.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
if (isSaf) {
|
||||
final uris = legacyPaths.where((path) => isContentUri(path)).toList();
|
||||
if (uris.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
const chunkSize = 500;
|
||||
final backfilled = <String, int>{};
|
||||
try {
|
||||
for (var i = 0; i < uris.length; i += chunkSize) {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
final end = (i + chunkSize < uris.length)
|
||||
? i + chunkSize
|
||||
: uris.length;
|
||||
final chunk = uris.sublist(i, end);
|
||||
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
|
||||
backfilled.addAll(chunkResult);
|
||||
}
|
||||
return backfilled;
|
||||
} catch (e) {
|
||||
_log.w('Failed to backfill SAF mod times: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
final paths = legacyPaths
|
||||
.where((path) => !isContentUri(path))
|
||||
.toList(growable: false);
|
||||
const chunkSize = 24;
|
||||
final backfilled = <String, int>{};
|
||||
|
||||
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
|
||||
final chunk = paths.sublist(i, end);
|
||||
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
|
||||
chunk.map((path) async {
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type == FileSystemEntityType.file) {
|
||||
return MapEntry(path, stat.modified.millisecondsSinceEpoch);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
for (final entry in chunkEntries) {
|
||||
if (entry != null) {
|
||||
backfilled[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return backfilled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
||||
part of 'local_library_provider.dart';
|
||||
|
||||
/// Source identity, availability probing, reconnect scans and the summary
|
||||
/// refresh that follows a source change.
|
||||
extension _LocalLibrarySources on LocalLibraryNotifier {
|
||||
String _newSourceId(String path) {
|
||||
const offset = 0xcbf29ce484222325;
|
||||
const prime = 0x100000001b3;
|
||||
const mask = 0xffffffffffffffff;
|
||||
var hash = offset;
|
||||
for (final unit in path.codeUnits) {
|
||||
hash = ((hash ^ unit) * prime) & mask;
|
||||
}
|
||||
return 'source_${hash.toRadixString(16).padLeft(16, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _refreshSourceAvailability() async {
|
||||
await _ensureLoadedFromDatabase();
|
||||
final reconnected = <String>{};
|
||||
while (ref.mounted) {
|
||||
final result = await _refreshSourceAvailabilityInDatabase();
|
||||
if (!ref.mounted) return;
|
||||
reconnected.addAll(result.reconnected);
|
||||
if (result.changed) await _refreshSummaryFromStorage();
|
||||
if (result.requestVersion == _availabilityRequestVersion) break;
|
||||
}
|
||||
|
||||
// Existing rows become visible as soon as availability flips. The
|
||||
// incremental pass then verifies changes made while storage was detached
|
||||
// without re-reading metadata for unchanged files.
|
||||
if (ref.mounted && _scanReconnectedOnRefresh && reconnected.isNotEmpty) {
|
||||
_pendingReconnectScans.addAll(reconnected);
|
||||
unawaited(_drainReconnectScans());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scanSourcesSequentially(
|
||||
Iterable<String> sourceIds, {
|
||||
bool forceFullScan = false,
|
||||
}) async {
|
||||
for (final sourceId in sourceIds) {
|
||||
if (!ref.mounted) break;
|
||||
await startSourceScan(sourceId, forceFullScan: forceFullScan);
|
||||
if (_scanCancelRequested) break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<LocalLibrarySource?> _sourceForScan(String sourceId) async {
|
||||
if (!ref.mounted) return null;
|
||||
await _ensureLoadedFromDatabase();
|
||||
if (!ref.mounted) return null;
|
||||
while (ref.mounted) {
|
||||
final sourceVersion = _sourceVersion;
|
||||
final source = (await _db.getSources())
|
||||
.where((entry) => entry.id == sourceId)
|
||||
.firstOrNull;
|
||||
if (!ref.mounted) return null;
|
||||
if (sourceVersion != _sourceVersion) continue;
|
||||
return source?.enabled == true && source?.available == true
|
||||
? source
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _drainReconnectScans() async {
|
||||
if (_drainingReconnectScans || !ref.mounted) return;
|
||||
_drainingReconnectScans = true;
|
||||
try {
|
||||
while (ref.mounted && _pendingReconnectScans.isNotEmpty) {
|
||||
final active = _scanCompletion;
|
||||
if (active != null) await active;
|
||||
if (!ref.mounted || _pendingReconnectScans.isEmpty) break;
|
||||
final sourceId = _pendingReconnectScans.first;
|
||||
await startSourceScan(sourceId);
|
||||
_pendingReconnectScans.remove(sourceId);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.e('Failed to scan reconnected library source: $error', error, stack);
|
||||
} finally {
|
||||
_drainingReconnectScans = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshSummaryFromStorage({
|
||||
DateTime? lastScannedAt,
|
||||
int? excludedDownloadedCount,
|
||||
}) async {
|
||||
final sourceVersion = ++_sourceVersion;
|
||||
final sources = await _db.getSources();
|
||||
if (!ref.mounted || sourceVersion != _sourceVersion) return;
|
||||
state = state.copyWith(sources: sources);
|
||||
final summary = await Future.wait<Object>([
|
||||
_db.getCount(),
|
||||
_db.getLookupIndex(),
|
||||
]);
|
||||
final count = summary[0] as int;
|
||||
final index = summary[1] as LocalLibraryLookupIndex;
|
||||
if (!ref.mounted || sourceVersion != _sourceVersion) return;
|
||||
final latestSourceScan = sources
|
||||
.map((source) => source.lastScannedAt)
|
||||
.whereType<DateTime>()
|
||||
.fold<DateTime?>(
|
||||
null,
|
||||
(latest, value) =>
|
||||
latest == null || value.isAfter(latest) ? value : latest,
|
||||
);
|
||||
state = state.copyWith(
|
||||
loadFailed: false,
|
||||
totalCount: count,
|
||||
loadedIndexVersion: state.loadedIndexVersion + 1,
|
||||
lastScannedAt: lastScannedAt ?? latestSourceScan,
|
||||
excludedDownloadedCount: excludedDownloadedCount,
|
||||
sources: sources,
|
||||
trackKeySet: index.matchKeys,
|
||||
isrcSet: index.isrcs,
|
||||
);
|
||||
_hasLoadedFromDatabase = true;
|
||||
_isLoaded = true;
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacySource() async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final path = settings.localLibraryPath.trim();
|
||||
if (path.isEmpty) return;
|
||||
if (await _db.getSourceByPath(path) != null) return;
|
||||
DateTime? legacyLastScannedAt;
|
||||
try {
|
||||
legacyLastScannedAt = readLocalLibraryLastScannedAt(await _prefs);
|
||||
} catch (_) {}
|
||||
_sourceVersion++;
|
||||
await _db.migrateLegacySource(
|
||||
path: path,
|
||||
displayName: _displayNameForPath(path),
|
||||
bookmark: settings.localLibraryBookmark.trim().isEmpty
|
||||
? null
|
||||
: settings.localLibraryBookmark,
|
||||
isRemovable: _looksLikeRemovablePath(path),
|
||||
available: await _isPathAvailable(
|
||||
path,
|
||||
bookmark: settings.localLibraryBookmark,
|
||||
),
|
||||
lastScannedAt: legacyLastScannedAt,
|
||||
);
|
||||
}
|
||||
|
||||
String _displayNameForPath(String path) {
|
||||
if (isContentUri(path)) {
|
||||
try {
|
||||
final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last);
|
||||
final relative = decoded.contains(':')
|
||||
? decoded.substring(decoded.indexOf(':') + 1)
|
||||
: decoded;
|
||||
if (relative.trim().isNotEmpty) {
|
||||
return relative.split('/').last;
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'Music';
|
||||
}
|
||||
final normalized = path
|
||||
.replaceAll('\\', '/')
|
||||
.replaceAll(RegExp(r'/+$'), '');
|
||||
final name = normalized.split('/').last;
|
||||
return name.isEmpty ? path : name;
|
||||
}
|
||||
|
||||
bool _looksLikeRemovablePath(String path) {
|
||||
if (isContentUri(path)) {
|
||||
try {
|
||||
final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last);
|
||||
return !decoded.startsWith('primary:');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
final normalized = path.replaceAll('\\', '/').toLowerCase();
|
||||
return normalized.startsWith('/storage/') &&
|
||||
!normalized.startsWith('/storage/emulated/');
|
||||
}
|
||||
|
||||
Future<bool> _isPathAvailable(String path, {String? bookmark}) async {
|
||||
return await _probePathAvailability(path, bookmark: bookmark) ?? true;
|
||||
}
|
||||
|
||||
Future<bool?> _probePathAvailability(String path, {String? bookmark}) async {
|
||||
// Network outages are transient. Keep the indexed collection visible;
|
||||
// opening/scanning a source reports connectivity errors without dropping it.
|
||||
if (path.startsWith('network://')) return true;
|
||||
try {
|
||||
if (_availabilityProbe != null) {
|
||||
return await _availabilityProbe(path, bookmark: bookmark);
|
||||
}
|
||||
if (Platform.isAndroid && isContentUri(path)) {
|
||||
return await PlatformBridge.probeSafTreeReadAccess(path);
|
||||
}
|
||||
if (Platform.isIOS && bookmark != null && bookmark.trim().isNotEmpty) {
|
||||
final access = await PlatformBridge.startAccessingIosBookmark(bookmark);
|
||||
if (access == null) return false;
|
||||
try {
|
||||
return await Directory(access.path).exists();
|
||||
} finally {
|
||||
await PlatformBridge.stopAccessingIosBookmark(access);
|
||||
}
|
||||
}
|
||||
return await Directory(path).exists();
|
||||
} catch (error) {
|
||||
// A busy provider or a filesystem exception cannot prove detachment.
|
||||
_log.w('Library source access probe failed: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<({List<String> reconnected, bool changed, int requestVersion})>
|
||||
_refreshSourceAvailabilityInDatabase() =>
|
||||
_availabilityCheckFuture ??= _checkSourceAvailability().whenComplete(() {
|
||||
_availabilityCheckFuture = null;
|
||||
});
|
||||
|
||||
Future<({List<String> reconnected, bool changed, int requestVersion})>
|
||||
_checkSourceAvailability() async {
|
||||
final reconnected = <String>{};
|
||||
var changed = false;
|
||||
while (ref.mounted) {
|
||||
final sourceVersion = _sourceVersion;
|
||||
final requestVersion = _availabilityRequestVersion;
|
||||
final sources = await _db.getSources();
|
||||
var inconclusive = false;
|
||||
for (final source in sources) {
|
||||
final available = await _probePathAvailability(
|
||||
source.path,
|
||||
bookmark: source.bookmark,
|
||||
);
|
||||
if (!ref.mounted ||
|
||||
sourceVersion != _sourceVersion ||
|
||||
requestVersion != _availabilityRequestVersion) {
|
||||
break;
|
||||
}
|
||||
if (available == null) {
|
||||
inconclusive = true;
|
||||
_log.w(
|
||||
'Library source access inconclusive; retaining status: ${source.id}',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (available != source.available) {
|
||||
await _db.updateSourceState(
|
||||
source.id,
|
||||
available: available,
|
||||
lastSeenAt: available ? DateTime.now() : null,
|
||||
);
|
||||
changed = true;
|
||||
if (available && source.enabled && source.isIndexed) {
|
||||
reconnected.add(source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ref.mounted) break;
|
||||
// A folder edit or a newer storage event supersedes pending probe
|
||||
// results. Re-read the source snapshot before applying them.
|
||||
if (sourceVersion != _sourceVersion ||
|
||||
requestVersion != _availabilityRequestVersion) {
|
||||
continue;
|
||||
}
|
||||
_availabilityRetry?.cancel();
|
||||
if (inconclusive && _availabilityRetryCount < 3) {
|
||||
_availabilityRetryCount++;
|
||||
_availabilityRetry = Timer(const Duration(seconds: 2), () {
|
||||
unawaited(refreshSourceAvailability(scanReconnected: true));
|
||||
});
|
||||
} else if (!inconclusive) {
|
||||
_availabilityRetryCount = 0;
|
||||
}
|
||||
return (
|
||||
reconnected: reconnected.toList(growable: false),
|
||||
changed: changed,
|
||||
requestVersion: requestVersion,
|
||||
);
|
||||
}
|
||||
return (
|
||||
reconnected: reconnected.toList(growable: false),
|
||||
changed: changed,
|
||||
requestVersion: _availabilityRequestVersion,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user