Files
SpotiFLAC-Mobile/lib/screens/settings/download_settings_page.dart
T

1233 lines
38 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget {
const DownloadSettingsPage({super.key});
@override
ConsumerState<DownloadSettingsPage> createState() =>
_DownloadSettingsPageState();
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final extensionState = ref.watch(extensionProvider);
final hasDownloadExtensions = extensionState.extensions.any(
(extension) => extension.enabled && extension.hasDownloadProvider,
);
final selectedDownloadService = resolveEffectiveDownloadService(
settings.defaultService,
extensionState,
);
final selectedDownloadExtension = extensionState.extensions
.where(
(extension) =>
extension.enabled &&
extension.hasDownloadProvider &&
extension.id == selectedDownloadService,
)
.firstOrNull;
final qualityOptions =
selectedDownloadExtension?.qualityOptions ?? const <QualityOption>[];
final canSelectQuality = qualityOptions.isNotEmpty;
final usesTidalCompatibilityOptions = selectedDownloadService.isNotEmpty
? ref
.read(extensionProvider.notifier)
.downloadProviderReplacesLegacyProvider(
selectedDownloadService,
'tidal',
)
: false;
final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions;
return PopScope(
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
AppSliverHeader.page(title: context.l10n.settingsDownload),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionService),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ServiceSelector(
currentService: settings.defaultService,
onChanged: (service) => ref
.read(settingsProvider.notifier)
.setDefaultService(service),
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.sectionAudioQuality,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: context.l10n.downloadAskBeforeDownload,
subtitle: !hasDownloadExtensions
? context.l10n.extensionsNoDownloadProvider
: canSelectQuality
? context.l10n.downloadAskQualitySubtitle
: context.l10n.downloadSelectServiceToEnable,
value: settings.askQualityBeforeDownload,
enabled: hasDownloadExtensions && canSelectQuality,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
if (!settings.askQualityBeforeDownload &&
canSelectQuality) ...[
for (final quality in qualityOptions)
_QualityOption(
title: _localizedQualityLabel(context, quality),
subtitle: _localizedQualityDescription(
context,
quality,
),
icon: _qualityIcon(quality.id),
isSelected: settings.audioQuality == quality.id,
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality(quality.id),
showDivider:
quality != qualityOptions.last ||
(usesTidalCompatibilityOptions &&
settings.audioQuality == 'HIGH'),
),
if (usesTidalCompatibilityOptions &&
settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: context.l10n.downloadLossyFormat,
subtitle: _getLossyCompatibilityFormatLabel(
context,
settings.tidalHighFormat,
),
onTap: () => _showLossyCompatibilityFormatPicker(
context,
ref,
settings.tidalHighFormat,
),
showDivider: false,
),
],
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.sectionPerformance,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.wifi,
title: context.l10n.settingsDownloadNetwork,
subtitle: settings.downloadNetworkMode == 'wifi_only'
? context.l10n.settingsDownloadNetworkWifiOnly
: context.l10n.settingsDownloadNetworkAny,
onTap: () => _showNetworkModePicker(
context,
ref,
settings.downloadNetworkMode,
),
),
SettingsItem(
icon: Icons.dynamic_feed_outlined,
title: context.l10n.settingsConcurrentDownloads,
subtitle: settings.concurrentDownloads <= 1
? context.l10n.concurrentDownloadsOne
: context.l10n.concurrentDownloadsCount(
settings.concurrentDownloads,
),
onTap: () => _showConcurrentDownloadsPicker(
context,
ref,
settings.concurrentDownloads,
),
),
if (Platform.isAndroid)
SettingsSwitchItem(
icon: Icons.downloading_outlined,
title: context.l10n.downloadNativeWorker,
subtitle: hasDownloadExtensions
? context.l10n.downloadNativeWorkerSubtitle
: context.l10n.extensionsNoDownloadProvider,
value:
settings.nativeDownloadWorkerEnabled &&
nativeWorkerAvailable,
enabled: nativeWorkerAvailable,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setNativeDownloadWorkerEnabled(value),
),
SettingsSwitchItem(
icon: Icons.security_outlined,
title: context.l10n.downloadNetworkCompatibilityMode,
subtitle: settings.networkCompatibilityMode
? context.l10n.downloadNetworkCompatibilityModeEnabled
: context.l10n.downloadNetworkCompatibilityModeDisabled,
value: settings.networkCompatibilityMode,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setNetworkCompatibilityMode(value),
),
SettingsSwitchItem(
icon: Icons.lan_outlined,
title: context.l10n.downloadAllowLocalNetwork,
subtitle: settings.allowLocalNetwork
? context.l10n.downloadAllowLocalNetworkEnabled
: context.l10n.downloadAllowLocalNetworkDisabled,
value: settings.allowLocalNetwork,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAllowLocalNetwork(value),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.sectionSearchSource,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
const _MetadataSourceSelector(),
const _DefaultSearchTabSelector(),
SettingsSwitchItem(
icon: Icons.sync,
title: context.l10n.optionsAutoFallback,
subtitle: context.l10n.optionsAutoFallbackSubtitle,
value: settings.autoFallback,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setAutoFallback(v),
),
SettingsItem(
icon: Icons.extension_outlined,
title: context.l10n.downloadFallbackExtensions,
subtitle: context.l10n.downloadFallbackExtensionsSubtitle,
onTap: () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const DownloadFallbackExtensionsPage(),
),
),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.public,
title: context.l10n.downloadSongLinkRegion,
subtitle: _getSongLinkRegionLabel(
context,
settings.songLinkRegion,
),
onTap: () => _showSongLinkRegionPicker(
context,
ref,
settings.songLinkRegion,
),
),
SettingsSwitchItem(
icon: Icons.file_download_outlined,
title: context.l10n.settingsAutoExportFailed,
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
value: settings.autoExportFailedDownloads,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAutoExportFailedDownloads(value),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
String _regionCountryName(BuildContext context, String code) {
final l10n = context.l10n;
switch (code) {
case 'US':
return l10n.regionCountryUS;
case 'GB':
return l10n.regionCountryGB;
case 'FR':
return l10n.regionCountryFR;
case 'DE':
return l10n.regionCountryDE;
case 'JP':
return l10n.regionCountryJP;
case 'KR':
return l10n.regionCountryKR;
case 'IN':
return l10n.regionCountryIN;
case 'ID':
return l10n.regionCountryID;
case 'BR':
return l10n.regionCountryBR;
case 'MX':
return l10n.regionCountryMX;
case 'AU':
return l10n.regionCountryAU;
case 'CA':
return l10n.regionCountryCA;
case 'XK':
return l10n.regionCountryXK;
default:
return code;
}
}
String _getSongLinkRegionLabel(BuildContext context, String code) {
final normalized = code.trim().toUpperCase();
final effective = normalized.isEmpty ? 'US' : normalized;
final name = _regionCountryName(context, effective);
return name == effective ? effective : '$effective - $name';
}
IconData _qualityIcon(String qualityId) {
final normalized = qualityId.toUpperCase();
if (normalized.startsWith('MP3_') || normalized == 'MP3') {
return Icons.audiotrack;
}
if (normalized.startsWith('OPUS_') || normalized == 'OPUS') {
return Icons.graphic_eq;
}
switch (normalized) {
case 'HI_RES_LOSSLESS':
return Icons.four_k;
case 'HI_RES':
return Icons.high_quality;
case 'LOSSLESS':
return Icons.music_note;
default:
return Icons.music_note;
}
}
String _localizedQualityLabel(BuildContext context, QualityOption quality) {
switch (quality.id.toUpperCase()) {
case 'LOSSLESS':
return context.l10n.qualityFlacLossless;
case 'HI_RES':
return context.l10n.qualityHiResFlac;
case 'HI_RES_LOSSLESS':
return context.l10n.qualityHiResFlacMax;
case 'HIGH':
return context.l10n.downloadLossy320;
default:
return quality.label;
}
}
String _localizedQualityDescription(
BuildContext context,
QualityOption quality,
) {
switch (quality.id.toUpperCase()) {
case 'LOSSLESS':
return context.l10n.qualityFlacLosslessSubtitle;
case 'HI_RES':
return context.l10n.qualityHiResFlacSubtitle;
case 'HI_RES_LOSSLESS':
return context.l10n.qualityHiResFlacMaxSubtitle;
case 'HIGH':
return _getLossyCompatibilityFormatLabel(
context,
ref.read(settingsProvider).tidalHighFormat,
);
default:
return quality.description ?? '';
}
}
String _getLossyCompatibilityFormatLabel(
BuildContext context,
String format,
) {
switch (format) {
case 'mp3_320':
return context.l10n.downloadLossyMp3;
case 'aac_320':
return context.l10n.downloadLossyAac;
case 'opus_256':
return context.l10n.downloadLossyOpus256;
case 'opus_128':
return context.l10n.downloadLossyOpus128;
default:
return context.l10n.downloadLossyMp3;
}
}
void _showLossyCompatibilityFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadLossy320Format,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadLossy320FormatDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: Text(context.l10n.downloadLossyMp3),
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
trailing: current == 'mp3_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: Text(context.l10n.downloadLossyAac),
subtitle: Text(context.l10n.downloadLossyAacSubtitle),
trailing: current == 'aac_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('aac_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus256),
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
trailing: current == 'opus_256'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus128),
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
trailing: current == 'opus_128'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showNetworkModePicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.settingsDownloadNetwork,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.settingsDownloadNetworkSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.signal_cellular_alt),
title: Text(context.l10n.settingsDownloadNetworkAny),
subtitle: Text(context.l10n.downloadNetworkAnySubtitle),
trailing: current == 'any'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setDownloadNetworkMode('any');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.wifi),
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
subtitle: Text(context.l10n.downloadNetworkWifiOnlySubtitle),
trailing: current == 'wifi_only'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setDownloadNetworkMode('wifi_only');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showConcurrentDownloadsPicker(
BuildContext context,
WidgetRef ref,
int current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.settingsConcurrentDownloads,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.settingsConcurrentDownloadsSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
for (final count in const [1, 2, 3])
ListTile(
leading: Icon(
count == 1
? Icons.looks_one_outlined
: count == 2
? Icons.looks_two_outlined
: Icons.looks_3_outlined,
),
title: Text(
count == 1
? context.l10n.concurrentDownloadsOne
: context.l10n.concurrentDownloadsCount(count),
),
trailing: current == count
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setConcurrentDownloads(count);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showSongLinkRegionPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
const regions = [
'AD',
'AE',
'AG',
'AL',
'AM',
'AO',
'AR',
'AT',
'AU',
'AZ',
'BA',
'BB',
'BD',
'BE',
'BF',
'BG',
'BH',
'BI',
'BJ',
'BN',
'BO',
'BR',
'BS',
'BT',
'BW',
'BZ',
'CA',
'CD',
'CG',
'CH',
'CI',
'CL',
'CM',
'CO',
'CR',
'CV',
'CW',
'CY',
'CZ',
'DE',
'DJ',
'DK',
'DM',
'DO',
'DZ',
'EC',
'EE',
'EG',
'ES',
'ET',
'FI',
'FJ',
'FM',
'FR',
'GA',
'GB',
'GD',
'GE',
'GH',
'GM',
'GN',
'GQ',
'GR',
'GT',
'GW',
'GY',
'HK',
'HN',
'HR',
'HT',
'HU',
'ID',
'IE',
'IL',
'IN',
'IQ',
'IS',
'IT',
'JM',
'JO',
'JP',
'KE',
'KG',
'KH',
'KI',
'KM',
'KN',
'KR',
'KW',
'KZ',
'LA',
'LB',
'LC',
'LI',
'LK',
'LR',
'LS',
'LT',
'LU',
'LV',
'LY',
'MA',
'MC',
'MD',
'ME',
'MG',
'MH',
'MK',
'ML',
'MN',
'MO',
'MR',
'MT',
'MU',
'MV',
'MW',
'MX',
'MY',
'MZ',
'NA',
'NE',
'NG',
'NI',
'NL',
'NO',
'NP',
'NR',
'NZ',
'OM',
'PA',
'PE',
'PG',
'PH',
'PK',
'PL',
'PS',
'PT',
'PW',
'PY',
'QA',
'RO',
'RS',
'RW',
'SA',
'SB',
'SC',
'SE',
'SG',
'SI',
'SK',
'SL',
'SM',
'SN',
'SR',
'ST',
'SV',
'SZ',
'TD',
'TG',
'TH',
'TJ',
'TL',
'TN',
'TO',
'TR',
'TT',
'TV',
'TW',
'TZ',
'UA',
'UG',
'US',
'UY',
'UZ',
'VC',
'VE',
'VN',
'VU',
'WS',
'XK',
'ZA',
'ZM',
'ZW',
];
final colorScheme = Theme.of(context).colorScheme;
final normalizedCurrent = current.trim().toUpperCase();
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
builder: (context) => _RegionPickerSheet(
regions: regions,
selected: normalizedCurrent,
countryNameOf: (context, code) => _regionCountryName(context, code),
onSelected: (code) {
ref.read(settingsProvider.notifier).setSongLinkRegion(code);
Navigator.pop(context);
},
),
);
}
}
/// Searchable region list.
///
/// The picker previously rendered ~190 ISO codes as a flat list with no filter,
/// so finding a country meant scrolling blind — only a handful of codes have a
/// localized name to recognize them by.
class _RegionPickerSheet extends StatefulWidget {
const _RegionPickerSheet({
required this.regions,
required this.selected,
required this.countryNameOf,
required this.onSelected,
});
final List<String> regions;
final String selected;
final String Function(BuildContext context, String code) countryNameOf;
final ValueChanged<String> onSelected;
@override
State<_RegionPickerSheet> createState() => _RegionPickerSheetState();
}
class _RegionPickerSheetState extends State<_RegionPickerSheet> {
final TextEditingController _searchController = TextEditingController();
String _query = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final query = _query.trim().toLowerCase();
final matches = query.isEmpty
? widget.regions
: widget.regions.where((code) {
if (code.toLowerCase().contains(query)) return true;
return widget
.countryNameOf(context, code)
.toLowerCase()
.contains(query);
}).toList();
return SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.7,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadSongLinkRegion,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.downloadSongLinkRegionDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
controller: _searchController,
autofocus: false,
textInputAction: TextInputAction.search,
onChanged: (value) => setState(() => _query = value),
decoration: InputDecoration(
hintText: MaterialLocalizations.of(context).searchFieldLabel,
prefixIcon: const Icon(Icons.search),
suffixIcon: _query.isEmpty
? null
: IconButton(
tooltip: context.l10n.dialogClear,
icon: const Icon(Icons.close),
onPressed: () {
_searchController.clear();
setState(() => _query = '');
},
),
),
),
),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
context.l10n.searchEmptyResultSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: matches.length,
itemBuilder: (context, index) {
final code = matches[index];
final isSelected = code == widget.selected;
final countryName = widget.countryNameOf(context, code);
return ListTile(
title: Text(countryName != code ? countryName : code),
subtitle: countryName != code ? Text(code) : null,
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () => widget.onSelected(code),
);
},
),
),
],
),
),
);
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final bool isSelected;
final VoidCallback onTap;
final bool showDivider;
const _QualityOption({
required this.title,
required this.subtitle,
required this.icon,
required this.isSelected,
required this.onTap,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SettingsItem(
icon: icon,
title: title,
subtitle: subtitle,
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: onTap,
showDivider: showDivider,
);
}
}
class _ServiceSelector extends ConsumerWidget {
final String currentService;
final ValueChanged<String> onChanged;
const _ServiceSelector({
required this.currentService,
required this.onChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final extState = ref.watch(extensionProvider);
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
.toList();
final effectiveService =
extensionProviders.any((extension) => extension.id == currentService)
? currentService
: '';
return Padding(
padding: const EdgeInsets.all(12),
child: extensionProviders.isEmpty
? Row(
children: [
Icon(
Icons.info_outline,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
)
: LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final columns = (constraints.maxWidth / 320).floor().clamp(
2,
4,
);
final chipWidth =
(constraints.maxWidth - spacing * (columns - 1)) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final extension in extensionProviders)
SizedBox(
width: chipWidth,
child: SettingsChoiceChip(
layout: SettingsChipLayout.column,
icon: Icons.extension,
label: extension.displayName,
isSelected: effectiveService == extension.id,
onTap: () => onChanged(extension.id),
),
),
],
);
},
),
);
}
}
class _MetadataSourceSelector extends ConsumerWidget {
const _MetadataSourceSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final rawSearchProvider = settings.searchProvider?.trim() ?? '';
final primarySearchExtension = defaultSearchExtension(extState.extensions);
final defaultProviderTarget =
primarySearchExtension?.displayName ??
context.l10n.extensionsNoCustomSearch;
final defaultProviderLabel =
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final searchProvider =
extState.extensions.any(
(e) => e.enabled && e.hasCustomSearch && e.id == rawSearchProvider,
)
? rawSearchProvider
: '';
Extension? activeExtension;
if (searchProvider.isNotEmpty) {
activeExtension = extState.extensions
.where((e) => e.id == searchProvider && e.enabled)
.firstOrNull;
}
final hasNonDefaultProvider = activeExtension != null;
String subtitle;
if (activeExtension != null) {
subtitle = context.l10n.optionsUsingExtension(
activeExtension.displayName,
);
} else {
subtitle = context.l10n.optionsPrimaryProviderSubtitle;
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.optionsPrimaryProvider,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasNonDefaultProvider
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
SettingsChoiceGrid(
children: [
SettingsChoiceChip(
icon: Icons.auto_awesome,
label: defaultProviderLabel,
isSelected: searchProvider.isEmpty,
onTap: () =>
ref.read(settingsProvider.notifier).setSearchProvider(''),
),
for (final ext in extState.extensions.where(
(e) => e.enabled && e.hasCustomSearch,
))
SettingsChoiceChip(
icon: Icons.extension,
label: ext.displayName,
isSelected: searchProvider == ext.id,
onTap: () => ref
.read(settingsProvider.notifier)
.setSearchProvider(ext.id),
),
],
),
],
),
);
}
}
class _DefaultSearchTabSelector extends ConsumerWidget {
const _DefaultSearchTabSelector();
String _labelForTab(BuildContext context, String tab) {
return switch (tab) {
'track' => context.l10n.searchTracks,
'artist' => context.l10n.searchArtists,
'album' => context.l10n.searchAlbums,
'playlist' => context.l10n.searchPlaylists,
_ => context.l10n.historyFilterAll,
};
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final current = settings.defaultSearchTab;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.optionsDefaultSearchTab,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
context.l10n.optionsDefaultSearchTabSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
SettingsChoiceGrid(
children: [
for (final tab in const [
'all',
'track',
'artist',
'album',
'playlist',
])
SettingsChoiceChip(
icon: switch (tab) {
'track' => Icons.music_note,
'artist' => Icons.person,
'album' => Icons.album,
'playlist' => Icons.queue_music,
_ => Icons.grid_view,
},
label: _labelForTab(context, tab),
isSelected: current == tab,
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab(tab),
),
],
),
],
),
);
}
}