From c91154ea3ead62bf373d6f72b51975f96d6da0c2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 15:46:12 +0700 Subject: [PATCH] feat: add built-in search provider in settings, fix bottom sheet overflow --- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/arb/app_en.arb | 2 +- lib/screens/settings/donate_page.dart | 170 +++++++++- lib/screens/settings/extensions_page.dart | 309 ++++++++++++++---- .../settings/options_settings_page.dart | 60 +++- lib/services/ffmpeg_service.dart | 56 +++- 7 files changed, 498 insertions(+), 103 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d38202cc..2fd44b83 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2323,7 +2323,7 @@ abstract class AppLocalizations { /// Default search provider option /// /// In en, this message translates to: - /// **'Default (Deezer/Spotify)'** + /// **'Default (Deezer)'** String get extensionDefaultProvider; /// Subtitle for default provider diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index cc37fb89..594df94e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1240,7 +1240,7 @@ class AppLocalizationsEn extends AppLocalizations { String get storeEmptyNoResults => 'No extensions found'; @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + String get extensionDefaultProvider => 'Default (Deezer)'; @override String get extensionDefaultProviderSubtitle => 'Use built-in search'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7797e048..7e882ab2 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1626,7 +1626,7 @@ "@storeEmptyNoResults": { "description": "Message when search/filter returns no results" }, - "extensionDefaultProvider": "Default (Deezer/Spotify)", + "extensionDefaultProvider": "Default (Deezer)", "@extensionDefaultProvider": { "description": "Default search provider option" }, diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 97f515ca..5ba2fcce 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = ['McNuggets Jimmy', 'zcc09', 'micahRichie', 'a fan', 'CJBGR']; + const donorNames = [ + 'McNuggets Jimmy', + 'zcc09', + 'micahRichie', + 'a fan', + 'CJBGR', + ]; // Match SettingsGroup color logic final cardColor = isDark @@ -480,31 +486,77 @@ int _cr(String v) { } // Highlighted supporters (hashes of names). -const _cv = {1211573191, 1003219236, 560908930}; +const _cv = {1211573191, 1003219236}; -class _SupporterChip extends StatelessWidget { +// Diamond tier supporters ($50+ donors). +const _dv = {560908930}; + +enum _SupporterTier { normal, gold, diamond } + +_SupporterTier _tierOf(String name) { + final h = _cr(name); + if (_dv.contains(h)) return _SupporterTier.diamond; + if (_cv.contains(h)) return _SupporterTier.gold; + return _SupporterTier.normal; +} + +class _SupporterChip extends StatefulWidget { final String name; final ColorScheme colorScheme; const _SupporterChip({required this.name, required this.colorScheme}); + @override + State<_SupporterChip> createState() => _SupporterChipState(); +} + +class _SupporterChipState extends State<_SupporterChip> + with SingleTickerProviderStateMixin { + late final _SupporterTier _tier; + AnimationController? _shimmerController; + + @override + void initState() { + super.initState(); + _tier = _tierOf(widget.name); + if (_tier == _SupporterTier.diamond) { + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2400), + )..repeat(); + } + } + + @override + void dispose() { + _shimmerController?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final e = _cv.contains(_cr(name)); + final isDark = Theme.of(context).brightness == Brightness.dark; + + if (_tier == _SupporterTier.diamond) { + return _buildDiamondChip(isDark); + } + + final isGold = _tier == _SupporterTier.gold; 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; + final chipColor = isGold + ? goldChipColor + : widget.colorScheme.secondaryContainer; + final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary; + final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor; return Material( color: effectiveChipColor, borderRadius: BorderRadius.circular(20), child: Container( - decoration: e + decoration: isGold ? BoxDecoration( borderRadius: BorderRadius.circular(20), border: Border.all( @@ -520,10 +572,12 @@ class _SupporterChip extends StatelessWidget { CircleAvatar( radius: 10, backgroundColor: accentColor.withValues(alpha: 0.2), - child: e + child: isGold ? Icon(Icons.star_rounded, size: 12, color: accentColor) : Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, @@ -533,10 +587,12 @@ class _SupporterChip extends StatelessWidget { ), const SizedBox(width: 8), Text( - name, + widget.name, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: e ? accentColor : colorScheme.onSecondaryContainer, - fontWeight: e ? FontWeight.w600 : FontWeight.w500, + color: isGold + ? accentColor + : widget.colorScheme.onSecondaryContainer, + fontWeight: isGold ? FontWeight.w600 : FontWeight.w500, ), ), ], @@ -544,6 +600,92 @@ class _SupporterChip extends StatelessWidget { ), ); } + + Widget _buildDiamondChip(bool isDark) { + const diamondLight = Color(0xFFE8F4FD); + const diamondDark = Color(0xFF0D2B3E); + const diamondAccent = Color(0xFF4FC3F7); + const diamondHighlight = Color(0xFFB3E5FC); + + final chipBg = isDark ? diamondDark : diamondLight; + + return AnimatedBuilder( + animation: _shimmerController!, + builder: (context, child) { + final t = _shimmerController!.value; + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment(-2.0 + 4.0 * t, 0.0), + end: Alignment(-1.0 + 4.0 * t, 0.0), + colors: [ + chipBg, + isDark + ? diamondAccent.withValues(alpha: 0.18) + : diamondHighlight.withValues(alpha: 0.7), + chipBg, + ], + stops: const [0.0, 0.5, 1.0], + ), + border: Border.all( + color: diamondAccent.withValues( + alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()), + ), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: diamondAccent.withValues( + alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()), + ), + blurRadius: 8, + spreadRadius: 0, + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + diamondAccent.withValues(alpha: 0.3), + diamondAccent.withValues(alpha: 0.15), + ], + ), + ), + child: const Icon( + Icons.diamond_rounded, + size: 12, + color: diamondAccent, + ), + ), + const SizedBox(width: 8), + Text( + widget.name, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: isDark ? diamondHighlight : diamondAccent, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + }, + ); + } } class _NoticeLine extends StatelessWidget { diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 196dd0aa..57979689 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/explore_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'; @@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState { _DownloadPriorityItem(), _MetadataPriorityItem(), _SearchProviderSelector(), + _HomeFeedProviderSelector(), ], ), ), @@ -586,6 +588,8 @@ class _MetadataPriorityItem extends ConsumerWidget { class _SearchProviderSelector extends ConsumerWidget { const _SearchProviderSelector(); + static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; + @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); @@ -596,20 +600,29 @@ class _SearchProviderSelector extends ConsumerWidget { .where((e) => e.enabled && e.hasCustomSearch) .toList(); + // Always allow tapping: built-in providers are always available + final hasAnyProvider = + searchProviders.isNotEmpty || _builtInProviders.isNotEmpty; + String currentProviderName = context.l10n.extensionDefaultProvider; if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - final ext = searchProviders - .where((e) => e.id == settings.searchProvider) - .firstOrNull; - currentProviderName = ext?.displayName ?? settings.searchProvider!; + // Check built-in first + if (_builtInProviders.containsKey(settings.searchProvider)) { + currentProviderName = _builtInProviders[settings.searchProvider]!; + } else { + 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 + onTap: !hasAnyProvider ? null : () => _showSearchProviderPicker( context, @@ -623,7 +636,7 @@ class _SearchProviderSelector extends ConsumerWidget { children: [ Icon( Icons.manage_search, - color: searchProviders.isEmpty + color: !hasAnyProvider ? colorScheme.outline : colorScheme.onSurfaceVariant, ), @@ -635,14 +648,12 @@ class _SearchProviderSelector extends ConsumerWidget { Text( context.l10n.extensionsSearchProvider, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: searchProviders.isEmpty - ? colorScheme.outline - : null, + color: !hasAnyProvider ? colorScheme.outline : null, ), ), const SizedBox(height: 2), Text( - searchProviders.isEmpty + !hasAnyProvider ? context.l10n.extensionsNoCustomSearch : currentProviderName, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -654,7 +665,7 @@ class _SearchProviderSelector extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: searchProviders.isEmpty + color: !hasAnyProvider ? colorScheme.outline : colorScheme.onSurfaceVariant, ), @@ -682,61 +693,245 @@ class _SearchProviderSelector extends ConsumerWidget { 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( - ctx.l10n.extensionsSearchProvider, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - ctx.l10n.extensionsSearchProviderDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + ctx.l10n.extensionsSearchProvider, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), - ), - ListTile( - 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) - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(null); - 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, + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + ctx.l10n.extensionsSearchProviderDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - trailing: settings.searchProvider == ext.id + ), + ListTile( + 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) ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(ext.id); + ref.read(settingsProvider.notifier).setSearchProvider(null); Navigator.pop(ctx); }, ), - ), - const SizedBox(height: 16), - ], + ..._builtInProviders.entries.map( + (entry) => ListTile( + leading: Icon(Icons.search, color: colorScheme.tertiary), + title: Text(entry.value), + subtitle: Text('Search with ${entry.value}'), + trailing: settings.searchProvider == entry.key + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider(entry.key); + Navigator.pop(ctx); + }, + ), + ), + if (searchProviders.isNotEmpty) const Divider(height: 1), + ...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), + ], + ), + ), + ), + ); + } +} + +class _HomeFeedProviderSelector extends ConsumerWidget { + const _HomeFeedProviderSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + final homeFeedProviders = extState.extensions + .where((e) => e.enabled && e.hasHomeFeed) + .toList(); + + final hasAnyProvider = homeFeedProviders.isNotEmpty; + + String currentProviderName = 'Auto'; + if (settings.homeFeedProvider != null && + settings.homeFeedProvider!.isNotEmpty) { + final ext = homeFeedProviders + .where((e) => e.id == settings.homeFeedProvider) + .firstOrNull; + currentProviderName = ext?.displayName ?? settings.homeFeedProvider!; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: !hasAnyProvider + ? null + : () => _showHomeFeedProviderPicker( + context, + ref, + settings, + homeFeedProviders, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.explore_outlined, + color: !hasAnyProvider + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Home Feed Provider', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: !hasAnyProvider ? colorScheme.outline : null, + ), + ), + const SizedBox(height: 2), + Text( + !hasAnyProvider + ? 'No extensions with home feed' + : currentProviderName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: !hasAnyProvider + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ); + } + + void _showHomeFeedProviderPicker( + BuildContext context, + WidgetRef ref, + dynamic settings, + List homeFeedProviders, + ) { + 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: (ctx) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Home Feed Provider', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Choose which extension provides the home feed on the main screen', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + leading: Icon(Icons.auto_awesome, color: colorScheme.primary), + title: const Text('Auto'), + subtitle: const Text('Automatically select the best available'), + trailing: + (settings.homeFeedProvider == null || + settings.homeFeedProvider!.isEmpty) + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setHomeFeedProvider(null); + ref.read(exploreProvider.notifier).refresh(); + Navigator.pop(ctx); + }, + ), + ...homeFeedProviders.map( + (ext) => ListTile( + leading: Icon(Icons.extension, color: colorScheme.secondary), + title: Text(ext.displayName), + subtitle: Text('Use ${ext.displayName} home feed'), + trailing: settings.homeFeedProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref + .read(settingsProvider.notifier) + .setHomeFeedProvider(ext.id); + ref.read(exploreProvider.notifier).refresh(); + Navigator.pop(ctx); + }, + ), + ), + const SizedBox(height: 16), + ], + ), ), ), ); diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 4a7618d0..3f5a2e7c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -611,24 +611,34 @@ class _MetadataSourceSelector extends ConsumerWidget { final ValueChanged onChanged; const _MetadataSourceSelector({required this.onChanged}); + static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; + @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); + final searchProvider = settings.searchProvider ?? ''; + final isBuiltIn = _builtInProviders.containsKey(searchProvider); + Extension? activeExtension; - if (settings.searchProvider != null && - settings.searchProvider!.isNotEmpty) { + if (searchProvider.isNotEmpty && !isBuiltIn) { activeExtension = extState.extensions - .where((e) => e.id == settings.searchProvider && e.enabled) + .where((e) => e.id == searchProvider && e.enabled) .firstOrNull; } - final hasExtensionSearch = activeExtension != null; + final hasNonDefaultProvider = isBuiltIn || activeExtension != null; - String? extensionName; - if (hasExtensionSearch) { - extensionName = activeExtension.displayName; + String subtitle; + if (isBuiltIn) { + subtitle = 'Using ${_builtInProviders[searchProvider]}'; + } else if (activeExtension != null) { + subtitle = context.l10n.optionsUsingExtension( + activeExtension.displayName, + ); + } else { + subtitle = context.l10n.optionsPrimaryProviderSubtitle; } return Padding( @@ -644,11 +654,9 @@ class _MetadataSourceSelector extends ConsumerWidget { ), const SizedBox(height: 4), Text( - hasExtensionSearch - ? context.l10n.optionsUsingExtension(extensionName!) - : context.l10n.optionsPrimaryProviderSubtitle, + subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: hasExtensionSearch + color: hasNonDefaultProvider ? colorScheme.primary : colorScheme.onSurfaceVariant, ), @@ -659,17 +667,41 @@ class _MetadataSourceSelector extends ConsumerWidget { _SourceChip( icon: Icons.graphic_eq, label: 'Deezer', - isSelected: !hasExtensionSearch, + isSelected: searchProvider.isEmpty, onTap: () { - if (hasExtensionSearch) { + if (hasNonDefaultProvider) { ref.read(settingsProvider.notifier).setSearchProvider(null); } onChanged('deezer'); }, ), + const SizedBox(width: 8), + _SourceChip( + icon: Icons.waves, + label: 'Tidal', + isSelected: searchProvider == 'tidal', + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider('tidal'); + onChanged('tidal'); + }, + ), + const SizedBox(width: 8), + _SourceChip( + icon: Icons.album, + label: 'Qobuz', + isSelected: searchProvider == 'qobuz', + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider('qobuz'); + onChanged('qobuz'); + }, + ), ], ), - if (hasExtensionSearch) ...[ + if (activeExtension != null) ...[ const SizedBox(height: 12), Row( children: [ diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index bf43717c..53efe84d 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -130,6 +130,25 @@ class FFmpegService { } } + static Future _executeWithArguments( + List arguments, + ) async { + try { + final session = await FFmpegKit.executeWithArguments(arguments); + final returnCode = await session.getReturnCode(); + final output = await session.getOutput() ?? ''; + + return FFmpegResult( + success: ReturnCode.isSuccess(returnCode), + returnCode: returnCode?.getValue() ?? -1, + output: output, + ); + } catch (e) { + _log.e('FFmpeg executeWithArguments error: $e'); + return FFmpegResult(success: false, returnCode: -1, output: e.toString()); + } + } + static Future convertM4aToFlac(String inputPath) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -1030,18 +1049,24 @@ class FFmpegService { }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); - - final StringBuffer cmdBuffer = StringBuffer(); - cmdBuffer.write('-i "$opusPath" '); - cmdBuffer.write('-map 0:a '); - cmdBuffer.write('-map_metadata -1 '); - cmdBuffer.write('-map_metadata:s:a -1 '); - cmdBuffer.write('-c:a copy '); + final arguments = [ + '-i', + opusPath, + '-map', + '0:a', + '-map_metadata', + '-1', + '-map_metadata:s:a', + '-1', + '-c:a', + 'copy', + ]; if (metadata != null) { metadata.forEach((key, value) { - final sanitizedValue = value.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata $key="$sanitizedValue" '); + arguments + ..add('-metadata') + ..add('$key=$value'); }); } @@ -1049,8 +1074,9 @@ class FFmpegService { try { final pictureBlock = await _createMetadataBlockPicture(coverPath); if (pictureBlock != null) { - final escapedBlock = pictureBlock.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" '); + arguments + ..add('-metadata') + ..add('METADATA_BLOCK_PICTURE=$pictureBlock'); _log.d( 'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)', ); @@ -1062,12 +1088,12 @@ class FFmpegService { } } - cmdBuffer.write('"$tempOutput" -y'); - - final command = cmdBuffer.toString(); + arguments + ..add(tempOutput) + ..add('-y'); _log.d('Executing FFmpeg Opus embed command'); - final result = await _execute(command); + final result = await _executeWithArguments(arguments); if (result.success) { try {