feat(settings): add extension verification browser mode preference

Let users choose whether signed-session verification opens in the
external browser or in-app browser first, with automatic fallback to
the other mode when launch fails.
This commit is contained in:
zarzet
2026-07-02 01:24:19 +07:00
parent eb36b0bb7b
commit 08c738dc69
7 changed files with 147 additions and 13 deletions
+12 -10
View File
@@ -43,14 +43,15 @@ class AppSettings {
final String singleFilenameFormat; final String singleFilenameFormat;
final String albumFolderStructure; final String albumFolderStructure;
final bool showExtensionStore; final bool showExtensionStore;
final String
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
final String locale; final String locale;
final String lyricsMode; final String lyricsMode;
final String final String
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128' tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool final bool autoExportFailedDownloads;
autoExportFailedDownloads;
final String final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final bool final bool
@@ -66,16 +67,13 @@ class AppSettings {
final String localLibraryPath; final String localLibraryPath;
final String final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool final bool localLibraryShowDuplicates;
localLibraryShowDuplicates;
final String final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly' localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool final bool hasCompletedTutorial;
hasCompletedTutorial;
final List<String> final List<String> lyricsProviders;
lyricsProviders;
final bool final bool
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease) lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
final bool final bool
@@ -90,8 +88,7 @@ class AppSettings {
final String final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0') lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
final bool final bool deduplicateDownloads;
deduplicateDownloads;
final bool saveDownloadHistory; final bool saveDownloadHistory;
final String playerMode; final String playerMode;
@@ -132,6 +129,7 @@ class AppSettings {
this.singleFilenameFormat = '{title} - {artist}', this.singleFilenameFormat = '{title} - {artist}',
this.albumFolderStructure = 'artist_album', this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, this.showExtensionStore = true,
this.extensionVerificationBrowserMode = 'external_first',
this.locale = 'system', this.locale = 'system',
this.lyricsMode = 'embed', this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320', this.tidalHighFormat = 'mp3_320',
@@ -199,6 +197,7 @@ class AppSettings {
String? singleFilenameFormat, String? singleFilenameFormat,
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
String? extensionVerificationBrowserMode,
String? locale, String? locale,
String? lyricsMode, String? lyricsMode,
String? tidalHighFormat, String? tidalHighFormat,
@@ -274,6 +273,9 @@ class AppSettings {
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat, singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
extensionVerificationBrowserMode:
extensionVerificationBrowserMode ??
this.extensionVerificationBrowserMode,
locale: locale ?? this.locale, locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode, lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
+3
View File
@@ -48,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
albumFolderStructure: albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album', json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true, showExtensionStore: json['showExtensionStore'] as bool? ?? true,
extensionVerificationBrowserMode:
json['extensionVerificationBrowserMode'] as String? ?? 'external_first',
locale: json['locale'] as String? ?? 'system', locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed', lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
@@ -125,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'singleFilenameFormat': instance.singleFilenameFormat, 'singleFilenameFormat': instance.singleFilenameFormat,
'albumFolderStructure': instance.albumFolderStructure, 'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
'locale': instance.locale, 'locale': instance.locale,
'lyricsMode': instance.lyricsMode, 'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat, 'tidalHighFormat': instance.tidalHighFormat,
@@ -2098,6 +2098,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final opened = await openPendingExtensionVerification( final opened = await openPendingExtensionVerification(
normalizedExtensionId, normalizedExtensionId,
browserMode: ref.read(settingsProvider).extensionVerificationBrowserMode,
); );
if (!opened) return false; if (!opened) return false;
+24
View File
@@ -28,6 +28,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
'album', 'album',
'playlist', 'playlist',
}; };
static const Set<String> _extensionVerificationBrowserModeValues = {
'external_first',
'in_app_first',
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -79,6 +83,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
defaultSearchTab: sanitizedDefaultSearchTab, defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: loaded.defaultService, defaultService: loaded.defaultService,
searchProvider: loaded.searchProvider, searchProvider: loaded.searchProvider,
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(
loaded.extensionVerificationBrowserMode,
),
); );
await _runMigrations(prefs); await _runMigrations(prefs);
@@ -270,6 +278,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'all'; return 'all';
} }
String _normalizeExtensionVerificationBrowserMode(String value) {
final normalized = value.trim().toLowerCase();
if (_extensionVerificationBrowserModeValues.contains(normalized)) {
return normalized;
}
return 'external_first';
}
String? _sanitizeRetiredBuiltInProviderId(String? providerId) { String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
final normalized = providerId?.trim().toLowerCase(); final normalized = providerId?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return providerId; if (normalized == null || normalized.isEmpty) return providerId;
@@ -557,6 +573,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setExtensionVerificationBrowserMode(String mode) {
state = state.copyWith(
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(mode),
);
_saveSettings();
}
void setLocale(String locale) { void setLocale(String locale) {
state = state.copyWith(locale: locale); state = state.copyWith(locale: locale);
_saveSettings(); _saveSettings();
+3
View File
@@ -686,6 +686,9 @@ class TrackNotifier extends Notifier<TrackState> {
try { try {
final opened = await openPendingExtensionVerification( final opened = await openPendingExtensionVerification(
normalizedExtensionId, normalizedExtensionId,
browserMode: ref
.read(settingsProvider)
.extensionVerificationBrowserMode,
); );
if (!opened) return false; if (!opened) return false;
@@ -75,6 +75,12 @@ class AppSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setShowExtensionStore(v), .setShowExtensionStore(v),
), ),
_VerificationBrowserModeSelector(
currentMode: settings.extensionVerificationBrowserMode,
onChanged: (mode) => ref
.read(settingsProvider.notifier)
.setExtensionVerificationBrowserMode(mode),
),
SettingsSwitchItem( SettingsSwitchItem(
icon: Icons.system_update, icon: Icons.system_update,
title: context.l10n.optionsCheckUpdates, title: context.l10n.optionsCheckUpdates,
@@ -374,6 +380,90 @@ class _UpdateChannelSelector extends StatelessWidget {
} }
} }
class _VerificationBrowserModeSelector extends StatelessWidget {
final String currentMode;
final ValueChanged<String> onChanged;
const _VerificationBrowserModeSelector({
required this.currentMode,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final normalizedMode = currentMode == 'in_app_first'
? 'in_app_first'
: 'external_first';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.open_in_browser,
color: colorScheme.onSurfaceVariant,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Verification browser',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
normalizedMode == 'external_first'
? 'Open challenges in the default browser first'
: 'Open challenges in the in-app browser first',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
_ChannelChip(
label: 'External',
isSelected: normalizedMode == 'external_first',
onTap: () => onChanged('external_first'),
),
const SizedBox(width: 8),
_ChannelChip(
label: 'In-app',
isSelected: normalizedMode == 'in_app_first',
onTap: () => onChanged('in_app_first'),
),
],
),
],
),
),
Divider(
height: 1,
thickness: 1,
indent: 56,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _ChannelChip extends StatelessWidget { class _ChannelChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
+14 -3
View File
@@ -27,7 +27,10 @@ bool _containsHttpStatusCode(String message, String code) {
message.contains('$code;'); message.contains('$code;');
} }
Future<bool> openPendingExtensionVerification(String extensionId) async { Future<bool> openPendingExtensionVerification(
String extensionId, {
String browserMode = 'external_first',
}) async {
final normalizedExtensionId = extensionId.trim(); final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false; if (normalizedExtensionId.isEmpty) return false;
@@ -41,9 +44,17 @@ Future<bool> openPendingExtensionVerification(String extensionId) async {
final uri = Uri.tryParse(authUrl); final uri = Uri.tryParse(authUrl);
if (uri == null) return false; if (uri == null) return false;
var launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView); final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first';
final firstMode = preferInApp
? LaunchMode.inAppBrowserView
: LaunchMode.externalApplication;
final fallbackMode = preferInApp
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView;
var launched = await launchUrl(uri, mode: firstMode);
if (!launched) { if (!launched) {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication); launched = await launchUrl(uri, mode: fallbackMode);
} }
if (launched) { if (launched) {