Files
SpotiFLAC-Mobile/lib/providers/settings_provider.dart
T
zarzet 1998a9300e feat(downloads): support up to 3 concurrent downloads
The Dart queue loop was already built around an activeDownloads map and
per-item progress tracking but was gated to one download at a time. Add
a concurrentDownloads setting (1-3, default 1) exposed in Download
Settings > Performance, and let the scheduler keep up to that many
items in flight. The experimental Android native worker is still
strictly sequential, so it is skipped in favor of the Dart queue while
concurrency is enabled.
2026-07-10 10:17:59 +07:00

702 lines
21 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _settingsCorruptBackupKey = 'app_settings_corrupt_backup';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 11;
const _spotifyClientSecretKey = 'spotify_client_secret';
const _retiredBuiltInProviderIds = {'deezer', 'qobuz', 'tidal', 'youtube'};
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',
'playlist',
};
static const Set<String> _extensionVerificationBrowserModeValues = {
'external_first',
'in_app_first',
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
bool _isSavingSettings = false;
bool _saveQueued = false;
String? _pendingSettingsJson;
@override
AppSettings build() {
_loadSettings();
return const AppSettings();
}
Future<void> _loadSettings() async {
final prefs = await _prefs;
final rawSettings = prefs.getString(_settingsKey);
if (rawSettings != null) {
AppSettings? loaded;
try {
final decoded = jsonDecode(rawSettings);
if (decoded is! Map) {
throw const FormatException('settings root must be a JSON object');
}
loaded = AppSettings.fromJson(Map<String, dynamic>.from(decoded));
} catch (e, stack) {
_log.e('Failed to load settings, resetting to defaults: $e', e, stack);
try {
await prefs.setString(_settingsCorruptBackupKey, rawSettings);
await prefs.remove(_settingsKey);
} catch (backupError) {
_log.w('Failed to backup corrupt settings: $backupError');
}
}
if (loaded != null) {
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
state = loaded.copyWith(
useExtensionProviders: true,
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: loaded.defaultService,
searchProvider: loaded.searchProvider,
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(
loaded.extensionVerificationBrowserMode,
),
);
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
}
await _cleanupRetiredSpotifySettings();
LogBuffer.loggingEnabled = state.enableLogging;
_syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
}
void _syncLyricsSettingsToBackend() {
unawaited(syncLyricsSettingsToBackend());
}
Future<void> syncLyricsSettingsToBackend() async {
if (!PlatformBridge.supportsCoreBackend) return;
try {
await PlatformBridge.setLyricsProviders(state.lyricsProviders);
} catch (e) {
_log.w('Failed to sync lyrics providers to backend: $e');
}
try {
await PlatformBridge.setLyricsFetchOptions({
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
});
} catch (e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
}
}
void _syncNetworkCompatibilitySettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
final compatibilityMode = state.networkCompatibilityMode;
PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode,
insecureTls: compatibilityMode,
).catchError((Object e) {
_log.w('Failed to sync network compatibility options to backend: $e');
});
PlatformBridge.setAllowPrivateNetwork(state.allowLocalNetwork).catchError((
Object e,
) {
_log.w('Failed to sync allow local network option to backend: $e');
});
}
void _syncExtensionFallbackSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setDownloadFallbackExtensionIds(
state.downloadFallbackExtensionIds,
).catchError((Object e) {
_log.w('Failed to sync extension fallback settings to backend: $e');
});
}
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf');
}
// Migration 2: existing users who already completed setup should skip tutorial
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
state = state.copyWith(hasCompletedTutorial: true);
}
if (state.lyricsProviders.contains('spotify_api')) {
final updatedProviders = state.lyricsProviders
.where((provider) => provider != 'spotify_api')
.toList();
state = state.copyWith(
lyricsProviders: updatedProviders.isEmpty
? const ['lrclib', 'apple_music']
: updatedProviders,
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
// 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);
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
}
}
Future<void> _saveSettings() async {
_pendingSettingsJson = jsonEncode(state.toJson());
if (_isSavingSettings) {
_saveQueued = true;
return;
}
_isSavingSettings = true;
try {
final prefs = await _prefs;
do {
final jsonToWrite = _pendingSettingsJson;
_saveQueued = false;
if (jsonToWrite != null) {
await prefs.setString(_settingsKey, jsonToWrite);
}
} while (_saveQueued);
} catch (e) {
_log.e('Failed to save settings: $e');
} finally {
_isSavingSettings = false;
}
}
/// Restores settings from a backup payload (the map produced by
/// [AppSettings.toJson]). Device-specific storage location fields
/// (download directory and SAF tree URI) are intentionally preserved from the
/// current device, because a SAF tree URI from another phone is not valid
/// here and would break downloads.
Future<void> restoreFromBackup(Map<String, dynamic> json) async {
final current = state;
AppSettings restored;
try {
restored = AppSettings.fromJson(Map<String, dynamic>.from(json));
} catch (e, stack) {
_log.e('Failed to parse settings from backup: $e', e, stack);
rethrow;
}
state = restored.copyWith(
// Always keep extension providers enabled (matches _loadSettings).
useExtensionProviders: true,
// Preserve this device's storage location; the backup's values point at
// the original device and would not resolve here.
downloadDirectory: current.downloadDirectory,
downloadDirectoryBookmark: current.downloadDirectoryBookmark,
storageMode: current.storageMode,
downloadTreeUri: current.downloadTreeUri,
);
await _saveSettings();
LogBuffer.loggingEnabled = state.enableLogging;
_syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
}
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return;
// A security-scoped bookmark is the source of truth for folders picked
// from Files: its stored path may legitimately point outside the app
// container, and the download flow re-resolves the real path from the
// bookmark. "Fixing" the path here would also drop the bookmark and
// silently revert the user's chosen folder.
if (state.downloadDirectoryBookmark.isNotEmpty) return;
final currentDir = state.downloadDirectory.trim();
if (currentDir.isEmpty) return;
final normalizedDir = await validateOrFixIosPath(currentDir);
if (normalizedDir == currentDir) return;
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
state = state.copyWith(
downloadDirectory: normalizedDir,
downloadDirectoryBookmark: '',
);
await _saveSettings();
}
String _normalizeSongLinkRegion(String region) {
final normalized = region.trim().toUpperCase();
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
return 'US';
}
String _normalizeDefaultSearchTab(String value) {
final normalized = value.trim().toLowerCase();
if (_searchTabValues.contains(normalized)) return normalized;
return 'all';
}
String _normalizeExtensionVerificationBrowserMode(String value) {
final normalized = value.trim().toLowerCase();
if (_extensionVerificationBrowserModeValues.contains(normalized)) {
return normalized;
}
return 'in_app_first';
}
String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
final normalized = providerId?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return providerId;
return _retiredBuiltInProviderIds.contains(normalized) ? null : providerId;
}
Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return;
state = state.copyWith(songLinkRegion: normalized);
await _saveSettings();
}
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
if (ids == null) {
return null;
}
final result = <String>[];
for (final id in ids) {
final normalized = id.trim();
if (normalized.isEmpty || result.contains(normalized)) {
continue;
}
result.add(normalized);
}
return result;
}
Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
if (storedSecret != null && storedSecret.isNotEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
}
}
void setDefaultService(String service) {
state = state.copyWith(
defaultService: _sanitizeRetiredBuiltInProviderId(service) ?? '',
);
_saveSettings();
}
void setAudioQuality(String quality) {
state = state.copyWith(audioQuality: quality);
_saveSettings();
}
void setFilenameFormat(String format) {
state = state.copyWith(filenameFormat: format);
_saveSettings();
}
void setSingleFilenameFormat(String format) {
state = state.copyWith(singleFilenameFormat: format);
_saveSettings();
}
void setDownloadDirectory(String directory, {String? iosBookmark}) {
state = state.copyWith(
downloadDirectory: directory,
downloadDirectoryBookmark: iosBookmark ?? '',
);
_saveSettings();
}
void setStorageMode(String mode) {
final normalized = mode == 'saf' ? 'saf' : 'app';
state = state.copyWith(storageMode: normalized);
_saveSettings();
}
void setDownloadTreeUri(String uri, {String? displayName}) {
final nextDisplay = displayName ?? state.downloadDirectory;
state = state.copyWith(
downloadTreeUri: uri,
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
downloadDirectory: nextDisplay,
downloadDirectoryBookmark: uri.isNotEmpty
? ''
: state.downloadDirectoryBookmark,
);
_saveSettings();
}
void setAutoFallback(bool enabled) {
state = state.copyWith(autoFallback: enabled);
_saveSettings();
}
void setEmbedLyrics(bool enabled) {
state = state.copyWith(embedLyrics: enabled);
_saveSettings();
}
void setEmbedReplayGain(bool enabled) {
state = state.copyWith(embedReplayGain: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
}
void setArtistTagMode(String mode) {
if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) {
state = state.copyWith(artistTagMode: mode);
_saveSettings();
}
}
void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode);
_saveSettings();
}
}
void setLyricsProviders(List<String> providers) {
state = state.copyWith(lyricsProviders: providers);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setLyricsIncludeTranslationNetease(bool enabled) {
state = state.copyWith(lyricsIncludeTranslationNetease: enabled);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setLyricsIncludeRomanizationNetease(bool enabled) {
state = state.copyWith(lyricsIncludeRomanizationNetease: enabled);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setLyricsMultiPersonWordByWord(bool enabled) {
state = state.copyWith(lyricsMultiPersonWordByWord: enabled);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setLyricsAppleElrcWordSync(bool enabled) {
state = state.copyWith(lyricsAppleElrcWordSync: enabled);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setMusixmatchLanguage(String languageCode) {
state = state.copyWith(
musixmatchLanguage: languageCode.trim().toLowerCase(),
);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setMaxQualityCover(bool enabled) {
state = state.copyWith(maxQualityCover: enabled);
_saveSettings();
}
void setFirstLaunchComplete() {
state = state.copyWith(isFirstLaunch: false);
_saveSettings();
}
void setCheckForUpdates(bool enabled) {
state = state.copyWith(checkForUpdates: enabled);
_saveSettings();
}
void setUpdateChannel(String channel) {
state = state.copyWith(updateChannel: channel);
_saveSettings();
}
void setHasSearchedBefore() {
if (!state.hasSearchedBefore) {
state = state.copyWith(hasSearchedBefore: true);
_saveSettings();
}
}
void setFolderOrganization(String organization) {
state = state.copyWith(folderOrganization: organization);
_saveSettings();
}
void setCreatePlaylistFolder(bool enabled) {
state = state.copyWith(createPlaylistFolder: enabled);
_saveSettings();
}
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
}
void setUsePrimaryArtistOnly(bool enabled) {
state = state.copyWith(usePrimaryArtistOnly: enabled);
_saveSettings();
}
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
}
void setHistoryFilterMode(String mode) {
state = state.copyWith(historyFilterMode: mode);
_saveSettings();
}
void setAskQualityBeforeDownload(bool enabled) {
state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings();
}
void setSearchProvider(String? provider) {
final sanitized = _sanitizeRetiredBuiltInProviderId(provider);
if (sanitized == null || sanitized.isEmpty) {
state = state.copyWith(clearSearchProvider: true);
} else {
state = state.copyWith(searchProvider: sanitized);
}
_saveSettings();
}
void setDefaultSearchTab(String tab) {
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
} else {
state = state.copyWith(homeFeedProvider: provider);
}
_saveSettings();
}
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
LogBuffer.loggingEnabled = enabled;
}
void setUseExtensionProviders(bool enabled) {
state = state.copyWith(useExtensionProviders: true);
_saveSettings();
}
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
state = state.copyWith(
downloadFallbackExtensionIds: sanitized,
clearDownloadFallbackExtensionIds:
extensionIds == null && state.downloadFallbackExtensionIds != null,
);
_saveSettings();
_syncExtensionFallbackSettingsToBackend();
}
void setSeparateSingles(bool enabled) {
state = state.copyWith(separateSingles: enabled);
_saveSettings();
}
void setAlbumFolderStructure(String structure) {
state = state.copyWith(albumFolderStructure: structure);
_saveSettings();
}
void setShowExtensionStore(bool enabled) {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();
}
void setExtensionVerificationBrowserMode(String mode) {
state = state.copyWith(
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(mode),
);
_saveSettings();
}
void setLocale(String locale) {
state = state.copyWith(locale: locale);
_saveSettings();
}
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
}
void setAutoExportFailedDownloads(bool enabled) {
state = state.copyWith(autoExportFailedDownloads: enabled);
_saveSettings();
}
void setDownloadNetworkMode(String mode) {
state = state.copyWith(downloadNetworkMode: mode);
_saveSettings();
}
void setConcurrentDownloads(int count) {
state = state.copyWith(concurrentDownloads: count.clamp(1, 3));
_saveSettings();
}
void setNetworkCompatibilityMode(bool enabled) {
state = state.copyWith(networkCompatibilityMode: enabled);
_saveSettings();
_syncNetworkCompatibilitySettingsToBackend();
}
void setAllowLocalNetwork(bool enabled) {
state = state.copyWith(allowLocalNetwork: enabled);
_saveSettings();
_syncNetworkCompatibilitySettingsToBackend();
}
void setSongLinkRegion(String region) {
final normalized = _normalizeSongLinkRegion(region);
state = state.copyWith(songLinkRegion: normalized);
_saveSettings();
}
void setNativeDownloadWorkerEnabled(bool enabled) {
state = state.copyWith(nativeDownloadWorkerEnabled: enabled);
_saveSettings();
}
void setLocalLibraryEnabled(bool enabled) {
state = state.copyWith(localLibraryEnabled: enabled);
_saveSettings();
}
void setLocalLibraryPath(String path) {
state = state.copyWith(localLibraryPath: path);
_saveSettings();
}
void setLocalLibraryBookmark(String bookmark) {
state = state.copyWith(localLibraryBookmark: bookmark);
_saveSettings();
}
void setLocalLibraryPathAndBookmark(String path, String bookmark) {
state = state.copyWith(
localLibraryPath: path,
localLibraryBookmark: bookmark,
);
_saveSettings();
}
void setLocalLibraryShowDuplicates(bool show) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
}
void setLocalLibraryAutoScan(String mode) {
state = state.copyWith(localLibraryAutoScan: mode);
_saveSettings();
}
void setTutorialComplete() {
state = state.copyWith(hasCompletedTutorial: true);
_saveSettings();
}
void setDeduplicateDownloads(bool enabled) {
state = state.copyWith(deduplicateDownloads: enabled);
_saveSettings();
}
void setSaveDownloadHistory(bool enabled) {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
void setPlayerMode(String mode) {
final normalized = mode == 'internal' ? 'internal' : 'external';
state = state.copyWith(playerMode: normalized);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
SettingsNotifier.new,
);