mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac679003e | |||
| 6a1265eac3 | |||
| 9570547ff9 | |||
| ef62fb218a | |||
| ba5c91090c | |||
| c454bcd5ee | |||
| 4d2ee6fca6 | |||
| 89851bbd62 | |||
| 2c614f9e2f | |||
| f36bee1095 | |||
| e4218a1894 |
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 zarzet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,8 @@ import Gobackend // Import Go framework
|
||||
case "setDownloadDirectory":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let path = args["path"] as! String
|
||||
try GobackendSetDownloadDirectory(path)
|
||||
GobackendSetDownloadDirectory(path, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "checkDuplicate":
|
||||
|
||||
+18
-2
@@ -1,12 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: SpotiFLACApp(),
|
||||
ProviderScope(
|
||||
child: const _EagerInitialization(
|
||||
child: SpotiFLACApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Eagerly initialize download history provider to load from storage
|
||||
ref.watch(downloadHistoryProvider);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,73 @@ class DownloadHistoryState {
|
||||
|
||||
// Download History Notifier (Riverpod 3.x)
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const _storageKey = 'download_history';
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
DownloadHistoryState build() {
|
||||
// Load history from storage on init
|
||||
_loadFromStorageSync();
|
||||
return const DownloadHistoryState();
|
||||
}
|
||||
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
void _loadFromStorageSync() {
|
||||
if (_isLoaded) return;
|
||||
Future.microtask(() async {
|
||||
await _loadFromStorage();
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_storageKey);
|
||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
state = state.copyWith(items: items);
|
||||
print('[DownloadHistory] Loaded ${items.length} items from storage');
|
||||
} else {
|
||||
print('[DownloadHistory] No history found in storage');
|
||||
}
|
||||
} 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));
|
||||
print('[DownloadHistory] Saved ${state.items.length} items to storage');
|
||||
} catch (e) {
|
||||
print('[DownloadHistory] Failed to save history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Force reload from storage (useful after app restart)
|
||||
Future<void> reloadFromStorage() async {
|
||||
await _loadFromStorage();
|
||||
}
|
||||
|
||||
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 +152,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 +161,7 @@ class DownloadQueueState {
|
||||
this.outputDir = '',
|
||||
this.filenameFormat = '{artist} - {title}',
|
||||
this.autoFallback = true,
|
||||
this.concurrentDownloads = 1,
|
||||
});
|
||||
|
||||
DownloadQueueState copyWith({
|
||||
@@ -95,6 +171,7 @@ class DownloadQueueState {
|
||||
String? outputDir,
|
||||
String? filenameFormat,
|
||||
bool? autoFallback,
|
||||
int? concurrentDownloads,
|
||||
}) {
|
||||
return DownloadQueueState(
|
||||
items: items ?? this.items,
|
||||
@@ -103,17 +180,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 +285,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 +453,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 +497,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>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
|
||||
@@ -54,9 +55,6 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||
),
|
||||
|
||||
// Theme Preview
|
||||
_buildThemePreview(context, colorScheme),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Download Section
|
||||
@@ -125,6 +123,45 @@ 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(),
|
||||
|
||||
// GitHub & Credits Section
|
||||
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||
title: const Text('SpotiFLAC Mobile'),
|
||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Mobile version maintained by zarzet\nOriginal project by afkarxyz',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
@@ -132,19 +169,64 @@ class SettingsScreen extends ConsumerWidget {
|
||||
ListTile(
|
||||
leading: Icon(Icons.info, color: colorScheme.primary),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('SpotiFLAC v1.0.3'),
|
||||
onTap: () => showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SpotiFLAC',
|
||||
applicationVersion: '1.0.3',
|
||||
applicationLegalese: '© 2024 SpotiFLAC',
|
||||
),
|
||||
subtitle: const Text('SpotiFLAC v1.1.1'),
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAboutDialog(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||
const SizedBox(width: 12),
|
||||
const Text('SpotiFLAC'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAboutRow('Version', '1.1.1', colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Mobile', 'zarzet', colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Original', 'afkarxyz', colorScheme),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'© 2026 SpotiFLAC',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
@@ -158,51 +240,6 @@ class SettingsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Theme Preview',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
|
||||
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
|
||||
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
|
||||
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorChip(String label, Color background, Color foreground) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: foreground, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeName(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light: return 'Light';
|
||||
@@ -423,4 +460,59 @@ class SettingsScreen extends ConsumerWidget {
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: 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)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
-46
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
|
||||
@@ -61,9 +62,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
|
||||
),
|
||||
|
||||
// Theme Preview
|
||||
_buildThemePreview(context, colorScheme),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Download Section
|
||||
@@ -132,6 +130,45 @@ 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(),
|
||||
|
||||
// GitHub & Credits Section
|
||||
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.code, color: colorScheme.primary),
|
||||
title: const Text('SpotiFLAC Mobile'),
|
||||
subtitle: const Text('github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
onTap: () => _launchUrl('https://github.com/zarzet/SpotiFLAC-Mobile'),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.computer, color: colorScheme.primary),
|
||||
title: const Text('Original SpotiFLAC (Desktop)'),
|
||||
subtitle: const Text('github.com/afkarxyz/SpotiFLAC'),
|
||||
onTap: () => _launchUrl('https://github.com/afkarxyz/SpotiFLAC'),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Mobile version maintained by zarzet\nOriginal project by afkarxyz',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
@@ -139,13 +176,8 @@ 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.3'),
|
||||
onTap: () => showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SpotiFLAC',
|
||||
applicationVersion: '1.0.3',
|
||||
applicationLegalese: '© 2024 SpotiFLAC',
|
||||
),
|
||||
subtitle: const Text('SpotiFLAC v1.1.1'),
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
|
||||
// Bottom padding for navigation bar
|
||||
@@ -154,6 +186,56 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
);
|
||||
}
|
||||
|
||||
void _showAboutDialog(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
|
||||
const SizedBox(width: 12),
|
||||
const Text('SpotiFLAC'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAboutRow('Version', '1.1.1', colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Mobile', 'zarzet', colorScheme),
|
||||
const SizedBox(height: 8),
|
||||
_buildAboutRow('Original', 'afkarxyz', colorScheme),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'© 2026 SpotiFLAC',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
@@ -167,42 +249,6 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemePreview(BuildContext context, ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Theme Preview', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildColorChip('Primary', colorScheme.primary, colorScheme.onPrimary),
|
||||
_buildColorChip('Secondary', colorScheme.secondary, colorScheme.onSecondary),
|
||||
_buildColorChip('Tertiary', colorScheme.tertiary, colorScheme.onTertiary),
|
||||
_buildColorChip('Surface', colorScheme.surface, colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorChip(String label, Color background, Color foreground) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
|
||||
child: Text(label, style: TextStyle(color: foreground, fontSize: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeName(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light: return 'Light';
|
||||
@@ -392,4 +438,59 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAli
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: 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)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,10 +314,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStepDot(0, 'Permission', colorScheme),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 2,
|
||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20), // Offset for label height
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 2,
|
||||
color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
_buildStepDot(1, 'Folder', colorScheme),
|
||||
],
|
||||
|
||||
@@ -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.3+4
|
||||
version: 1.1.1+8
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user