From 6a1265eac32d5193d16de38a91ab09e025d7ed24 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 1 Jan 2026 22:09:39 +0700 Subject: [PATCH] v1.1.0: Parallel downloads, bug fixes, history persistence --- CHANGELOG.md | 38 +++ lib/models/download_item.g.dart | 42 +-- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 20 +- lib/models/track.g.dart | 77 ++--- lib/providers/download_queue_provider.dart | 362 ++++++++++++--------- lib/providers/settings_provider.dart | 7 + lib/screens/settings_screen.dart | 53 ++- lib/screens/settings_tab.dart | 53 ++- pubspec.yaml | 2 +- 10 files changed, 430 insertions(+), 228 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ce672339 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 53736f6c..0f6ad94b 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -7,21 +7,22 @@ part of 'download_item.dart'; // ************************************************************************** DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( - id: json['id'] as String, - track: Track.fromJson(json['track'] as Map), - 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), + 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 _$DownloadItemToJson(DownloadItem instance) => { '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( - Map 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; -} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 1c77dda4..c91f221b 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 692bbb54..57a53eb0 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -7,15 +7,16 @@ part of 'settings.dart'; // ************************************************************************** AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson(AppSettings instance) => { @@ -27,4 +28,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, + 'concurrentDownloads': instance.concurrentDownloads, }; diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index b0778bea..57e65c2b 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -7,37 +7,38 @@ part of 'track.dart'; // ************************************************************************** Track _$TrackFromJson(Map 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), - ); + 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, + ), +); Map _$TrackToJson(Track instance) => { - '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 json) => ServiceAvailability( @@ -50,12 +51,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => ); Map _$ServiceAvailabilityToJson( - ServiceAvailability instance) => - { - 'tidal': instance.tidal, - 'qobuz': instance.qobuz, - 'amazon': instance.amazon, - 'tidalUrl': instance.tidalUrl, - 'qobuzUrl': instance.qobuzUrl, - 'amazonUrl': instance.amazonUrl, - }; + ServiceAvailability instance, +) => { + 'tidal': instance.tidal, + 'qobuz': instance.qobuz, + 'amazon': instance.amazon, + 'tidalUrl': instance.tidalUrl, + 'qobuzUrl': instance.qobuzUrl, + 'amazonUrl': instance.amazonUrl, +}; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index d1448c38..cf49e3c6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -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 { 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 { } 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 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 { print('[DownloadQueue] Queue processing finished'); state = state.copyWith(isProcessing: false, currentDownload: null); } + + /// Sequential download processing (original behavior) + Future _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 _processQueueParallel() async { + final maxConcurrent = state.concurrentDownloads; + final activeDownloads = >{}; // 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 _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 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( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 111c3fd9..f92d6832 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -64,6 +64,13 @@ class SettingsNotifier extends Notifier { 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( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1e3772ed..aac1807a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 _launchUrl(String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { diff --git a/lib/screens/settings_tab.dart b/lib/screens/settings_tab.dart index 50e85339..0e8c2db8 100644 --- a/lib/screens/settings_tab.dart +++ b/lib/screens/settings_tab.dart @@ -133,6 +133,16 @@ class _SettingsTabState extends ConsumerState 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 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 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 _launchUrl(String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { diff --git a/pubspec.yaml b/pubspec.yaml index 8c7b9369..65d2a1f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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