mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
perf(downloads): stream concurrent native queue work
This commit is contained in:
@@ -660,6 +660,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final postProcessingEnabled =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any((e) => e.enabled && e.hasPostProcessing);
|
||||
final selectedDownloadExtension = extensionState.extensions
|
||||
.where(
|
||||
(extension) =>
|
||||
extension.enabled &&
|
||||
extension.hasDownloadProvider &&
|
||||
extension.id.toLowerCase() == item.service.toLowerCase(),
|
||||
)
|
||||
.firstOrNull;
|
||||
final normalizedTrackNumber =
|
||||
(track.trackNumber != null && track.trackNumber! > 0)
|
||||
? track.trackNumber!
|
||||
@@ -752,6 +760,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
: '',
|
||||
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
|
||||
songLinkRegion: settings.songLinkRegion,
|
||||
networkConcurrencyLimit:
|
||||
selectedDownloadExtension
|
||||
?.downloadTransferPolicy
|
||||
.maxConcurrentDownloads ??
|
||||
3,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -181,12 +181,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
if (!Platform.isAndroid || !settings.nativeDownloadWorkerEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (settings.concurrentDownloads > 1) {
|
||||
// The 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;
|
||||
}
|
||||
@@ -563,26 +557,36 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
}
|
||||
|
||||
final contexts = <String, _NativeWorkerRequestContext>{};
|
||||
final requests = <Map<String, dynamic>>[];
|
||||
for (final item in queuedItems) {
|
||||
final context = await _buildAndroidNativeWorkerRequest(item, settings);
|
||||
if (context == null) {
|
||||
_log.w(
|
||||
'Native worker gate rejected ${item.track.name}; falling back to Dart queue',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
contexts[item.id] = context;
|
||||
requests.add({
|
||||
Map<String, dynamic> encodeRequest(
|
||||
DownloadItem item,
|
||||
_NativeWorkerRequestContext context,
|
||||
) {
|
||||
return {
|
||||
'contract_version': DownloadRequestPayload.nativeWorkerContractVersion,
|
||||
'item_id': item.id,
|
||||
'track_name': item.track.name,
|
||||
'artist_name': item.track.artistName,
|
||||
'item_json': jsonEncode(item.toJson()),
|
||||
'request_json': context.requestJson,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Only the first request blocks startup. The rest are prepared with a
|
||||
// small metadata pipeline and appended to the already-running native
|
||||
// worker, reducing time-to-first-byte for large albums/playlists.
|
||||
final firstItem = queuedItems.first;
|
||||
final firstContext = await _buildAndroidNativeWorkerRequest(
|
||||
firstItem,
|
||||
settings,
|
||||
);
|
||||
if (firstContext == null) {
|
||||
_log.w(
|
||||
'Native worker gate rejected ${firstItem.track.name}; falling back to Dart queue',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
contexts[firstItem.id] = firstContext;
|
||||
|
||||
if (!canStartForegroundDownloadForLifecycle(
|
||||
WidgetsBinding.instance.lifecycleState,
|
||||
)) {
|
||||
@@ -601,9 +605,10 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
final runId = _newNativeWorkerRunId();
|
||||
await _persistNativeWorkerRunId(runId);
|
||||
final reconciledIds = <String>{};
|
||||
Future<void>? preparationFuture;
|
||||
try {
|
||||
await PlatformBridge.startNativeDownloadWorker(
|
||||
requests: requests,
|
||||
requests: [encodeRequest(firstItem, firstContext)],
|
||||
settings: {
|
||||
'worker': 'android_native',
|
||||
'version': 1,
|
||||
@@ -613,9 +618,62 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'save_download_history': settings.saveDownloadHistory,
|
||||
'download_network_mode': settings.downloadNetworkMode,
|
||||
'concurrent_downloads': settings.concurrentDownloads.clamp(1, 3),
|
||||
'finalizer_concurrency': 1,
|
||||
'preparation_streaming': true,
|
||||
'expected_total_items': queuedItems.length,
|
||||
},
|
||||
);
|
||||
|
||||
preparationFuture = () async {
|
||||
var nextIndex = 1;
|
||||
final preparationConcurrency = min(2, queuedItems.length - 1);
|
||||
try {
|
||||
await Future.wait(
|
||||
List.generate(preparationConcurrency, (_) async {
|
||||
while (nextIndex < queuedItems.length) {
|
||||
final index = nextIndex++;
|
||||
final item = queuedItems[index];
|
||||
try {
|
||||
final context = await _buildAndroidNativeWorkerRequest(
|
||||
item,
|
||||
settings,
|
||||
);
|
||||
if (context == null) {
|
||||
_log.w(
|
||||
'Native worker gate rejected ${item.track.name}; leaving it queued for the Dart worker',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
contexts[item.id] = context;
|
||||
await PlatformBridge.appendNativeDownloadWorkerRequests(
|
||||
runId: runId,
|
||||
requests: [encodeRequest(item, context)],
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_log.e(
|
||||
'Could not prepare native request for ${item.track.name}: $e',
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await PlatformBridge.finishNativeDownloadWorkerPreparation(
|
||||
runId: runId,
|
||||
);
|
||||
} catch (_) {
|
||||
// Do not leave the foreground worker waiting forever on an open
|
||||
// preparation channel if the final hand-off fails.
|
||||
await PlatformBridge.cancelNativeDownloadWorker();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}();
|
||||
|
||||
final runStartWait = Stopwatch()..start();
|
||||
var lastStateSerial = 0;
|
||||
while (true) {
|
||||
@@ -694,6 +752,13 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
_failedInSession++;
|
||||
}
|
||||
} finally {
|
||||
if (preparationFuture != null) {
|
||||
try {
|
||||
await preparationFuture;
|
||||
} catch (e) {
|
||||
_log.w('Native worker preparation pipeline stopped: $e');
|
||||
}
|
||||
}
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
_stopConnectivityMonitoring();
|
||||
try {
|
||||
|
||||
@@ -50,6 +50,46 @@ List<String>? _tryDecodeStringListPreference(String rawJson, String key) {
|
||||
}
|
||||
}
|
||||
|
||||
class ExtensionDownloadTransferPolicy {
|
||||
final int maxAttempts;
|
||||
final String resumePolicy;
|
||||
final bool persistentCheckpoint;
|
||||
final int maxParallelSegments;
|
||||
final int maxConcurrentDownloads;
|
||||
|
||||
const ExtensionDownloadTransferPolicy({
|
||||
this.maxAttempts = 3,
|
||||
this.resumePolicy = 'none',
|
||||
this.persistentCheckpoint = false,
|
||||
this.maxParallelSegments = 3,
|
||||
this.maxConcurrentDownloads = 3,
|
||||
});
|
||||
|
||||
factory ExtensionDownloadTransferPolicy.fromCapabilities(
|
||||
Map<String, dynamic> capabilities,
|
||||
) {
|
||||
final raw = capabilities['downloadTransfer'];
|
||||
if (raw is! Map) return const ExtensionDownloadTransferPolicy();
|
||||
final values = Map<String, dynamic>.from(raw);
|
||||
int boundedInt(String key, int fallback, int min, int max) {
|
||||
final value = values[key];
|
||||
final parsed = value is num ? value.round() : fallback;
|
||||
return parsed.clamp(min, max).toInt();
|
||||
}
|
||||
|
||||
final requestedResume = values['resumePolicy']?.toString().trim();
|
||||
final resumePolicy = requestedResume == 'validated' ? 'validated' : 'none';
|
||||
return ExtensionDownloadTransferPolicy(
|
||||
maxAttempts: boundedInt('maxAttempts', 3, 1, 8),
|
||||
resumePolicy: resumePolicy,
|
||||
persistentCheckpoint:
|
||||
resumePolicy == 'validated' && values['persistentCheckpoint'] == true,
|
||||
maxParallelSegments: boundedInt('maxParallelSegments', 3, 1, 8),
|
||||
maxConcurrentDownloads: boundedInt('maxConcurrentDownloads', 3, 1, 3),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// First enabled custom-search extension, preferring ones marked primary.
|
||||
Extension? defaultSearchExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
@@ -239,6 +279,8 @@ class Extension {
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
bool get hasServiceHealth => serviceHealth.isNotEmpty;
|
||||
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||
ExtensionDownloadTransferPolicy get downloadTransferPolicy =>
|
||||
ExtensionDownloadTransferPolicy.fromCapabilities(capabilities);
|
||||
bool get requiresNativeContainerConversion =>
|
||||
capabilities['requiresContainerConversion'] == true ||
|
||||
capabilities['requiresNativeContainerConversion'] == true;
|
||||
|
||||
@@ -60,6 +60,7 @@ class DownloadRequestPayload {
|
||||
final String qualityVariant;
|
||||
final bool qualityVariantCollisionOnly;
|
||||
final String songLinkRegion;
|
||||
final int networkConcurrencyLimit;
|
||||
|
||||
const DownloadRequestPayload({
|
||||
this.contractVersion = nativeWorkerContractVersion,
|
||||
@@ -121,6 +122,7 @@ class DownloadRequestPayload {
|
||||
this.qualityVariant = '',
|
||||
this.qualityVariantCollisionOnly = false,
|
||||
this.songLinkRegion = 'US',
|
||||
this.networkConcurrencyLimit = 3,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -184,6 +186,7 @@ class DownloadRequestPayload {
|
||||
'quality_variant': qualityVariant,
|
||||
'quality_variant_collision_only': qualityVariantCollisionOnly,
|
||||
'songlink_region': songLinkRegion,
|
||||
'network_concurrency_limit': networkConcurrencyLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +254,7 @@ class DownloadRequestPayload {
|
||||
qualityVariant: qualityVariant,
|
||||
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
|
||||
songLinkRegion: songLinkRegion,
|
||||
networkConcurrencyLimit: networkConcurrencyLimit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1192,6 +1192,34 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> appendNativeDownloadWorkerRequests({
|
||||
required String runId,
|
||||
required List<Map<String, dynamic>> requests,
|
||||
}) async {
|
||||
if (requests.isEmpty) return;
|
||||
final payloadDir = await _nativeWorkerPayloadDir();
|
||||
final stamp = DateTime.now().microsecondsSinceEpoch;
|
||||
final requestPath = '${payloadDir.path}/append_$stamp.json';
|
||||
await File(requestPath).writeAsString(jsonEncode(requests), flush: true);
|
||||
try {
|
||||
await _channel.invokeMethod('appendNativeDownloadWorkerRequests', {
|
||||
'run_id': runId,
|
||||
'requests_path': requestPath,
|
||||
});
|
||||
} catch (_) {
|
||||
unawaited(_deleteFileIfExists(requestPath));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> finishNativeDownloadWorkerPreparation({
|
||||
required String runId,
|
||||
}) async {
|
||||
await _channel.invokeMethod('finishNativeDownloadWorkerPreparation', {
|
||||
'run_id': runId,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _deleteFileIfExists(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
|
||||
Reference in New Issue
Block a user