feat: remove Tidal built-in provider, add extension download dedup/ISRC/Lyrics APIs, and expand l10n/a11y

Remove Tidal from built-in provider registry (metadata, search, download,
URL parsing) and delete tidal.go. Introduce extension runtime APIs for
lyrics lookup (getLyricsLRC), ISRC existence check (checkISRCExists), and
ISRC index management (addToISRCIndex). Refactor extension download response
construction into normalizeExtensionDownloadResult/overlayExtensionDownloadMetadata
helpers with AlreadyExists support and ISRC indexing. Switch download mirrors
to DoRequestWithUserAgent for ISP blocking detection. Add 50+ new
localization keys and accessibility labels across all supported locales.
This commit is contained in:
zarzet committed 2026-04-18 22:12:14 +07:00
1 parent 6895e45f2c
commit 16ce6089fb
49 files changed
+4928 -3384

No files matched your search

+78 -5
View File
@@ -1455,6 +1455,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final decoded = jsonDecode(itemJson);
if (decoded is! Map) continue;
var item = DownloadItem.fromJson(Map<String, dynamic>.from(decoded));
final normalizedService = _normalizeQueuedService(item.service);
if (normalizedService != item.service) {
item = item.copyWith(service: normalizedService);
}
if (item.status == DownloadStatus.downloading) {
item = item.copyWith(status: DownloadStatus.queued, progress: 0);
}
@@ -2395,7 +2399,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (extensionPreferred != null) {
return extensionPreferred;
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
if (_usesBuiltInCompatibleDownloadProvider(service, 'tidal') &&
quality == 'HIGH') {
return '.m4a';
}
final q = quality.toLowerCase();
@@ -2405,6 +2410,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '.flac';
}
bool _usesBuiltInCompatibleDownloadProvider(
String service,
String builtInProviderId,
) {
return ref
.read(extensionProvider.notifier)
.downloadProviderMatchesBuiltIn(service, builtInProviderId);
}
String _normalizeQueuedService(String service) {
final normalized = service.trim();
if (normalized.isEmpty) {
return normalized;
}
final replacement = ref
.read(extensionProvider.notifier)
.replacedBuiltInDownloadProviderFor(normalized);
if (replacement != null && replacement.isNotEmpty) {
return replacement;
}
return normalized;
}
bool _hasActiveDownloadProvider(String service) {
final normalized = service.trim();
if (normalized.isEmpty) {
return false;
}
if (isBuiltInDownloadProvider(normalized)) {
return true;
}
final extensionState = ref.read(extensionProvider);
return extensionState.extensions.any(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.id.toLowerCase() == normalized.toLowerCase(),
);
}
String _mimeTypeForExt(String ext) {
switch (ext.toLowerCase()) {
case '.m4a':
@@ -2837,7 +2885,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final item = DownloadItem(
id: id,
track: track,
service: service,
service: _normalizeQueuedService(service),
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
@@ -2869,7 +2917,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return DownloadItem(
id: id,
track: track,
service: service,
service: _normalizeQueuedService(service),
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
@@ -4388,6 +4436,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
Future<void> _downloadSingleItem(DownloadItem item) async {
final normalizedService = _normalizeQueuedService(item.service);
if (normalizedService != item.service) {
item = item.copyWith(service: normalizedService);
state = state.copyWith(
items: [
for (final existing in state.items)
if (existing.id == item.id) item else existing,
],
currentDownload: state.currentDownload?.id == item.id
? item
: state.currentDownload,
);
_saveQueueToStorage();
}
if (!_hasActiveDownloadProvider(item.service)) {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Download provider is no longer available',
errorType: DownloadErrorType.notFound,
);
return;
}
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
var pausedDuringThisRun = false;
@@ -4748,7 +4821,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
if (trackToDownload.id.startsWith('tidal:')) {
payloadTidalId = trackToDownload.id.substring(6);
if (item.service == 'tidal') {
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) {
payloadSpotifyId = '';
}
}
@@ -5051,7 +5124,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!wasExisting &&
isContentUriPath &&
effectiveSafMode &&
actualService == 'tidal' &&
_usesBuiltInCompatibleDownloadProvider(actualService, 'tidal') &&
filePath.endsWith('.flac') &&
(mimeType == null || mimeType.contains('flac'));
+164 -10
View File
@@ -211,6 +211,20 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
List<String> get replacesBuiltInProviders {
final value = capabilities['replacesBuiltInProviders'];
if (value is! List) return const [];
final normalized = <String>[];
for (final item in value) {
if (item is! String) continue;
final trimmed = item.trim().toLowerCase();
if (trimmed.isEmpty || normalized.contains(trimmed)) continue;
normalized.add(trimmed);
}
return normalized;
}
String? get preferredDownloadOutputExtension {
final value = capabilities['downloadOutputExtension'];
if (value is! String) return null;
@@ -743,6 +757,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
await _reconcileDefaultDownloadService();
_reconcileSearchProvider();
_log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) {
@@ -849,6 +865,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
await _reconcileDefaultDownloadService();
_reconcileSearchProvider();
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
@@ -861,16 +879,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
final availableProviders = getAllDownloadProviders();
if (availableProviders.isNotEmpty) {
final fallbackService = availableProviders.first;
ref
.read(settingsProvider.notifier)
.setDefaultService(fallbackService);
_log.d(
'Reset default service to $fallbackService because extension $extensionId was disabled',
);
}
final fallbackService =
_firstEnabledExtensionDownloadProviderId() ?? '';
ref
.read(settingsProvider.notifier)
.setDefaultService(fallbackService);
_log.d(
fallbackService.isEmpty
? 'Cleared default service because extension $extensionId was disabled'
: 'Reset default service to $fallbackService because extension $extensionId was disabled',
);
}
}
} catch (e) {
@@ -896,6 +914,142 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
_log.d('Reconciled provider priority after extension update: $sanitized');
}
String? _firstEnabledExtensionDownloadProviderId() {
return state.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.map((ext) => ext.id)
.firstOrNull;
}
String? replacedBuiltInDownloadProviderFor(String providerId) {
final normalized = providerId.trim().toLowerCase();
if (normalized.isEmpty) return null;
return state.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.replacesBuiltInProviders.contains(normalized),
)
.map((ext) => ext.id)
.firstOrNull;
}
String? replacedBuiltInSearchProviderFor(String providerId) {
final normalized = providerId.trim().toLowerCase();
if (normalized.isEmpty) return null;
return state.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.replacesBuiltInProviders.contains(normalized),
)
.map((ext) => ext.id)
.firstOrNull;
}
bool downloadProviderMatchesBuiltIn(
String providerId,
String builtInProviderId,
) {
final normalizedProvider = providerId.trim().toLowerCase();
final normalizedBuiltIn = builtInProviderId.trim().toLowerCase();
if (normalizedProvider.isEmpty || normalizedBuiltIn.isEmpty) return false;
if (normalizedProvider == normalizedBuiltIn) return true;
final extension = state.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.where((ext) => ext.id.toLowerCase() == normalizedProvider)
.firstOrNull;
return extension?.replacesBuiltInProviders.contains(normalizedBuiltIn) ??
false;
}
Future<void> _reconcileDefaultDownloadService() async {
final settings = ref.read(settingsProvider);
final preferredExtensionId = _firstEnabledExtensionDownloadProviderId();
final currentService = settings.defaultService.trim();
if (currentService.isEmpty) {
if (preferredExtensionId != null) {
ref
.read(settingsProvider.notifier)
.setDefaultService(preferredExtensionId);
_log.d(
'Adopted first enabled download extension as default service: $preferredExtensionId',
);
}
return;
}
final replacementExtensionId = replacedBuiltInDownloadProviderFor(
currentService,
);
if (replacementExtensionId != null) {
ref
.read(settingsProvider.notifier)
.setDefaultService(replacementExtensionId);
_log.d(
'Migrated retired built-in service $currentService to $replacementExtensionId',
);
return;
}
final currentExtension = state.extensions
.where((ext) => ext.id == currentService)
.firstOrNull;
final isMissingOrInvalidExtension =
currentExtension == null ||
!currentExtension.enabled ||
!currentExtension.hasDownloadProvider;
if (!isBuiltInDownloadProvider(currentService) &&
isMissingOrInvalidExtension) {
final fallbackService = preferredExtensionId ?? '';
ref.read(settingsProvider.notifier).setDefaultService(fallbackService);
_log.d(
fallbackService.isEmpty
? 'Cleared default service because $currentService is no longer available'
: 'Reset default service to $fallbackService because $currentService is no longer available',
);
}
}
void _reconcileSearchProvider() {
final settings = ref.read(settingsProvider);
final currentSearchProvider = settings.searchProvider?.trim();
if (currentSearchProvider == null || currentSearchProvider.isEmpty) {
return;
}
final replacementExtensionId = replacedBuiltInSearchProviderFor(
currentSearchProvider,
);
if (replacementExtensionId != null) {
ref
.read(settingsProvider.notifier)
.setSearchProvider(replacementExtensionId);
_log.d(
'Migrated retired built-in search provider $currentSearchProvider to $replacementExtensionId',
);
return;
}
final hasMatchingExtension = state.extensions.any(
(ext) =>
ext.enabled && ext.hasCustomSearch && ext.id == currentSearchProvider,
);
if (!isBuiltInSearchProvider(currentSearchProvider) &&
!hasMatchingExtension) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
_log.d(
'Cleared stale search provider because $currentSearchProvider is no longer available',
);
}
}
Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true,
}) async {
+10 -4
View File
@@ -12,13 +12,18 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 10;
const _currentMigrationVersion = 11;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
static const Set<String> _searchTabValues = {
'all',
'track',
'artist',
'album',
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -137,10 +142,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7/10: retired built-in services reset back to Tidal
// Migration 7/11: retired built-in services no longer fall back to a
// preinstalled provider.
if (state.defaultService == 'youtube' ||
state.defaultService == 'deezer') {
state = state.copyWith(defaultService: 'tidal');
state = state.copyWith(defaultService: '');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();