Files
SpotiFLAC-Mobile/lib/providers/settings_provider.dart
T
zarzet 09c31d9dba refactor(batch): share local track actions and phase settings
Use shared coordinators for queueing FLAC replacements and batch metadata enrichment from album and queue screens. Keep selection lifecycle handling in each screen and synchronize a settings snapshot once per phase.

Cover cancellation, partial failure, screen disposal, and settings changes between preview and apply.
2026-09-06 14:04:05 +07:00

901 lines
28 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/audio_format_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');
/// Startup override used to render the correct route/locale on the first
/// frame. The notifier still performs migrations and full validation after it
/// mounts.
final initialSettingsProvider = Provider<AppSettings>(
(ref) => const AppSettings(),
);
/// Set during bootstrap when the saved Android SAF tree no longer has a valid
/// persisted grant. MainShell consumes this once to block downloads until the
/// user explicitly repairs or changes the destination.
final initialSafAccessLostProvider = Provider<bool>((ref) => false);
bool hasPersistedAppSettings(SharedPreferences prefs) {
final rawSettings = prefs.getString(_settingsKey);
return rawSettings != null && rawSettings.isNotEmpty;
}
AppSettings loadBootstrapSettings(SharedPreferences prefs) {
final rawSettings = prefs.getString(_settingsKey);
if (rawSettings == null || rawSettings.isEmpty) return const AppSettings();
try {
final decoded = jsonDecode(rawSettings);
if (decoded is! Map) return const AppSettings();
return AppSettings.fromJson(Map<String, dynamic>.from(decoded));
} catch (_) {
return const AppSettings();
}
}
/// Removes values that are only valid for one OS installation while keeping
/// portable user preferences. This is used when Android restores app data into
/// a fresh package install despite backup being disabled (for example an OEM
/// device-to-device transfer).
AppSettings resetInstallationBoundSettings(AppSettings settings) {
return settings.copyWith(
downloadDirectory: '',
downloadDirectoryBookmark: '',
storageMode: 'app',
downloadTreeUri: '',
isFirstLaunch: true,
useAllFilesAccess: false,
localLibraryEnabled: false,
localLibraryPath: '',
localLibraryBookmark: '',
localLibraryAutoScan: 'off',
hasCompletedTutorial: false,
);
}
Future<void> resetRestoredInstallationSettings(SharedPreferences prefs) async {
final rawSettings = prefs.getString(_settingsKey);
if (rawSettings == null || rawSettings.isEmpty) return;
try {
final decoded = jsonDecode(rawSettings);
if (decoded is! Map) {
await prefs.remove(_settingsKey);
return;
}
final restored = AppSettings.fromJson(Map<String, dynamic>.from(decoded));
final sanitized = resetInstallationBoundSettings(restored);
await prefs.setString(_settingsKey, jsonEncode(sanitized.toJson()));
} catch (e) {
_log.w('Failed to sanitize restored settings; resetting to defaults: $e');
await prefs.remove(_settingsKey);
}
}
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> _libraryViewValues = {
'last',
'all',
'albums',
'singles',
'playlists',
};
static const Set<String> _extensionVerificationBrowserModeValues = {
'external_first',
'in_app_first',
};
static const Set<int> _embeddedCoverMaxDimensionValues = {
0,
500,
1000,
1500,
2000,
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
bool _isSavingSettings = false;
bool _saveQueued = false;
String? _pendingSettingsJson;
@override
AppSettings build() {
unawaited(_loadSettings());
return ref.read(initialSettingsProvider);
}
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,
defaultLibraryView: _normalizeDefaultLibraryView(
loaded.defaultLibraryView,
),
libraryQualityLabelMode: _normalizeLibraryQualityLabelMode(
loaded.libraryQualityLabelMode,
),
autoConvertFormat: normalizeAutoConvertFormat(
loaded.autoConvertFormat,
),
autoConvertBitrate: normalizeAutoConvertBitrate(
loaded.autoConvertBitrate,
),
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
loaded.embeddedCoverMaxDimension,
),
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({AppSettings? settings}) async {
final snapshot = settings ?? state;
if (!PlatformBridge.supportsCoreBackend) return;
try {
await PlatformBridge.setLyricsProviders(snapshot.lyricsProviders);
} catch (e) {
_log.w('Failed to sync lyrics providers to backend: $e');
}
try {
await PlatformBridge.setLyricsFetchOptions({
'include_translation_netease': snapshot.lyricsIncludeTranslationNetease,
'include_romanization_netease':
snapshot.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': snapshot.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': snapshot.lyricsAppleElrcWordSync,
'musixmatch_language': snapshot.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: false,
).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,
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
restored.embeddedCoverMaxDimension,
),
);
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 _normalizeDefaultLibraryView(String value) {
final normalized = value.trim().toLowerCase();
if (_libraryViewValues.contains(normalized)) return normalized;
return 'last';
}
String _normalizeLibraryQualityLabelMode(String value) {
return switch (value) {
AppSettings.libraryQualityLabelFileFormat =>
AppSettings.libraryQualityLabelFileFormat,
AppSettings.libraryQualityLabelBitDepthOnly =>
AppSettings.libraryQualityLabelBitDepthOnly,
AppSettings.libraryQualityLabelBitDepth =>
AppSettings.libraryQualityLabelBitDepth,
AppSettings.libraryQualityLabelBitDepthBitrate =>
AppSettings.libraryQualityLabelBitDepthBitrate,
_ => AppSettings.libraryQualityLabelBitrate,
};
}
String _normalizeExtensionVerificationBrowserMode(String value) {
final normalized = value.trim().toLowerCase();
if (_extensionVerificationBrowserModeValues.contains(normalized)) {
return normalized;
}
return 'in_app_first';
}
int _normalizeEmbeddedCoverMaxDimension(int value) {
return _embeddedCoverMaxDimensionValues.contains(value) ? value : 0;
}
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();
}
/// Atomically leaves SAF and persists a writable app-managed destination.
///
/// Keeping these fields in one update avoids an intermediate saved state
/// where app-folder mode still points at the SAF display name, or where an
/// invalid tree URI can switch the mode back to SAF.
Future<void> useAppFolderStorage(String directory) async {
state = state.copyWith(
storageMode: 'app',
downloadDirectory: directory,
downloadDirectoryBookmark: '',
downloadTreeUri: '',
);
await _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 setPlaybackNormalization(bool enabled) {
state = state.copyWith(playbackNormalization: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
}
void setEmbeddedCoverMaxDimension(int maxDimension) {
state = state.copyWith(
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
maxDimension,
),
);
_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 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 setDefaultLibraryView(String view) {
state = state.copyWith(
defaultLibraryView: _normalizeDefaultLibraryView(view),
);
_saveSettings();
}
void setLibraryQualityLabelMode(String mode) {
state = state.copyWith(
libraryQualityLabelMode: _normalizeLibraryQualityLabelMode(mode),
);
_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 setHeroAnimationsEnabled(bool enabled) {
state = state.copyWith(heroAnimationsEnabled: enabled);
_saveSettings();
}
void setForceBackdropBlur(bool enabled) {
state = state.copyWith(forceBackdropBlur: 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 setAutoConvertDownloads(bool enabled) {
state = state.copyWith(autoConvertDownloads: enabled);
_saveSettings();
}
void setAutoConvertFormat(String format) {
state = state.copyWith(
autoConvertFormat: normalizeAutoConvertFormat(format),
);
_saveSettings();
}
void setAutoConvertBitrate(String bitrate) {
state = state.copyWith(
autoConvertBitrate: normalizeAutoConvertBitrate(bitrate),
);
_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 setAllowQualityVariants(bool enabled) {
state = state.copyWith(allowQualityVariants: 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,
);