mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-13 14:40:20 +02:00
refactor: remove built-in provider registry in favor of extensions
All search, metadata, and download providers are now exclusively supplied by extensions. The built-in provider registry that previously exposed Deezer/Tidal/Qobuz as hardcoded providers is fully removed. Removed across Go, Dart, Kotlin, and Swift: - BuiltInProviderSpec class, registry, and all accessor helpers - SearchProviderAllJSON, GetBuiltInProvidersJSON, ParseProviderURLJSON, ParseDeezerURLExport Go exports and their platform channel bindings - Built-in provider items in search dropdown, service picker, and provider priority UI lists - provider_ui_utils.dart helper file Deezer metadata enrichment (ISRC lookup, extended metadata, cover upgrade) remains fully functional through direct DeezerClient calls in the download pipeline — these are not part of the provider registry and are unaffected. Mark deezer as a retired built-in metadata provider so stale user priority lists are cleaned up on next launch.
This commit is contained in:
@@ -1773,7 +1773,7 @@ abstract class AppLocalizations {
|
||||
/// 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.'**
|
||||
/// **'Choose which installed download extensions can be used during automatic fallback.'**
|
||||
String get providerPriorityFallbackExtensionsDescription;
|
||||
|
||||
/// Hint below the extension fallback selection list
|
||||
|
||||
@@ -956,7 +956,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -946,7 +946,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -946,7 +946,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -937,7 +937,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -925,7 +925,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
@@ -4734,7 +4734,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -956,7 +956,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
@@ -4710,7 +4710,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
@@ -8187,7 +8187,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1223,7 +1223,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1127,7 +1127,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -3290,7 +3290,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@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": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -2567,9 +2567,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (normalized.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (isBuiltInDownloadProvider(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
return extensionState.extensions.any(
|
||||
|
||||
@@ -48,44 +48,6 @@ List<String>? _tryDecodeStringListPreference(String rawJson, String key) {
|
||||
}
|
||||
}
|
||||
|
||||
class BuiltInProviderSpec {
|
||||
final String id;
|
||||
final String displayName;
|
||||
final bool supportsMetadata;
|
||||
final bool supportsDownload;
|
||||
final bool supportsSearch;
|
||||
|
||||
const BuiltInProviderSpec({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
this.supportsMetadata = false,
|
||||
this.supportsDownload = false,
|
||||
this.supportsSearch = false,
|
||||
});
|
||||
|
||||
factory BuiltInProviderSpec.fromJson(Map<String, dynamic> json) {
|
||||
return BuiltInProviderSpec(
|
||||
id: json['id'] as String? ?? '',
|
||||
displayName:
|
||||
json['display_name'] as String? ??
|
||||
json['displayName'] as String? ??
|
||||
'',
|
||||
supportsMetadata: json['supports_metadata'] as bool? ?? false,
|
||||
supportsDownload: json['supports_download'] as bool? ?? false,
|
||||
supportsSearch: json['supports_search'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<BuiltInProviderSpec> _builtInProviderRegistry = const [];
|
||||
|
||||
List<BuiltInProviderSpec> get builtInProviderSpecs =>
|
||||
List<BuiltInProviderSpec>.unmodifiable(_builtInProviderRegistry);
|
||||
|
||||
void _replaceBuiltInProviderRegistry(List<BuiltInProviderSpec> providers) {
|
||||
_builtInProviderRegistry = List<BuiltInProviderSpec>.unmodifiable(providers);
|
||||
}
|
||||
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -302,66 +264,16 @@ class Extension {
|
||||
}
|
||||
}
|
||||
|
||||
BuiltInProviderSpec? builtInProviderSpecForId(String? providerId) {
|
||||
if (providerId == null) return null;
|
||||
|
||||
for (final provider in builtInProviderSpecs) {
|
||||
if (provider.id == providerId) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
List<BuiltInProviderSpec> _builtInProvidersWhere(
|
||||
bool Function(BuiltInProviderSpec provider) predicate,
|
||||
) {
|
||||
return List<BuiltInProviderSpec>.unmodifiable(
|
||||
builtInProviderSpecs.where(predicate),
|
||||
);
|
||||
}
|
||||
|
||||
List<BuiltInProviderSpec> get builtInSearchProviderSpecs =>
|
||||
_builtInProvidersWhere((provider) => provider.supportsSearch);
|
||||
|
||||
List<BuiltInProviderSpec> get builtInMetadataProviderSpecs =>
|
||||
_builtInProvidersWhere((provider) => provider.supportsMetadata);
|
||||
|
||||
List<BuiltInProviderSpec> get builtInDownloadProviderSpecs =>
|
||||
_builtInProvidersWhere((provider) => provider.supportsDownload);
|
||||
|
||||
List<String> get builtInSearchProviderIds => List<String>.unmodifiable(
|
||||
builtInSearchProviderSpecs.map((provider) => provider.id),
|
||||
);
|
||||
|
||||
List<String> get builtInMetadataProviderIds => List<String>.unmodifiable(
|
||||
builtInMetadataProviderSpecs.map((provider) => provider.id),
|
||||
);
|
||||
|
||||
List<String> get builtInDownloadProviderIds => List<String>.unmodifiable(
|
||||
builtInDownloadProviderSpecs.map((provider) => provider.id),
|
||||
);
|
||||
|
||||
String resolveEffectiveDownloadService(
|
||||
String requestedService,
|
||||
ExtensionState extensionState,
|
||||
) {
|
||||
final normalizedRequested = requestedService.trim().toLowerCase();
|
||||
final builtInDownloadIds = extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsDownload)
|
||||
.map((provider) => provider.id.trim().toLowerCase())
|
||||
.where((providerId) => providerId.isNotEmpty)
|
||||
.toSet();
|
||||
final enabledDownloadExtensions = extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.toList(growable: false);
|
||||
|
||||
if (normalizedRequested.isNotEmpty) {
|
||||
if (builtInDownloadIds.contains(normalizedRequested)) {
|
||||
return normalizedRequested;
|
||||
}
|
||||
|
||||
final matchingExtension = enabledDownloadExtensions
|
||||
.where((ext) => ext.id.trim().toLowerCase() == normalizedRequested)
|
||||
.firstOrNull;
|
||||
@@ -379,25 +291,7 @@ String resolveEffectiveDownloadService(
|
||||
}
|
||||
}
|
||||
|
||||
const preferredBuiltInOrder = ['tidal', 'qobuz', 'deezer'];
|
||||
for (final builtInId in preferredBuiltInOrder) {
|
||||
final replacement = enabledDownloadExtensions
|
||||
.where((ext) => ext.replacesBuiltInProviders.contains(builtInId))
|
||||
.firstOrNull;
|
||||
if (replacement != null) {
|
||||
return replacement.id;
|
||||
}
|
||||
if (builtInDownloadIds.contains(builtInId)) {
|
||||
return builtInId;
|
||||
}
|
||||
}
|
||||
|
||||
return enabledDownloadExtensions.firstOrNull?.id ??
|
||||
extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsDownload)
|
||||
.map((provider) => provider.id)
|
||||
.firstOrNull ??
|
||||
'';
|
||||
return enabledDownloadExtensions.firstOrNull?.id ?? '';
|
||||
}
|
||||
|
||||
String resolveEffectiveMetadataProvider(
|
||||
@@ -405,20 +299,11 @@ String resolveEffectiveMetadataProvider(
|
||||
ExtensionState extensionState,
|
||||
) {
|
||||
final normalizedRequested = requestedProvider.trim().toLowerCase();
|
||||
final builtInMetadataIds = extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsMetadata)
|
||||
.map((provider) => provider.id.trim().toLowerCase())
|
||||
.where((providerId) => providerId.isNotEmpty)
|
||||
.toSet();
|
||||
final enabledMetadataExtensions = extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasMetadataProvider)
|
||||
.toList(growable: false);
|
||||
|
||||
if (normalizedRequested.isNotEmpty) {
|
||||
if (builtInMetadataIds.contains(normalizedRequested)) {
|
||||
return normalizedRequested;
|
||||
}
|
||||
|
||||
final matchingExtension = enabledMetadataExtensions
|
||||
.where((ext) => ext.id.trim().toLowerCase() == normalizedRequested)
|
||||
.firstOrNull;
|
||||
@@ -436,12 +321,7 @@ String resolveEffectiveMetadataProvider(
|
||||
}
|
||||
}
|
||||
|
||||
return enabledMetadataExtensions.firstOrNull?.id ??
|
||||
extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsMetadata)
|
||||
.map((provider) => provider.id)
|
||||
.firstOrNull ??
|
||||
'';
|
||||
return enabledMetadataExtensions.firstOrNull?.id ?? '';
|
||||
}
|
||||
|
||||
bool isDeezerCompatibleDownloadService(
|
||||
@@ -453,10 +333,6 @@ bool isDeezerCompatibleDownloadService(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedService == 'deezer') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return extensionState.extensions.any(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
@@ -466,33 +342,10 @@ bool isDeezerCompatibleDownloadService(
|
||||
);
|
||||
}
|
||||
|
||||
bool isBuiltInSearchProvider(String? providerId) =>
|
||||
builtInProviderSpecForId(providerId)?.supportsSearch ?? false;
|
||||
|
||||
bool isBuiltInMetadataProvider(String? providerId) =>
|
||||
builtInProviderSpecForId(providerId)?.supportsMetadata ?? false;
|
||||
|
||||
bool isBuiltInDownloadProvider(String? providerId) =>
|
||||
builtInProviderSpecForId(providerId)?.supportsDownload ?? false;
|
||||
|
||||
String? get defaultBuiltInSearchProviderId => builtInSearchProviderSpecs.isEmpty
|
||||
? null
|
||||
: builtInSearchProviderSpecs.first.id;
|
||||
|
||||
String? get defaultBuiltInSearchProviderDisplayName =>
|
||||
builtInSearchProviderSpecs.isEmpty
|
||||
? null
|
||||
: builtInSearchProviderSpecs.first.displayName;
|
||||
|
||||
String resolveProviderDisplayName(
|
||||
String providerId, {
|
||||
Iterable<Extension> extensions = const [],
|
||||
}) {
|
||||
final builtIn = builtInProviderSpecForId(providerId);
|
||||
if (builtIn != null) {
|
||||
return builtIn.displayName;
|
||||
}
|
||||
|
||||
for (final extension in extensions) {
|
||||
if (extension.id == providerId) {
|
||||
return extension.displayName;
|
||||
@@ -884,7 +737,6 @@ class ExtensionSetting {
|
||||
|
||||
class ExtensionState {
|
||||
final List<Extension> extensions;
|
||||
final List<BuiltInProviderSpec> builtInProviders;
|
||||
final List<String> providerPriority;
|
||||
final List<String> metadataProviderPriority;
|
||||
final Map<String, ExtensionHealthStatus> healthStatuses;
|
||||
@@ -894,7 +746,6 @@ class ExtensionState {
|
||||
|
||||
const ExtensionState({
|
||||
this.extensions = const [],
|
||||
this.builtInProviders = const [],
|
||||
this.providerPriority = const [],
|
||||
this.metadataProviderPriority = const [],
|
||||
this.healthStatuses = const {},
|
||||
@@ -905,7 +756,6 @@ class ExtensionState {
|
||||
|
||||
ExtensionState copyWith({
|
||||
List<Extension>? extensions,
|
||||
List<BuiltInProviderSpec>? builtInProviders,
|
||||
List<String>? providerPriority,
|
||||
List<String>? metadataProviderPriority,
|
||||
Map<String, ExtensionHealthStatus>? healthStatuses,
|
||||
@@ -915,7 +765,6 @@ class ExtensionState {
|
||||
}) {
|
||||
return ExtensionState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
builtInProviders: builtInProviders ?? this.builtInProviders,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
metadataProviderPriority:
|
||||
metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
@@ -998,12 +847,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await refreshBuiltInProviders();
|
||||
} catch (e) {
|
||||
_log.w('Failed to refresh built-in providers before init: $e');
|
||||
}
|
||||
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
state = state.copyWith(
|
||||
isInitialized: true,
|
||||
@@ -1168,16 +1011,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> refreshBuiltInProviders() async {
|
||||
final list = await PlatformBridge.getBuiltInProviders();
|
||||
final providers = list
|
||||
.map((e) => BuiltInProviderSpec.fromJson(e))
|
||||
.where((provider) => provider.id.isNotEmpty)
|
||||
.toList();
|
||||
_replaceBuiltInProviderRegistry(providers);
|
||||
state = state.copyWith(builtInProviders: providers);
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
@@ -1402,8 +1235,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
String? replacedBuiltInDownloadProviderFor(String providerId) {
|
||||
@@ -1505,8 +1337,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
currentExtension == null ||
|
||||
!currentExtension.enabled ||
|
||||
!currentExtension.hasDownloadProvider;
|
||||
if (!isBuiltInDownloadProvider(currentService) &&
|
||||
isMissingOrInvalidExtension) {
|
||||
if (isMissingOrInvalidExtension) {
|
||||
final fallbackService = preferredExtensionId ?? '';
|
||||
ref.read(settingsProvider.notifier).setDefaultService(fallbackService);
|
||||
_log.d(
|
||||
@@ -1551,8 +1382,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
(ext) =>
|
||||
ext.enabled && ext.hasCustomSearch && ext.id == currentSearchProvider,
|
||||
);
|
||||
if (!isBuiltInSearchProvider(currentSearchProvider) &&
|
||||
!hasMatchingExtension) {
|
||||
if (!hasMatchingExtension) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(
|
||||
@@ -1815,13 +1645,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = List<String>.from(builtInDownloadProviderIds);
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
return state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.map((ext) => ext.id)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> getAllMetadataProviders() {
|
||||
@@ -1837,7 +1664,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
return [
|
||||
...primarySearchMetadataExtensions,
|
||||
...builtInMetadataProviderIds,
|
||||
...otherMetadataExtensions,
|
||||
];
|
||||
}
|
||||
@@ -1865,14 +1691,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
final hasPreferredExtension = preferredOrder.any(
|
||||
(provider) => !isBuiltInMetadataProvider(provider),
|
||||
);
|
||||
final hasSavedExtension = result.any(
|
||||
(provider) => !isBuiltInMetadataProvider(provider),
|
||||
);
|
||||
|
||||
if (!hasSavedExtension && hasPreferredExtension) {
|
||||
if (result.isEmpty && preferredOrder.isNotEmpty) {
|
||||
return List<String>.from(preferredOrder);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,12 +69,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
|
||||
loaded.defaultSearchTab,
|
||||
);
|
||||
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
|
||||
loaded.defaultService,
|
||||
);
|
||||
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
|
||||
loaded.searchProvider,
|
||||
);
|
||||
state = loaded.copyWith(
|
||||
useExtensionProviders: true,
|
||||
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
|
||||
@@ -82,10 +76,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
loaded.downloadFallbackExtensionIds != null &&
|
||||
sanitizedDownloadFallbackExtensionIds == null,
|
||||
defaultSearchTab: sanitizedDefaultSearchTab,
|
||||
defaultService: sanitizedDefaultService ?? '',
|
||||
searchProvider: sanitizedSearchProvider,
|
||||
clearSearchProvider:
|
||||
loaded.searchProvider != null && sanitizedSearchProvider == null,
|
||||
defaultService: loaded.defaultService,
|
||||
searchProvider: loaded.searchProvider,
|
||||
);
|
||||
|
||||
await _runMigrations(prefs);
|
||||
@@ -166,23 +158,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
// Migration 7/11: retired built-in services no longer fall back to a
|
||||
// preinstalled provider.
|
||||
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
|
||||
state.defaultService,
|
||||
);
|
||||
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
|
||||
state.searchProvider,
|
||||
);
|
||||
if (sanitizedDefaultService != state.defaultService ||
|
||||
sanitizedSearchProvider != state.searchProvider) {
|
||||
state = state.copyWith(
|
||||
defaultService: sanitizedDefaultService ?? '',
|
||||
searchProvider: sanitizedSearchProvider,
|
||||
clearSearchProvider:
|
||||
state.searchProvider != null && sanitizedSearchProvider == null,
|
||||
);
|
||||
}
|
||||
// Migration 7/11: retired built-in services are now reconciled after
|
||||
// extensions load so manifest-declared replacements can adopt old prefs.
|
||||
if (!state.useExtensionProviders) {
|
||||
state = state.copyWith(useExtensionProviders: true);
|
||||
}
|
||||
|
||||
+144
-398
@@ -195,7 +195,6 @@ class SearchPlaylist {
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
static const int _maxPreWarmTracksPerRequest = 80;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
@@ -204,15 +203,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
bool _usesBuiltInUrlResolver(String url) {
|
||||
final normalized = url.toLowerCase();
|
||||
return normalized.contains('deezer.com') ||
|
||||
normalized.contains('deezer.page.link') ||
|
||||
normalized.contains('qobuz.com') ||
|
||||
normalized.startsWith('qobuzapp://') ||
|
||||
normalized.contains('tidal.com');
|
||||
}
|
||||
|
||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -220,7 +210,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
try {
|
||||
var extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler == null && !_usesBuiltInUrlResolver(url)) {
|
||||
if (extensionHandler == null) {
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
if (!extensionState.isInitialized && extensionState.isLoading) {
|
||||
_log.i(
|
||||
@@ -234,132 +224,124 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
if (extensionHandler == null) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'url_not_recognized',
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
if (result != null &&
|
||||
result['type'] == 'track' &&
|
||||
result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
} else if (result != null &&
|
||||
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (result != null &&
|
||||
result['type'] == 'track' &&
|
||||
result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt < 3) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
} else if (result != null &&
|
||||
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Failed to load track metadata from extension',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') &&
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId:
|
||||
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
|
||||
albumName:
|
||||
result['name'] as String? ??
|
||||
(result['album'] as Map<String, dynamic>?)?['name']
|
||||
as String?,
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: normalizeCoverReference(
|
||||
result['cover_url']?.toString(),
|
||||
),
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||
),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
artistData['header_image']?.toString(),
|
||||
),
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < 3) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
final handledBuiltInUrl = await _tryResolveBuiltInProviderUrl(
|
||||
url,
|
||||
requestId,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
if (handledBuiltInUrl) {
|
||||
return;
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Failed to load track metadata from extension',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') &&
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId:
|
||||
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
|
||||
albumName:
|
||||
result['name'] as String? ??
|
||||
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||
),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
artistData['header_image']?.toString(),
|
||||
),
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
@@ -377,138 +359,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _tryResolveBuiltInProviderUrl(String url, int requestId) async {
|
||||
Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = await PlatformBridge.parseProviderUrl(url);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return true;
|
||||
|
||||
final providerId = parsed['provider_id']?.toString();
|
||||
final type = parsed['type']?.toString();
|
||||
final id = parsed['id']?.toString();
|
||||
if (providerId == null ||
|
||||
providerId.isEmpty ||
|
||||
type == null ||
|
||||
type.isEmpty ||
|
||||
id == null ||
|
||||
id.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_log.i('Detected built-in provider URL: $providerId:$type:$id');
|
||||
|
||||
final metadata = await _getResolvedProviderMetadata(providerId, type, id);
|
||||
if (!_isRequestValid(requestId)) return true;
|
||||
|
||||
_applyResolvedProviderMetadata(providerId, type, id, metadata);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _getResolvedProviderMetadata(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
) async {
|
||||
return PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
);
|
||||
}
|
||||
|
||||
void _applyResolvedProviderMetadata(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
Map<String, dynamic> metadata,
|
||||
) {
|
||||
switch (resourceType) {
|
||||
case 'track':
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
return;
|
||||
case 'album':
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: _buildResolvedAlbumId(providerId, resourceId),
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks, service: providerId);
|
||||
return;
|
||||
case 'playlist':
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistName,
|
||||
coverUrl: coverUrl,
|
||||
);
|
||||
_preWarmCacheForTracks(tracks, service: providerId);
|
||||
return;
|
||||
case 'artist':
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
final topTracksList = metadata['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
(artistInfo['header_image'] ?? artistInfo['cover_url'])?.toString(),
|
||||
),
|
||||
monthlyListeners: artistInfo['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String _buildResolvedAlbumId(String providerId, String resourceId) {
|
||||
if (providerId == 'deezer') {
|
||||
return resourceId;
|
||||
}
|
||||
return '$providerId:$resourceId';
|
||||
}
|
||||
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? filterOverride,
|
||||
String? builtInSearchProvider,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
@@ -516,33 +369,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
|
||||
String? resolvedProvider = builtInSearchProvider;
|
||||
if (resolvedProvider == null || resolvedProvider.isEmpty) {
|
||||
final explicitProvider = settings.searchProvider?.trim();
|
||||
if (explicitProvider != null && explicitProvider.isNotEmpty) {
|
||||
resolvedProvider = explicitProvider;
|
||||
} else {
|
||||
resolvedProvider =
|
||||
extensionState.extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
resolvedProvider ??= defaultBuiltInSearchProviderId;
|
||||
String? resolvedProvider;
|
||||
final explicitProvider = settings.searchProvider?.trim();
|
||||
if (explicitProvider != null && explicitProvider.isNotEmpty) {
|
||||
resolvedProvider = explicitProvider;
|
||||
} else {
|
||||
resolvedProvider =
|
||||
extensionState.extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
if (resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
!isBuiltInSearchProvider(resolvedProvider) &&
|
||||
!extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.id == resolvedProvider,
|
||||
) &&
|
||||
@@ -562,7 +411,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
resolvedProvider ??= defaultBuiltInSearchProviderId;
|
||||
}
|
||||
|
||||
final isEnabledExtensionProvider =
|
||||
@@ -571,11 +419,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.id == resolvedProvider,
|
||||
);
|
||||
final isBuiltInProvider = isBuiltInSearchProvider(resolvedProvider);
|
||||
|
||||
if (resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
!isBuiltInProvider &&
|
||||
isEnabledExtensionProvider) {
|
||||
final resolvedFilter = requestFilter ?? 'track';
|
||||
Map<String, dynamic>? options;
|
||||
@@ -589,23 +435,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final fallbackBuiltInProvider = builtInSearchProvider?.isNotEmpty == true
|
||||
? builtInSearchProvider
|
||||
: defaultBuiltInSearchProviderId;
|
||||
final effectiveBuiltInProvider = isBuiltInProvider
|
||||
? resolvedProvider
|
||||
: fallbackBuiltInProvider;
|
||||
|
||||
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
@@ -614,47 +443,21 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
|
||||
try {
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
final effectiveProvider = effectiveBuiltInProvider;
|
||||
final includeExtensions = settings.useExtensionProviders;
|
||||
|
||||
_log.i(
|
||||
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
|
||||
'Search started: provider=metadata_extensions, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
if (isBuiltInSearchProvider(effectiveProvider)) {
|
||||
_log.d('Calling built-in search API for $effectiveProvider...');
|
||||
results = await PlatformBridge.searchProviderAll(
|
||||
effectiveProvider,
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: requestFilter,
|
||||
);
|
||||
} else {
|
||||
_log.d('Calling metadata provider track search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
results = const <String, List<dynamic>>{
|
||||
'tracks': <dynamic>[],
|
||||
'artists': <dynamic>[],
|
||||
'albums': <dynamic>[],
|
||||
'playlists': <dynamic>[],
|
||||
};
|
||||
}
|
||||
_log.d('Calling metadata provider track search API...');
|
||||
final metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
'metadata_extensions returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
@@ -662,12 +465,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
final trackSearchResults = metadataTrackResults.isNotEmpty
|
||||
? metadataTrackResults
|
||||
: trackList.whereType<Map<String, dynamic>>().toList();
|
||||
final trackSearchResults = metadataTrackResults;
|
||||
const artistList = <dynamic>[];
|
||||
const albumList = <dynamic>[];
|
||||
|
||||
_log.d(
|
||||
'Raw results: ${trackSearchResults.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||
@@ -712,7 +512,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||
const playlistList = <dynamic>[];
|
||||
final playlists = <SearchPlaylist>[];
|
||||
for (int i = 0; i < playlistList.length; i++) {
|
||||
final p = playlistList[i];
|
||||
@@ -740,7 +540,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
searchSource: effectiveProvider,
|
||||
searchSource: resolvedProvider,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -915,33 +715,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
return Track(
|
||||
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
@@ -1054,33 +827,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _preWarmCacheForTracks(List<Track> tracks, {String? service}) {
|
||||
if (tracks.isEmpty) return;
|
||||
final cacheRequests = <Map<String, String>>[];
|
||||
for (final track in tracks) {
|
||||
final isrc = track.isrc;
|
||||
if (isrc == null || isrc.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final effectiveService =
|
||||
(track.source?.trim().isNotEmpty == true ? track.source : service)
|
||||
?.trim();
|
||||
cacheRequests.add({
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id,
|
||||
if (effectiveService != null && effectiveService.isNotEmpty)
|
||||
'service': effectiveService,
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cacheRequests.isEmpty) return;
|
||||
|
||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||
|
||||
@@ -29,7 +29,6 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
@@ -315,26 +314,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null &&
|
||||
explicit.isNotEmpty &&
|
||||
(isBuiltInSearchProvider(explicit) ||
|
||||
extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
))) {
|
||||
extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
)) {
|
||||
return explicit;
|
||||
}
|
||||
return _defaultSearchExtension(extensions)?.id ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
return _defaultSearchExtension(extensions)?.id;
|
||||
}
|
||||
|
||||
bool _hasSearchProvider(
|
||||
String? explicitSearchProvider,
|
||||
List<Extension> extensions,
|
||||
List<BuiltInProviderSpec> builtInProviders,
|
||||
) {
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null && explicit.isNotEmpty) {
|
||||
if (builtInProviders.any((p) => p.supportsSearch && p.id == explicit)) {
|
||||
return true;
|
||||
}
|
||||
if (extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
)) {
|
||||
@@ -342,8 +335,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
return extensions.any((ext) => ext.enabled && ext.hasCustomSearch) ||
|
||||
builtInProviders.any((provider) => provider.supportsSearch);
|
||||
return extensions.any((ext) => ext.enabled && ext.hasCustomSearch);
|
||||
}
|
||||
|
||||
String? _sanitizeSearchFilterForProvider(
|
||||
@@ -358,8 +350,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final canonicalFilter = _canonicalSearchFilterId(filter);
|
||||
|
||||
if (currentSearchProvider == null ||
|
||||
currentSearchProvider.isEmpty ||
|
||||
isBuiltInSearchProvider(currentSearchProvider)) {
|
||||
currentSearchProvider.isEmpty) {
|
||||
switch (canonicalFilter) {
|
||||
case 'track':
|
||||
case 'artist':
|
||||
@@ -554,8 +545,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
if (isBuiltInSearchProvider(searchProvider)) return true;
|
||||
|
||||
final extension = extState.extensions
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
@@ -655,13 +644,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||
_invalidateSearchSortCaches();
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null && isBuiltInSearchProvider(searchProvider);
|
||||
|
||||
final isExtensionEnabled =
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isBuiltInProvider &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
@@ -677,19 +662,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
options: options,
|
||||
selectedFilter: selectedFilter,
|
||||
);
|
||||
} else if (isBuiltInProvider) {
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(
|
||||
query,
|
||||
filterOverride: selectedFilter,
|
||||
builtInSearchProvider: searchProvider,
|
||||
);
|
||||
} else {
|
||||
if (searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isExtensionEnabled &&
|
||||
!isBuiltInProvider) {
|
||||
!isExtensionEnabled) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref
|
||||
@@ -1147,7 +1123,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
(s) => (
|
||||
isInitialized: s.isInitialized,
|
||||
error: s.error,
|
||||
builtInProviders: s.builtInProviders,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1195,7 +1170,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final hasSearchProvider = _hasSearchProvider(
|
||||
explicitSearchProvider,
|
||||
extensions,
|
||||
extensionReadiness.builtInProviders,
|
||||
);
|
||||
final showSearchBar = hasSearchProvider || isSearchProviderLoading;
|
||||
final hasResults =
|
||||
@@ -3378,11 +3352,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
final builtIn = builtInProviderSpecForId(searchProvider);
|
||||
if (builtIn != null && builtIn.supportsSearch) {
|
||||
return context.l10n.homeSearchHintProvider(builtIn.displayName);
|
||||
}
|
||||
|
||||
final ext = extState.extensions
|
||||
.where((e) => e.id == searchProvider)
|
||||
.firstOrNull;
|
||||
|
||||
@@ -36,9 +36,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final searchProviders = extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
|
||||
final hasAnyProvider = searchProviders.isNotEmpty;
|
||||
final isProviderLoading =
|
||||
!providerReadiness.isInitialized && providerReadiness.error == null;
|
||||
|
||||
@@ -71,11 +69,9 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final resolvedCurrentProvider =
|
||||
rawCurrentProvider != null &&
|
||||
rawCurrentProvider.isNotEmpty &&
|
||||
(isBuiltInSearchProvider(rawCurrentProvider) ||
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider))
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider)
|
||||
? rawCurrentProvider
|
||||
: _defaultSearchExtension(searchProviders)?.id ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
: _defaultSearchExtension(searchProviders)?.id;
|
||||
final currentProvider =
|
||||
resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty
|
||||
? resolvedCurrentProvider
|
||||
@@ -88,9 +84,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
final isBuiltInProvider =
|
||||
currentProvider != null && isBuiltInSearchProvider(currentProvider);
|
||||
|
||||
IconData displayIcon = Icons.search;
|
||||
String? iconPath;
|
||||
if (currentExt != null) {
|
||||
@@ -98,8 +91,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = resolveProviderIcon(currentProvider);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -137,36 +128,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
onProviderChanged?.call();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
...builtInProviders.map(
|
||||
(provider) => PopupMenuItem<String>(
|
||||
value: provider.id,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
resolveProviderIcon(provider.id),
|
||||
size: 20,
|
||||
color: currentProvider == provider.id
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
provider.displayName,
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == provider.id
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == provider.id)
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||
...searchProviders.map(
|
||||
(ext) => PopupMenuItem<String>(
|
||||
value: ext.id,
|
||||
|
||||
@@ -726,29 +726,23 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
|
||||
final searchProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
|
||||
final hasAnyProvider = searchProviders.isNotEmpty;
|
||||
|
||||
final resolvedProviderId =
|
||||
(settings.searchProvider != null && settings.searchProvider!.isNotEmpty)
|
||||
? settings.searchProvider!
|
||||
: searchProviders.firstOrNull?.id ?? defaultBuiltInSearchProviderId;
|
||||
: searchProviders.firstOrNull?.id;
|
||||
String currentProviderName = context.l10n.optionsPrimaryProviderSubtitle;
|
||||
if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) {
|
||||
if (isBuiltInSearchProvider(resolvedProviderId)) {
|
||||
currentProviderName = resolveProviderDisplayName(resolvedProviderId);
|
||||
} else {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == resolvedProviderId)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? resolvedProviderId;
|
||||
}
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == resolvedProviderId)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? resolvedProviderId;
|
||||
}
|
||||
|
||||
return Column(
|
||||
@@ -817,7 +811,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -850,25 +843,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
...builtInProviders.map(
|
||||
(provider) => ListTile(
|
||||
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
||||
title: Text(provider.displayName),
|
||||
subtitle: Text(
|
||||
ctx.l10n.extensionsSearchWith(provider.displayName),
|
||||
),
|
||||
trailing: settings.searchProvider == provider.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(provider.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const Divider(height: 1),
|
||||
...searchProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
|
||||
@@ -2,7 +2,6 @@ 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/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
@@ -67,6 +66,11 @@ class _MetadataProviderPriorityPageState
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
extension: ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
@@ -126,6 +130,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final Extension? extension;
|
||||
|
||||
const _MetadataProviderItem({
|
||||
super.key,
|
||||
@@ -133,6 +138,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
this.extension,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -147,7 +153,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(context, provider);
|
||||
final info = _getProviderInfo(context, provider, extension);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -184,9 +190,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -220,34 +224,12 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
_MetadataProviderInfo _getProviderInfo(
|
||||
BuildContext context,
|
||||
String provider,
|
||||
Extension? extension,
|
||||
) {
|
||||
final builtIn = builtInProviderSpecForId(provider);
|
||||
if (builtIn != null) {
|
||||
return _MetadataProviderInfo(
|
||||
name: builtIn.displayName,
|
||||
icon: resolveProviderIcon(
|
||||
provider,
|
||||
builtInDefaultIcon: Icons.library_music,
|
||||
),
|
||||
description: context.l10n.providerBuiltIn,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (provider == 'deezer') {
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.album,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
|
||||
return _MetadataProviderInfo(
|
||||
name: provider,
|
||||
name: extension?.displayName ?? provider,
|
||||
icon: Icons.extension,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -256,12 +238,10 @@ class _MetadataProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final String description;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_MetadataProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.description,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
|
||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
@@ -139,6 +138,11 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
extension: ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
@@ -232,6 +236,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final Extension? extension;
|
||||
|
||||
const _ProviderItem({
|
||||
super.key,
|
||||
@@ -239,6 +244,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
this.extension,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -253,7 +259,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(provider);
|
||||
final info = _getProviderInfo(provider, extension);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -290,9 +296,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -306,9 +310,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn
|
||||
? context.l10n.providerBuiltIn
|
||||
: context.l10n.providerExtension,
|
||||
context.l10n.providerExtension,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -325,28 +327,10 @@ class _ProviderItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider) {
|
||||
final builtIn = builtInProviderSpecForId(provider);
|
||||
if (builtIn != null) {
|
||||
return _ProviderInfo(
|
||||
name: builtIn.displayName,
|
||||
icon: resolveProviderIcon(provider),
|
||||
isBuiltIn: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (provider == 'deezer') {
|
||||
return _ProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.graphic_eq,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider, Extension? extension) {
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
name: extension?.displayName ?? provider,
|
||||
icon: Icons.extension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -354,11 +338,9 @@ class _ProviderItem extends StatelessWidget {
|
||||
class _ProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_ProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -884,23 +884,6 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchProviderAll(
|
||||
String providerId,
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
int artistLimit = 2,
|
||||
String? filter,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchProviderAll', {
|
||||
'provider_id': providerId,
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
'filter': filter ?? '',
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'searchProviderAll');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
@@ -912,13 +895,6 @@ class PlatformBridge {
|
||||
return _decodeRequiredMapResult(result, 'getDeezerRelatedArtists');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> parseProviderUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseProviderUrl', {
|
||||
'url': url,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'parseProviderUrl');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getProviderMetadata(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
@@ -1394,11 +1370,6 @@ class PlatformBridge {
|
||||
return _decodeMapListResult(result, 'getSearchProviders');
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getBuiltInProviders() async {
|
||||
final result = await _channel.invokeMethod('getBuiltInProviders');
|
||||
return _decodeMapListResult(result, 'getBuiltInProviders');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> handleURLWithExtension(
|
||||
String url,
|
||||
) async {
|
||||
|
||||
@@ -61,17 +61,6 @@ Future<List<Map<String, dynamic>>> _searchMetadataProvider(
|
||||
required String filter,
|
||||
required int limit,
|
||||
}) async {
|
||||
if (isBuiltInSearchProvider(providerId)) {
|
||||
final result = await PlatformBridge.searchProviderAll(
|
||||
providerId,
|
||||
query,
|
||||
trackLimit: 0,
|
||||
artistLimit: filter == 'artist' ? limit : 0,
|
||||
filter: filter,
|
||||
);
|
||||
return _extractSearchItems(result, filter);
|
||||
}
|
||||
|
||||
return PlatformBridge.customSearchWithExtension(
|
||||
providerId,
|
||||
query,
|
||||
@@ -79,24 +68,6 @@ Future<List<Map<String, dynamic>>> _searchMetadataProvider(
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractSearchItems(
|
||||
Map<String, dynamic> result,
|
||||
String filter,
|
||||
) {
|
||||
final key = switch (filter) {
|
||||
'artist' => 'artists',
|
||||
'album' => 'albums',
|
||||
_ => '${filter}s',
|
||||
};
|
||||
final items = result[key];
|
||||
if (items is! List) return const [];
|
||||
|
||||
return items
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _metadataSearchProviderCandidates(
|
||||
BuildContext context, {
|
||||
String? sourceProviderId,
|
||||
@@ -142,10 +113,6 @@ List<String> _metadataSearchProviderCandidates(
|
||||
addProvider(extension.id);
|
||||
}
|
||||
|
||||
for (final providerId in builtInSearchProviderIds) {
|
||||
addProvider(providerId);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -153,7 +120,6 @@ bool _canSearchMetadataProvider(
|
||||
String providerId,
|
||||
ExtensionState extensionState,
|
||||
) {
|
||||
if (isBuiltInSearchProvider(providerId)) return true;
|
||||
return extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == providerId,
|
||||
);
|
||||
@@ -333,8 +299,7 @@ void _pushArtistScreen(
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
final isExtension =
|
||||
extensionId != null && !isBuiltInMetadataProvider(extensionId);
|
||||
final isExtension = extensionId != null;
|
||||
final resolvedProviderId = extensionId;
|
||||
|
||||
_pushViaPreferredNavigator(
|
||||
@@ -362,8 +327,7 @@ void _pushAlbumScreen(
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
final isExtension =
|
||||
extensionId != null && !isBuiltInMetadataProvider(extensionId);
|
||||
final isExtension = extensionId != null;
|
||||
final resolvedExtensionId = extensionId;
|
||||
|
||||
_pushViaPreferredNavigator(
|
||||
@@ -677,19 +641,9 @@ bool _canNavigateArtistDirectly({
|
||||
required String? extensionId,
|
||||
}) {
|
||||
if (extensionId != null) return true;
|
||||
final providerPrefix = _resourceProviderPrefix(artistId);
|
||||
if (providerPrefix != null && isBuiltInMetadataProvider(providerPrefix)) {
|
||||
return true;
|
||||
}
|
||||
return _spotifyArtistIdPattern.hasMatch(artistId);
|
||||
}
|
||||
|
||||
String? _resourceProviderPrefix(String resourceId) {
|
||||
final colonIndex = resourceId.indexOf(':');
|
||||
if (colonIndex <= 0) return null;
|
||||
return resourceId.substring(0, colonIndex).trim();
|
||||
}
|
||||
|
||||
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
|
||||
|
||||
class ClickableAlbumName extends StatelessWidget {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
IconData resolveProviderIcon(
|
||||
String providerId, {
|
||||
IconData tidalIcon = Icons.music_note,
|
||||
IconData builtInDefaultIcon = Icons.album,
|
||||
IconData deezerIcon = Icons.graphic_eq,
|
||||
IconData fallbackIcon = Icons.extension,
|
||||
}) {
|
||||
final builtIn = builtInProviderSpecForId(providerId);
|
||||
if (builtIn != null) {
|
||||
if (providerId == 'tidal') {
|
||||
return tidalIcon;
|
||||
}
|
||||
return builtInDefaultIcon;
|
||||
}
|
||||
|
||||
if (providerId == 'deezer') {
|
||||
return deezerIcon;
|
||||
}
|
||||
|
||||
return fallbackIcon;
|
||||
}
|
||||
@@ -5,67 +5,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
class BuiltInService {
|
||||
final String id;
|
||||
final String label;
|
||||
final List<QualityOption> qualityOptions;
|
||||
final bool isDisabled; // If true, service is grayed out (fallback only)
|
||||
final String? disabledReason;
|
||||
|
||||
const BuiltInService({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.qualityOptions,
|
||||
this.isDisabled = false,
|
||||
this.disabledReason,
|
||||
});
|
||||
}
|
||||
|
||||
const _builtInServiceCatalog = {
|
||||
'tidal': BuiltInService(
|
||||
id: 'tidal',
|
||||
label: 'Tidal',
|
||||
qualityOptions: [
|
||||
QualityOption(
|
||||
id: 'LOSSLESS',
|
||||
label: 'FLAC Lossless',
|
||||
description: '16-bit / 44.1kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES',
|
||||
label: 'Hi-Res FLAC',
|
||||
description: '24-bit / up to 96kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES_LOSSLESS',
|
||||
label: 'Hi-Res FLAC Max',
|
||||
description: '24-bit / up to 192kHz',
|
||||
),
|
||||
],
|
||||
),
|
||||
'qobuz': BuiltInService(
|
||||
id: 'qobuz',
|
||||
label: 'Qobuz',
|
||||
qualityOptions: [
|
||||
QualityOption(
|
||||
id: 'LOSSLESS',
|
||||
label: 'FLAC Lossless',
|
||||
description: '16-bit / 44.1kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES',
|
||||
label: 'Hi-Res FLAC',
|
||||
description: '24-bit / up to 96kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES_LOSSLESS',
|
||||
label: 'Hi-Res FLAC Max',
|
||||
description: '24-bit / up to 192kHz',
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
final String? artistName;
|
||||
@@ -118,18 +57,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
late String _selectedService;
|
||||
|
||||
List<BuiltInService> _availableBuiltInServices() {
|
||||
final availableIds = builtInDownloadProviderIds.toSet();
|
||||
final services = <BuiltInService>[];
|
||||
for (final id in availableIds) {
|
||||
final service = _builtInServiceCatalog[id];
|
||||
if (service != null) {
|
||||
services.add(service);
|
||||
}
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
List<Extension> _downloadExtensions() {
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
return extensionState.extensions
|
||||
@@ -137,13 +64,8 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool _serviceExists(
|
||||
String serviceId,
|
||||
List<BuiltInService> builtInServices,
|
||||
List<Extension> downloadExtensions,
|
||||
) {
|
||||
bool _serviceExists(String serviceId, List<Extension> downloadExtensions) {
|
||||
if (serviceId.isEmpty) return false;
|
||||
if (builtInServices.any((service) => service.id == serviceId)) return true;
|
||||
return downloadExtensions.any((ext) => ext.id == serviceId);
|
||||
}
|
||||
|
||||
@@ -154,39 +76,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
if (!mounted) return;
|
||||
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
|
||||
});
|
||||
final builtInServices = _availableBuiltInServices();
|
||||
final downloadExtensions = _downloadExtensions();
|
||||
final recommended = widget.recommendedService;
|
||||
if (recommended != null &&
|
||||
_serviceExists(recommended, builtInServices, downloadExtensions)) {
|
||||
if (recommended != null && _serviceExists(recommended, downloadExtensions)) {
|
||||
_selectedService = recommended;
|
||||
} else {
|
||||
_selectedService = ref.read(settingsProvider).defaultService;
|
||||
}
|
||||
if (!_serviceExists(
|
||||
_selectedService,
|
||||
builtInServices,
|
||||
downloadExtensions,
|
||||
)) {
|
||||
_selectedService = builtInServices.isNotEmpty
|
||||
? builtInServices.first.id
|
||||
: downloadExtensions.isNotEmpty
|
||||
if (!_serviceExists(_selectedService, downloadExtensions)) {
|
||||
_selectedService = downloadExtensions.isNotEmpty
|
||||
? downloadExtensions.first.id
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
List<QualityOption> _getQualityOptions(
|
||||
List<BuiltInService> builtInServices,
|
||||
List<Extension> downloadExtensions,
|
||||
) {
|
||||
final builtIn = builtInServices
|
||||
.where((service) => service.id == _selectedService)
|
||||
.firstOrNull;
|
||||
if (builtIn != null) {
|
||||
return builtIn.qualityOptions;
|
||||
}
|
||||
|
||||
List<QualityOption> _getQualityOptions(List<Extension> downloadExtensions) {
|
||||
final ext = downloadExtensions
|
||||
.where((e) => e.id == _selectedService)
|
||||
.firstOrNull;
|
||||
@@ -201,14 +105,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final builtInServices = _availableBuiltInServices();
|
||||
final downloadExtensions = _downloadExtensions();
|
||||
final hasProviders =
|
||||
builtInServices.isNotEmpty || downloadExtensions.isNotEmpty;
|
||||
final qualityOptions = _getQualityOptions(
|
||||
builtInServices,
|
||||
downloadExtensions,
|
||||
);
|
||||
final hasProviders = downloadExtensions.isNotEmpty;
|
||||
final qualityOptions = _getQualityOptions(downloadExtensions);
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
@@ -257,21 +156,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final service in builtInServices)
|
||||
_ServiceChip(
|
||||
label: service.isDisabled
|
||||
? '${service.label} (${service.disabledReason})'
|
||||
: widget.recommendedService == service.id
|
||||
? '${service.label} (Recommended)'
|
||||
: service.label,
|
||||
isSelected: _selectedService == service.id,
|
||||
isDisabled: service.isDisabled,
|
||||
onTap: service.isDisabled
|
||||
? null
|
||||
: () => setState(
|
||||
() => _selectedService = service.id,
|
||||
),
|
||||
),
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
label: widget.recommendedService == ext.id
|
||||
@@ -302,19 +186,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (builtInServices.any(
|
||||
(service) => service.id == _selectedService,
|
||||
))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
context.l10n.qualityNote,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final quality in qualityOptions)
|
||||
_QualityOption(
|
||||
title: _localizedQualityLabel(context, quality),
|
||||
@@ -429,7 +300,6 @@ class _ServiceChip extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final String? iconPath;
|
||||
final String? healthStatus;
|
||||
final bool isDisabled;
|
||||
|
||||
const _ServiceChip({
|
||||
required this.label,
|
||||
@@ -437,21 +307,18 @@ class _ServiceChip extends StatelessWidget {
|
||||
required this.onTap,
|
||||
this.iconPath,
|
||||
this.healthStatus,
|
||||
this.isDisabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: isDisabled ? null : onTap,
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isDisabled
|
||||
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||
: isSelected
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -465,7 +332,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (healthStatus != null) ...[
|
||||
_ServiceHealthDot(status: healthStatus!, isDisabled: isDisabled),
|
||||
_ServiceHealthDot(status: healthStatus!),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (iconPath != null) ...[
|
||||
@@ -479,9 +346,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.extension,
|
||||
size: 18,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -493,9 +358,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -509,15 +372,12 @@ class _ServiceChip extends StatelessWidget {
|
||||
|
||||
class _ServiceHealthDot extends StatelessWidget {
|
||||
final String status;
|
||||
final bool isDisabled;
|
||||
|
||||
const _ServiceHealthDot({required this.status, required this.isDisabled});
|
||||
const _ServiceHealthDot({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = isDisabled
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3)
|
||||
: _serviceHealthColor(status);
|
||||
final color = _serviceHealthColor(status);
|
||||
return Tooltip(
|
||||
message: _serviceHealthTooltip(status),
|
||||
child: Container(
|
||||
|
||||
Reference in New Issue
Block a user