mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-13 14:40:20 +02:00
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:
@@ -129,7 +129,7 @@ abstract class AppLocalizations {
|
||||
/// App name - DO NOT TRANSLATE
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SpotiFLAC'**
|
||||
/// **'SpotiFLAC Mobile'**
|
||||
String get appName;
|
||||
|
||||
/// Bottom navigation - Home tab
|
||||
|
||||
@@ -58,6 +58,8 @@ class AppSettings {
|
||||
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
|
||||
final String
|
||||
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
||||
final bool
|
||||
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
|
||||
|
||||
final bool localLibraryEnabled; // Enable local library scanning
|
||||
final String localLibraryPath; // Path to scan for audio files
|
||||
@@ -133,6 +135,7 @@ class AppSettings {
|
||||
this.downloadNetworkMode = 'any',
|
||||
this.networkCompatibilityMode = false,
|
||||
this.songLinkRegion = 'US',
|
||||
this.nativeDownloadWorkerEnabled = false,
|
||||
this.localLibraryEnabled = false,
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryBookmark = '',
|
||||
@@ -196,6 +199,7 @@ class AppSettings {
|
||||
String? downloadNetworkMode,
|
||||
bool? networkCompatibilityMode,
|
||||
String? songLinkRegion,
|
||||
bool? nativeDownloadWorkerEnabled,
|
||||
bool? localLibraryEnabled,
|
||||
String? localLibraryPath,
|
||||
String? localLibraryBookmark,
|
||||
@@ -269,6 +273,8 @@ class AppSettings {
|
||||
networkCompatibilityMode:
|
||||
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
||||
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
||||
nativeDownloadWorkerEnabled:
|
||||
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
|
||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||
|
||||
@@ -58,6 +58,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
||||
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
|
||||
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
||||
nativeDownloadWorkerEnabled:
|
||||
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
|
||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
||||
@@ -129,6 +131,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
'networkCompatibilityMode': instance.networkCompatibilityMode,
|
||||
'songLinkRegion': instance.songLinkRegion,
|
||||
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,6 +263,9 @@ class Extension {
|
||||
bool get hasServiceHealth => serviceHealth.isNotEmpty;
|
||||
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||
bool get requiresNativeContainerConversion =>
|
||||
capabilities['requiresContainerConversion'] == true ||
|
||||
capabilities['requiresNativeContainerConversion'] == true;
|
||||
List<String> get replacesBuiltInProviders {
|
||||
final value = capabilities['replacesBuiltInProviders'];
|
||||
if (value is! List) return const [];
|
||||
|
||||
@@ -569,6 +569,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setNativeDownloadWorkerEnabled(bool enabled) {
|
||||
state = state.copyWith(nativeDownloadWorkerEnabled: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryEnabled(bool enabled) {
|
||||
state = state.copyWith(localLibraryEnabled: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -23,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final hasDownloadExtensions = extensionState.extensions.any(
|
||||
(extension) => extension.enabled && extension.hasDownloadProvider,
|
||||
);
|
||||
final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
@@ -141,6 +144,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
settings.downloadNetworkMode,
|
||||
),
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.downloading_outlined,
|
||||
title: 'Native download worker',
|
||||
titleTrailing: const _BetaBadge(),
|
||||
subtitle: hasDownloadExtensions
|
||||
? 'Beta Android service worker for extension downloads'
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
value:
|
||||
settings.nativeDownloadWorkerEnabled &&
|
||||
nativeWorkerAvailable,
|
||||
enabled: nativeWorkerAvailable,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setNativeDownloadWorkerEnabled(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.security_outlined,
|
||||
title: context.l10n.downloadNetworkCompatibilityMode,
|
||||
@@ -594,6 +613,29 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
// ── Private widgets (reused from original) ─────────────────────────────────
|
||||
|
||||
class _BetaBadge extends StatelessWidget {
|
||||
const _BetaBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'BETA',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceSelector extends ConsumerWidget {
|
||||
final String currentService;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,20 +4,22 @@ class SettingsGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
const SettingsGroup({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.margin,
|
||||
});
|
||||
const SettingsGroup({super.key, required this.children, this.margin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
|
||||
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
@@ -28,10 +30,7 @@ class SettingsGroup extends StatelessWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: children),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -58,7 +57,7 @@ class SettingsItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -78,17 +77,13 @@ class SettingsItem extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -99,7 +94,10 @@ class SettingsItem extends StatelessWidget {
|
||||
trailing!,
|
||||
] else if (onTap != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -121,6 +119,7 @@ class SettingsItem extends StatelessWidget {
|
||||
class SettingsSwitchItem extends StatelessWidget {
|
||||
final IconData? icon;
|
||||
final String title;
|
||||
final Widget? titleTrailing;
|
||||
final String? subtitle;
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
@@ -131,6 +130,7 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
super.key,
|
||||
this.icon,
|
||||
required this.title,
|
||||
this.titleTrailing,
|
||||
this.subtitle,
|
||||
required this.value,
|
||||
this.onChanged,
|
||||
@@ -142,7 +142,7 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDisabled = !enabled || onChanged == null;
|
||||
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -157,26 +157,49 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24),
|
||||
Icon(
|
||||
icon,
|
||||
color: isDisabled
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : null,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: isDisabled
|
||||
? colorScheme.outline
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (titleTrailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
titleTrailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: isDisabled
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user