mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a1265eac3 | |||
| 9570547ff9 | |||
| ef62fb218a | |||
| ba5c91090c | |||
| c454bcd5ee | |||
| 4d2ee6fca6 | |||
| 89851bbd62 |
@@ -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
|
||||
@@ -1,9 +1,9 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="icon.png" width="128" />
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||
@@ -13,9 +13,14 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Screenshot
|
||||
## Screenshots
|
||||
|
||||
<!--  -->
|
||||
<p align="center">
|
||||
<img src="docs/Screenshot_20260101-210622_SpotiFLAC.png" width="200" />
|
||||
<img src="docs/Screenshot_20260101-210626_SpotiFLAC.png" width="200" />
|
||||
<img src="docs/Screenshot_20260101-210633_SpotiFLAC.png" width="200" />
|
||||
<img src="docs/Screenshot_20260101-210653_SpotiFLAC.png" width="200" />
|
||||
</p>
|
||||
|
||||
## Other project
|
||||
|
||||
|
||||
@@ -127,6 +127,12 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cleanupConnections" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cleanupConnections()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -5,6 +5,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -124,6 +125,13 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields to prevent filename/path issues
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
var filePath string
|
||||
var err error
|
||||
|
||||
@@ -172,6 +180,13 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields to prevent filename/path issues
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
// Build service order starting with preferred service
|
||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||
preferredService := req.Service
|
||||
@@ -239,6 +254,12 @@ func GetDownloadProgress() string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// CleanupConnections closes idle HTTP connections
|
||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||
func CleanupConnections() {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
// SetDownloadDirectory sets the default download directory
|
||||
func SetDownloadDirectory(path string) error {
|
||||
return setDownloadDir(path)
|
||||
|
||||
@@ -63,7 +63,8 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
// Trim leading/trailing whitespace to prevent filename issues
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
+48
-1
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -41,13 +42,59 @@ const (
|
||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||
)
|
||||
|
||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
|
||||
// Shared HTTP client for general requests (reuses connections)
|
||||
var sharedClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
||||
var downloadClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||
// Uses shared transport for connection reuse
|
||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: sharedTransport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSharedClient returns the shared HTTP client for general requests
|
||||
func GetSharedClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
// GetDownloadClient returns the shared HTTP client for downloads
|
||||
func GetDownloadClient() *http.Client {
|
||||
return downloadClient
|
||||
}
|
||||
|
||||
// CloseIdleConnections closes idle connections in the shared transport
|
||||
// Call this periodically during large batch downloads to prevent connection buildup
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
+20
-3
@@ -693,9 +693,19 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// If we have a direct URL (BTS format), download directly
|
||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||
if directURL != "" {
|
||||
resp, err := client.Get(directURL)
|
||||
// Set current file being downloaded
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
req, err := http.NewRequest("GET", directURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
@@ -705,13 +715,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Set total bytes for progress tracking
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
// Use ProgressWriter for tracking
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -31,6 +33,28 @@ class DownloadHistoryItem {
|
||||
required this.service,
|
||||
required this.downloadedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'trackName': trackName,
|
||||
'artistName': artistName,
|
||||
'albumName': albumName,
|
||||
'coverUrl': coverUrl,
|
||||
'filePath': filePath,
|
||||
'service': service,
|
||||
'downloadedAt': downloadedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
|
||||
id: json['id'] as String,
|
||||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
service: json['service'] as String,
|
||||
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
// Download History State
|
||||
@@ -46,23 +70,54 @@ class DownloadHistoryState {
|
||||
|
||||
// Download History Notifier (Riverpod 3.x)
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const _storageKey = 'download_history';
|
||||
|
||||
@override
|
||||
DownloadHistoryState build() {
|
||||
// Load history from storage on init
|
||||
Future.microtask(() => _loadFromStorage());
|
||||
return const DownloadHistoryState();
|
||||
}
|
||||
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_storageKey);
|
||||
if (jsonStr != null) {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(items: items);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[DownloadHistory] Failed to load history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = state.items.map((e) => e.toJson()).toList();
|
||||
await prefs.setString(_storageKey, jsonEncode(jsonList));
|
||||
} catch (e) {
|
||||
print('[DownloadHistory] Failed to save history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void addToHistory(DownloadHistoryItem item) {
|
||||
state = state.copyWith(items: [item, ...state.items]);
|
||||
_saveToStorage();
|
||||
}
|
||||
|
||||
void removeFromHistory(String id) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.id != id).toList(),
|
||||
);
|
||||
_saveToStorage();
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
state = const DownloadHistoryState();
|
||||
_saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,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 [],
|
||||
@@ -86,6 +142,7 @@ class DownloadQueueState {
|
||||
this.outputDir = '',
|
||||
this.filenameFormat = '{artist} - {title}',
|
||||
this.autoFallback = true,
|
||||
this.concurrentDownloads = 1,
|
||||
});
|
||||
|
||||
DownloadQueueState copyWith({
|
||||
@@ -95,6 +152,7 @@ class DownloadQueueState {
|
||||
String? outputDir,
|
||||
String? filenameFormat,
|
||||
bool? autoFallback,
|
||||
int? concurrentDownloads,
|
||||
}) {
|
||||
return DownloadQueueState(
|
||||
items: items ?? this.items,
|
||||
@@ -103,17 +161,21 @@ 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)
|
||||
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Timer? _progressTimer;
|
||||
int _downloadCount = 0; // Counter for connection cleanup
|
||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
@@ -204,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -371,7 +434,34 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Output directory: ${state.outputDir}');
|
||||
print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}');
|
||||
|
||||
// Use parallel processing if concurrentDownloads > 1
|
||||
if (state.concurrentDownloads > 1) {
|
||||
await _processQueueParallel();
|
||||
} else {
|
||||
await _processQueueSequential();
|
||||
}
|
||||
|
||||
_stopProgressPolling();
|
||||
|
||||
// Final cleanup after queue finishes
|
||||
if (_downloadCount > 0) {
|
||||
print('[DownloadQueue] Final connection cleanup...');
|
||||
try {
|
||||
await PlatformBridge.cleanupConnections();
|
||||
} catch (e) {
|
||||
print('[DownloadQueue] Final cleanup failed: $e');
|
||||
}
|
||||
_downloadCount = 0;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -388,130 +478,190 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
break;
|
||||
}
|
||||
|
||||
print('[DownloadQueue] Processing: ${nextItem.track.name} by ${nextItem.track.artistName}');
|
||||
print('[DownloadQueue] Cover URL: ${nextItem.track.coverUrl}');
|
||||
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();
|
||||
|
||||
state = state.copyWith(currentDownload: nextItem);
|
||||
updateItemStatus(nextItem.id, DownloadStatus.downloading);
|
||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||
print('[DownloadQueue] No more items to process');
|
||||
break;
|
||||
}
|
||||
|
||||
// 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();
|
||||
// Start new downloads up to max concurrent limit
|
||||
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
|
||||
final item = queuedItems.removeAt(0);
|
||||
|
||||
print('[DownloadQueue] Result: $result');
|
||||
// Mark as downloading immediately to prevent double-processing
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_stopProgressPolling();
|
||||
print('[DownloadQueue] Exception: $e');
|
||||
print('[DownloadQueue] StackTrace: $stackTrace');
|
||||
updateItemStatus(
|
||||
nextItem.id,
|
||||
DownloadStatus.failed,
|
||||
error: e.toString(),
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
_stopProgressPolling();
|
||||
print('[DownloadQueue] Queue processing finished');
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
/// 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -195,4 +195,10 @@ class PlatformBridge {
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Cleanup idle HTTP connections to prevent TCP exhaustion
|
||||
/// Call this periodically during large batch downloads
|
||||
static Future<void> cleanupConnections() async {
|
||||
await _channel.invokeMethod('cleanupConnections');
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user