feat: add experimental Android native download worker

Introduce a service-owned download worker that offloads the full
download-and-finalize pipeline to DownloadService on Android, keeping
downloads alive independently of the Flutter UI process.

Key changes:
- Extract SAF download logic from MainActivity into SafDownloadHandler
- Add NativeDownloadFinalizer for Kotlin-side decryption, format
  conversion, metadata embedding, ReplayGain, post-processing, and
  history persistence
- Extend DownloadService with native queue management (start, pause,
  resume, cancel) using coroutine-based worker with AtomicFile snapshots
- Add Dart-side orchestration: snapshot polling, run-id correlation,
  adoption on app restart, and fallback to Dart queue
- Forward embedReplayGain, tidalHighFormat, and postProcessingEnabled
  through Go backend DownloadRequest struct
- Add nativeDownloadWorkerEnabled setting with UI toggle
- Make DownloadQueueLookup collections unmodifiable
This commit is contained in:
zarzet
2026-05-05 02:41:00 +07:00
parent b306056995
commit 6b342aeac6
18 changed files with 4716 additions and 368 deletions
@@ -1,4 +1,7 @@
class DownloadRequestPayload {
static const int nativeWorkerContractVersion = 1;
final int contractVersion;
final String isrc;
final String service;
final String spotifyId;
@@ -14,6 +17,9 @@ class DownloadRequestPayload {
final String artistTagMode;
final bool embedLyrics;
final bool embedMaxQualityCover;
final bool embedReplayGain;
final bool postProcessingEnabled;
final String tidalHighFormat;
final int trackNumber;
final int discNumber;
final int totalTracks;
@@ -37,9 +43,12 @@ class DownloadRequestPayload {
final String safRelativeDir;
final String safFileName;
final String safOutputExt;
final bool stageSafOutput;
final bool requiresContainerConversion;
final String songLinkRegion;
const DownloadRequestPayload({
this.contractVersion = nativeWorkerContractVersion,
this.isrc = '',
this.service = '',
this.spotifyId = '',
@@ -55,6 +64,9 @@ class DownloadRequestPayload {
this.artistTagMode = 'joined',
this.embedLyrics = true,
this.embedMaxQualityCover = true,
this.embedReplayGain = false,
this.postProcessingEnabled = false,
this.tidalHighFormat = 'mp3_320',
this.trackNumber = 0,
this.discNumber = 0,
this.totalTracks = 1,
@@ -78,11 +90,14 @@ class DownloadRequestPayload {
this.safRelativeDir = '',
this.safFileName = '',
this.safOutputExt = '',
this.stageSafOutput = false,
this.requiresContainerConversion = false,
this.songLinkRegion = 'US',
});
Map<String, dynamic> toJson() {
return {
'contract_version': contractVersion,
'isrc': isrc,
'service': service,
'spotify_id': spotifyId,
@@ -98,6 +113,9 @@ class DownloadRequestPayload {
'artist_tag_mode': artistTagMode,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'embed_replaygain': embedReplayGain,
'post_processing_enabled': postProcessingEnabled,
'tidal_high_format': tidalHighFormat,
'track_number': trackNumber,
'disc_number': discNumber,
'total_tracks': totalTracks,
@@ -121,6 +139,8 @@ class DownloadRequestPayload {
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
'stage_saf_output': stageSafOutput,
'requires_container_conversion': requiresContainerConversion,
'songlink_region': songLinkRegion,
};
}
@@ -130,6 +150,7 @@ class DownloadRequestPayload {
bool? useFallback,
}) {
return DownloadRequestPayload(
contractVersion: contractVersion,
isrc: isrc,
service: service,
spotifyId: spotifyId,
@@ -145,6 +166,9 @@ class DownloadRequestPayload {
artistTagMode: artistTagMode,
embedLyrics: embedLyrics,
embedMaxQualityCover: embedMaxQualityCover,
embedReplayGain: embedReplayGain,
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: tidalHighFormat,
trackNumber: trackNumber,
discNumber: discNumber,
totalTracks: totalTracks,
@@ -168,6 +192,8 @@ class DownloadRequestPayload {
safRelativeDir: safRelativeDir,
safFileName: safFileName,
safOutputExt: safOutputExt,
stageSafOutput: stageSafOutput,
requiresContainerConversion: requiresContainerConversion,
songLinkRegion: songLinkRegion,
);
}
+29
View File
@@ -837,6 +837,35 @@ class PlatformBridge {
return result as bool;
}
static Future<void> startNativeDownloadWorker({
required List<Map<String, dynamic>> requests,
Map<String, dynamic> settings = const {},
}) async {
await _channel.invokeMethod('startNativeDownloadWorker', {
'requests_json': jsonEncode(requests),
'settings_json': jsonEncode(settings),
});
}
static Future<void> pauseNativeDownloadWorker() async {
await _channel.invokeMethod('pauseNativeDownloadWorker');
}
static Future<void> resumeNativeDownloadWorker() async {
await _channel.invokeMethod('resumeNativeDownloadWorker');
}
static Future<void> cancelNativeDownloadWorker() async {
await _channel.invokeMethod('cancelNativeDownloadWorker');
}
static Future<Map<String, dynamic>> getNativeDownloadWorkerSnapshot() async {
final result = await _channel.invokeMethod(
'getNativeDownloadWorkerSnapshot',
);
return _decodeMapResult(result);
}
static Future<void> preWarmTrackCache(
List<Map<String, String>> tracks,
) async {