mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(queue): split progress, verification, and connectivity into part files
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user