mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 21:30:23 +02:00
Merge branch 'dev'
# Conflicts: # README.md # lib/constants/app_info.dart # lib/l10n/app_localizations_ru.dart # pubspec.yaml
This commit is contained in:
@@ -149,6 +149,12 @@ class AboutPage extends StatelessWidget {
|
||||
subtitle:
|
||||
'Partner lyrics proxy for Apple Music and QQ Music sources',
|
||||
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: 'Ruubiiiii',
|
||||
description: 'Provided Qobuz & Deezer API for the project',
|
||||
githubUsername: 'Ruubiiiii',
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -763,6 +763,7 @@ class _LanguageSelector extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
@@ -165,6 +166,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>[];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
@@ -207,17 +209,39 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'laflame', colorScheme: colorScheme),
|
||||
_DonorTile(
|
||||
name: 'Elias el Autentico',
|
||||
colorScheme: colorScheme,
|
||||
showDivider: false,
|
||||
),
|
||||
if (donorNames.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.emoji_events_outlined,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No supporters yet — be the first!',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: donorNames
|
||||
.map(
|
||||
(name) =>
|
||||
_SupporterChip(name: name, colorScheme: colorScheme),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -273,6 +297,19 @@ class _DonateLinksCard extends StatelessWidget {
|
||||
url: AppInfo.githubSponsorsUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_CryptoWalletItem(
|
||||
title: 'USDT (TRC20)',
|
||||
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
|
||||
color: const Color(0xFF26A17B),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -348,55 +385,166 @@ class _DonateCardItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DonorTile extends StatelessWidget {
|
||||
final String name;
|
||||
class _CryptoWalletItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String walletAddress;
|
||||
final Color color;
|
||||
final ColorScheme colorScheme;
|
||||
final bool showDivider;
|
||||
|
||||
const _DonorTile({
|
||||
required this.name,
|
||||
const _CryptoWalletItem({
|
||||
required this.title,
|
||||
required this.walletAddress,
|
||||
required this.color,
|
||||
required this.colorScheme,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: walletAddress));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$title address copied to clipboard'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
'\$',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
walletAddress,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.copy_rounded,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _cr(String v) {
|
||||
int r = 0x1F;
|
||||
for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; }
|
||||
return r;
|
||||
}
|
||||
// Highlighted supporters (hashes of names): none for now.
|
||||
const _cv = <int>{};
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _SupporterChip({required this.name, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = _cv.contains(_cr(name));
|
||||
const goldChipColor = Color(0xFFFFF8DC);
|
||||
const goldAccentColor = Color(0xFFB8860B);
|
||||
const goldDarkChipColor = Color(0xFF3A3000);
|
||||
|
||||
final chipColor = e
|
||||
? goldChipColor
|
||||
: colorScheme.secondaryContainer;
|
||||
final accentColor = e
|
||||
? goldAccentColor
|
||||
: colorScheme.primary;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final effectiveChipColor = e && isDark
|
||||
? goldDarkChipColor
|
||||
: chipColor;
|
||||
|
||||
return Material(
|
||||
color: effectiveChipColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: e
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: accentColor.withValues(alpha: 0.4),
|
||||
width: 1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: e
|
||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||
: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: e
|
||||
? accentColor
|
||||
: colorScheme.onSecondaryContainer,
|
||||
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,207 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer'];
|
||||
static const _songLinkRegions = [
|
||||
'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',
|
||||
];
|
||||
static const _songLinkRegionNames = <String, String>{
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'FR': 'France',
|
||||
'DE': 'Germany',
|
||||
'JP': 'Japan',
|
||||
'KR': 'South Korea',
|
||||
'IN': 'India',
|
||||
'ID': 'Indonesia',
|
||||
'BR': 'Brazil',
|
||||
'MX': 'Mexico',
|
||||
'AU': 'Australia',
|
||||
'CA': 'Canada',
|
||||
'XK': 'Kosovo',
|
||||
};
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasAllFilesAccess = false;
|
||||
bool _artistFolderFiltersExpanded = false;
|
||||
@@ -261,6 +461,33 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
currentValue: settings.youtubeOpusBitrate,
|
||||
options: const [128, 256],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeOpusBitrate(value),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
currentValue: settings.youtubeMp3Bitrate,
|
||||
options: const [128, 256, 320],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeMp3Bitrate(value),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -271,73 +498,93 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.subtitles_outlined,
|
||||
title: context.l10n.optionsEmbedLyrics,
|
||||
subtitle: settings.embedMetadata
|
||||
? context.l10n.optionsEmbedLyricsSubtitle
|
||||
: 'Disabled while Embed Metadata is turned off',
|
||||
value: settings.embedLyrics,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedLyrics(value),
|
||||
showDivider: settings.embedMetadata && settings.embedLyrics,
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: 'Lyrics Providers',
|
||||
subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LyricsProviderPriorityPage(),
|
||||
if (settings.embedMetadata && settings.embedLyrics) ...[
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(
|
||||
context,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate_outlined,
|
||||
title: 'Netease: Include Translation',
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? 'Append translated lyrics when available'
|
||||
: 'Use original lyrics only',
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeTranslationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.text_fields_outlined,
|
||||
title: 'Netease: Include Romanization',
|
||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||
? 'Append romanized lyrics when available'
|
||||
: 'Disabled',
|
||||
value: settings.lyricsIncludeRomanizationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeRomanizationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
title: 'Apple/QQ Multi-Person Word-by-Word',
|
||||
subtitle: settings.lyricsMultiPersonWordByWord
|
||||
? 'Enable v1/v2 speaker and [bg:] tags'
|
||||
: 'Simplified word-by-word formatting',
|
||||
value: settings.lyricsMultiPersonWordByWord,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsMultiPersonWordByWord(value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.language_outlined,
|
||||
title: 'Musixmatch Language',
|
||||
subtitle: settings.musixmatchLanguage.isEmpty
|
||||
? 'Auto (original)'
|
||||
: settings.musixmatchLanguage.toUpperCase(),
|
||||
onTap: () => _showMusixmatchLanguagePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.musixmatchLanguage,
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: 'Lyrics Providers',
|
||||
subtitle: _getLyricsProvidersSubtitle(
|
||||
settings.lyricsProviders,
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LyricsProviderPriorityPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate_outlined,
|
||||
title: 'Netease: Include Translation',
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? 'Append translated lyrics when available'
|
||||
: 'Use original lyrics only',
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeTranslationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.text_fields_outlined,
|
||||
title: 'Netease: Include Romanization',
|
||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||
? 'Append romanized lyrics when available'
|
||||
: 'Disabled',
|
||||
value: settings.lyricsIncludeRomanizationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeRomanizationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
title: 'Apple/QQ Multi-Person Word-by-Word',
|
||||
subtitle: settings.lyricsMultiPersonWordByWord
|
||||
? 'Enable v1/v2 speaker and [bg:] tags'
|
||||
: 'Simplified word-by-word formatting',
|
||||
value: settings.lyricsMultiPersonWordByWord,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsMultiPersonWordByWord(value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.language_outlined,
|
||||
title: 'Musixmatch Language',
|
||||
subtitle: settings.musixmatchLanguage.isEmpty
|
||||
? 'Auto (original)'
|
||||
: settings.musixmatchLanguage.toUpperCase(),
|
||||
onTap: () => _showMusixmatchLanguagePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.musixmatchLanguage,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -504,6 +751,29 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
settings.downloadNetworkMode,
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.public,
|
||||
title: 'SongLink Region',
|
||||
subtitle: _getSongLinkRegionLabel(settings.songLinkRegion),
|
||||
onTap: () => _showSongLinkRegionPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.songLinkRegion,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.security_outlined,
|
||||
title: 'Network compatibility mode',
|
||||
subtitle: settings.networkCompatibilityMode
|
||||
? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'
|
||||
: 'Off: strict HTTPS certificate validation (recommended)',
|
||||
value: settings.networkCompatibilityMode,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setNetworkCompatibilityMode(value);
|
||||
},
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.file_download_outlined,
|
||||
title: context.l10n.settingsAutoExportFailed,
|
||||
@@ -603,6 +873,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -737,6 +1008,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -954,6 +1226,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1033,6 +1306,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1180,6 +1454,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getSongLinkRegionLabel(String code) {
|
||||
final normalized = code.trim().toUpperCase();
|
||||
final effective = normalized.isEmpty ? 'US' : normalized;
|
||||
final name = _songLinkRegionNames[effective];
|
||||
if (name == null) return effective;
|
||||
return '$effective - $name';
|
||||
}
|
||||
|
||||
void _showLyricsModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -1188,6 +1470,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1253,6 +1536,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
|
||||
static const _providerDisplayNames = <String, String>{
|
||||
'spotify_api': 'Spotify Lyrics API',
|
||||
'lrclib': 'LRCLIB',
|
||||
'netease': 'Netease',
|
||||
'musixmatch': 'Musixmatch',
|
||||
@@ -1262,9 +1546,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
String _getLyricsProvidersSubtitle(List<String> providers) {
|
||||
if (providers.isEmpty) return 'None enabled';
|
||||
return providers
|
||||
.map((p) => _providerDisplayNames[p] ?? p)
|
||||
.join(' > ');
|
||||
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
|
||||
}
|
||||
|
||||
String _normalizeMusixmatchLanguage(String value) {
|
||||
@@ -1272,6 +1554,68 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||
}
|
||||
|
||||
void _showYoutubeBitratePicker({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required int currentValue,
|
||||
required List<int> options,
|
||||
required void Function(int value) onSave,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(sheetContext).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final bitrate in options)
|
||||
ListTile(
|
||||
title: Text('$bitrate kbps'),
|
||||
trailing: bitrate == currentValue
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
onSave(bitrate);
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMusixmatchLanguagePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -1282,6 +1626,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1300,9 +1645,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
children: [
|
||||
Text(
|
||||
'Musixmatch Language',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
@@ -1331,7 +1676,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider.notifier).setMusixmatchLanguage('');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMusixmatchLanguage('');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Auto'),
|
||||
@@ -1378,6 +1725,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1462,6 +1810,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -1524,6 +1873,75 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showSongLinkRegionPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final normalizedCurrent = current.trim().toUpperCase();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'SongLink Region',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Used as userCountry for SongLink API lookup.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _songLinkRegions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final code = _songLinkRegions[index];
|
||||
final isSelected = code == normalizedCurrent;
|
||||
final displayName = _songLinkRegionNames[code];
|
||||
return ListTile(
|
||||
title: Text(code),
|
||||
subtitle: displayName != null ? Text(displayName) : null,
|
||||
trailing: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSongLinkRegion(code);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFolderOrganizationPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -1532,6 +1950,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -1632,16 +2051,13 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube'];
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final isExtensionService = ![
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'amazon',
|
||||
].contains(currentService);
|
||||
final isExtensionService = !builtInServiceIds.contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
@@ -1654,47 +2070,56 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.smart_display,
|
||||
label: 'YouTube',
|
||||
isSelected: effectiveService == 'youtube',
|
||||
onTap: () => onChanged('youtube'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (int i = 0; i < extensionProviders.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extensionProviders[i].displayName,
|
||||
isSelected: effectiveService == extensionProviders[i].id,
|
||||
onTap: () => onChanged(extensionProviders[i].id),
|
||||
),
|
||||
for (final extension in extensionProviders)
|
||||
_ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extension.displayName,
|
||||
isSelected: effectiveService == extension.id,
|
||||
onTap: () => onChanged(extension.id),
|
||||
),
|
||||
],
|
||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1728,38 +2153,35 @@ class _ServiceChip extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
return Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,12 +20,15 @@ class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
static final RegExp _platformExceptionPattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
|
||||
static final RegExp _platformExceptionSimplePattern =
|
||||
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
|
||||
static final RegExp _trailingNullsPattern =
|
||||
RegExp(r',\s*null\s*,\s*null\)?$');
|
||||
static final RegExp _platformExceptionPattern = RegExp(
|
||||
r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),',
|
||||
);
|
||||
static final RegExp _platformExceptionSimplePattern = RegExp(
|
||||
r'PlatformException\([^,]+,\s*(.+?),\s*null',
|
||||
);
|
||||
static final RegExp _trailingNullsPattern = RegExp(
|
||||
r',\s*null\s*,\s*null\)?$',
|
||||
);
|
||||
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
|
||||
|
||||
@override
|
||||
@@ -40,11 +43,13 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(extensionsDir, dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,67 +64,205 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
context.l10n.extensionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
title: Text(
|
||||
context.l10n.extensionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.error != null)
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
extState.error!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.extensionsProviderPrioritySection,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.extensionsInstalledSection,
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.extension_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensionsSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: extState.extensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < extState.extensions.length - 1,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ExtensionDetailPage(extensionId: ext.id),
|
||||
),
|
||||
),
|
||||
onToggle: (enabled) => ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setExtensionEnabled(ext.id, enabled),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.l10n.extensionsInstallButton),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
extState.error!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -127,131 +270,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
||||
),
|
||||
|
||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.extension_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsNoExtensionsSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (extState.extensions.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: extState.extensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < extState.extensions.length - 1,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ExtensionDetailPage(extensionId: ext.id),
|
||||
),
|
||||
),
|
||||
onToggle: (enabled) => ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setExtensionEnabled(ext.id, enabled),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(context.l10n.extensionsInstallButton),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,9 +288,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarSelectExtFile),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.snackbarSelectExtFile)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -287,12 +306,12 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
} else {
|
||||
message = _getFriendlyErrorMessage(extState.error);
|
||||
}
|
||||
|
||||
|
||||
ref.read(extensionProvider.notifier).clearError();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,9 +320,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
/// Parse error message to be more user-friendly
|
||||
String _getFriendlyErrorMessage(String? error) {
|
||||
if (error == null) return 'Failed to install extension';
|
||||
|
||||
|
||||
String message = error;
|
||||
|
||||
|
||||
if (message.contains('PlatformException')) {
|
||||
final match = _platformExceptionPattern.firstMatch(message);
|
||||
if (match != null) {
|
||||
@@ -315,10 +334,10 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message = message.replaceAll(_trailingNullsPattern, '');
|
||||
message = message.replaceAll(_leadingCommaPattern, '');
|
||||
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -359,7 +378,9 @@ class _ExtensionItem extends StatelessWidget {
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
|
||||
child:
|
||||
extension.iconPath != null &&
|
||||
extension.iconPath!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
@@ -396,7 +417,8 @@ class _ExtensionItem extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasError
|
||||
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
|
||||
? extension.errorMessage ??
|
||||
context.l10n.extensionsErrorLoading
|
||||
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
@@ -435,17 +457,16 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final hasDownloadExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasDownloadProvider);
|
||||
|
||||
|
||||
final hasDownloadExtensions = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasDownloadProvider,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasDownloadExtensions
|
||||
onTap: hasDownloadExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ProviderPriorityPage(),
|
||||
),
|
||||
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
@@ -454,8 +475,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -466,14 +487,12 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsDownloadPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasDownloadExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
color: hasDownloadExtensions ? null : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasDownloadExtensions
|
||||
hasDownloadExtensions
|
||||
? context.l10n.extensionsDownloadPrioritySubtitle
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -485,8 +504,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasDownloadExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
@@ -503,12 +522,13 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final hasMetadataExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||
|
||||
|
||||
final hasMetadataExtensions = extState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: hasMetadataExtensions
|
||||
onTap: hasMetadataExtensions
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -522,8 +542,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -534,14 +554,12 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsMetadataPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasMetadataExtensions
|
||||
? null
|
||||
: colorScheme.outline,
|
||||
color: hasMetadataExtensions ? null : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasMetadataExtensions
|
||||
hasMetadataExtensions
|
||||
? context.l10n.extensionsMetadataPrioritySubtitle
|
||||
: context.l10n.extensionsNoMetadataProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -553,8 +571,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
color: hasMetadataExtensions
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
@@ -572,32 +590,40 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
final searchProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == settings.searchProvider)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
}
|
||||
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: searchProviders.isEmpty
|
||||
? null
|
||||
: () => _showSearchProviderPicker(context, ref, settings, searchProviders),
|
||||
onTap: searchProviders.isEmpty
|
||||
? null
|
||||
: () => _showSearchProviderPicker(
|
||||
context,
|
||||
ref,
|
||||
settings,
|
||||
searchProviders,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.manage_search,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -608,14 +634,14 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
searchProviders.isEmpty
|
||||
searchProviders.isEmpty
|
||||
? context.l10n.extensionsNoCustomSearch
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -627,8 +653,8 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
@@ -646,9 +672,10 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
@@ -662,9 +689,9 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
ctx.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@@ -680,7 +707,9 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||
trailing:
|
||||
(settings.searchProvider == null ||
|
||||
settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
@@ -688,18 +717,23 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
...searchProviders.map((ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
)),
|
||||
...searchProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(
|
||||
ext.searchBehavior?.placeholder ??
|
||||
ctx.l10n.extensionsCustomSearch,
|
||||
),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -656,14 +656,8 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isScanning
|
||||
? context.l10n
|
||||
.libraryTracksCount(scannedFiles)
|
||||
.replaceAll(scannedFiles.toString(), '')
|
||||
.trim()
|
||||
: context.l10n
|
||||
.libraryTracksCount(displayCount)
|
||||
.replaceAll(displayCount.toString(), '')
|
||||
.trim(),
|
||||
? context.l10n.libraryTracksUnit(scannedFiles)
|
||||
: context.l10n.libraryTracksUnit(displayCount),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LyricsProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
@@ -16,6 +16,7 @@ class _LyricsProviderPriorityPageState
|
||||
extends ConsumerState<LyricsProviderPriorityPage> {
|
||||
static const _allProviderIds = [
|
||||
'lrclib',
|
||||
'spotify_api',
|
||||
'netease',
|
||||
'musixmatch',
|
||||
'apple_music',
|
||||
@@ -26,9 +27,8 @@ class _LyricsProviderPriorityPageState
|
||||
late List<String> _initialProviders;
|
||||
bool _hasChanges = false;
|
||||
|
||||
List<String> get _disabledProviders => _allProviderIds
|
||||
.where((id) => !_enabledProviders.contains(id))
|
||||
.toList();
|
||||
List<String> get _disabledProviders =>
|
||||
_allProviderIds.where((id) => !_enabledProviders.contains(id)).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,204 +39,86 @@ class _LyricsProviderPriorityPageState
|
||||
}
|
||||
|
||||
void _markChanged() {
|
||||
final changed = _enabledProviders.length != _initialProviders.length ||
|
||||
!_enabledProviders
|
||||
.asMap()
|
||||
.entries
|
||||
.every((e) =>
|
||||
e.key < _initialProviders.length &&
|
||||
_initialProviders[e.key] == e.value);
|
||||
final changed =
|
||||
_enabledProviders.length != _initialProviders.length ||
|
||||
!_enabledProviders.asMap().entries.every(
|
||||
(e) =>
|
||||
e.key < _initialProviders.length &&
|
||||
_initialProviders[e.key] == e.value,
|
||||
);
|
||||
setState(() => _hasChanges = changed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final disabled = _disabledProviders;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// ── Collapsing App Bar ──
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding:
|
||||
EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Lyrics Providers',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
return PrioritySettingsScaffold(
|
||||
hasChanges: _hasChanges,
|
||||
title: 'Lyrics Providers',
|
||||
description:
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.',
|
||||
infoText:
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.',
|
||||
onSave: _saveChanges,
|
||||
onConfirmDiscard: _confirmDiscard,
|
||||
slivers: [
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Enabled (${_enabledProviders.length})',
|
||||
),
|
||||
|
||||
// ── Description ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Text(
|
||||
'Enable, disable and reorder lyrics sources. '
|
||||
'Providers are tried top-to-bottom until lyrics are found.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _enabledProviders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = _enabledProviders[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _EnabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
onToggle: () => _disableProvider(id),
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _enabledProviders.removeAt(oldIndex);
|
||||
_enabledProviders.insert(newIndex, item);
|
||||
});
|
||||
_markChanged();
|
||||
},
|
||||
),
|
||||
|
||||
// ── Enabled section header ──
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Enabled (${_enabledProviders.length})',
|
||||
),
|
||||
),
|
||||
|
||||
// ── Reorderable enabled list ──
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _enabledProviders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = _enabledProviders[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _EnabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
onToggle: () => _disableProvider(id),
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _enabledProviders.removeAt(oldIndex);
|
||||
_enabledProviders.insert(newIndex, item);
|
||||
});
|
||||
_markChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Disabled section header ──
|
||||
if (disabled.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Disabled (${disabled.length})',
|
||||
),
|
||||
),
|
||||
|
||||
// ── Disabled list ──
|
||||
if (disabled.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final id = disabled[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _DisabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
onToggle: () => _enableProvider(id),
|
||||
);
|
||||
},
|
||||
childCount: disabled.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Info banner ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline,
|
||||
size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Extension lyrics providers always run before '
|
||||
'built-in providers. At least one provider must '
|
||||
'remain enabled.',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (disabled.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Disabled (${disabled.length})',
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (disabled.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final id = disabled[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _DisabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
onToggle: () => _enableProvider(id),
|
||||
);
|
||||
}, childCount: disabled.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,8 +164,7 @@ class _LyricsProviderPriorityPageState
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard changes?'),
|
||||
content:
|
||||
const Text('You have unsaved changes that will be lost.'),
|
||||
content: const Text('You have unsaved changes that will be lost.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -303,6 +184,12 @@ class _LyricsProviderPriorityPageState
|
||||
|
||||
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
|
||||
switch (id) {
|
||||
case 'spotify_api':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Spotify Lyrics API',
|
||||
description: 'Spotify-sourced synced lyrics via community API',
|
||||
icon: Icons.music_note_outlined,
|
||||
);
|
||||
case 'lrclib':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'LRCLIB',
|
||||
@@ -419,17 +306,15 @@ class _EnabledProviderItem extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -438,18 +323,12 @@ class _EnabledProviderItem extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: true,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
child: Switch(value: true, onChanged: (_) => onToggle()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -498,8 +377,7 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Empty space aligned with numbered badge
|
||||
@@ -515,9 +393,7 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -525,12 +401,8 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -539,10 +411,7 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: false,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
child: Switch(value: false, onChanged: (_) => onToggle()),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,16 +2,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const MetadataProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MetadataProviderPriorityPage> createState() => _MetadataProviderPriorityPageState();
|
||||
ConsumerState<MetadataProviderPriorityPage> createState() =>
|
||||
_MetadataProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderPriorityPage> {
|
||||
class _MetadataProviderPriorityPageState
|
||||
extends ConsumerState<MetadataProviderPriorityPage> {
|
||||
late List<String> _providers;
|
||||
bool _hasChanges = false;
|
||||
|
||||
@@ -23,8 +25,10 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
||||
|
||||
final allProviders = ref
|
||||
.read(extensionProvider.notifier)
|
||||
.getAllMetadataProviders();
|
||||
|
||||
if (extState.metadataProviderPriority.isNotEmpty) {
|
||||
_providers = List.from(extState.metadataProviderPriority);
|
||||
for (final provider in allProviders) {
|
||||
@@ -40,142 +44,43 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
context.l10n.metadataProviderPriorityTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.l10n.metadataProviderPriorityDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _MetadataProviderItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.metadataProviderPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
return PrioritySettingsScaffold(
|
||||
hasChanges: _hasChanges,
|
||||
title: context.l10n.metadataProviderPriorityTitle,
|
||||
description: context.l10n.metadataProviderPriorityDescription,
|
||||
descriptionPadding: const EdgeInsets.all(16),
|
||||
infoText: context.l10n.metadataProviderPriorityInfo,
|
||||
saveLabel: context.l10n.dialogSave,
|
||||
onSave: _saveChanges,
|
||||
onConfirmDiscard: _confirmDiscard,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = _providers[index];
|
||||
return _MetadataProviderItem(
|
||||
key: ValueKey(provider),
|
||||
provider: provider,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,7 +106,9 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers);
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.setMetadataProviderPriority(_providers);
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
@@ -300,10 +207,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -312,7 +216,10 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) {
|
||||
_MetadataProviderInfo _getProviderInfo(
|
||||
BuildContext context,
|
||||
String provider,
|
||||
) {
|
||||
switch (provider) {
|
||||
case 'deezer':
|
||||
return _MetadataProviderInfo(
|
||||
|
||||
@@ -164,11 +164,24 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseExtensionProviders(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.sell_outlined,
|
||||
title: 'Embed Metadata',
|
||||
subtitle: settings.embedMetadata
|
||||
? 'Write metadata, cover art, and embedded lyrics to files'
|
||||
: 'Disabled (advanced): skip all metadata embedding',
|
||||
value: settings.embedMetadata,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.image,
|
||||
title: context.l10n.optionsMaxQualityCover,
|
||||
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
|
||||
subtitle: settings.embedMetadata
|
||||
? context.l10n.optionsMaxQualityCoverSubtitle
|
||||
: 'Disabled when metadata embedding is off',
|
||||
value: settings.maxQualityCover,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMaxQualityCover(v),
|
||||
@@ -375,6 +388,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -972,9 +986,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.optionsSpotifyDeprecationWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -8,7 +8,8 @@ class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
|
||||
ConsumerState<ProviderPriorityPage> createState() =>
|
||||
_ProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
@@ -23,8 +24,10 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
|
||||
void _loadProviders() {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
||||
|
||||
final allProviders = ref
|
||||
.read(extensionProvider.notifier)
|
||||
.getAllDownloadProviders();
|
||||
|
||||
if (extState.providerPriority.isNotEmpty) {
|
||||
_providers = List.from(extState.providerPriority);
|
||||
for (final provider in allProviders) {
|
||||
@@ -86,13 +89,17 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.providerPriorityTitle,
|
||||
style: TextStyle(
|
||||
@@ -156,14 +163,19 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.providerPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -292,7 +304,9 @@ class _ProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
|
||||
info.isBuiltIn
|
||||
? context.l10n.providerBuiltIn
|
||||
: context.l10n.providerExtension,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -300,10 +314,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -321,17 +332,19 @@ class _ProviderItem extends StatelessWidget {
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _ProviderInfo(
|
||||
name: 'Qobuz',
|
||||
icon: Icons.album,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
|
||||
case 'amazon':
|
||||
return _ProviderInfo(
|
||||
name: 'Amazon Music',
|
||||
icon: Icons.shopping_bag,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'youtube':
|
||||
return _ProviderInfo(
|
||||
name: 'YouTube',
|
||||
icon: Icons.play_circle_outline,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
|
||||
Reference in New Issue
Block a user