feat: add built-in search provider in settings, fix bottom sheet overflow

This commit is contained in:
zarzet
2026-03-25 15:46:12 +07:00
parent 4f365ca7fe
commit c91154ea3e
7 changed files with 498 additions and 103 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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"
},
+156 -14
View File
@@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>['McNuggets Jimmy', 'zcc09', 'micahRichie', 'a fan', 'CJBGR'];
const donorNames = <String>[
'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 = <int>{1211573191, 1003219236, 560908930};
const _cv = <int>{1211573191, 1003219236};
class _SupporterChip extends StatelessWidget {
// Diamond tier supporters ($50+ donors).
const _dv = <int>{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 {
+252 -57
View File
@@ -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<ExtensionsPage> {
_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<Extension> 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),
],
),
),
),
);
+46 -14
View File
@@ -611,24 +611,34 @@ class _MetadataSourceSelector extends ConsumerWidget {
final ValueChanged<String> 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: [
+41 -15
View File
@@ -130,6 +130,25 @@ class FFmpegService {
}
}
static Future<FFmpegResult> _executeWithArguments(
List<String> 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<String?> 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 = <String>[
'-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 {