v1.1.0: Parallel downloads, bug fixes, history persistence

This commit is contained in:
zarzet
2026-01-01 22:09:39 +07:00
parent 9570547ff9
commit 6a1265eac3
10 changed files with 430 additions and 228 deletions
+38
View File
@@ -0,0 +1,38 @@
# Changelog
## [1.1.0] - 2026-01-01
### Added
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
- Default: Sequential (1 at a time) for stability
- Options: 1, 2, or 3 concurrent downloads
- Warning about potential rate limiting from streaming services
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
### Fixed
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
### Changed
- Updated version to 1.1.0
### Technical Details
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
- Added shared `http.Transport` with connection pooling in `httputil.go`
- Added `CleanupConnections()` export for Flutter to call via method channel
## [1.0.5] - Previous Release
- Material Expressive 3 UI
- Dynamic color support
- Swipe navigation with PageView
- Settings as bottom navigation tab
- APK size optimization
+12 -30
View File
@@ -7,21 +7,22 @@ part of 'download_item.dart';
// **************************************************************************
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
id: json['id'] as String,
track: Track.fromJson(json['track'] as Map<String, dynamic>),
service: json['service'] as String,
status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
id: json['id'] as String,
track: Track.fromJson(json['track'] as Map<String, dynamic>),
service: json['service'] as String,
status:
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
<String, dynamic>{
'id': instance.id,
'track': instance.track.toJson(),
'track': instance.track,
'service': instance.service,
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
@@ -37,22 +38,3 @@ const _$DownloadStatusEnumMap = {
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
};
K? $enumDecodeNullable<K, V>(
Map<K, V> enumValues,
Object? source, {
K? unknownValue,
}) {
if (source == null) {
return null;
}
return enumValues.entries
.singleWhere(
(e) => e.value == source,
orElse: () => throw ArgumentError(
'`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}',
),
)
.key;
}
+4
View File
@@ -12,6 +12,7 @@ class AppSettings {
final bool embedLyrics;
final bool maxQualityCover;
final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3
const AppSettings({
this.defaultService = 'tidal',
@@ -22,6 +23,7 @@ class AppSettings {
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off)
});
AppSettings copyWith({
@@ -33,6 +35,7 @@ class AppSettings {
bool? embedLyrics,
bool? maxQualityCover,
bool? isFirstLaunch,
int? concurrentDownloads,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -43,6 +46,7 @@ class AppSettings {
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
);
}
+11 -9
View File
@@ -7,15 +7,16 @@ part of 'settings.dart';
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
defaultService: json['defaultService'] as String? ?? 'tidal',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
);
defaultService: json['defaultService'] as String? ?? 'tidal',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
<String, dynamic>{
@@ -27,4 +28,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
};
+39 -38
View File
@@ -7,37 +7,38 @@ part of 'track.dart';
// **************************************************************************
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
id: json['id'] as String,
name: json['name'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>),
);
id: json['id'] as String,
name: json['name'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'availability': instance.availability?.toJson(),
};
'id': instance.id,
'name': instance.name,
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'availability': instance.availability,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
ServiceAvailability(
@@ -50,12 +51,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
);
Map<String, dynamic> _$ServiceAvailabilityToJson(
ServiceAvailability instance) =>
<String, dynamic>{
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
};
ServiceAvailability instance,
) => <String, dynamic>{
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
};
+216 -146
View File
@@ -133,6 +133,7 @@ class DownloadQueueState {
final String outputDir;
final String filenameFormat;
final bool autoFallback;
final int concurrentDownloads; // 1 = sequential, max 3
const DownloadQueueState({
this.items = const [],
@@ -141,6 +142,7 @@ class DownloadQueueState {
this.outputDir = '',
this.filenameFormat = '{artist} - {title}',
this.autoFallback = true,
this.concurrentDownloads = 1,
});
DownloadQueueState copyWith({
@@ -150,6 +152,7 @@ class DownloadQueueState {
String? outputDir,
String? filenameFormat,
bool? autoFallback,
int? concurrentDownloads,
}) {
return DownloadQueueState(
items: items ?? this.items,
@@ -158,12 +161,14 @@ class DownloadQueueState {
outputDir: outputDir ?? this.outputDir,
filenameFormat: filenameFormat ?? this.filenameFormat,
autoFallback: autoFallback ?? this.autoFallback,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
);
}
int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length;
int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length;
int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length;
int get activeDownloadsCount => items.where((i) => i.status == DownloadStatus.downloading).length;
}
// Download Queue Notifier (Riverpod 3.x)
@@ -261,6 +266,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
filenameFormat: settings.filenameFormat,
autoFallback: settings.autoFallback,
concurrentDownloads: settings.concurrentDownloads,
);
}
@@ -428,153 +434,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
print('[DownloadQueue] Output directory: ${state.outputDir}');
print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}');
while (true) {
final nextItem = state.items.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
id: '',
track: const Track(id: '', name: '', artistName: '', albumName: '', duration: 0),
service: '',
createdAt: DateTime.now(),
),
);
if (nextItem.id.isEmpty) {
print('[DownloadQueue] No more items to process');
break;
}
print('[DownloadQueue] Processing: ${nextItem.track.name} by ${nextItem.track.artistName}');
print('[DownloadQueue] Cover URL: ${nextItem.track.coverUrl}');
state = state.copyWith(currentDownload: nextItem);
updateItemStatus(nextItem.id, DownloadStatus.downloading);
// Start progress polling
_startProgressPolling(nextItem.id);
try {
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
result = await PlatformBridge.downloadWithFallback(
isrc: nextItem.track.isrc ?? '',
spotifyId: nextItem.track.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
albumArtist: nextItem.track.albumArtist,
coverUrl: nextItem.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: nextItem.track.trackNumber ?? 1,
discNumber: nextItem.track.discNumber ?? 1,
releaseDate: nextItem.track.releaseDate,
preferredService: nextItem.service,
);
} else {
result = await PlatformBridge.downloadTrack(
isrc: nextItem.track.isrc ?? '',
service: nextItem.service,
spotifyId: nextItem.track.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
albumArtist: nextItem.track.albumArtist,
coverUrl: nextItem.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: nextItem.track.trackNumber ?? 1,
discNumber: nextItem.track.discNumber ?? 1,
releaseDate: nextItem.track.releaseDate,
);
}
// Stop progress polling for this item
_stopProgressPolling();
print('[DownloadQueue] Result: $result');
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
print('[DownloadQueue] Download success, file: $filePath');
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
print('[DownloadQueue] Converting M4A to FLAC...');
updateItemStatus(nextItem.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
print('[DownloadQueue] Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
nextItem.track,
);
print('[DownloadQueue] Metadata and cover embedded successfully');
} catch (e) {
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
}
}
}
updateItemStatus(
nextItem.id,
DownloadStatus.completed,
progress: 1.0,
filePath: filePath,
);
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
id: nextItem.id,
trackName: nextItem.track.name,
artistName: nextItem.track.artistName,
albumName: nextItem.track.albumName,
coverUrl: nextItem.track.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? nextItem.service,
downloadedAt: DateTime.now(),
),
);
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
print('[DownloadQueue] Download failed: $errorMsg');
updateItemStatus(
nextItem.id,
DownloadStatus.failed,
error: errorMsg,
);
}
// Increment download counter and cleanup connections periodically
_downloadCount++;
if (_downloadCount % _cleanupInterval == 0) {
print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Connection cleanup failed: $e');
}
}
} catch (e, stackTrace) {
_stopProgressPolling();
print('[DownloadQueue] Exception: $e');
print('[DownloadQueue] StackTrace: $stackTrace');
updateItemStatus(
nextItem.id,
DownloadStatus.failed,
error: e.toString(),
);
}
// Use parallel processing if concurrentDownloads > 1
if (state.concurrentDownloads > 1) {
await _processQueueParallel();
} else {
await _processQueueSequential();
}
_stopProgressPolling();
@@ -593,6 +459,210 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
print('[DownloadQueue] Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
}
/// Sequential download processing (original behavior)
Future<void> _processQueueSequential() async {
while (true) {
final nextItem = state.items.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
id: '',
track: const Track(id: '', name: '', artistName: '', albumName: '', duration: 0),
service: '',
createdAt: DateTime.now(),
),
);
if (nextItem.id.isEmpty) {
print('[DownloadQueue] No more items to process');
break;
}
await _downloadSingleItem(nextItem);
}
}
/// Parallel download processing with worker pool
Future<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
while (true) {
// Get queued items
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
print('[DownloadQueue] No more items to process');
break;
}
// Start new downloads up to max concurrent limit
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
final item = queuedItems.removeAt(0);
// Mark as downloading immediately to prevent double-processing
updateItemStatus(item.id, DownloadStatus.downloading);
// Create the download future
final future = _downloadSingleItem(item).whenComplete(() {
activeDownloads.remove(item.id);
});
activeDownloads[item.id] = future;
print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
}
// Wait for at least one download to complete before checking for more
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
}
}
// Wait for all remaining downloads to complete
if (activeDownloads.isNotEmpty) {
await Future.wait(activeDownloads.values);
}
}
/// Download a single item (used by both sequential and parallel processing)
Future<void> _downloadSingleItem(DownloadItem item) async {
print('[DownloadQueue] Processing: ${item.track.name} by ${item.track.artistName}');
print('[DownloadQueue] Cover URL: ${item.track.coverUrl}');
// Only set currentDownload for sequential mode (for progress polling)
if (state.concurrentDownloads == 1) {
state = state.copyWith(currentDownload: item);
_startProgressPolling(item.id);
}
updateItemStatus(item.id, DownloadStatus.downloading);
try {
Map<String, dynamic> result;
if (state.autoFallback) {
print('[DownloadQueue] Using auto-fallback mode');
result = await PlatformBridge.downloadWithFallback(
isrc: item.track.isrc ?? '',
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
preferredService: item.service,
);
} else {
result = await PlatformBridge.downloadTrack(
isrc: item.track.isrc ?? '',
service: item.service,
spotifyId: item.track.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
outputDir: state.outputDir,
filenameFormat: state.filenameFormat,
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
);
}
// Stop progress polling for this item (sequential mode only)
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
print('[DownloadQueue] Result: $result');
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
print('[DownloadQueue] Download success, file: $filePath');
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
print('[DownloadQueue] Converting M4A to FLAC...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
print('[DownloadQueue] Converted to: $flacPath');
// After conversion, embed metadata and cover to the new FLAC file
print('[DownloadQueue] Embedding metadata and cover to converted FLAC...');
try {
await _embedMetadataAndCover(
flacPath,
item.track,
);
print('[DownloadQueue] Metadata and cover embedded successfully');
} catch (e) {
print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e');
}
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
progress: 1.0,
filePath: filePath,
);
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: item.track.name,
artistName: item.track.artistName,
albumName: item.track.albumName,
coverUrl: item.track.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
),
);
}
} else {
final errorMsg = result['error'] as String? ?? 'Download failed';
print('[DownloadQueue] Download failed: $errorMsg');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
);
}
// Increment download counter and cleanup connections periodically
_downloadCount++;
if (_downloadCount % _cleanupInterval == 0) {
print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...');
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
print('[DownloadQueue] Connection cleanup failed: $e');
}
}
} catch (e, stackTrace) {
if (state.concurrentDownloads == 1) {
_stopProgressPolling();
}
print('[DownloadQueue] Exception: $e');
print('[DownloadQueue] StackTrace: $stackTrace');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: e.toString(),
);
}
}
}
final downloadQueueProvider = NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
+7
View File
@@ -64,6 +64,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(isFirstLaunch: false);
_saveSettings();
}
void setConcurrentDownloads(int count) {
// Clamp between 1 and 3
final clamped = count.clamp(1, 3);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+51 -2
View File
@@ -126,6 +126,16 @@ class SettingsScreen extends ConsumerWidget {
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
const Divider(),
@@ -162,11 +172,11 @@ class SettingsScreen extends ConsumerWidget {
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: const Text('SpotiFLAC v1.0.5'),
subtitle: const Text('SpotiFLAC v1.1.0'),
onTap: () => showAboutDialog(
context: context,
applicationName: 'SpotiFLAC',
applicationVersion: '1.0.5',
applicationVersion: '1.1.0',
applicationLegalese: '© 2024 SpotiFLAC\n\nMobile: zarzet\nOriginal: afkarxyz',
),
),
@@ -454,6 +464,45 @@ class SettingsScreen extends ConsumerWidget {
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Text(
'⚠️ Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
+51 -2
View File
@@ -133,6 +133,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
const Divider(),
@@ -169,11 +179,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: const Text('SpotiFLAC v1.0.5'),
subtitle: const Text('SpotiFLAC v1.1.0'),
onTap: () => showAboutDialog(
context: context,
applicationName: 'SpotiFLAC',
applicationVersion: '1.0.5',
applicationVersion: '1.1.0',
applicationLegalese: '© 2024 SpotiFLAC\n\nMobile: zarzet\nOriginal: afkarxyz',
),
),
@@ -423,6 +433,45 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Text(
'⚠️ Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 1.0.5+6
version: 1.1.0+7
environment:
sdk: ^3.10.0