feat: add configurable extension download fallback

This commit is contained in:
zarzet
2026-04-06 02:55:03 +07:00
parent add4af831e
commit 43469a7ef2
28 changed files with 863 additions and 2 deletions
@@ -2965,6 +2965,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"setDownloadFallbackExtensionIds" -> {
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
}
result.success(null)
}
"setMetadataProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) {
+24
View File
@@ -2665,6 +2665,30 @@ func GetProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil
}
func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error {
if strings.TrimSpace(providerIDsJSON) == "" {
SetExtensionFallbackProviderIDs(nil)
return nil
}
var providerIDs []string
if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil {
return err
}
SetExtensionFallbackProviderIDs(providerIDs)
return nil
}
func GetExtensionFallbackProviderIDsJSON() (string, error) {
providerIDs := GetExtensionFallbackProviderIDs()
jsonBytes, err := json.Marshal(providerIDs)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
+15
View File
@@ -2,6 +2,21 @@ package gobackend
import "testing"
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
}
if got := GetExtensionFallbackProviderIDs(); got != nil {
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
}
}
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
TrackName: "Bonus Track",
+67
View File
@@ -676,6 +676,9 @@ func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) (
var providerPriority []string
var providerPriorityMu sync.RWMutex
var extensionFallbackProviderIDs []string
var extensionFallbackProviderIDsMu sync.RWMutex
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
@@ -701,6 +704,65 @@ func GetProviderPriority() []string {
return result
}
func SetExtensionFallbackProviderIDs(providerIDs []string) {
extensionFallbackProviderIDsMu.Lock()
defer extensionFallbackProviderIDsMu.Unlock()
if providerIDs == nil {
extensionFallbackProviderIDs = nil
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
return
}
sanitized := make([]string, 0, len(providerIDs))
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" || isBuiltInProvider(strings.ToLower(providerID)) {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
extensionFallbackProviderIDs = sanitized
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
}
func GetExtensionFallbackProviderIDs() []string {
extensionFallbackProviderIDsMu.RLock()
defer extensionFallbackProviderIDsMu.RUnlock()
if extensionFallbackProviderIDs == nil {
return nil
}
result := make([]string, len(extensionFallbackProviderIDs))
copy(result, extensionFallbackProviderIDs)
return result
}
func isExtensionFallbackAllowed(providerID string) bool {
if isBuiltInProvider(strings.ToLower(providerID)) {
return true
}
allowed := GetExtensionFallbackProviderIDs()
if allowed == nil {
return true
}
for _, allowedProviderID := range allowed {
if allowedProviderID == providerID {
return true
}
}
return false
}
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
@@ -1308,6 +1370,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
if !isBuiltInProvider(providerIDNormalized) && !isExtensionFallbackAllowed(providerID) {
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
continue
}
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerIDNormalized) {
+49
View File
@@ -19,6 +19,55 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
}
}
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
if len(got) != len(want) {
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
}
}
}
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("custom-ext") {
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
}
if !isExtensionFallbackAllowed("qobuz") {
t.Fatal("expected built-in provider to remain allowed")
}
}
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
if !isExtensionFallbackAllowed("allowed-ext") {
t.Fatal("expected explicitly allowed extension to be permitted")
}
if isExtensionFallbackAllowed("blocked-ext") {
t.Fatal("expected extension outside allowlist to be blocked")
}
if !isExtensionFallbackAllowed("deezer") {
t.Fatal("expected built-in provider to ignore extension allowlist")
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc
+7
View File
@@ -607,6 +607,13 @@ import Gobackend // Import Go framework
let response = GobackendGetProviderPriorityJSON(&error)
if let error = error { throw error }
return response
case "setDownloadFallbackExtensionIds":
let args = call.arguments as! [String: Any]
let extensionIdsJson = args["extension_ids"] as? String ?? ""
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
if let error = error { throw error }
return nil
case "setMetadataProviderPriority":
let args = call.arguments as! [String: Any]
+30
View File
@@ -1738,6 +1738,24 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo;
/// Section title for choosing which download extensions can be used as fallback providers
///
/// In en, this message translates to:
/// **'Extension Fallback'**
String get providerPriorityFallbackExtensionsTitle;
/// Section description for extension fallback selection
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
String get providerPriorityFallbackExtensionsDescription;
/// Hint below the extension fallback selection list
///
/// In en, this message translates to:
/// **'Only enabled extensions with download-provider capability are listed here.'**
String get providerPriorityFallbackExtensionsHint;
/// Label for built-in providers (Tidal/Qobuz)
///
/// In en, this message translates to:
@@ -2644,6 +2662,18 @@ abstract class AppLocalizations {
/// **'Set download service order'**
String get extensionsDownloadPrioritySubtitle;
/// Setting and page title for choosing which download extensions can be used during fallback
///
/// In en, this message translates to:
/// **'Fallback Extensions'**
String get extensionsFallbackTitle;
/// Subtitle for download fallback extensions menu
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used as fallback'**
String get extensionsFallbackSubtitle;
/// Empty state - no download providers
///
/// In en, this message translates to:
+18
View File
@@ -940,6 +940,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get providerPriorityInfo =>
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Integriert';
@@ -1438,6 +1449,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Download-Service-Reihenfolge festlegen';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'Keine Erweiterungen mit Download-Provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -928,6 +928,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1417,6 +1428,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -930,6 +930,17 @@ class AppLocalizationsId extends AppLocalizations {
String get providerPriorityInfo =>
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Fallback Ekstensi';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.';
@override
String get providerBuiltIn => 'Bawaan';
@@ -1423,6 +1434,13 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Atur urutan layanan unduhan';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
@override
String get extensionsNoDownloadProvider =>
'Tidak ada ekstensi dengan provider unduhan';
+18
View File
@@ -920,6 +920,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => '内蔵';
@@ -1409,6 +1420,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
+18
View File
@@ -908,6 +908,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1395,6 +1406,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -940,6 +940,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get providerPriorityInfo =>
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Встроенные';
@@ -1439,6 +1450,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Установка порядок сервисов скачивания';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'Нет расширений с провайдером загрузки';
+18
View File
@@ -931,6 +931,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get providerPriorityInfo =>
'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Dahili';
@@ -1421,6 +1432,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
+20
View File
@@ -1203,6 +1203,18 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1857,6 +1869,14 @@
"@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority"
},
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "No extensions with download provider",
"@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers"
+20
View File
@@ -1119,6 +1119,18 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerPriorityFallbackExtensionsTitle": "Fallback Ekstensi",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Bawaan",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1713,6 +1725,14 @@
"@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority"
},
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers"
+7
View File
@@ -33,6 +33,7 @@ class AppSettings {
final bool askQualityBeforeDownload;
final bool enableLogging;
final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds;
final String? searchProvider;
final String? homeFeedProvider;
final bool separateSingles;
@@ -108,6 +109,7 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.enableLogging = false,
this.useExtensionProviders = true,
this.downloadFallbackExtensionIds,
this.searchProvider,
this.homeFeedProvider,
this.separateSingles = false,
@@ -170,6 +172,8 @@ class AppSettings {
bool? askQualityBeforeDownload,
bool? enableLogging,
bool? useExtensionProviders,
List<String>? downloadFallbackExtensionIds,
bool clearDownloadFallbackExtensionIds = false,
String? searchProvider,
bool clearSearchProvider = false,
String? homeFeedProvider,
@@ -232,6 +236,9 @@ class AppSettings {
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
? null
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
+5
View File
@@ -35,6 +35,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
downloadFallbackExtensionIds:
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
@@ -105,6 +109,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
+49 -1
View File
@@ -35,9 +35,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(
final loaded = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
state = loaded.copyWith(
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
);
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
@@ -50,6 +60,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
}
void _syncLyricsSettingsToBackend() {
@@ -83,6 +94,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
});
}
void _syncExtensionFallbackSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setDownloadFallbackExtensionIds(
state.downloadFallbackExtensionIds,
).catchError((Object e) {
_log.w('Failed to sync extension fallback settings to backend: $e');
});
}
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -172,6 +193,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
if (ids == null) {
return null;
}
final result = <String>[];
for (final id in ids) {
final normalized = id.trim();
if (normalized.isEmpty || result.contains(normalized)) {
continue;
}
result.add(normalized);
}
return result;
}
Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
@@ -390,6 +427,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
state = state.copyWith(
downloadFallbackExtensionIds: sanitized,
clearDownloadFallbackExtensionIds:
extensionIds == null && state.downloadFallbackExtensionIds != null,
);
_saveSettings();
_syncExtensionFallbackSettingsToBackend();
}
void setSeparateSingles(bool enabled) {
state = state.copyWith(separateSingles: enabled);
_saveSettings();
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadFallbackExtensionsPage extends ConsumerStatefulWidget {
const DownloadFallbackExtensionsPage({super.key});
@override
ConsumerState<DownloadFallbackExtensionsPage> createState() =>
_DownloadFallbackExtensionsPageState();
}
class _DownloadFallbackExtensionsPageState
extends ConsumerState<DownloadFallbackExtensionsPage> {
late List<Extension> _extensions;
late Set<String> _selectedExtensionIds;
bool _hasChanges = false;
@override
void initState() {
super.initState();
_loadExtensions();
}
void _loadExtensions() {
final extState = ref.read(extensionProvider);
final settings = ref.read(settingsProvider);
_extensions = extState.extensions
.where(
(extension) => extension.enabled && extension.hasDownloadProvider,
)
.toList();
final savedIds = settings.downloadFallbackExtensionIds;
if (savedIds == null) {
_selectedExtensionIds = _extensions
.map((extension) => extension.id)
.toSet();
} else {
final allowedIds = _extensions.map((extension) => extension.id).toSet();
_selectedExtensionIds = savedIds
.where((extensionId) => allowedIds.contains(extensionId))
.toSet();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
},
child: Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (_hasChanges) {
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
} else {
Navigator.pop(context);
}
},
),
actions: [
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.extensionsFallbackTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.providerPriorityFallbackExtensionsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
if (_extensions.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
if (_extensions.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: SettingsGroup(
margin: EdgeInsets.zero,
children: List.generate(_extensions.length, (index) {
final extension = _extensions[index];
final isSelected = _selectedExtensionIds.contains(
extension.id,
);
return SettingsSwitchItem(
icon: Icons.extension_rounded,
title: extension.displayName,
subtitle: extension.id,
value: isSelected,
showDivider: index != _extensions.length - 1,
onChanged: (value) {
setState(() {
if (value) {
_selectedExtensionIds.add(extension.id);
} else {
_selectedExtensionIds.remove(extension.id);
}
_hasChanges = true;
});
},
);
}),
),
),
),
if (_extensions.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
context.l10n.providerPriorityFallbackExtensionsHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
Future<bool> _confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogDiscard),
),
],
),
);
return result ?? false;
}
void _saveChanges() {
final allExtensionIds = _extensions
.map((extension) => extension.id)
.toList();
final selectedExtensionIds = allExtensionIds
.where(_selectedExtensionIds.contains)
.toList();
final fallbackExtensionIds =
selectedExtensionIds.length == allExtensionIds.length
? null
: selectedExtensionIds;
ref
.read(settingsProvider.notifier)
.setDownloadFallbackExtensionIds(fallbackExtensionIds);
setState(() {
_hasChanges = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
);
}
}
+70 -1
View File
@@ -8,9 +8,10 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: SettingsGroup(
children: [
_DownloadPriorityItem(),
_DownloadFallbackItem(),
_MetadataPriorityItem(),
_SearchProviderSelector(),
_HomeFeedProviderSelector(),
@@ -588,6 +590,73 @@ class _MetadataPriorityItem extends ConsumerWidget {
}
}
class _DownloadFallbackItem extends ConsumerWidget {
const _DownloadFallbackItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasDownloadExtensions = extState.extensions.any(
(e) => e.enabled && e.hasDownloadProvider,
);
return InkWell(
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const DownloadFallbackExtensionsPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.alt_route,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsFallbackTitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? context.l10n.extensionsFallbackSubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _SearchProviderSelector extends ConsumerWidget {
const _SearchProviderSelector();
+9
View File
@@ -781,6 +781,15 @@ class PlatformBridge {
return list.map((e) => e as String).toList();
}
static Future<void> setDownloadFallbackExtensionIds(
List<String>? extensionIds,
) async {
_log.d('setDownloadFallbackExtensionIds: $extensionIds');
await _channel.invokeMethod('setDownloadFallbackExtensionIds', {
'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds),
});
}
static Future<void> setMetadataProviderPriority(
List<String> providerIds,
) async {