mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
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:
1 parent
6895e45f2c
commit
16ce6089fb
49 files changed
+4928
-3384
No files matched your search
@@ -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'));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user