feat(downloads): support up to 3 concurrent downloads

The Dart queue loop was already built around an activeDownloads map and
per-item progress tracking but was gated to one download at a time. Add
a concurrentDownloads setting (1-3, default 1) exposed in Download
Settings > Performance, and let the scheduler keep up to that many
items in flight. The experimental Android native worker is still
strictly sequential, so it is skipped in favor of the Dart queue while
concurrency is enabled.
This commit is contained in:
zarzet
2026-07-10 05:58:25 +07:00
parent 19f69a6090
commit 1998a9300e
22 changed files with 376 additions and 5 deletions
+24
View File
@@ -3074,6 +3074,30 @@ abstract class AppLocalizations {
/// **'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'**
String get settingsDownloadNetworkSubtitle;
/// Setting title - how many tracks download at the same time
///
/// In en, this message translates to:
/// **'Concurrent downloads'**
String get settingsConcurrentDownloads;
/// Subtitle explaining the concurrent downloads picker
///
/// In en, this message translates to:
/// **'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.'**
String get settingsConcurrentDownloadsSubtitle;
/// Concurrent downloads option - sequential
///
/// In en, this message translates to:
/// **'1 track at a time'**
String get concurrentDownloadsOne;
/// Concurrent downloads option - parallel
///
/// In en, this message translates to:
/// **'Up to {count} tracks at once'**
String concurrentDownloadsCount(int count);
/// Album folder option
///
/// In en, this message translates to:
+15
View File
@@ -1658,6 +1658,21 @@ class AppLocalizationsAr extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+15
View File
@@ -1683,6 +1683,21 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Wähle aus, welches Netzwerk für Downloads verwendet werden soll. Wenn nur WLAN aktiviert wird, werden Downloads auf mobilen Daten angehalten.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Künstler/Album';
+15
View File
@@ -1658,6 +1658,21 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+15
View File
@@ -1658,6 +1658,21 @@ class AppLocalizationsEs extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+15
View File
@@ -1706,6 +1706,21 @@ class AppLocalizationsFr extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choisissez le réseau à utiliser pour les téléchargements. Si vous sélectionnez « Wi-Fi uniquement », les téléchargements seront interrompus lorsque vous utilisez les données mobiles.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artiste / Album';
+15
View File
@@ -1658,6 +1658,21 @@ class AppLocalizationsHi extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+15
View File
@@ -1664,6 +1664,21 @@ class AppLocalizationsId extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artis / Album';
+15
View File
@@ -1646,6 +1646,21 @@ class AppLocalizationsJa extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'アーティスト / アルバム';
+15
View File
@@ -1643,6 +1643,21 @@ class AppLocalizationsKo extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+15
View File
@@ -1658,6 +1658,21 @@ class AppLocalizationsNl extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+15
View File
@@ -1658,6 +1658,21 @@ class AppLocalizationsPt extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+15
View File
@@ -1681,6 +1681,21 @@ class AppLocalizationsRu extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Выберите, какую сеть использовать для скачивания. Когда установлено значение только WiFi — скачивания через мобильную сеть будут приостановлены.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Исполнитель / Альбом';
+15
View File
@@ -1677,6 +1677,21 @@ class AppLocalizationsTr extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'İndirmeler için hangi ağın kullanılacağını seçin. Yalnızca WiFi olarak ayarlandığında, mobil veriye geçildiğinde indirmeler duraklatılır.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Sanatçı / Albüm';
+15
View File
@@ -1684,6 +1684,21 @@ class AppLocalizationsUk extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Вибрати мережу для завантажень. Якщо встановлено значення «Тільки Wi-Fi», завантаження призупиняться через мобільні дані.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Артист / Альбом';
+15
View File
@@ -1658,6 +1658,21 @@ class AppLocalizationsZh extends AppLocalizations {
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get settingsConcurrentDownloads => 'Concurrent downloads';
@override
String get settingsConcurrentDownloadsSubtitle =>
'Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.';
@override
String get concurrentDownloadsOne => '1 track at a time';
@override
String concurrentDownloadsCount(int count) {
return 'Up to $count tracks at once';
}
@override
String get albumFolderArtistAlbum => 'Artist / Album';
+21
View File
@@ -2175,6 +2175,27 @@
"@settingsDownloadNetworkSubtitle": {
"description": "Subtitle explaining network preference"
},
"settingsConcurrentDownloads": "Concurrent downloads",
"@settingsConcurrentDownloads": {
"description": "Setting title - how many tracks download at the same time"
},
"settingsConcurrentDownloadsSubtitle": "Downloading several tracks at once is faster, but some providers may rate-limit parallel requests.",
"@settingsConcurrentDownloadsSubtitle": {
"description": "Subtitle explaining the concurrent downloads picker"
},
"concurrentDownloadsOne": "1 track at a time",
"@concurrentDownloadsOne": {
"description": "Concurrent downloads option - sequential"
},
"concurrentDownloadsCount": "Up to {count} tracks at once",
"@concurrentDownloadsCount": {
"description": "Concurrent downloads option - parallel",
"placeholders": {
"count": {
"type": "int"
}
}
},
"albumFolderArtistAlbum": "Artist / Album",
"@albumFolderArtistAlbum": {
"description": "Album folder option"
+5
View File
@@ -62,6 +62,8 @@ class AppSettings {
songLinkRegion; // SongLink userCountry region code used for platform lookup
final bool
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
final int
concurrentDownloads; // Max simultaneous downloads in the Dart queue (1-3)
final bool localLibraryEnabled;
final String localLibraryPath;
@@ -140,6 +142,7 @@ class AppSettings {
this.allowLocalNetwork = false,
this.songLinkRegion = 'US',
this.nativeDownloadWorkerEnabled = false,
this.concurrentDownloads = 1,
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryBookmark = '',
@@ -208,6 +211,7 @@ class AppSettings {
bool? allowLocalNetwork,
String? songLinkRegion,
bool? nativeDownloadWorkerEnabled,
int? concurrentDownloads,
bool? localLibraryEnabled,
String? localLibraryPath,
String? localLibraryBookmark,
@@ -289,6 +293,7 @@ class AppSettings {
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
nativeDownloadWorkerEnabled:
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
+2
View File
@@ -62,6 +62,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
nativeDownloadWorkerEnabled:
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
@@ -138,6 +139,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'allowLocalNetwork': instance.allowLocalNetwork,
'songLinkRegion': instance.songLinkRegion,
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
'concurrentDownloads': instance.concurrentDownloads,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
+13 -5
View File
@@ -3742,6 +3742,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!Platform.isAndroid || !settings.nativeDownloadWorkerEnabled) {
return false;
}
if (settings.concurrentDownloads > 1) {
// The experimental native worker downloads strictly sequentially, so
// prefer the Dart queue when the user enabled concurrent downloads.
_log.i('Concurrent downloads enabled; skipping native worker');
return false;
}
if (!settings.useExtensionProviders) {
return false;
}
@@ -5470,7 +5476,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
try {
await _processQueueSequential();
await _runQueueLoop();
} finally {
if (iosDownloadBookmarkActive) {
await PlatformBridge.stopAccessingIosBookmark();
@@ -5550,7 +5556,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
Future<void> _processQueueSequential() async {
Future<void> _runQueueLoop() async {
final activeDownloads = <String, Future<void>>{};
_startMultiProgressPolling();
@@ -5582,9 +5588,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
break;
}
// One download at a time: only start the next item once the current
// download has finished, to stay within the API's single-request limit.
if (activeDownloads.isEmpty &&
final maxConcurrent = ref
.read(settingsProvider)
.concurrentDownloads
.clamp(1, 3);
while (activeDownloads.length < maxConcurrent &&
queuedItems.isNotEmpty &&
!state.isPaused) {
final item = queuedItems.removeAt(0);
+5
View File
@@ -613,6 +613,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setConcurrentDownloads(int count) {
state = state.copyWith(concurrentDownloads: count.clamp(1, 3));
_saveSettings();
}
void setNetworkCompatibilityMode(bool enabled) {
state = state.copyWith(networkCompatibilityMode: enabled);
_saveSettings();
@@ -195,6 +195,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
settings.downloadNetworkMode,
),
),
SettingsItem(
icon: Icons.dynamic_feed_outlined,
title: context.l10n.settingsConcurrentDownloads,
subtitle: settings.concurrentDownloads <= 1
? context.l10n.concurrentDownloadsOne
: context.l10n.concurrentDownloadsCount(
settings.concurrentDownloads,
),
onTap: () => _showConcurrentDownloadsPicker(
context,
ref,
settings.concurrentDownloads,
),
),
if (Platform.isAndroid)
SettingsSwitchItem(
icon: Icons.downloading_outlined,
@@ -598,6 +612,73 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
void _showConcurrentDownloadsPicker(
BuildContext context,
WidgetRef ref,
int current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.settingsConcurrentDownloads,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.settingsConcurrentDownloadsSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
for (final count in const [1, 2, 3])
ListTile(
leading: Icon(
count == 1
? Icons.looks_one_outlined
: count == 2
? Icons.looks_two_outlined
: Icons.looks_3_outlined,
),
title: Text(
count == 1
? context.l10n.concurrentDownloadsOne
: context.l10n.concurrentDownloadsCount(count),
),
trailing: current == count
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setConcurrentDownloads(count);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showSongLinkRegionPicker(
BuildContext context,
WidgetRef ref,