feat: add FLAC/ALAC bidirectional lossless conversion support

- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
This commit is contained in:
zarzet
2026-03-16 02:13:45 +07:00
parent 35f2f119db
commit b8af75bf6e
22 changed files with 1037 additions and 218 deletions
+103 -49
View File
@@ -912,6 +912,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
bool isLosslessTarget = false;
showModalBottomSheet(
context: context,
@@ -923,7 +924,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -960,51 +961,75 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1057,12 +1082,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) selected.add(item);
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1074,16 +1106,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1103,8 +1141,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0;
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -1207,13 +1247,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
+101 -49
View File
@@ -1131,6 +1131,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
bool isLosslessTarget = false;
showModalBottomSheet(
context: context,
@@ -1142,7 +1143,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -1179,51 +1180,75 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1276,6 +1301,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
currentFormat = 'FLAC';
} else if (fmt == 'm4a') {
currentFormat = 'M4A';
} else if (fmt == 'mp3') {
currentFormat = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
@@ -1287,15 +1314,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
currentFormat = 'FLAC';
} else if (lower.endsWith('.m4a')) {
currentFormat = 'M4A';
} else if (lower.endsWith('.mp3')) {
currentFormat = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
currentFormat = 'Opus';
}
}
if (currentFormat != null && currentFormat != targetFormat) {
selected.add(item);
}
if (currentFormat == null || currentFormat == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1307,16 +1339,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1481,13 +1519,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
+103 -51
View File
@@ -4757,6 +4757,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
) async {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
bool isLosslessTarget = false;
var didStartConversion = false;
_hideSelectionOverlay();
@@ -4772,7 +4773,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -4809,51 +4810,75 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -4929,14 +4954,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) {
selectedItems.add(item);
}
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selectedItems.add(item);
}
if (selectedItems.isEmpty) {
@@ -4949,16 +4979,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
// Confirm
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selectedItems.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selectedItems.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selectedItems.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -4978,8 +5014,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
int successCount = 0;
final total = selectedItems.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -5093,13 +5131,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
+121 -53
View File
@@ -2662,6 +2662,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool get _isConvertibleFormat {
final lower = cleanFilePath.toLowerCase();
return lower.endsWith('.flac') ||
lower.endsWith('.m4a') ||
lower.endsWith('.mp3') ||
lower.endsWith('.opus') ||
lower.endsWith('.ogg');
@@ -2692,6 +2693,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.flac')) return 'FLAC';
if (lower.endsWith('.m4a')) return 'M4A';
if (lower.endsWith('.mp3')) return 'MP3';
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
if (lower.endsWith('.cue')) return 'CUE';
@@ -2749,8 +2751,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
String _buildConvertedQualityLabel(String targetFormat, String bitrate) {
final upper = targetFormat.toUpperCase();
if (upper == 'ALAC' || upper == 'FLAC') {
return '$upper Lossless';
}
final normalizedBitrate = bitrate.trim().toLowerCase();
return '${targetFormat.toUpperCase()} $normalizedBitrate';
return '$upper $normalizedBitrate';
}
String? _extractLossyBitrateLabel(String? quality) {
@@ -2790,17 +2796,27 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _showConvertSheet(BuildContext context) {
final currentFormat = _currentFileFormat;
// Available target formats (exclude current)
final formats = <String>[
'MP3',
'Opus',
].where((f) => f != currentFormat).toList();
final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A';
// Build available target formats based on source
final formats = <String>[];
if (currentFormat == 'FLAC') {
// FLAC can convert to both
formats.addAll(['ALAC', 'MP3', 'Opus']);
} else if (currentFormat == 'M4A') {
formats.addAll(['FLAC', 'MP3', 'Opus']);
} else if (currentFormat == 'MP3') {
formats.add('Opus');
} else if (currentFormat == 'Opus') {
formats.add('MP3');
} else {
formats.addAll(['MP3', 'Opus']);
}
String selectedFormat = formats.first;
String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k';
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
showModalBottomSheet(
context: context,
@@ -2849,53 +2865,79 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
// Reset bitrate to default for format
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
// Only show bitrate for lossy targets
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
// Show lossless indicator
if (isLosslessTarget && isLosslessSource) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
@@ -2917,7 +2959,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
child: Text(
'$currentFormat -> $selectedFormat @ $selectedBitrate',
isLosslessTarget
? '$currentFormat -> $selectedFormat (Lossless)'
: '$currentFormat -> $selectedFormat @ $selectedBitrate',
),
),
),
@@ -3402,17 +3446,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String targetFormat,
required String bitrate,
}) {
final isLossless =
targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC';
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(dialogContext.l10n.trackConvertConfirmTitle),
content: Text(
dialogContext.l10n.trackConvertConfirmMessage(
sourceFormat,
targetFormat,
bitrate,
),
isLossless
? dialogContext.l10n.trackConvertConfirmMessageLossless(
sourceFormat,
targetFormat,
)
: dialogContext.l10n.trackConvertConfirmMessage(
sourceFormat,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -3561,11 +3613,27 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default: // mp3
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,