refactor(queue): split progress, verification, and connectivity into part files

This commit is contained in:
zarzet
2026-07-26 22:47:15 +07:00
parent 9dc44ecd88
commit 49bfefb750
4 changed files with 809 additions and 792 deletions
+5 -792
View File
@@ -41,6 +41,9 @@ export 'package:spotiflac_android/services/history_database.dart'
show HistoryLookupRequest, HistoryBatchLookupRequest;
part 'download_queue_provider_paths.dart';
part 'download_queue_provider_progress.dart';
part 'download_queue_provider_verification.dart';
part 'download_queue_provider_connectivity.dart';
part 'download_queue_provider_native_worker.dart';
part 'download_queue_provider_finalization.dart';
part 'download_queue_provider_replaygain.dart';
@@ -92,24 +95,6 @@ final _qualityFilenameTokenPattern = RegExp(
caseSensitive: false,
);
class _ProgressUpdate {
final DownloadStatus status;
final double progress;
final double? speedMBps;
final int? bytesReceived;
final int? bytesTotal;
final String preparationStage;
const _ProgressUpdate({
required this.status,
required this.progress,
this.speedMBps,
this.bytesReceived,
this.bytesTotal,
this.preparationStage = '',
});
}
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _queuePersistDebounce;
Future<void> _queuePersistenceWrite = Future<void>.value();
@@ -163,9 +148,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool _networkPausedByWifiOnly = false;
List<ConnectivityResult>? _lastConnectivityResults;
DateTime _lastConnectionCleanupAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastReconnectRetryPromptAt = DateTime.fromMillisecondsSinceEpoch(
0,
);
DateTime _lastReconnectRetryPromptAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _connectionCleanupDebounce = Duration(seconds: 2);
String? _lastServiceTrackName;
String? _lastServiceArtistName;
@@ -194,49 +177,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// then computes and writes album gain/peak to every track in the album.
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
double _normalizeProgressForUi(double value) {
final clamped = value.clamp(0.0, 1.0).toDouble();
if (clamped <= 0) return 0;
if (clamped >= 1) return 1;
final rounded = double.parse(clamped.toStringAsFixed(2));
return rounded == 0 ? 0.01 : rounded;
}
double _normalizeSpeedForUi(double value) {
if (value <= 0) return 0;
return double.parse(value.toStringAsFixed(1));
}
int _normalizeBytesForUi(int value) {
if (value <= 0) return 0;
return (value ~/ _bytesUiStep) * _bytesUiStep;
}
bool _shouldUpdateProgressNotification({
required String trackName,
required String artistName,
required int progress,
required int total,
required int queueCount,
}) {
final safeTotal = total > 0 ? total : 1;
final percent = ((progress * 100) / safeTotal).round().clamp(0, 100);
final changed =
trackName != _lastNotifTrackName ||
artistName != _lastNotifArtistName ||
percent != _lastNotifPercent ||
queueCount != _lastNotifQueueCount;
if (!changed) {
return false;
}
_lastNotifTrackName = trackName;
_lastNotifArtistName = artistName;
_lastNotifPercent = percent;
_lastNotifQueueCount = queueCount;
return true;
}
@override
DownloadQueueState build() {
ref.listen<AppSettings>(settingsProvider, (previous, next) {
@@ -350,201 +290,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Completes when the app is in the foreground. Verification challenges
/// can only be handled there: launching a browser from the background is
/// blocked by the OS and the challenge would expire unseen.
Future<void> _waitForForeground() async {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
return;
}
final completer = Completer<void>();
final listener = AppLifecycleListener(
onResume: () {
if (!completer.isCompleted) completer.complete();
},
);
try {
await completer.future;
} finally {
listener.dispose();
}
}
Future<bool> _openVerificationAndWait(String extensionId) {
final key = extensionId.trim().toLowerCase();
final activeFlow = _verificationFlowsByExtension[key];
if (activeFlow != null) {
_log.i(
'Joining active verification flow for $extensionId instead of opening another challenge',
);
return activeFlow;
}
final flow = _runVerificationFlow(extensionId, key);
_verificationFlowsByExtension[key] = flow;
return flow;
}
Future<bool> _runVerificationFlow(String extensionId, String key) async {
try {
return await openVerificationAndAwaitGrant(
extensionId,
browserMode: ref
.read(settingsProvider)
.extensionVerificationBrowserMode,
awaitForeground: (normalizedExtensionId) async {
if (WidgetsBinding.instance.lifecycleState !=
AppLifecycleState.resumed) {
_log.i(
'Verification required for $normalizedExtensionId while app is in '
'background; deferring challenge until the app is foregrounded',
);
try {
await _notificationService.showVerificationRequired();
} catch (error) {
_log.w(
'Failed to show the verification-required notification: $error',
);
}
await _waitForForeground();
try {
await _notificationService.cancelVerificationRequired();
} catch (error) {
_log.w(
'Failed to clear the verification-required notification: $error',
);
}
}
},
);
} finally {
_verificationFlowsByExtension.remove(key);
}
}
Future<bool> _handleVerificationRequiredDownload(
DownloadItem item,
String errorMsg,
String? verificationService,
) async {
final targetService = (verificationService ?? '').trim().isNotEmpty
? verificationService!.trim()
: item.service;
if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) {
_log.e(
'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge',
);
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: DownloadErrorType.verificationRequired,
);
_failedInSession++;
return true;
}
_log.i(
'Download for ${item.track.name} requires verification; waiting for $targetService grant',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
error: 'Waiting for verification',
errorType: DownloadErrorType.verificationRequired,
);
final verified = await _openVerificationAndWait(targetService);
final current = _findItemById(item.id);
if (current == null || _isLocallyCancelled(item.id, item: current)) {
_log.i('Verification completed after item was removed or cancelled');
return true;
}
// Only a completed grant consumes the automatic retry. If bootstrap or
// browser launch failed before a challenge opened, a later attempt must
// still be allowed after the network changes.
_verificationRetryGuard.recordVerificationResult(
item.id,
targetService,
granted: verified,
);
if (verified) {
_log.i(
'Verification complete for $targetService; retrying ${item.track.name}',
);
updateItemStatus(
item.id,
DownloadStatus.queued,
progress: 0,
speedMBps: 0,
error: 'Retrying after verification',
errorType: DownloadErrorType.verificationRequired,
);
_saveQueueToStorage();
return true;
}
_log.e('Verification did not complete for $targetService');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: DownloadErrorType.verificationRequired,
);
_failedInSession++;
return true;
}
Duration _rateLimitBackoffDelay(String errorMsg) {
final lower = errorMsg.toLowerCase();
final retryAfterMatch = RegExp(
r'retry[- ]?after(?: seconds)?[:= ]+(\d+)',
caseSensitive: false,
).firstMatch(lower);
final parsedSeconds = retryAfterMatch == null
? null
: int.tryParse(retryAfterMatch.group(1) ?? '');
final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt();
return Duration(seconds: seconds);
}
Future<bool> _handleRateLimitedDownload(
DownloadItem item,
String errorMsg,
) async {
if (_rateLimitRetriedItemIds.contains(item.id)) {
return false;
}
_rateLimitRetriedItemIds.add(item.id);
final delay = _rateLimitBackoffDelay(errorMsg);
_log.i(
'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
error: 'Rate limited, retrying after ${delay.inSeconds}s',
errorType: DownloadErrorType.rateLimit,
);
await Future<void>.delayed(delay);
final current = _findItemById(item.id);
if (current == null || _isLocallyCancelled(item.id, item: current)) {
return true;
}
updateItemStatus(
item.id,
DownloadStatus.queued,
progress: 0,
speedMBps: 0,
error: 'Retrying after rate limit',
errorType: DownloadErrorType.rateLimit,
);
_saveQueueToStorage();
return true;
}
void _saveQueueToStorage() {
_queuePersistDebounce?.cancel();
_queuePersistDebounce = Timer(_queuePersistDebounceDuration, () {
@@ -604,392 +349,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
void _startMultiProgressPolling() {
_idleProgressPollTick = 0;
_progressPoller.start(useStream: Platform.isAndroid || Platform.isIOS);
}
/// Idle-cadence gate for the fallback polling timer: polls every tick while
/// a download is active, but only every [_idleProgressPollEveryTicks]th
/// tick while idle-but-queued, and not at all while paused/empty.
bool _shouldPollDownloadProgressTick() {
final currentItems = state.items;
final hasQueuedItems = currentItems.any(
(item) => item.status == DownloadStatus.queued,
);
final hasActiveItems = currentItems.any(
(item) =>
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing,
);
if (!hasActiveItems) {
if (state.isPaused || !hasQueuedItems) {
_idleProgressPollTick = 0;
return false;
}
_idleProgressPollTick =
(_idleProgressPollTick + 1) % _idleProgressPollEveryTicks;
return _idleProgressPollTick == 0;
}
_idleProgressPollTick = 0;
return true;
}
void _processAllDownloadProgress(Map<String, dynamic> allProgress) {
final rawItems = allProgress['items'];
final items = rawItems is Map
? rawItems.map((key, value) => MapEntry(key.toString(), value))
: const <String, dynamic>{};
final currentItems = state.items;
final lookup = state.lookup;
final queuedCount = lookup.queuedCount;
final downloadingCount = lookup.activeDownloadsCount;
DownloadItem? firstDownloading;
bool hasFinalizingItem = lookup.finalizingCount > 0;
String? finalizingTrackName;
String? finalizingArtistName;
if (downloadingCount > 0 || hasFinalizingItem) {
for (final item in currentItems) {
if (firstDownloading == null &&
item.status == DownloadStatus.downloading) {
firstDownloading = item;
}
if (finalizingTrackName == null &&
item.status == DownloadStatus.finalizing) {
hasFinalizingItem = true;
finalizingTrackName = item.track.name;
finalizingArtistName = item.track.artistName;
}
if ((downloadingCount == 0 || firstDownloading != null) &&
(!hasFinalizingItem || finalizingTrackName != null)) {
break;
}
}
}
final progressUpdates = <String, _ProgressUpdate>{};
for (final entry in items.entries) {
final itemId = entry.key;
final localItem = lookup.byItemId[itemId];
if (localItem == null) {
continue;
}
if (_isPausePending(itemId)) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.completed ||
localItem.status == DownloadStatus.failed) {
continue;
}
if (localItem.status == DownloadStatus.finalizing) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
hasFinalizingItem = true;
finalizingTrackName = localItem.track.name;
finalizingArtistName = localItem.track.artistName;
continue;
}
final rawItemProgress = entry.value;
if (rawItemProgress is! Map) {
continue;
}
final itemProgress = Map<String, dynamic>.from(rawItemProgress);
final bytesReceived =
(itemProgress['bytes_received'] as num?)?.toInt() ?? 0;
final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0;
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
final progressFromBackend =
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
final hasRealProgress =
status != 'preparing' &&
(bytesReceived > 0 || bytesTotal > 0 || progressFromBackend > 0);
if (status == 'finalizing') {
progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing,
progress: 1.0,
);
hasFinalizingItem = true;
finalizingTrackName = localItem.track.name;
finalizingArtistName = localItem.track.artistName;
continue;
}
if (status == 'preparing') {
progressUpdates[itemId] = _ProgressUpdate(
status: DownloadStatus.downloading,
progress: 0.0,
speedMBps: 0,
bytesReceived: 0,
bytesTotal: 0,
preparationStage: itemProgress['stage']?.toString() ?? '',
);
if (LogBuffer.loggingEnabled) {
_log.d('Preparing [$itemId]: waiting for real download bytes');
}
continue;
}
if (isDownloading || hasRealProgress) {
double percentage = 0.0;
if (bytesTotal > 0) {
percentage = bytesReceived / bytesTotal;
} else {
percentage = progressFromBackend;
}
final normalizedProgress = _normalizeProgressForUi(percentage);
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
progressUpdates[itemId] = _ProgressUpdate(
status: DownloadStatus.downloading,
progress: normalizedProgress,
speedMBps: normalizedSpeed,
bytesReceived: normalizedBytes,
bytesTotal: bytesTotal,
);
if (LogBuffer.loggingEnabled) {
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
if (bytesTotal > 0) {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
} else {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (stream/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
}
}
}
}
if (progressUpdates.isNotEmpty) {
var updatedItems = currentItems;
bool changed = false;
final changedIndices = <int>[];
for (final entry in progressUpdates.entries) {
final index = lookup.indexByItemId[entry.key];
if (index == null) continue;
final current = updatedItems[index];
if (current.status == DownloadStatus.skipped ||
current.status == DownloadStatus.completed ||
current.status == DownloadStatus.failed) {
continue;
}
final update = entry.value;
if (current.status == DownloadStatus.finalizing &&
update.status != DownloadStatus.finalizing) {
continue;
}
final next = current.copyWith(
status: update.status,
progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
bytesTotal: update.bytesTotal ?? current.bytesTotal,
preparationStage: update.preparationStage,
);
if (current.status != next.status ||
current.progress != next.progress ||
current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived ||
current.bytesTotal != next.bytesTotal ||
current.preparationStage != next.preparationStage) {
if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems);
changed = true;
}
updatedItems[index] = next;
changedIndices.add(index);
}
}
if (changed) {
state = state.copyWith(
items: updatedItems,
lookup: state.lookup.updatedForIndices(
previousItems: currentItems,
nextItems: updatedItems,
changedIndices: changedIndices,
),
);
}
}
if (hasFinalizingItem && finalizingTrackName != null) {
final safeArtistName = finalizingArtistName ?? '';
if (Platform.isAndroid) {
_maybeUpdateAndroidDownloadService(
trackName: finalizingTrackName,
artistName: _notificationService.embeddingMetadataLabel,
progress: 100,
total: 100,
queueCount: queuedCount,
status: 'finalizing',
);
} else if (finalizingTrackName != _lastFinalizingTrackName ||
safeArtistName != _lastFinalizingArtistName) {
_notificationService.showDownloadFinalizing(
trackName: finalizingTrackName,
artistName: safeArtistName,
);
_lastFinalizingTrackName = finalizingTrackName;
_lastFinalizingArtistName = safeArtistName;
}
return;
}
_lastFinalizingTrackName = null;
_lastFinalizingArtistName = null;
if (items.isNotEmpty) {
if (downloadingCount > 0 && firstDownloading != null) {
final rawProgress = items[firstDownloading.id];
if (rawProgress is! Map) {
return;
}
final selectedProgress = Map<String, dynamic>.from(rawProgress);
final bytesReceived =
(selectedProgress['bytes_received'] as num?)?.toInt() ?? 0;
final bytesTotal =
(selectedProgress['bytes_total'] as num?)?.toInt() ?? 0;
final backendStatus =
selectedProgress['status'] as String? ?? 'downloading';
final trackName = downloadingCount == 1
? firstDownloading.track.name
: '$downloadingCount downloads';
final artistName = downloadingCount == 1
? firstDownloading.track.artistName
: 'Downloading...';
int notifProgress = bytesReceived;
int notifTotal = bytesTotal;
final progressPercent =
(selectedProgress['progress'] as num?)?.toDouble() ?? 0.0;
if (backendStatus == 'preparing') {
notifProgress = 0;
notifTotal = 0;
} else if (bytesTotal <= 0) {
notifProgress = (progressPercent * 100).toInt();
notifTotal = 100;
}
final serviceStatus = notifTotal <= 0 ? 'preparing' : 'downloading';
if (!Platform.isAndroid &&
_shouldUpdateProgressNotification(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: notifTotal,
queueCount: queuedCount,
)) {
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
_notificationService.showDownloadProgress(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: safeNotifTotal,
);
}
if (Platform.isAndroid) {
_maybeUpdateAndroidDownloadService(
trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName,
progress: notifProgress,
total: notifTotal,
queueCount: queuedCount,
status: serviceStatus,
);
}
}
}
}
void _maybeUpdateAndroidDownloadService({
required String trackName,
required String artistName,
required int progress,
required int total,
required int queueCount,
String status = 'downloading',
}) {
final now = DateTime.now();
final progressBucket = total <= 0
? -1
: (() {
final progressPercent = ((progress * 100) / total)
.round()
.clamp(0, 100)
.toInt();
return progressPercent == 100
? 100
: ((progressPercent ~/ _serviceProgressStepPercent) *
_serviceProgressStepPercent)
.clamp(0, 100)
.toInt();
})();
final didContentChange =
trackName != _lastServiceTrackName ||
artistName != _lastServiceArtistName ||
status != _lastServiceStatus ||
queueCount != _lastServiceQueueCount ||
progressBucket != _lastServicePercent;
final allowHeartbeat =
now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5);
if (!didContentChange && !allowHeartbeat) {
return;
}
_lastServiceTrackName = trackName;
_lastServiceArtistName = artistName;
_lastServiceStatus = status;
_lastServicePercent = progressBucket;
_lastServiceQueueCount = queueCount;
_lastServiceUpdateAt = now;
PlatformBridge.updateDownloadServiceProgress(
trackName: trackName,
artistName: artistName,
progress: progress,
total: total,
queueCount: queueCount,
status: status,
).catchError((_) {});
}
void _stopProgressPolling() {
_progressPoller.stop();
_idleProgressPollTick = 0;
_lastServiceTrackName = null;
_lastServiceArtistName = null;
_lastServiceStatus = null;
_lastServicePercent = -1;
_lastServiceQueueCount = -1;
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
_lastFinalizingTrackName = null;
_lastFinalizingArtistName = null;
_lastNotifTrackName = null;
_lastNotifArtistName = null;
_lastNotifPercent = -1;
_lastNotifQueueCount = -1;
}
bool _isSafMode(AppSettings settings) {
return Platform.isAndroid &&
settings.storageMode == 'saf' &&
@@ -1594,8 +953,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(item) =>
(item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped) &&
(!networkOnly ||
item.errorType == DownloadErrorType.network),
(!networkOnly || item.errorType == DownloadErrorType.network),
)
.map((item) => item.id)
.toSet();
@@ -1810,151 +1168,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return null;
}
bool _hasWifiConnection(List<ConnectivityResult> results) {
return results.contains(ConnectivityResult.wifi);
}
void _startConnectivityMonitoring() {
_connectivitySub?.cancel();
_connectivitySub = Connectivity().onConnectivityChanged.listen(
_handleConnectivityResults,
onError: (Object error, StackTrace stackTrace) {
_log.w('Connectivity monitoring failed: $error');
},
cancelOnError: false,
);
}
void _stopConnectivityMonitoring({bool clearNetworkPause = true}) {
if (clearNetworkPause) {
_networkPausedByWifiOnly = false;
}
// Keep listening while network-failed items remain so the reconnect
// retry prompt can still fire when the queue is otherwise idle.
if (_hasNetworkFailedItems) return;
_connectivitySub?.cancel();
_connectivitySub = null;
}
bool get _hasNetworkFailedItems => state.items.any(
(item) =>
item.status == DownloadStatus.failed &&
item.errorType == DownloadErrorType.network,
);
/// Offers a one-tap retry for network-failed items once connectivity
/// returns, debounced so network flapping doesn't spam snackbars.
void _maybeOfferRetryAfterReconnect(List<ConnectivityResult> results) {
if (results.every((result) => result == ConnectivityResult.none)) return;
final failedCount = state.items
.where(
(item) =>
item.status == DownloadStatus.failed &&
item.errorType == DownloadErrorType.network,
)
.length;
if (failedCount == 0) return;
final now = DateTime.now();
if (now.difference(_lastReconnectRetryPromptAt) <
const Duration(minutes: 1)) {
return;
}
_lastReconnectRetryPromptAt = now;
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null || !context.mounted) return;
final l10n = context.l10n;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.queueNetworkFailedOffline(failedCount)),
action: SnackBarAction(
label: l10n.dialogRetry,
onPressed: () => retryAllFailed(networkOnly: true),
),
),
);
}
void _handleDownloadNetworkModeChanged(String mode) {
if (mode == 'wifi_only') {
if (state.isProcessing || _networkPausedByWifiOnly) {
_startConnectivityMonitoring();
}
return;
}
final shouldResume = _networkPausedByWifiOnly && state.isPaused;
// Keep monitoring active in every mode while downloading so idle
// connections are still recycled when the network switches.
if (state.isProcessing) {
_networkPausedByWifiOnly = false;
_startConnectivityMonitoring();
} else {
_stopConnectivityMonitoring();
}
if (shouldResume) {
resumeQueue();
}
}
/// Closes idle backend HTTP connections when the connectivity set changes
/// (e.g. WiFi <-> cellular). Stale sockets bound to the old interface would
/// otherwise be reused, stalling the first request after a network switch.
/// Applies to every download network mode. Fire-and-forget with a light
/// debounce so network flapping does not spam the bridge.
void _maybeCleanupOnNetworkChange(List<ConnectivityResult> results) {
final previous = _lastConnectivityResults;
final unchanged =
previous != null && _connectivitySetEquals(previous, results);
_lastConnectivityResults = List<ConnectivityResult>.unmodifiable(results);
if (previous == null || unchanged) return;
final now = DateTime.now();
if (now.difference(_lastConnectionCleanupAt) < _connectionCleanupDebounce) {
return;
}
_lastConnectionCleanupAt = now;
_log.i('Network changed, closing idle backend connections');
unawaited(
PlatformBridge.cleanupConnections().catchError((Object e) {
_log.w('Failed to clean up connections after network change: $e');
}),
);
}
bool _connectivitySetEquals(
List<ConnectivityResult> a,
List<ConnectivityResult> b,
) {
final setA = a.toSet();
final setB = b.toSet();
return setA.length == setB.length && setA.containsAll(setB);
}
void _handleConnectivityResults(List<ConnectivityResult> results) {
_maybeCleanupOnNetworkChange(results);
_maybeOfferRetryAfterReconnect(results);
final settings = ref.read(settingsProvider);
if (settings.downloadNetworkMode != 'wifi_only') return;
if (_hasWifiConnection(results)) {
if (_networkPausedByWifiOnly && state.isPaused) {
_networkPausedByWifiOnly = false;
_log.i('WiFi restored, resuming network-paused queue');
resumeQueue();
}
return;
}
if (state.isProcessing && !state.isPaused) {
_networkPausedByWifiOnly = true;
_log.w('WiFi connection lost, pausing active queue');
pauseQueue();
}
}
DownloadErrorType _downloadErrorTypeFromBackend(String? errorType) {
switch (errorType) {
case 'not_found':
@@ -0,0 +1,150 @@
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'download_queue_provider.dart';
extension _DownloadQueueConnectivity on DownloadQueueNotifier {
bool _hasWifiConnection(List<ConnectivityResult> results) {
return results.contains(ConnectivityResult.wifi);
}
void _startConnectivityMonitoring() {
_connectivitySub?.cancel();
_connectivitySub = Connectivity().onConnectivityChanged.listen(
_handleConnectivityResults,
onError: (Object error, StackTrace stackTrace) {
_log.w('Connectivity monitoring failed: $error');
},
cancelOnError: false,
);
}
void _stopConnectivityMonitoring({bool clearNetworkPause = true}) {
if (clearNetworkPause) {
_networkPausedByWifiOnly = false;
}
// Keep listening while network-failed items remain so the reconnect
// retry prompt can still fire when the queue is otherwise idle.
if (_hasNetworkFailedItems) return;
_connectivitySub?.cancel();
_connectivitySub = null;
}
bool get _hasNetworkFailedItems => state.items.any(
(item) =>
item.status == DownloadStatus.failed &&
item.errorType == DownloadErrorType.network,
);
/// Offers a one-tap retry for network-failed items once connectivity
/// returns, debounced so network flapping doesn't spam snackbars.
void _maybeOfferRetryAfterReconnect(List<ConnectivityResult> results) {
if (results.every((result) => result == ConnectivityResult.none)) return;
final failedCount = state.items
.where(
(item) =>
item.status == DownloadStatus.failed &&
item.errorType == DownloadErrorType.network,
)
.length;
if (failedCount == 0) return;
final now = DateTime.now();
if (now.difference(_lastReconnectRetryPromptAt) <
const Duration(minutes: 1)) {
return;
}
_lastReconnectRetryPromptAt = now;
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null || !context.mounted) return;
final l10n = context.l10n;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.queueNetworkFailedOffline(failedCount)),
action: SnackBarAction(
label: l10n.dialogRetry,
onPressed: () => retryAllFailed(networkOnly: true),
),
),
);
}
void _handleDownloadNetworkModeChanged(String mode) {
if (mode == 'wifi_only') {
if (state.isProcessing || _networkPausedByWifiOnly) {
_startConnectivityMonitoring();
}
return;
}
final shouldResume = _networkPausedByWifiOnly && state.isPaused;
// Keep monitoring active in every mode while downloading so idle
// connections are still recycled when the network switches.
if (state.isProcessing) {
_networkPausedByWifiOnly = false;
_startConnectivityMonitoring();
} else {
_stopConnectivityMonitoring();
}
if (shouldResume) {
resumeQueue();
}
}
/// Closes idle backend HTTP connections when the connectivity set changes
/// (e.g. WiFi <-> cellular). Stale sockets bound to the old interface would
/// otherwise be reused, stalling the first request after a network switch.
/// Applies to every download network mode. Fire-and-forget with a light
/// debounce so network flapping does not spam the bridge.
void _maybeCleanupOnNetworkChange(List<ConnectivityResult> results) {
final previous = _lastConnectivityResults;
final unchanged =
previous != null && _connectivitySetEquals(previous, results);
_lastConnectivityResults = List<ConnectivityResult>.unmodifiable(results);
if (previous == null || unchanged) return;
final now = DateTime.now();
if (now.difference(_lastConnectionCleanupAt) <
DownloadQueueNotifier._connectionCleanupDebounce) {
return;
}
_lastConnectionCleanupAt = now;
_log.i('Network changed, closing idle backend connections');
unawaited(
PlatformBridge.cleanupConnections().catchError((Object e) {
_log.w('Failed to clean up connections after network change: $e');
}),
);
}
bool _connectivitySetEquals(
List<ConnectivityResult> a,
List<ConnectivityResult> b,
) {
final setA = a.toSet();
final setB = b.toSet();
return setA.length == setB.length && setA.containsAll(setB);
}
void _handleConnectivityResults(List<ConnectivityResult> results) {
_maybeCleanupOnNetworkChange(results);
_maybeOfferRetryAfterReconnect(results);
final settings = ref.read(settingsProvider);
if (settings.downloadNetworkMode != 'wifi_only') return;
if (_hasWifiConnection(results)) {
if (_networkPausedByWifiOnly && state.isPaused) {
_networkPausedByWifiOnly = false;
_log.i('WiFi restored, resuming network-paused queue');
resumeQueue();
}
return;
}
if (state.isProcessing && !state.isPaused) {
_networkPausedByWifiOnly = true;
_log.w('WiFi connection lost, pausing active queue');
pauseQueue();
}
}
}
@@ -0,0 +1,455 @@
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'download_queue_provider.dart';
class _ProgressUpdate {
final DownloadStatus status;
final double progress;
final double? speedMBps;
final int? bytesReceived;
final int? bytesTotal;
final String preparationStage;
const _ProgressUpdate({
required this.status,
required this.progress,
this.speedMBps,
this.bytesReceived,
this.bytesTotal,
this.preparationStage = '',
});
}
extension _DownloadQueueProgress on DownloadQueueNotifier {
double _normalizeProgressForUi(double value) {
final clamped = value.clamp(0.0, 1.0).toDouble();
if (clamped <= 0) return 0;
if (clamped >= 1) return 1;
final rounded = double.parse(clamped.toStringAsFixed(2));
return rounded == 0 ? 0.01 : rounded;
}
double _normalizeSpeedForUi(double value) {
if (value <= 0) return 0;
return double.parse(value.toStringAsFixed(1));
}
int _normalizeBytesForUi(int value) {
if (value <= 0) return 0;
return (value ~/ DownloadQueueNotifier._bytesUiStep) *
DownloadQueueNotifier._bytesUiStep;
}
bool _shouldUpdateProgressNotification({
required String trackName,
required String artistName,
required int progress,
required int total,
required int queueCount,
}) {
final safeTotal = total > 0 ? total : 1;
final percent = ((progress * 100) / safeTotal).round().clamp(0, 100);
final changed =
trackName != _lastNotifTrackName ||
artistName != _lastNotifArtistName ||
percent != _lastNotifPercent ||
queueCount != _lastNotifQueueCount;
if (!changed) {
return false;
}
_lastNotifTrackName = trackName;
_lastNotifArtistName = artistName;
_lastNotifPercent = percent;
_lastNotifQueueCount = queueCount;
return true;
}
void _startMultiProgressPolling() {
_idleProgressPollTick = 0;
_progressPoller.start(useStream: Platform.isAndroid || Platform.isIOS);
}
/// Idle-cadence gate for the fallback polling timer: polls every tick while
/// a download is active, but only every [_idleProgressPollEveryTicks]th
/// tick while idle-but-queued, and not at all while paused/empty.
bool _shouldPollDownloadProgressTick() {
final currentItems = state.items;
final hasQueuedItems = currentItems.any(
(item) => item.status == DownloadStatus.queued,
);
final hasActiveItems = currentItems.any(
(item) =>
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing,
);
if (!hasActiveItems) {
if (state.isPaused || !hasQueuedItems) {
_idleProgressPollTick = 0;
return false;
}
_idleProgressPollTick =
(_idleProgressPollTick + 1) %
DownloadQueueNotifier._idleProgressPollEveryTicks;
return _idleProgressPollTick == 0;
}
_idleProgressPollTick = 0;
return true;
}
void _processAllDownloadProgress(Map<String, dynamic> allProgress) {
final rawItems = allProgress['items'];
final items = rawItems is Map
? rawItems.map((key, value) => MapEntry(key.toString(), value))
: const <String, dynamic>{};
final currentItems = state.items;
final lookup = state.lookup;
final queuedCount = lookup.queuedCount;
final downloadingCount = lookup.activeDownloadsCount;
DownloadItem? firstDownloading;
bool hasFinalizingItem = lookup.finalizingCount > 0;
String? finalizingTrackName;
String? finalizingArtistName;
if (downloadingCount > 0 || hasFinalizingItem) {
for (final item in currentItems) {
if (firstDownloading == null &&
item.status == DownloadStatus.downloading) {
firstDownloading = item;
}
if (finalizingTrackName == null &&
item.status == DownloadStatus.finalizing) {
hasFinalizingItem = true;
finalizingTrackName = item.track.name;
finalizingArtistName = item.track.artistName;
}
if ((downloadingCount == 0 || firstDownloading != null) &&
(!hasFinalizingItem || finalizingTrackName != null)) {
break;
}
}
}
final progressUpdates = <String, _ProgressUpdate>{};
for (final entry in items.entries) {
final itemId = entry.key;
final localItem = lookup.byItemId[itemId];
if (localItem == null) {
continue;
}
if (_isPausePending(itemId)) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.completed ||
localItem.status == DownloadStatus.failed) {
continue;
}
if (localItem.status == DownloadStatus.finalizing) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
hasFinalizingItem = true;
finalizingTrackName = localItem.track.name;
finalizingArtistName = localItem.track.artistName;
continue;
}
final rawItemProgress = entry.value;
if (rawItemProgress is! Map) {
continue;
}
final itemProgress = Map<String, dynamic>.from(rawItemProgress);
final bytesReceived =
(itemProgress['bytes_received'] as num?)?.toInt() ?? 0;
final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0;
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
final progressFromBackend =
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
final hasRealProgress =
status != 'preparing' &&
(bytesReceived > 0 || bytesTotal > 0 || progressFromBackend > 0);
if (status == 'finalizing') {
progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing,
progress: 1.0,
);
hasFinalizingItem = true;
finalizingTrackName = localItem.track.name;
finalizingArtistName = localItem.track.artistName;
continue;
}
if (status == 'preparing') {
progressUpdates[itemId] = _ProgressUpdate(
status: DownloadStatus.downloading,
progress: 0.0,
speedMBps: 0,
bytesReceived: 0,
bytesTotal: 0,
preparationStage: itemProgress['stage']?.toString() ?? '',
);
if (LogBuffer.loggingEnabled) {
_log.d('Preparing [$itemId]: waiting for real download bytes');
}
continue;
}
if (isDownloading || hasRealProgress) {
double percentage = 0.0;
if (bytesTotal > 0) {
percentage = bytesReceived / bytesTotal;
} else {
percentage = progressFromBackend;
}
final normalizedProgress = _normalizeProgressForUi(percentage);
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
progressUpdates[itemId] = _ProgressUpdate(
status: DownloadStatus.downloading,
progress: normalizedProgress,
speedMBps: normalizedSpeed,
bytesReceived: normalizedBytes,
bytesTotal: bytesTotal,
);
if (LogBuffer.loggingEnabled) {
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
if (bytesTotal > 0) {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
} else {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (stream/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
}
}
}
}
if (progressUpdates.isNotEmpty) {
var updatedItems = currentItems;
bool changed = false;
final changedIndices = <int>[];
for (final entry in progressUpdates.entries) {
final index = lookup.indexByItemId[entry.key];
if (index == null) continue;
final current = updatedItems[index];
if (current.status == DownloadStatus.skipped ||
current.status == DownloadStatus.completed ||
current.status == DownloadStatus.failed) {
continue;
}
final update = entry.value;
if (current.status == DownloadStatus.finalizing &&
update.status != DownloadStatus.finalizing) {
continue;
}
final next = current.copyWith(
status: update.status,
progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
bytesTotal: update.bytesTotal ?? current.bytesTotal,
preparationStage: update.preparationStage,
);
if (current.status != next.status ||
current.progress != next.progress ||
current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived ||
current.bytesTotal != next.bytesTotal ||
current.preparationStage != next.preparationStage) {
if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems);
changed = true;
}
updatedItems[index] = next;
changedIndices.add(index);
}
}
if (changed) {
state = state.copyWith(
items: updatedItems,
lookup: state.lookup.updatedForIndices(
previousItems: currentItems,
nextItems: updatedItems,
changedIndices: changedIndices,
),
);
}
}
if (hasFinalizingItem && finalizingTrackName != null) {
final safeArtistName = finalizingArtistName ?? '';
if (Platform.isAndroid) {
_maybeUpdateAndroidDownloadService(
trackName: finalizingTrackName,
artistName: _notificationService.embeddingMetadataLabel,
progress: 100,
total: 100,
queueCount: queuedCount,
status: 'finalizing',
);
} else if (finalizingTrackName != _lastFinalizingTrackName ||
safeArtistName != _lastFinalizingArtistName) {
_notificationService.showDownloadFinalizing(
trackName: finalizingTrackName,
artistName: safeArtistName,
);
_lastFinalizingTrackName = finalizingTrackName;
_lastFinalizingArtistName = safeArtistName;
}
return;
}
_lastFinalizingTrackName = null;
_lastFinalizingArtistName = null;
if (items.isNotEmpty) {
if (downloadingCount > 0 && firstDownloading != null) {
final rawProgress = items[firstDownloading.id];
if (rawProgress is! Map) {
return;
}
final selectedProgress = Map<String, dynamic>.from(rawProgress);
final bytesReceived =
(selectedProgress['bytes_received'] as num?)?.toInt() ?? 0;
final bytesTotal =
(selectedProgress['bytes_total'] as num?)?.toInt() ?? 0;
final backendStatus =
selectedProgress['status'] as String? ?? 'downloading';
final trackName = downloadingCount == 1
? firstDownloading.track.name
: '$downloadingCount downloads';
final artistName = downloadingCount == 1
? firstDownloading.track.artistName
: 'Downloading...';
int notifProgress = bytesReceived;
int notifTotal = bytesTotal;
final progressPercent =
(selectedProgress['progress'] as num?)?.toDouble() ?? 0.0;
if (backendStatus == 'preparing') {
notifProgress = 0;
notifTotal = 0;
} else if (bytesTotal <= 0) {
notifProgress = (progressPercent * 100).toInt();
notifTotal = 100;
}
final serviceStatus = notifTotal <= 0 ? 'preparing' : 'downloading';
if (!Platform.isAndroid &&
_shouldUpdateProgressNotification(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: notifTotal,
queueCount: queuedCount,
)) {
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
_notificationService.showDownloadProgress(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: safeNotifTotal,
);
}
if (Platform.isAndroid) {
_maybeUpdateAndroidDownloadService(
trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName,
progress: notifProgress,
total: notifTotal,
queueCount: queuedCount,
status: serviceStatus,
);
}
}
}
}
void _maybeUpdateAndroidDownloadService({
required String trackName,
required String artistName,
required int progress,
required int total,
required int queueCount,
String status = 'downloading',
}) {
final now = DateTime.now();
final progressBucket = total <= 0
? -1
: (() {
final progressPercent = ((progress * 100) / total)
.round()
.clamp(0, 100)
.toInt();
return progressPercent == 100
? 100
: ((progressPercent ~/
DownloadQueueNotifier
._serviceProgressStepPercent) *
DownloadQueueNotifier._serviceProgressStepPercent)
.clamp(0, 100)
.toInt();
})();
final didContentChange =
trackName != _lastServiceTrackName ||
artistName != _lastServiceArtistName ||
status != _lastServiceStatus ||
queueCount != _lastServiceQueueCount ||
progressBucket != _lastServicePercent;
final allowHeartbeat =
now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5);
if (!didContentChange && !allowHeartbeat) {
return;
}
_lastServiceTrackName = trackName;
_lastServiceArtistName = artistName;
_lastServiceStatus = status;
_lastServicePercent = progressBucket;
_lastServiceQueueCount = queueCount;
_lastServiceUpdateAt = now;
PlatformBridge.updateDownloadServiceProgress(
trackName: trackName,
artistName: artistName,
progress: progress,
total: total,
queueCount: queueCount,
status: status,
).catchError((_) {});
}
void _stopProgressPolling() {
_progressPoller.stop();
_idleProgressPollTick = 0;
_lastServiceTrackName = null;
_lastServiceArtistName = null;
_lastServiceStatus = null;
_lastServicePercent = -1;
_lastServiceQueueCount = -1;
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
_lastFinalizingTrackName = null;
_lastFinalizingArtistName = null;
_lastNotifTrackName = null;
_lastNotifArtistName = null;
_lastNotifPercent = -1;
_lastNotifQueueCount = -1;
}
}
@@ -0,0 +1,199 @@
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'download_queue_provider.dart';
extension _DownloadQueueVerificationGate on DownloadQueueNotifier {
/// Completes when the app is in the foreground. Verification challenges
/// can only be handled there: launching a browser from the background is
/// blocked by the OS and the challenge would expire unseen.
Future<void> _waitForForeground() async {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
return;
}
final completer = Completer<void>();
final listener = AppLifecycleListener(
onResume: () {
if (!completer.isCompleted) completer.complete();
},
);
try {
await completer.future;
} finally {
listener.dispose();
}
}
Future<bool> _openVerificationAndWait(String extensionId) {
final key = extensionId.trim().toLowerCase();
final activeFlow = _verificationFlowsByExtension[key];
if (activeFlow != null) {
_log.i(
'Joining active verification flow for $extensionId instead of opening another challenge',
);
return activeFlow;
}
final flow = _runVerificationFlow(extensionId, key);
_verificationFlowsByExtension[key] = flow;
return flow;
}
Future<bool> _runVerificationFlow(String extensionId, String key) async {
try {
return await openVerificationAndAwaitGrant(
extensionId,
browserMode: ref
.read(settingsProvider)
.extensionVerificationBrowserMode,
awaitForeground: (normalizedExtensionId) async {
if (WidgetsBinding.instance.lifecycleState !=
AppLifecycleState.resumed) {
_log.i(
'Verification required for $normalizedExtensionId while app is in '
'background; deferring challenge until the app is foregrounded',
);
try {
await _notificationService.showVerificationRequired();
} catch (error) {
_log.w(
'Failed to show the verification-required notification: $error',
);
}
await _waitForForeground();
try {
await _notificationService.cancelVerificationRequired();
} catch (error) {
_log.w(
'Failed to clear the verification-required notification: $error',
);
}
}
},
);
} finally {
_verificationFlowsByExtension.remove(key);
}
}
Future<bool> _handleVerificationRequiredDownload(
DownloadItem item,
String errorMsg,
String? verificationService,
) async {
final targetService = (verificationService ?? '').trim().isNotEmpty
? verificationService!.trim()
: item.service;
if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) {
_log.e(
'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge',
);
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: DownloadErrorType.verificationRequired,
);
_failedInSession++;
return true;
}
_log.i(
'Download for ${item.track.name} requires verification; waiting for $targetService grant',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
error: 'Waiting for verification',
errorType: DownloadErrorType.verificationRequired,
);
final verified = await _openVerificationAndWait(targetService);
final current = _findItemById(item.id);
if (current == null || _isLocallyCancelled(item.id, item: current)) {
_log.i('Verification completed after item was removed or cancelled');
return true;
}
// Only a completed grant consumes the automatic retry. If bootstrap or
// browser launch failed before a challenge opened, a later attempt must
// still be allowed after the network changes.
_verificationRetryGuard.recordVerificationResult(
item.id,
targetService,
granted: verified,
);
if (verified) {
_log.i(
'Verification complete for $targetService; retrying ${item.track.name}',
);
updateItemStatus(
item.id,
DownloadStatus.queued,
progress: 0,
speedMBps: 0,
error: 'Retrying after verification',
errorType: DownloadErrorType.verificationRequired,
);
_saveQueueToStorage();
return true;
}
_log.e('Verification did not complete for $targetService');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: DownloadErrorType.verificationRequired,
);
_failedInSession++;
return true;
}
Duration _rateLimitBackoffDelay(String errorMsg) {
final lower = errorMsg.toLowerCase();
final retryAfterMatch = RegExp(
r'retry[- ]?after(?: seconds)?[:= ]+(\d+)',
caseSensitive: false,
).firstMatch(lower);
final parsedSeconds = retryAfterMatch == null
? null
: int.tryParse(retryAfterMatch.group(1) ?? '');
final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt();
return Duration(seconds: seconds);
}
Future<bool> _handleRateLimitedDownload(
DownloadItem item,
String errorMsg,
) async {
if (_rateLimitRetriedItemIds.contains(item.id)) {
return false;
}
_rateLimitRetriedItemIds.add(item.id);
final delay = _rateLimitBackoffDelay(errorMsg);
_log.i(
'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
error: 'Rate limited, retrying after ${delay.inSeconds}s',
errorType: DownloadErrorType.rateLimit,
);
await Future<void>.delayed(delay);
final current = _findItemById(item.id);
if (current == null || _isLocallyCancelled(item.id, item: current)) {
return true;
}
updateItemStatus(
item.id,
DownloadStatus.queued,
progress: 0,
speedMBps: 0,
error: 'Retrying after rate limit',
errorType: DownloadErrorType.rateLimit,
);
_saveQueueToStorage();
return true;
}
}