Compare commits

...

9 Commits

Author SHA1 Message Date
zarzet fb5d8826a2 fix: avoid native worker binder payload limit 2026-05-08 01:06:48 +07:00
zarzet 4bc28704ff docs: update credits and trendshift badge 2026-05-08 00:40:26 +07:00
zarzet ed7171133f fix: show missing extension state for returning users 2026-05-08 00:40:26 +07:00
zarzet 67885e17ed fix: preserve selected metadata and update credits 2026-05-08 00:40:26 +07:00
zarzet fd4da1b7c4 fix: declare dataSync type when starting foreground download service
Use the 3-arg startForeground overload with FOREGROUND_SERVICE_TYPE_DATA_SYNC on API 29+ so the runtime FGS type matches the manifest declaration. Silences the ForegroundServiceTypeLoggerModule warning on targetSdk 36.
2026-05-08 00:40:25 +07:00
zarzet 242a57b7eb fix: restore default quality settings 2026-05-08 00:40:25 +07:00
zarzet 18467c54d6 fix: stabilize library search and bump version 2026-05-08 00:40:25 +07:00
zarzet 8238e2fe68 fix: prevent settings editor white screens 2026-05-08 00:40:25 +07:00
github-actions[bot] 13c2360b7e chore: update AltStore source to v4.5.0 2026-05-06 15:40:37 +00:00
20 changed files with 651 additions and 160 deletions
+2 -2
View File
@@ -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" -> {
+4 -4
View File
@@ -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
}
]
}
+4 -3
View File
@@ -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 {
+2 -1
View File
@@ -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")
}
+2 -1
View File
@@ -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 {
+9
View File
@@ -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) {
+2 -2
View File
@@ -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;
+173 -90
View File
@@ -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,
),
+19 -6
View File
@@ -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) {
+1 -2
View File
@@ -1189,8 +1189,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
!hasSearchProvider &&
!hasHomeFeedExtension &&
!hasExploreContent &&
!hasResults &&
!hasHistoryItems;
!hasResults;
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
+18 -4
View File
@@ -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),
+14 -7
View File
@@ -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;
+11 -3
View File
@@ -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});
}
+53 -4
View File
@@ -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
View File
@@ -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