mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-11 02:08:44 +02:00
Merge branch 'dev'
This commit is contained in:
commit
9fda7ef596
30 files changed
+9592
-191
No files matched your search
@@ -141,6 +141,14 @@ class AboutPage extends StatelessWidget {
|
||||
title: context.l10n.aboutSpotiSaver,
|
||||
subtitle: context.l10n.aboutSpotiSaverDesc,
|
||||
onTap: () => _launchUrl('https://spotisaver.net'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: 'Paxsenix',
|
||||
subtitle:
|
||||
'Partner lyrics proxy for Apple Music and QQ Music sources',
|
||||
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -68,7 +68,9 @@ class DonatePage extends StatelessWidget {
|
||||
// Combined notice card
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
|
||||
color: colorScheme.secondaryContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
@@ -98,7 +100,8 @@ class DonatePage extends StatelessWidget {
|
||||
const SizedBox(height: 10),
|
||||
_NoticeLine(
|
||||
icon: Icons.block,
|
||||
text: 'Not selling early access, premium features, or paywalls',
|
||||
text:
|
||||
'Not selling early access, premium features, or paywalls',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -110,36 +113,40 @@ class DonatePage extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.favorite_border,
|
||||
text: 'Your support is the only way to keep this project alive',
|
||||
text:
|
||||
'Your support is the only way to keep this project alive',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 24,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
color: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
_NoticeLine(
|
||||
icon: Icons.history,
|
||||
text: 'Your name stays permanently in every version it was included in',
|
||||
text:
|
||||
'Your name stays permanently in every version it was included in',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.update,
|
||||
text: 'Supporter list is updated monthly and embedded in the app',
|
||||
text:
|
||||
'Supporter list is updated monthly and embedded in the app',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.cloud_off,
|
||||
text: 'No remote server -- everything is stored locally',
|
||||
text:
|
||||
'No remote server -- everything is stored locally',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -205,6 +212,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
_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,
|
||||
@@ -414,9 +422,9 @@ class _NoticeLine extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
@@ -279,6 +280,62 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: 'Lyrics Providers',
|
||||
subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LyricsProviderPriorityPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
@@ -1195,6 +1252,111 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
static const _providerDisplayNames = <String, String>{
|
||||
'lrclib': 'LRCLIB',
|
||||
'netease': 'Netease',
|
||||
'musixmatch': 'Musixmatch',
|
||||
'apple_music': 'Apple Music',
|
||||
'qqmusic': 'QQ Music',
|
||||
};
|
||||
|
||||
String _getLyricsProvidersSubtitle(List<String> providers) {
|
||||
if (providers.isEmpty) return 'None enabled';
|
||||
return providers
|
||||
.map((p) => _providerDisplayNames[p] ?? p)
|
||||
.join(' > ');
|
||||
}
|
||||
|
||||
String _normalizeMusixmatchLanguage(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||
}
|
||||
|
||||
void _showMusixmatchLanguagePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String currentLanguage,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController(text: currentLanguage);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Musixmatch Language',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Language code',
|
||||
hintText: 'auto / en / es / ja',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider.notifier).setMusixmatchLanguage('');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Auto'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final normalized = _normalizeMusixmatchLanguage(
|
||||
controller.text,
|
||||
);
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMusixmatchLanguage(normalized);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getTidalHighFormatLabel(String format) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
|
||||
@@ -218,6 +218,11 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
title: context.l10n.extensionDownloadProvider,
|
||||
enabled: extension.hasDownloadProvider,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.lyrics,
|
||||
title: context.l10n.extensionLyricsProvider,
|
||||
enabled: extension.hasLyricsProvider,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.manage_search,
|
||||
title: context.l10n.extensionsSearchProvider,
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
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/settings_group.dart';
|
||||
|
||||
class LyricsProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const LyricsProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LyricsProviderPriorityPage> createState() =>
|
||||
_LyricsProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _LyricsProviderPriorityPageState
|
||||
extends ConsumerState<LyricsProviderPriorityPage> {
|
||||
static const _allProviderIds = [
|
||||
'lrclib',
|
||||
'netease',
|
||||
'musixmatch',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
];
|
||||
|
||||
late List<String> _enabledProviders;
|
||||
late List<String> _initialProviders;
|
||||
bool _hasChanges = false;
|
||||
|
||||
List<String> get _disabledProviders => _allProviderIds
|
||||
.where((id) => !_enabledProviders.contains(id))
|
||||
.toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final settings = ref.read(settingsProvider);
|
||||
_enabledProviders = List.from(settings.lyricsProviders);
|
||||
_initialProviders = List.from(settings.lyricsProviders);
|
||||
}
|
||||
|
||||
void _markChanged() {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── State mutations ──
|
||||
|
||||
void _enableProvider(String id) {
|
||||
setState(() => _enabledProviders.add(id));
|
||||
_markChanged();
|
||||
}
|
||||
|
||||
void _disableProvider(String id) {
|
||||
if (_enabledProviders.length <= 1) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('At least one provider must remain enabled'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _enabledProviders.remove(id));
|
||||
_markChanged();
|
||||
}
|
||||
|
||||
// ── Save / Discard ──
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsProviders(List<String>.from(_enabledProviders));
|
||||
setState(() {
|
||||
_initialProviders = List.from(_enabledProviders);
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Lyrics provider priority saved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard changes?'),
|
||||
content:
|
||||
const Text('You have unsaved changes that will be lost.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// ── Provider metadata ──
|
||||
|
||||
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
|
||||
switch (id) {
|
||||
case 'lrclib':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'LRCLIB',
|
||||
description: 'Open-source synced lyrics database',
|
||||
icon: Icons.subtitles_outlined,
|
||||
);
|
||||
case 'netease':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Netease',
|
||||
description: 'NetEase Cloud Music (good for Asian songs)',
|
||||
icon: Icons.cloud_outlined,
|
||||
);
|
||||
case 'musixmatch':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Musixmatch',
|
||||
description: 'Largest lyrics database (multi-language)',
|
||||
icon: Icons.translate,
|
||||
);
|
||||
case 'apple_music':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Apple Music',
|
||||
description: 'Word-by-word synced lyrics (via proxy)',
|
||||
icon: Icons.music_note,
|
||||
);
|
||||
case 'qqmusic':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'QQ Music',
|
||||
description: 'QQ Music (good for Chinese songs, via proxy)',
|
||||
icon: Icons.queue_music,
|
||||
);
|
||||
default:
|
||||
return _LyricsProviderInfo(
|
||||
name: id,
|
||||
description: 'Extension provider',
|
||||
icon: Icons.extension,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Enabled provider card (reorderable)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _EnabledProviderItem extends StatelessWidget {
|
||||
final String providerId;
|
||||
final _LyricsProviderInfo info;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _EnabledProviderItem({
|
||||
super.key,
|
||||
required this.providerId,
|
||||
required this.info,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Numbered badge
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Icon
|
||||
Icon(info.icon, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
// Name + description
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Enable/disable switch
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: true,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Disabled provider card
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _DisabledProviderItem extends StatelessWidget {
|
||||
final String providerId;
|
||||
final _LyricsProviderInfo info;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _DisabledProviderItem({
|
||||
super.key,
|
||||
required this.providerId,
|
||||
required this.info,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.03),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerLow;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Opacity(
|
||||
opacity: 0.6,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Empty space aligned with numbered badge
|
||||
const SizedBox(width: 28),
|
||||
const SizedBox(width: 16),
|
||||
// Icon (muted)
|
||||
Icon(info.icon, color: colorScheme.outline),
|
||||
const SizedBox(width: 12),
|
||||
// Name + description
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Switch
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: false,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Provider info model
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _LyricsProviderInfo {
|
||||
final String name;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
|
||||
const _LyricsProviderInfo({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user