feat: add extension system with skipBuiltInFallback support

- Add extension manager, manifest, runtime, providers, settings
- Add extension provider and UI pages (extensions, detail, priority)
- Add download service picker widget
- Add metadata provider priority page
- Add source field to Track model for extension tracking
- Add skipBuiltInFallback manifest option to skip built-in providers
- Update download queue to use source extension first
- Add extension upgrade support without data loss
This commit is contained in:
zarzet committed 2026-01-12 00:17:52 +07:00
1 parent 24ef66be4c
commit 57cb575483
45 files changed
+10491 -1018

No files matched your search

@@ -8,12 +8,18 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerWidget {
const DownloadSettingsPage({super.key});
// Built-in services that support quality options
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
// Check if current service is built-in (supports quality options)
final isBuiltInService = _builtInServices.contains(settings.defaultService);
return PopScope(
canPop: true,
@@ -87,13 +93,17 @@ class DownloadSettingsPage extends ConsumerWidget {
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
subtitle: 'Choose quality for each download',
subtitle: isBuiltInService
? 'Choose quality for each download'
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
// Not selected visually if extension is active
enabled: isBuiltInService,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
if (!settings.askQualityBeforeDownload) ...[
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
@@ -120,6 +130,29 @@ class DownloadSettingsPage extends ConsumerWidget {
showDivider: false,
),
],
if (!isBuiltInService) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Select Tidal, Qobuz, or Amazon above to configure quality',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
],
),
),
@@ -359,8 +392,9 @@ class DownloadSettingsPage extends ConsumerWidget {
} else {
// Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath();
if (result != null)
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
}
@@ -0,0 +1,964 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget {
final String extensionId;
const ExtensionDetailPage({super.key, required this.extensionId});
@override
ConsumerState<ExtensionDetailPage> createState() => _ExtensionDetailPageState();
}
class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
Map<String, dynamic> _settings = {};
bool _isLoadingSettings = true;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final settings = await ref
.read(extensionProvider.notifier)
.getExtensionSettings(widget.extensionId);
setState(() {
_settings = settings;
_isLoadingSettings = false;
});
}
@override
Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider);
final extension = extState.extensions.firstWhere(
(e) => e.id == widget.extensionId,
orElse: () => const Extension(
id: '',
name: '',
displayName: 'Unknown',
version: '0.0.0',
author: 'Unknown',
description: '',
enabled: false,
status: 'error',
),
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final hasError = extension.status == 'error';
return Scaffold(
body: CustomScrollView(
slivers: [
// 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: () => 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(
extension.displayName,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Extension Info Card
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: hasError
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(extension.iconPath!),
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
hasError ? Icons.error_outline : Icons.extension,
size: 28,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
),
)
: Icon(
hasError ? Icons.error_outline : Icons.extension,
size: 28,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
extension.displayName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'v${extension.version}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Switch(
value: extension.enabled,
onChanged: hasError
? null
: (enabled) => ref
.read(extensionProvider.notifier)
.setExtensionEnabled(widget.extensionId, enabled),
),
],
),
if (extension.description.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
extension.description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 16),
_InfoRow(label: 'Author', value: extension.author),
_InfoRow(label: 'ID', value: extension.id),
if (hasError && extension.errorMessage != null)
_InfoRow(
label: 'Error',
value: extension.errorMessage!,
isError: true,
),
],
),
),
),
),
// Capabilities
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Capabilities'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_CapabilityItem(
icon: Icons.search,
title: 'Metadata Provider',
enabled: extension.hasMetadataProvider,
),
_CapabilityItem(
icon: Icons.download,
title: 'Download Provider',
enabled: extension.hasDownloadProvider,
),
_CapabilityItem(
icon: Icons.manage_search,
title: 'Custom Search',
enabled: extension.hasCustomSearch,
subtitle: extension.searchBehavior?.placeholder,
),
_CapabilityItem(
icon: Icons.compare_arrows,
title: 'Custom Track Matching',
enabled: extension.hasCustomMatching,
subtitle: extension.trackMatching?.strategy != null
? 'Strategy: ${extension.trackMatching!.strategy}'
: null,
),
_CapabilityItem(
icon: Icons.auto_fix_high,
title: 'Post-Processing',
enabled: extension.hasPostProcessing,
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
? '${extension.postProcessing!.hooks.length} hook(s) available'
: null,
showDivider: false,
),
],
),
),
// Search Provider Section (if extension has custom search)
if (extension.hasCustomSearch) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Search Provider'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_SearchProviderInfo(
extension: extension,
),
],
),
),
],
// Post-Processing Hooks (if available)
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Post-Processing Hooks'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.postProcessing!.hooks.asMap().entries.map((entry) {
final index = entry.key;
final hook = entry.value;
return _PostProcessingHookItem(
hook: hook,
showDivider: index < extension.postProcessing!.hooks.length - 1,
);
}).toList(),
),
),
],
// Permissions
if (extension.permissions.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Permissions'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.permissions.asMap().entries.map((entry) {
final index = entry.key;
final permission = entry.value;
return _PermissionItem(
permission: permission,
showDivider: index < extension.permissions.length - 1,
);
}).toList(),
),
),
],
// Settings
if (extension.settings.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Settings'),
),
if (_isLoadingSettings)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
)
else
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.settings.asMap().entries.map((entry) {
final index = entry.key;
final setting = entry.value;
return _SettingItem(
setting: setting,
value: _settings[setting.key] ?? setting.defaultValue,
showDivider: index < extension.settings.length - 1,
onChanged: (value) => _updateSetting(setting.key, value),
);
}).toList(),
),
),
],
// Remove button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton.icon(
onPressed: () => _confirmRemove(context),
icon: const Icon(Icons.delete_outline),
label: const Text('Remove Extension'),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Future<void> _updateSetting(String key, dynamic value) async {
setState(() {
_settings[key] = value;
});
await ref
.read(extensionProvider.notifier)
.setExtensionSettings(widget.extensionId, _settings);
}
Future<void> _confirmRemove(BuildContext context) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove Extension'),
content: const Text(
'Are you sure you want to remove this extension? '
'This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
),
child: const Text('Remove'),
),
],
),
);
if (confirmed == true && mounted) {
final success = await ref
.read(extensionProvider.notifier)
.removeExtension(widget.extensionId);
if (success && mounted) {
Navigator.pop(this.context);
}
}
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final bool isError;
const _InfoRow({
required this.label,
required this.value,
this.isError = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 60,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isError ? colorScheme.error : colorScheme.onSurface,
),
),
),
],
),
);
}
}
class _CapabilityItem extends StatelessWidget {
final IconData icon;
final String title;
final bool enabled;
final bool showDivider;
final String? subtitle;
const _CapabilityItem({
required this.icon,
required this.title,
required this.enabled,
this.showDivider = true,
this.subtitle,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
icon,
color: enabled ? colorScheme.primary : colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null && enabled) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
Icon(
enabled ? Icons.check_circle : Icons.cancel_outlined,
color: enabled ? colorScheme.primary : colorScheme.outline,
),
],
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 56,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _PermissionItem extends StatelessWidget {
final String permission;
final bool showDivider;
const _PermissionItem({
required this.permission,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Parse permission to get icon and description
IconData icon = Icons.security;
String description = permission;
if (permission.startsWith('network:')) {
icon = Icons.language;
description = 'Network access to: ${permission.substring(8)}';
} else if (permission.startsWith('storage:')) {
icon = Icons.folder;
description = 'Storage access: ${permission.substring(8)}';
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Text(
description,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 56,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _SettingItem extends StatelessWidget {
final ExtensionSetting setting;
final dynamic value;
final bool showDivider;
final ValueChanged<dynamic> onChanged;
const _SettingItem({
required this.setting,
required this.value,
required this.onChanged,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
Widget trailing;
switch (setting.type) {
case 'boolean':
trailing = Switch(
value: value as bool? ?? false,
onChanged: onChanged,
);
break;
case 'select':
trailing = DropdownButton<String>(
value: value as String?,
items: setting.options?.map((opt) {
return DropdownMenuItem(value: opt, child: Text(opt));
}).toList(),
onChanged: onChanged,
underline: const SizedBox(),
);
break;
default:
trailing = Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: setting.type == 'string' || setting.type == 'number'
? () => _showEditDialog(context)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
setting.label,
style: Theme.of(context).textTheme.bodyLarge,
),
if (setting.description != null) ...[
const SizedBox(height: 2),
Text(
setting.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (setting.type == 'string' || setting.type == 'number') ...[
const SizedBox(height: 4),
Text(
value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
],
),
),
trailing,
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 16,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
void _showEditDialog(BuildContext context) {
final controller = TextEditingController(text: value?.toString() ?? '');
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(setting.label),
content: TextField(
controller: controller,
keyboardType: setting.type == 'number'
? TextInputType.number
: TextInputType.text,
decoration: InputDecoration(
hintText: setting.description ?? 'Enter value',
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final newValue = setting.type == 'number'
? num.tryParse(controller.text)
: controller.text;
onChanged(newValue);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
}
class _PostProcessingHookItem extends StatelessWidget {
final PostProcessingHook hook;
final bool showDivider;
const _PostProcessingHookItem({
required this.hook,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.auto_fix_high,
color: colorScheme.onTertiaryContainer,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
hook.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (hook.description != null) ...[
const SizedBox(height: 2),
Text(
hook.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (hook.supportedFormats.isNotEmpty) ...[
const SizedBox(height: 4),
Wrap(
spacing: 4,
children: hook.supportedFormats.map((format) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
format.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}).toList(),
),
],
],
),
),
if (hook.defaultEnabled)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Auto',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
),
],
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 72,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _SearchProviderInfo extends StatelessWidget {
final Extension extension;
const _SearchProviderInfo({
required this.extension,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final searchBehavior = extension.searchBehavior;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.manage_search,
color: colorScheme.onSecondaryContainer,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Custom Search Available',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'This extension provides its own search functionality',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Search placeholder info
if (searchBehavior?.placeholder != null) ...[
_InfoTile(
icon: Icons.text_fields,
label: 'Search Hint',
value: searchBehavior!.placeholder!,
),
const SizedBox(height: 8),
],
// Primary search info
_InfoTile(
icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border,
label: 'Priority',
value: searchBehavior?.primary == true
? 'Primary search provider'
: 'Secondary search provider',
),
const SizedBox(height: 16),
// Usage instructions
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
);
}
}
class _InfoTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoTile({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'$label: ',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
);
}
}
+721
View File
@@ -0,0 +1,721 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionsPage extends ConsumerStatefulWidget {
const ExtensionsPage({super.key});
@override
ConsumerState<ExtensionsPage> createState() => _ExtensionsPageState();
}
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
@override
void initState() {
super.initState();
_initializeExtensions();
}
Future<void> _initializeExtensions() async {
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if they don't exist
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
}
}
@override
Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// 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: () => 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(
'Extensions',
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Loading indicator
if (extState.isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
// Error message
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),
),
),
],
),
),
),
),
// Provider Priority
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Provider Priority'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_DownloadPriorityItem(),
_MetadataPriorityItem(),
_SearchProviderSelector(),
],
),
),
// Installed Extensions
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Installed Extensions'),
),
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(
'No extensions installed',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Install .spotiflac-ext files to add new providers',
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(),
),
),
// Install button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _installExtension,
icon: const Icon(Icons.add),
label: const Text('Install Extension'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
),
// Info section
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(
'Extensions can add new metadata and download providers. '
'Only install extensions from trusted sources.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
],
),
);
}
Future<void> _installExtension() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: false,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.path != null) {
if (!file.path!.endsWith('.spotiflac-ext')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a .spotiflac-ext file'),
),
);
}
return;
}
final success = await ref
.read(extensionProvider.notifier)
.installExtension(file.path!);
if (mounted) {
final extState = ref.read(extensionProvider);
String message;
if (success) {
message = 'Extension installed successfully';
} else {
// Parse friendly error message
message = _getFriendlyErrorMessage(extState.error);
}
// Clear the error from state to avoid showing it twice (in error container)
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
}
}
/// Parse error message to be more user-friendly
String _getFriendlyErrorMessage(String? error) {
if (error == null) return 'Failed to install extension';
String message = error;
// Remove PlatformException wrapper if present
// Format: PlatformException(ERROR, actual message, null, null)
if (message.contains('PlatformException')) {
// Try to extract the actual error message
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
if (match != null) {
message = match.group(1)?.trim() ?? message;
} else {
// Fallback: try simpler extraction
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message;
}
}
}
// Clean up any remaining artifacts
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
return message;
}
}
class _ExtensionItem extends StatelessWidget {
final Extension extension;
final bool showDivider;
final VoidCallback onTap;
final ValueChanged<bool> onToggle;
const _ExtensionItem({
required this.extension,
required this.showDivider,
required this.onTap,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasError = extension.status == 'error';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: hasError
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(extension.iconPath!),
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
hasError ? Icons.error_outline : Icons.extension,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
),
)
: Icon(
hasError ? Icons.error_outline : Icons.extension,
color: hasError
? colorScheme.error
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
extension.displayName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
hasError
? extension.errorMessage ?? 'Error loading extension'
: 'v${extension.version} by ${extension.author}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
],
),
),
// Toggle switch
Switch(
value: extension.enabled,
onChanged: hasError ? null : onToggle,
),
],
),
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 76,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _DownloadPriorityItem extends ConsumerWidget {
const _DownloadPriorityItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Check if any extension has download provider
final hasDownloadExtensions = extState.extensions
.any((e) => e.enabled && e.hasDownloadProvider);
return InkWell(
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ProviderPriorityPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.download,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Download Priority',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions
? null
: colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? 'Set download service order'
: 'No extensions with download provider',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _MetadataPriorityItem extends ConsumerWidget {
const _MetadataPriorityItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Check if any extension has metadata provider
final hasMetadataExtensions = extState.extensions
.any((e) => e.enabled && e.hasMetadataProvider);
return InkWell(
onTap: hasMetadataExtensions
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const MetadataProviderPriorityPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.search,
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Metadata Priority',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasMetadataExtensions
? null
: colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasMetadataExtensions
? 'Set search & metadata source order'
: 'No extensions with metadata provider',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _SearchProviderSelector extends ConsumerWidget {
const _SearchProviderSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Get extensions with custom search
final searchProviders = extState.extensions
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
// Get current provider name
String currentProviderName = 'Default (Deezer/Spotify)';
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),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.manage_search,
color: searchProviders.isEmpty
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Search Provider',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: searchProviders.isEmpty
? colorScheme.outline
: null,
),
),
const SizedBox(height: 2),
Text(
searchProviders.isEmpty
? 'No extensions with custom search'
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: searchProviders.isEmpty
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
],
),
),
),
],
);
}
void _showSearchProviderPicker(
BuildContext context,
WidgetRef ref,
dynamic settings,
List<Extension> searchProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Search Provider',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose which service to use for searching tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
// Default option
ListTile(
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: const Text('Default (Deezer/Spotify)'),
subtitle: const Text('Use built-in search'),
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref.read(settingsProvider.notifier).setSearchProvider(null);
Navigator.pop(ctx);
},
),
// Extension options
...searchProviders.map((ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
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),
],
),
),
);
}
}
@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key});
@override
ConsumerState<MetadataProviderPriorityPage> createState() => _MetadataProviderPriorityPageState();
}
class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderPriorityPage> {
late List<String> _providers;
bool _hasChanges = false;
@override
void initState() {
super.initState();
_loadProviders();
}
void _loadProviders() {
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
// Use saved priority if available, otherwise use default order
if (extState.metadataProviderPriority.isNotEmpty) {
_providers = List.from(extState.metadataProviderPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
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: [
// 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(
'Metadata Priority',
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder metadata providers. The app will try providers '
'from top to bottom when searching for tracks and fetching metadata.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
// Provider list
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;
});
},
),
),
// Info section
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(
'Deezer has no rate limits and is recommended as primary. '
'Spotify may rate limit after many requests.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
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. Do you want to discard them?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
),
],
),
);
return result ?? false;
}
Future<void> _saveChanges() async {
await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers);
setState(() {
_hasChanges = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Metadata provider priority saved')),
);
}
}
}
class _MetadataProviderItem extends StatelessWidget {
final String provider;
final int index;
final bool isFirst;
final bool isLast;
const _MetadataProviderItem({
super.key,
required this.provider,
required this.index,
required this.isFirst,
required this.isLast,
});
@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;
final info = _getProviderInfo(provider);
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: [
// Priority number
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),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
? colorScheme.primary
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
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,
),
),
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
_MetadataProviderInfo _getProviderInfo(String provider) {
switch (provider) {
case 'deezer':
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: 'No rate limits',
isBuiltIn: true,
);
case 'spotify':
return _MetadataProviderInfo(
name: 'Spotify',
icon: Icons.music_note,
description: 'May rate limit',
isBuiltIn: true,
);
default:
// Extension provider
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
description: 'Extension',
isBuiltIn: false,
);
}
}
}
class _MetadataProviderInfo {
final String name;
final IconData icon;
final String description;
final bool isBuiltIn;
_MetadataProviderInfo({
required this.name,
required this.icon,
required this.description,
required this.isBuiltIn,
});
}
+89 -14
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget {
@@ -11,6 +12,8 @@ class OptionsSettingsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final extensionState = ref.watch(extensionProvider);
final hasExtensions = extensionState.extensions.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
@@ -129,6 +132,18 @@ class OptionsSettingsPage extends ConsumerWidget {
onChanged: (v) =>
ref.read(settingsProvider.notifier).setAutoFallback(v),
),
if (hasExtensions)
SettingsSwitchItem(
icon: Icons.extension,
title: 'Use Extension Providers',
subtitle: settings.useExtensionProviders
? 'Extensions will be tried first'
: 'Using built-in providers only',
value: settings.useExtensionProviders,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setUseExtensionProviders(v),
),
SettingsSwitchItem(
icon: Icons.lyrics,
title: 'Embed Lyrics',
@@ -345,11 +360,15 @@ class OptionsSettingsPage extends ConsumerWidget {
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -380,11 +399,15 @@ class OptionsSettingsPage extends ConsumerWidget {
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -745,7 +768,7 @@ class _ChannelChip extends StatelessWidget {
}
}
class _MetadataSourceSelector extends StatelessWidget {
class _MetadataSourceSelector extends ConsumerWidget {
final String currentSource;
final ValueChanged<String> onChanged;
const _MetadataSourceSelector({
@@ -754,8 +777,21 @@ class _MetadataSourceSelector extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
// Check if extension search provider is active
final hasExtensionSearch = settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty;
String? extensionName;
if (hasExtensionSearch) {
final ext = extState.extensions.where((e) => e.id == settings.searchProvider).firstOrNull;
extensionName = ext?.displayName ?? settings.searchProvider;
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -769,9 +805,13 @@ class _MetadataSourceSelector extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'Service used when searching by track name.',
hasExtensionSearch
? 'Using extension: $extensionName'
: 'Service used when searching by track name.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
color: hasExtensionSearch
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
@@ -780,18 +820,53 @@ class _MetadataSourceSelector extends StatelessWidget {
_SourceChip(
icon: Icons.graphic_eq,
label: 'Deezer',
isSelected: currentSource == 'deezer',
onTap: () => onChanged('deezer'),
// Not selected if extension is active
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
onTap: () {
// If extension was active, reset it to default
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
onChanged('deezer');
},
),
const SizedBox(width: 12),
_SourceChip(
icon: Icons.music_note,
label: 'Spotify',
isSelected: currentSource == 'spotify',
onTap: () => onChanged('spotify'),
// Not selected if extension is active
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
onTap: () {
// If extension was active, reset it to default
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
onChanged('spotify');
},
),
],
),
if (hasExtensionSearch) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Tap Deezer or Spotify to switch back from extension',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
],
),
);
@@ -802,13 +877,13 @@ class _SourceChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback? onTap;
const _SourceChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
this.onTap,
});
@override
@@ -0,0 +1,369 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key});
@override
ConsumerState<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
}
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
late List<String> _providers;
bool _hasChanges = false;
@override
void initState() {
super.initState();
_loadProviders();
}
void _loadProviders() {
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
// Use saved priority if available, otherwise use default order
if (extState.providerPriority.isNotEmpty) {
// Start with saved priority
_providers = List.from(extState.providerPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
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: [
// 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(
'Provider Priority',
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder download providers. The app will try providers '
'from top to bottom when downloading tracks.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
// Provider list
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
itemCount: _providers.length,
itemBuilder: (context, index) {
final provider = _providers[index];
return _ProviderItem(
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;
});
},
),
),
// Info section
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(
'If a track is not available on the first provider, '
'the app will automatically try the next one.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
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. Do you want to discard them?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
),
],
),
);
return result ?? false;
}
Future<void> _saveChanges() async {
await ref.read(extensionProvider.notifier).setProviderPriority(_providers);
setState(() {
_hasChanges = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Provider priority saved')),
);
}
}
}
class _ProviderItem extends StatelessWidget {
final String provider;
final int index;
final bool isFirst;
final bool isLast;
const _ProviderItem({
super.key,
required this.provider,
required this.index,
required this.isFirst,
required this.isLast,
});
@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;
// Get provider info
final info = _getProviderInfo(provider);
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: [
// Priority number
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),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
? colorScheme.primary
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
info.isBuiltIn ? 'Built-in' : 'Extension',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
_ProviderInfo _getProviderInfo(String provider) {
switch (provider) {
case 'tidal':
return _ProviderInfo(
name: 'Tidal',
icon: Icons.music_note,
isBuiltIn: true,
);
case 'qobuz':
return _ProviderInfo(
name: 'Qobuz',
icon: Icons.album,
isBuiltIn: true,
);
case 'amazon':
return _ProviderInfo(
name: 'Amazon Music',
icon: Icons.shopping_bag,
isBuiltIn: true,
);
default:
// Extension provider
return _ProviderInfo(
name: provider,
icon: Icons.extension,
isBuiltIn: false,
);
}
}
}
class _ProviderInfo {
final String name;
final IconData icon;
final bool isBuiltIn;
_ProviderInfo({
required this.name,
required this.icon,
required this.isBuiltIn,
});
}
+15 -4
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
@@ -31,8 +32,11 @@ class SettingsTab extends ConsumerWidget {
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);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
@@ -58,7 +62,8 @@ class SettingsTab extends ConsumerWidget {
icon: Icons.palette_outlined,
title: 'Appearance',
subtitle: 'Theme, colors, display',
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
onTap: () =>
_navigateTo(context, const AppearanceSettingsPage()),
),
SettingsItem(
icon: Icons.download_outlined,
@@ -71,6 +76,12 @@ class SettingsTab extends ConsumerWidget {
title: 'Options',
subtitle: 'Fallback, lyrics, cover art, updates',
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
title: 'Extensions',
subtitle: 'Manage download providers',
onTap: () => _navigateTo(context, const ExtensionsPage()),
showDivider: false,
),
],
@@ -97,7 +108,7 @@ class SettingsTab extends ConsumerWidget {
],
),
),
// Fill remaining space
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],