mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb5d8826a2 | |||
| 4bc28704ff | |||
| ed7171133f | |||
| 67885e17ed | |||
| fd4da1b7c4 | |||
| 242a57b7eb | |||
| 18467c54d6 | |||
| 8238e2fe68 | |||
| 13c2360b7e |
@@ -7,8 +7,8 @@
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/17247">
|
||||
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
@@ -57,6 +58,8 @@ class DownloadService : Service() {
|
||||
const val EXTRA_STATUS = "status"
|
||||
const val EXTRA_REQUESTS_JSON = "requests_json"
|
||||
const val EXTRA_SETTINGS_JSON = "settings_json"
|
||||
const val EXTRA_REQUESTS_PATH = "requests_path"
|
||||
const val EXTRA_SETTINGS_PATH = "settings_path"
|
||||
private const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json"
|
||||
private const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json"
|
||||
private const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json"
|
||||
@@ -116,6 +119,19 @@ class DownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun startNativeQueueFromFiles(context: Context, requestsPath: String, settingsPath: String = "") {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_START_NATIVE_QUEUE
|
||||
putExtra(EXTRA_REQUESTS_PATH, requestsPath)
|
||||
putExtra(EXTRA_SETTINGS_PATH, settingsPath)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseNativeQueue(context: Context) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_PAUSE_NATIVE_QUEUE
|
||||
@@ -281,8 +297,18 @@ class DownloadService : Service() {
|
||||
stopForegroundService()
|
||||
}
|
||||
ACTION_START_NATIVE_QUEUE -> {
|
||||
val requestsJson = intent.getStringExtra(EXTRA_REQUESTS_JSON) ?: "[]"
|
||||
val settingsJson = intent.getStringExtra(EXTRA_SETTINGS_JSON) ?: "{}"
|
||||
val requestsJson = readNativeQueuePayload(
|
||||
intent,
|
||||
EXTRA_REQUESTS_JSON,
|
||||
EXTRA_REQUESTS_PATH,
|
||||
"[]"
|
||||
)
|
||||
val settingsJson = readNativeQueuePayload(
|
||||
intent,
|
||||
EXTRA_SETTINGS_JSON,
|
||||
EXTRA_SETTINGS_PATH,
|
||||
"{}"
|
||||
)
|
||||
startNativeWorker(requestsJson, settingsJson)
|
||||
}
|
||||
ACTION_PAUSE_NATIVE_QUEUE -> {
|
||||
@@ -365,6 +391,36 @@ class DownloadService : Service() {
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun readNativeQueuePayload(
|
||||
intent: Intent,
|
||||
jsonExtra: String,
|
||||
pathExtra: String,
|
||||
defaultValue: String,
|
||||
): String {
|
||||
val path = intent.getStringExtra(pathExtra).orEmpty()
|
||||
if (path.isNotBlank()) {
|
||||
return try {
|
||||
val file = File(path)
|
||||
val payload = file.readText()
|
||||
if (!file.delete()) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Failed to delete native worker payload file: $path"
|
||||
)
|
||||
}
|
||||
payload.ifBlank { defaultValue }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Failed to read native worker payload file: ${e.message}"
|
||||
)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
return intent.getStringExtra(jsonExtra) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -400,7 +456,15 @@ class DownloadService : Service() {
|
||||
ensureWakeLock()
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNativeWorker(requestsJson: String, settingsJson: String) {
|
||||
|
||||
@@ -2797,7 +2797,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"startNativeDownloadWorker" -> {
|
||||
val requestsJson = call.argument<String>("requests_json") ?: "[]"
|
||||
val settingsJson = call.argument<String>("settings_json") ?: "{}"
|
||||
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
|
||||
val requestsPath = call.argument<String>("requests_path") ?: ""
|
||||
val settingsPath = call.argument<String>("settings_path") ?: ""
|
||||
if (requestsPath.isNotBlank()) {
|
||||
DownloadService.startNativeQueueFromFiles(
|
||||
this@MainActivity,
|
||||
requestsPath,
|
||||
settingsPath
|
||||
)
|
||||
} else {
|
||||
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"pauseNativeDownloadWorker" -> {
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.3.1",
|
||||
"versionDate": "2026-04-14",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
|
||||
"version": "4.5.0",
|
||||
"versionDate": "2026-05-06",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.0/SpotiFLAC-v4.5.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34773644
|
||||
"size": 37191956
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,9 +25,10 @@ const (
|
||||
)
|
||||
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
AllowHTTP bool `json:"allowHttp,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionSetting struct {
|
||||
|
||||
@@ -258,7 +258,8 @@ func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout ti
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
if req.URL.Scheme != "https" &&
|
||||
!(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
if parsed.Scheme != "https" &&
|
||||
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
|
||||
@@ -144,6 +144,15 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||
t.Error("Expected notallowed.com to be denied")
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("http://api.allowed.com/path"); err == nil {
|
||||
t.Error("Expected http URL to be denied without allowHttp")
|
||||
}
|
||||
|
||||
ext.Manifest.Permissions.AllowHTTP = true
|
||||
if err := runtime.validateDomain("http://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected http URL to be allowed with allowHttp, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppInfo {
|
||||
static const String version = '4.5.0';
|
||||
static const String buildNumber = '127';
|
||||
static const String version = '4.5.1';
|
||||
static const String buildNumber = '128';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
@@ -4372,6 +4372,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isUsableIndex(int? number, int? total) {
|
||||
if (number == null || number <= 0) return false;
|
||||
return total == null || total <= 0 || number <= total;
|
||||
}
|
||||
|
||||
int? _resolvePositiveMetadataInt(int? sourceValue, int? backendValue) {
|
||||
if (sourceValue != null && sourceValue > 0) return sourceValue;
|
||||
return backendValue;
|
||||
}
|
||||
|
||||
int? _resolveMetadataIndex({
|
||||
required int? sourceValue,
|
||||
required int? backendValue,
|
||||
required int? total,
|
||||
}) {
|
||||
if (_isUsableIndex(sourceValue, total)) return sourceValue;
|
||||
if (_isUsableIndex(backendValue, total)) return backendValue;
|
||||
return sourceValue != null && sourceValue > 0 ? sourceValue : backendValue;
|
||||
}
|
||||
|
||||
String? _resolveMetadataText(String? sourceValue, String? backendValue) {
|
||||
return normalizeOptionalString(sourceValue) ??
|
||||
normalizeOptionalString(backendValue);
|
||||
}
|
||||
|
||||
Track _buildTrackForMetadataEmbedding(
|
||||
Track baseTrack,
|
||||
Map<String, dynamic> backendResult,
|
||||
@@ -4401,18 +4426,44 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendComposer = normalizeOptionalString(
|
||||
backendResult['composer']?.toString(),
|
||||
);
|
||||
final sourceAlbumName = normalizeOptionalString(baseTrack.albumName);
|
||||
final sourceAlbumArtist = normalizeOptionalString(baseTrack.albumArtist);
|
||||
final sourceIsrc = normalizeOptionalString(baseTrack.isrc);
|
||||
final sourceReleaseDate = normalizeOptionalString(baseTrack.releaseDate);
|
||||
final sourceComposer = normalizeOptionalString(baseTrack.composer);
|
||||
final resolvedTotalTracks = _resolvePositiveMetadataInt(
|
||||
baseTrack.totalTracks,
|
||||
backendTotalTracks,
|
||||
);
|
||||
final resolvedTotalDiscs = _resolvePositiveMetadataInt(
|
||||
baseTrack.totalDiscs,
|
||||
backendTotalDiscs,
|
||||
);
|
||||
final resolvedTrackNumber = _resolveMetadataIndex(
|
||||
sourceValue: baseTrack.trackNumber,
|
||||
backendValue: backendTrackNum,
|
||||
total: resolvedTotalTracks,
|
||||
);
|
||||
final resolvedDiscNumber = _resolveMetadataIndex(
|
||||
sourceValue: baseTrack.discNumber,
|
||||
backendValue: backendDiscNum,
|
||||
total: resolvedTotalDiscs,
|
||||
);
|
||||
|
||||
final hasOverrides =
|
||||
backendTrackNum != null ||
|
||||
backendDiscNum != null ||
|
||||
backendYear != null ||
|
||||
backendAlbum != null ||
|
||||
backendIsrc != null ||
|
||||
resolvedTrackNumber != baseTrack.trackNumber ||
|
||||
resolvedDiscNumber != baseTrack.discNumber ||
|
||||
resolvedTotalTracks != baseTrack.totalTracks ||
|
||||
resolvedTotalDiscs != baseTrack.totalDiscs ||
|
||||
resolvedAlbumArtist != sourceAlbumArtist ||
|
||||
(sourceReleaseDate == null && backendYear != null) ||
|
||||
(sourceAlbumName == null && backendAlbum != null) ||
|
||||
(sourceIsrc == null && backendIsrc != null) ||
|
||||
(baseCoverUrl == null && backendCoverUrl != null) ||
|
||||
backendAlbumArtist != null ||
|
||||
backendComposer != null ||
|
||||
backendTotalTracks != null ||
|
||||
backendTotalDiscs != null;
|
||||
(sourceAlbumArtist == null &&
|
||||
resolvedAlbumArtist == null &&
|
||||
backendAlbumArtist != null) ||
|
||||
(sourceComposer == null && backendComposer != null);
|
||||
|
||||
if (!hasOverrides) {
|
||||
return baseTrack;
|
||||
@@ -4422,22 +4473,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
id: baseTrack.id,
|
||||
name: baseTrack.name,
|
||||
artistName: baseTrack.artistName,
|
||||
albumName: backendAlbum ?? baseTrack.albumName,
|
||||
albumArtist: backendAlbumArtist ?? resolvedAlbumArtist,
|
||||
albumName: sourceAlbumName ?? backendAlbum ?? baseTrack.albumName,
|
||||
albumArtist:
|
||||
resolvedAlbumArtist ?? sourceAlbumArtist ?? backendAlbumArtist,
|
||||
artistId: baseTrack.artistId,
|
||||
albumId: baseTrack.albumId,
|
||||
coverUrl: resolvedCoverUrl,
|
||||
duration: baseTrack.duration,
|
||||
isrc: backendIsrc ?? baseTrack.isrc,
|
||||
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
|
||||
discNumber: backendDiscNum ?? baseTrack.discNumber,
|
||||
totalDiscs: backendTotalDiscs ?? baseTrack.totalDiscs,
|
||||
releaseDate: backendYear ?? baseTrack.releaseDate,
|
||||
isrc: sourceIsrc ?? backendIsrc,
|
||||
trackNumber: resolvedTrackNumber,
|
||||
discNumber: resolvedDiscNumber,
|
||||
totalDiscs: resolvedTotalDiscs,
|
||||
releaseDate: sourceReleaseDate ?? backendYear,
|
||||
deezerId: baseTrack.deezerId,
|
||||
availability: baseTrack.availability,
|
||||
albumType: baseTrack.albumType,
|
||||
totalTracks: backendTotalTracks ?? baseTrack.totalTracks,
|
||||
composer: backendComposer ?? baseTrack.composer,
|
||||
totalTracks: resolvedTotalTracks,
|
||||
composer: sourceComposer ?? backendComposer,
|
||||
source: baseTrack.source,
|
||||
);
|
||||
}
|
||||
@@ -5710,10 +5762,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendArtist = result['artist'] as String?;
|
||||
final backendAlbum = result['album'] as String?;
|
||||
final backendYear = result['release_date'] as String?;
|
||||
final backendTrackNum = result['track_number'] as int?;
|
||||
final backendDiscNum = result['disc_number'] as int?;
|
||||
final backendTotalTracks = result['total_tracks'] as int?;
|
||||
final backendTotalDiscs = result['total_discs'] as int?;
|
||||
final backendTrackNum = _parsePositiveInt(result['track_number']);
|
||||
final backendDiscNum = _parsePositiveInt(result['disc_number']);
|
||||
final backendTotalTracks = _parsePositiveInt(result['total_tracks']);
|
||||
final backendTotalDiscs = _parsePositiveInt(result['total_discs']);
|
||||
final backendISRC = result['isrc'] as String?;
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
@@ -5725,21 +5777,51 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
lowerFilePath.endsWith('.mp3') ||
|
||||
lowerFilePath.endsWith('.opus') ||
|
||||
lowerFilePath.endsWith('.ogg');
|
||||
final historyTotalTracks = _resolvePositiveMetadataInt(
|
||||
trackToDownload.totalTracks,
|
||||
backendTotalTracks,
|
||||
);
|
||||
final historyTotalDiscs = _resolvePositiveMetadataInt(
|
||||
trackToDownload.totalDiscs,
|
||||
backendTotalDiscs,
|
||||
);
|
||||
final historyTrackNumber = _resolveMetadataIndex(
|
||||
sourceValue: trackToDownload.trackNumber,
|
||||
backendValue: backendTrackNum,
|
||||
total: historyTotalTracks,
|
||||
);
|
||||
final historyDiscNumber = _resolveMetadataIndex(
|
||||
sourceValue: trackToDownload.discNumber,
|
||||
backendValue: backendDiscNum,
|
||||
total: historyTotalDiscs,
|
||||
);
|
||||
final historyTitle =
|
||||
_resolveMetadataText(trackToDownload.name, backendTitle) ??
|
||||
item.track.name;
|
||||
final historyArtist =
|
||||
_resolveMetadataText(trackToDownload.artistName, backendArtist) ??
|
||||
item.track.artistName;
|
||||
final historyAlbum =
|
||||
_resolveMetadataText(trackToDownload.albumName, backendAlbum) ??
|
||||
item.track.albumName;
|
||||
final historyIsrc = _resolveMetadataText(trackToDownload.isrc, backendISRC);
|
||||
final historyReleaseDate = _resolveMetadataText(
|
||||
trackToDownload.releaseDate,
|
||||
backendYear,
|
||||
);
|
||||
final historyComposer = _resolveMetadataText(
|
||||
trackToDownload.composer,
|
||||
backendComposer,
|
||||
);
|
||||
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
DownloadHistoryItem(
|
||||
id: item.id,
|
||||
trackName: (backendTitle != null && backendTitle.isNotEmpty)
|
||||
? backendTitle
|
||||
: trackToDownload.name,
|
||||
artistName: (backendArtist != null && backendArtist.isNotEmpty)
|
||||
? backendArtist
|
||||
: trackToDownload.artistName,
|
||||
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
||||
? backendAlbum
|
||||
: trackToDownload.albumName,
|
||||
trackName: historyTitle,
|
||||
artistName: historyArtist,
|
||||
albumName: historyAlbum,
|
||||
albumArtist: normalizeOptionalString(trackToDownload.albumArtist),
|
||||
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
|
||||
filePath: filePath,
|
||||
@@ -5758,33 +5840,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
safRepaired: false,
|
||||
service: result['service'] as String? ?? item.service,
|
||||
downloadedAt: DateTime.now(),
|
||||
isrc: (backendISRC != null && backendISRC.isNotEmpty)
|
||||
? backendISRC
|
||||
: trackToDownload.isrc,
|
||||
isrc: historyIsrc,
|
||||
spotifyId: trackToDownload.id,
|
||||
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
|
||||
? backendTrackNum
|
||||
: trackToDownload.trackNumber,
|
||||
totalTracks: (backendTotalTracks != null && backendTotalTracks > 0)
|
||||
? backendTotalTracks
|
||||
: trackToDownload.totalTracks,
|
||||
discNumber: (backendDiscNum != null && backendDiscNum > 0)
|
||||
? backendDiscNum
|
||||
: trackToDownload.discNumber,
|
||||
totalDiscs: (backendTotalDiscs != null && backendTotalDiscs > 0)
|
||||
? backendTotalDiscs
|
||||
: trackToDownload.totalDiscs,
|
||||
trackNumber: historyTrackNumber,
|
||||
totalTracks: historyTotalTracks,
|
||||
discNumber: historyDiscNumber,
|
||||
totalDiscs: historyTotalDiscs,
|
||||
duration: trackToDownload.duration,
|
||||
releaseDate: (backendYear != null && backendYear.isNotEmpty)
|
||||
? backendYear
|
||||
: trackToDownload.releaseDate,
|
||||
releaseDate: historyReleaseDate,
|
||||
quality: actualQuality,
|
||||
bitDepth: isLossyOutput ? null : actualBitDepth,
|
||||
sampleRate: isLossyOutput ? null : actualSampleRate,
|
||||
genre: normalizeOptionalString(backendGenre),
|
||||
composer: (backendComposer != null && backendComposer.isNotEmpty)
|
||||
? backendComposer
|
||||
: trackToDownload.composer,
|
||||
composer: historyComposer,
|
||||
label: normalizeOptionalString(backendLabel),
|
||||
copyright: normalizeOptionalString(backendCopyright),
|
||||
),
|
||||
@@ -8003,10 +8071,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendArtist = result['artist'] as String?;
|
||||
final backendAlbum = result['album'] as String?;
|
||||
final backendYear = result['release_date'] as String?;
|
||||
final backendTrackNum = result['track_number'] as int?;
|
||||
final backendDiscNum = result['disc_number'] as int?;
|
||||
final backendTotalTracks = result['total_tracks'] as int?;
|
||||
final backendTotalDiscs = result['total_discs'] as int?;
|
||||
final backendTrackNum = _parsePositiveInt(result['track_number']);
|
||||
final backendDiscNum = _parsePositiveInt(result['disc_number']);
|
||||
final backendTotalTracks = _parsePositiveInt(result['total_tracks']);
|
||||
final backendTotalDiscs = _parsePositiveInt(result['total_discs']);
|
||||
final backendBitDepth = result['actual_bit_depth'] as int?;
|
||||
final backendSampleRate = result['actual_sample_rate'] as int?;
|
||||
final backendBitrateKbps = _readPositiveBitrateKbps(
|
||||
@@ -8097,22 +8165,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
lowerFilePath.endsWith('.ogg');
|
||||
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
|
||||
final historySampleRate = isLossyOutput ? null : finalSampleRate;
|
||||
final historyTotalTracks = _resolvePositiveMetadataInt(
|
||||
trackToDownload.totalTracks,
|
||||
backendTotalTracks,
|
||||
);
|
||||
final historyTotalDiscs = _resolvePositiveMetadataInt(
|
||||
trackToDownload.totalDiscs,
|
||||
backendTotalDiscs,
|
||||
);
|
||||
final historyTrackNumber = _resolveMetadataIndex(
|
||||
sourceValue: trackToDownload.trackNumber,
|
||||
backendValue: backendTrackNum,
|
||||
total: historyTotalTracks,
|
||||
);
|
||||
final historyDiscNumber = _resolveMetadataIndex(
|
||||
sourceValue: trackToDownload.discNumber,
|
||||
backendValue: backendDiscNum,
|
||||
total: historyTotalDiscs,
|
||||
);
|
||||
final historyTitle =
|
||||
_resolveMetadataText(trackToDownload.name, backendTitle) ??
|
||||
item.track.name;
|
||||
final historyArtist =
|
||||
_resolveMetadataText(trackToDownload.artistName, backendArtist) ??
|
||||
item.track.artistName;
|
||||
final historyAlbum =
|
||||
_resolveMetadataText(trackToDownload.albumName, backendAlbum) ??
|
||||
item.track.albumName;
|
||||
final historyIsrc = _resolveMetadataText(
|
||||
trackToDownload.isrc,
|
||||
backendISRC,
|
||||
);
|
||||
final historyReleaseDate = _resolveMetadataText(
|
||||
trackToDownload.releaseDate,
|
||||
backendYear,
|
||||
);
|
||||
final historyComposer = _resolveMetadataText(
|
||||
trackToDownload.composer,
|
||||
backendComposer,
|
||||
);
|
||||
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
DownloadHistoryItem(
|
||||
id: item.id,
|
||||
trackName: (backendTitle != null && backendTitle.isNotEmpty)
|
||||
? backendTitle
|
||||
: trackToDownload.name,
|
||||
artistName:
|
||||
(backendArtist != null && backendArtist.isNotEmpty)
|
||||
? backendArtist
|
||||
: trackToDownload.artistName,
|
||||
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
||||
? backendAlbum
|
||||
: trackToDownload.albumName,
|
||||
trackName: historyTitle,
|
||||
artistName: historyArtist,
|
||||
albumName: historyAlbum,
|
||||
albumArtist: historyAlbumArtist,
|
||||
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
|
||||
filePath: filePath,
|
||||
@@ -8127,36 +8227,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
safRepaired: false,
|
||||
service: result['service'] as String? ?? item.service,
|
||||
downloadedAt: DateTime.now(),
|
||||
isrc: (backendISRC != null && backendISRC.isNotEmpty)
|
||||
? backendISRC
|
||||
: trackToDownload.isrc,
|
||||
isrc: historyIsrc,
|
||||
spotifyId: trackToDownload.id,
|
||||
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
|
||||
? backendTrackNum
|
||||
: trackToDownload.trackNumber,
|
||||
totalTracks:
|
||||
(backendTotalTracks != null && backendTotalTracks > 0)
|
||||
? backendTotalTracks
|
||||
: trackToDownload.totalTracks,
|
||||
discNumber: (backendDiscNum != null && backendDiscNum > 0)
|
||||
? backendDiscNum
|
||||
: trackToDownload.discNumber,
|
||||
totalDiscs:
|
||||
(backendTotalDiscs != null && backendTotalDiscs > 0)
|
||||
? backendTotalDiscs
|
||||
: trackToDownload.totalDiscs,
|
||||
trackNumber: historyTrackNumber,
|
||||
totalTracks: historyTotalTracks,
|
||||
discNumber: historyDiscNumber,
|
||||
totalDiscs: historyTotalDiscs,
|
||||
duration: trackToDownload.duration,
|
||||
releaseDate: (backendYear != null && backendYear.isNotEmpty)
|
||||
? backendYear
|
||||
: trackToDownload.releaseDate,
|
||||
releaseDate: historyReleaseDate,
|
||||
quality: actualQuality,
|
||||
bitDepth: historyBitDepth,
|
||||
sampleRate: historySampleRate,
|
||||
genre: effectiveGenre,
|
||||
composer:
|
||||
(backendComposer != null && backendComposer.isNotEmpty)
|
||||
? backendComposer
|
||||
: trackToDownload.composer,
|
||||
composer: historyComposer,
|
||||
label: effectiveLabel,
|
||||
copyright: effectiveCopyright,
|
||||
),
|
||||
|
||||
@@ -1645,10 +1645,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
return state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.map((ext) => ext.id)
|
||||
.toList(growable: false);
|
||||
return _distinctProviderIds(
|
||||
state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.map((ext) => ext.id),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> getAllMetadataProviders() {
|
||||
@@ -1662,10 +1663,22 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
.where((ext) => ext.searchBehavior?.primary != true)
|
||||
.map((ext) => ext.id);
|
||||
|
||||
return [
|
||||
return _distinctProviderIds([
|
||||
...primarySearchMetadataExtensions,
|
||||
...otherMetadataExtensions,
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
List<String> _distinctProviderIds(Iterable<String> ids) {
|
||||
final seen = <String>{};
|
||||
final result = <String>[];
|
||||
for (final id in ids) {
|
||||
final normalized = id.trim();
|
||||
if (normalized.isNotEmpty && seen.add(normalized)) {
|
||||
result.add(normalized);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
List<String> _replaceRetiredBuiltInMetadataProviders(List<String> input) {
|
||||
|
||||
@@ -1189,8 +1189,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
!hasSearchProvider &&
|
||||
!hasHomeFeedExtension &&
|
||||
!hasExploreContent &&
|
||||
!hasResults &&
|
||||
!hasHistoryItems;
|
||||
!hasResults;
|
||||
|
||||
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
|
||||
previous,
|
||||
|
||||
@@ -2472,6 +2472,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final hasQueueItems = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
||||
);
|
||||
final historyTotalCount = ref.watch(
|
||||
downloadHistoryProvider.select((state) => state.totalCount),
|
||||
);
|
||||
final localLibraryTotalCount = ref.watch(
|
||||
localLibraryProvider.select((state) => state.totalCount),
|
||||
);
|
||||
final localLibraryEnabled = ref.watch(
|
||||
settingsProvider.select((s) => s.localLibraryEnabled),
|
||||
);
|
||||
@@ -2565,6 +2571,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
(pageValues[historyFilterMode]?.isLoading ?? false);
|
||||
final hasAnyLibraryItems =
|
||||
queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0;
|
||||
final hasLibraryContent =
|
||||
historyTotalCount > 0 ||
|
||||
(localLibraryEnabled && localLibraryTotalCount > 0);
|
||||
final hasActiveSearch =
|
||||
_searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty;
|
||||
final shouldShowLibraryControls =
|
||||
hasLibraryContent || hasAnyLibraryItems || hasActiveSearch;
|
||||
|
||||
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
||||
final selectionItems = getFilterData(
|
||||
@@ -2644,7 +2657,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (hasAnyLibraryItems || hasQueueItems)
|
||||
if (shouldShowLibraryControls || hasQueueItems)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
@@ -2709,7 +2722,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
if (hasQueueItems) _buildQueueItemsSliver(context, colorScheme),
|
||||
|
||||
if (hasAnyLibraryItems)
|
||||
if (shouldShowLibraryControls)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
@@ -3809,12 +3822,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
(filterMode != 'albums' ||
|
||||
(filteredGroupedAlbums.isEmpty &&
|
||||
filteredGroupedLocalAlbums.isEmpty)) &&
|
||||
!showFilteringIndicator)
|
||||
!showFilteringIndicator &&
|
||||
!isPageLoading)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _buildEmptyState(context, colorScheme, filterMode),
|
||||
)
|
||||
else if (isPageLoading && filterMode != 'albums')
|
||||
else if (isPageLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
|
||||
@@ -91,6 +91,13 @@ class AboutPage extends StatelessWidget {
|
||||
name: 'Amonoman',
|
||||
description: context.l10n.aboutLogoArtist,
|
||||
githubUsername: 'Amonoman',
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: 'Ruubiiiii',
|
||||
description:
|
||||
'Provided many APIs and backend resources. A huge help for the project!',
|
||||
githubUsername: 'Ruubiiiii',
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -136,12 +143,6 @@ class AboutPage extends StatelessWidget {
|
||||
subtitle:
|
||||
'Partner lyrics proxy for Apple Music and QQ Music sources',
|
||||
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: 'Ruubiiiii',
|
||||
description: 'Provided Qobuz & Deezer API for the project',
|
||||
githubUsername: 'Ruubiiiii',
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -517,7 +518,13 @@ class _TranslatorsSection extends StatelessWidget {
|
||||
flag: '🇨🇳',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Сергей Ильченко',
|
||||
name: 'Serhii Ilchenko',
|
||||
crowdinUsername: 'Sega_Mostky',
|
||||
language: 'Ukrainian',
|
||||
flag: '🇺🇦',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Serhii Ilchenko',
|
||||
crowdinUsername: 'Sega_Mostky',
|
||||
language: 'Russian',
|
||||
flag: '🇷🇺',
|
||||
|
||||
@@ -25,6 +25,26 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final hasDownloadExtensions = extensionState.extensions.any(
|
||||
(extension) => extension.enabled && extension.hasDownloadProvider,
|
||||
);
|
||||
final selectedDownloadService = resolveEffectiveDownloadService(
|
||||
settings.defaultService,
|
||||
extensionState,
|
||||
);
|
||||
final selectedDownloadExtension = extensionState.extensions
|
||||
.where(
|
||||
(extension) =>
|
||||
extension.enabled &&
|
||||
extension.hasDownloadProvider &&
|
||||
extension.id == selectedDownloadService,
|
||||
)
|
||||
.firstOrNull;
|
||||
final qualityOptions =
|
||||
selectedDownloadExtension?.qualityOptions ?? const <QualityOption>[];
|
||||
final canSelectQuality = qualityOptions.isNotEmpty;
|
||||
final isTidalService = selectedDownloadService.isNotEmpty
|
||||
? ref
|
||||
.read(extensionProvider.notifier)
|
||||
.downloadProviderMatchesBuiltIn(selectedDownloadService, 'tidal')
|
||||
: false;
|
||||
final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
@@ -101,16 +121,51 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadAskBeforeDownload,
|
||||
subtitle: hasDownloadExtensions
|
||||
subtitle: !hasDownloadExtensions
|
||||
? context.l10n.extensionsNoDownloadProvider
|
||||
: canSelectQuality
|
||||
? context.l10n.downloadAskQualitySubtitle
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
: context.l10n.downloadSelectServiceToEnable,
|
||||
value: settings.askQualityBeforeDownload,
|
||||
enabled: hasDownloadExtensions,
|
||||
enabled: hasDownloadExtensions && canSelectQuality,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAskQualityBeforeDownload(value),
|
||||
showDivider: false,
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload &&
|
||||
canSelectQuality) ...[
|
||||
for (final quality in qualityOptions)
|
||||
_QualityOption(
|
||||
title: _localizedQualityLabel(context, quality),
|
||||
subtitle: _localizedQualityDescription(
|
||||
context,
|
||||
quality,
|
||||
),
|
||||
icon: _qualityIcon(quality.id),
|
||||
isSelected: settings.audioQuality == quality.id,
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality(quality.id),
|
||||
showDivider:
|
||||
quality != qualityOptions.last ||
|
||||
(isTidalService && settings.audioQuality == 'HIGH'),
|
||||
),
|
||||
if (isTidalService && settings.audioQuality == 'HIGH')
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadLossyFormat,
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
context,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
onTap: () => _showTidalHighFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -266,6 +321,161 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
return name == null ? effective : '$effective - $name';
|
||||
}
|
||||
|
||||
IconData _qualityIcon(String qualityId) {
|
||||
final normalized = qualityId.toUpperCase();
|
||||
if (normalized.startsWith('MP3_') || normalized == 'MP3') {
|
||||
return Icons.audiotrack;
|
||||
}
|
||||
if (normalized.startsWith('OPUS_') || normalized == 'OPUS') {
|
||||
return Icons.graphic_eq;
|
||||
}
|
||||
|
||||
switch (normalized) {
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return Icons.four_k;
|
||||
case 'HI_RES':
|
||||
return Icons.high_quality;
|
||||
case 'LOSSLESS':
|
||||
return Icons.music_note;
|
||||
default:
|
||||
return Icons.music_note;
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedQualityLabel(BuildContext context, QualityOption quality) {
|
||||
switch (quality.id.toUpperCase()) {
|
||||
case 'LOSSLESS':
|
||||
return context.l10n.qualityFlacLossless;
|
||||
case 'HI_RES':
|
||||
return context.l10n.qualityHiResFlac;
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return context.l10n.qualityHiResFlacMax;
|
||||
case 'HIGH':
|
||||
return context.l10n.downloadLossy320;
|
||||
default:
|
||||
return quality.label;
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedQualityDescription(
|
||||
BuildContext context,
|
||||
QualityOption quality,
|
||||
) {
|
||||
switch (quality.id.toUpperCase()) {
|
||||
case 'LOSSLESS':
|
||||
return context.l10n.qualityFlacLosslessSubtitle;
|
||||
case 'HI_RES':
|
||||
return context.l10n.qualityHiResFlacSubtitle;
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return context.l10n.qualityHiResFlacMaxSubtitle;
|
||||
case 'HIGH':
|
||||
return _getTidalHighFormatLabel(
|
||||
context,
|
||||
ref.read(settingsProvider).tidalHighFormat,
|
||||
);
|
||||
default:
|
||||
return quality.description ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
String _getTidalHighFormatLabel(BuildContext context, String format) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return context.l10n.downloadLossyMp3;
|
||||
case 'opus_256':
|
||||
return context.l10n.downloadLossyOpus256;
|
||||
case 'opus_128':
|
||||
return context.l10n.downloadLossyOpus128;
|
||||
default:
|
||||
return context.l10n.downloadLossyMp3;
|
||||
}
|
||||
}
|
||||
|
||||
void _showTidalHighFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320Format,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320FormatDesc,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: Text(context.l10n.downloadLossyMp3),
|
||||
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
|
||||
trailing: current == 'mp3_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus256),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
|
||||
trailing: current == 'opus_256'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_256');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus128),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
|
||||
trailing: current == 'opus_128'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNetworkModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -629,6 +839,39 @@ class _BetaBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final bool showDivider;
|
||||
|
||||
const _QualityOption({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return SettingsItem(
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
trailing: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
showDivider: showDivider,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceSelector extends ConsumerWidget {
|
||||
final String currentService;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@@ -755,8 +755,8 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -794,7 +794,15 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description ??
|
||||
context.l10n.downloadFilenameDescription as String,
|
||||
context.l10n.downloadFilenameDescription(
|
||||
'{album}',
|
||||
'{artist}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
'{title}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -924,7 +932,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
).whenComplete(controller.dispose);
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(
|
||||
|
||||
@@ -114,14 +114,13 @@ class _MetadataProviderPriorityPageState
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setMetadataProviderPriority(_providers);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarMetadataProviderSaved)),
|
||||
);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarMetadataProviderSaved)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +187,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
info.icon,
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
Icon(info.icon, color: colorScheme.secondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
||||
@@ -220,14 +220,13 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setProviderPriority(_providers);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
|
||||
);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,10 +293,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
info.icon,
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
Icon(info.icon, color: colorScheme.secondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -339,8 +335,5 @@ class _ProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
|
||||
_ProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
});
|
||||
_ProviderInfo({required this.name, required this.icon});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -841,10 +842,58 @@ class PlatformBridge {
|
||||
required List<Map<String, dynamic>> requests,
|
||||
Map<String, dynamic> settings = const {},
|
||||
}) async {
|
||||
await _channel.invokeMethod('startNativeDownloadWorker', {
|
||||
'requests_json': jsonEncode(requests),
|
||||
'settings_json': jsonEncode(settings),
|
||||
});
|
||||
final requestsJson = jsonEncode(requests);
|
||||
final settingsJson = jsonEncode(settings);
|
||||
final payloadDir = await _nativeWorkerPayloadDir();
|
||||
await _cleanupNativeWorkerPayloads(payloadDir);
|
||||
final stamp = DateTime.now().microsecondsSinceEpoch;
|
||||
final requestPath = '${payloadDir.path}/requests_$stamp.json';
|
||||
final settingsPath = '${payloadDir.path}/settings_$stamp.json';
|
||||
await File(requestPath).writeAsString(requestsJson, flush: true);
|
||||
await File(settingsPath).writeAsString(settingsJson, flush: true);
|
||||
try {
|
||||
await _channel.invokeMethod('startNativeDownloadWorker', {
|
||||
'requests_path': requestPath,
|
||||
'settings_path': settingsPath,
|
||||
});
|
||||
} catch (_) {
|
||||
unawaited(_deleteFileIfExists(requestPath));
|
||||
unawaited(_deleteFileIfExists(settingsPath));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _deleteFileIfExists(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<Directory> _nativeWorkerPayloadDir() async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final payloadDir = Directory('${tempDir.path}/native_worker_payloads');
|
||||
if (!await payloadDir.exists()) {
|
||||
await payloadDir.create(recursive: true);
|
||||
}
|
||||
return payloadDir;
|
||||
}
|
||||
|
||||
static Future<void> _cleanupNativeWorkerPayloads(Directory payloadDir) async {
|
||||
final cutoff = DateTime.now().subtract(const Duration(days: 1));
|
||||
try {
|
||||
await for (final entity in payloadDir.list(followLinks: false)) {
|
||||
if (entity is! File || !entity.path.endsWith('.json')) continue;
|
||||
final stat = await entity.stat();
|
||||
if (stat.modified.isBefore(cutoff)) {
|
||||
try {
|
||||
await entity.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> pauseNativeDownloadWorker() async {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||
publish_to: "none"
|
||||
version: 4.5.0+127
|
||||
version: 4.5.1+128
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user