mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 19:05:57 +02:00
feat(audio): add dither and resampler options for lossless conversion
Let users choose FFmpeg dithering when reducing bit depth and SoXr or SWR resampling when changing sample rate across single-track and batch lossless conversion flows.
This commit is contained in:
@@ -1149,13 +1149,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||
onConvert: (format, bitrate, losslessQuality) {
|
||||
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1168,6 +1169,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
required String bitrate,
|
||||
LosslessConversionQuality losslessQuality =
|
||||
const LosslessConversionQuality(),
|
||||
LosslessConversionProcessing losslessProcessing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <DownloadHistoryItem>[];
|
||||
@@ -1322,6 +1325,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
deleteOriginal: !isSaf,
|
||||
sourceBitDepth: item.bitDepth,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
|
||||
@@ -1328,13 +1328,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||
onConvert: (format, bitrate, losslessQuality) {
|
||||
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1347,6 +1348,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
required String bitrate,
|
||||
LosslessConversionQuality losslessQuality =
|
||||
const LosslessConversionQuality(),
|
||||
LosslessConversionProcessing losslessProcessing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
@@ -1500,6 +1503,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
deleteOriginal: !isSaf,
|
||||
sourceBitDepth: item.bitDepth,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
|
||||
@@ -5569,7 +5569,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||
onConvert: (format, bitrate, losslessQuality) {
|
||||
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||
didStartConversion = true;
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
@@ -5577,6 +5577,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -5611,6 +5612,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required String bitrate,
|
||||
LosslessConversionQuality losslessQuality =
|
||||
const LosslessConversionQuality(),
|
||||
LosslessConversionProcessing losslessProcessing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) async {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
final selectedItems = <UnifiedLibraryItem>[];
|
||||
@@ -5770,6 +5773,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
sourceBitDepth:
|
||||
item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
|
||||
@@ -3822,6 +3822,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||
int? selectedMaxBitDepth;
|
||||
int? selectedMaxSampleRate;
|
||||
String selectedDither = 'none';
|
||||
String selectedResampler = 'swr';
|
||||
final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth);
|
||||
final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate);
|
||||
|
||||
@@ -3970,6 +3972,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
} else {
|
||||
selectedMaxBitDepth = null;
|
||||
selectedMaxSampleRate = null;
|
||||
selectedDither = 'none';
|
||||
selectedResampler = 'swr';
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -4018,9 +4022,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: selectedMaxBitDepth == null,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedMaxBitDepth = null,
|
||||
),
|
||||
onTap: () => setSheetState(() {
|
||||
selectedMaxBitDepth = null;
|
||||
selectedDither = 'none';
|
||||
}),
|
||||
),
|
||||
...bitDepthOptions.map((depth) {
|
||||
return choice(
|
||||
@@ -4056,9 +4061,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: selectedMaxSampleRate == null,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedMaxSampleRate = null,
|
||||
),
|
||||
onTap: () => setSheetState(() {
|
||||
selectedMaxSampleRate = null;
|
||||
selectedResampler = 'swr';
|
||||
}),
|
||||
),
|
||||
...sampleRateOptions.map((rate) {
|
||||
return choice(
|
||||
@@ -4078,6 +4084,55 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && selectedMaxBitDepth != null)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(context.l10n.trackConvertDithering),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: losslessDitherOptions.map((mode) {
|
||||
return choice(
|
||||
label: context.l10n.losslessDitherOptionLabel(
|
||||
mode,
|
||||
),
|
||||
selected: mode == selectedDither,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedDither = mode,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && selectedMaxSampleRate != null)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(context.l10n.trackConvertResampler),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: losslessResamplerOptions.map((mode) {
|
||||
return choice(
|
||||
label: context.l10n
|
||||
.losslessResamplerOptionLabel(mode),
|
||||
selected: mode == selectedResampler,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedResampler = mode,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && isLosslessSource)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
@@ -4142,6 +4197,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
maxBitDepth: selectedMaxBitDepth,
|
||||
maxSampleRate: selectedMaxSampleRate,
|
||||
),
|
||||
losslessProcessing: LosslessConversionProcessing(
|
||||
dither: selectedDither,
|
||||
resampler: selectedResampler,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
@@ -4642,6 +4701,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
required String bitrate,
|
||||
LosslessConversionQuality losslessQuality =
|
||||
const LosslessConversionQuality(),
|
||||
LosslessConversionProcessing losslessProcessing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) {
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
showDialog<void>(
|
||||
@@ -4687,6 +4748,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
},
|
||||
child: Text(dialogContext.l10n.trackConvertFormat),
|
||||
@@ -4702,6 +4764,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
required String bitrate,
|
||||
LosslessConversionQuality losslessQuality =
|
||||
const LosslessConversionQuality(),
|
||||
LosslessConversionProcessing losslessProcessing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) async {
|
||||
if (_isConverting) return;
|
||||
setState(() => _isConverting = true);
|
||||
@@ -4778,6 +4842,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
deleteOriginal: !isSaf,
|
||||
sourceBitDepth: bitDepth,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
|
||||
@@ -393,12 +393,19 @@ class FFmpegService {
|
||||
required String codec,
|
||||
int? targetBitDepth,
|
||||
int? targetSampleRate,
|
||||
LosslessConversionProcessing processing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) {
|
||||
if (targetSampleRate != null && targetSampleRate > 0) {
|
||||
arguments
|
||||
..add('-ar')
|
||||
..add(targetSampleRate.toString());
|
||||
}
|
||||
final sampleFmt = _losslessOutputSampleFormat(
|
||||
codec: codec,
|
||||
targetBitDepth: targetBitDepth,
|
||||
);
|
||||
_appendLosslessAresampleFilter(
|
||||
arguments,
|
||||
targetSampleRate: targetSampleRate,
|
||||
outputSampleFormat: sampleFmt,
|
||||
processing: processing,
|
||||
);
|
||||
if (targetBitDepth == null || targetBitDepth <= 0) return;
|
||||
|
||||
if (codec == 'flac') {
|
||||
@@ -431,6 +438,48 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static String? _losslessOutputSampleFormat({
|
||||
required String codec,
|
||||
int? targetBitDepth,
|
||||
}) {
|
||||
if (targetBitDepth == null || targetBitDepth <= 0) return null;
|
||||
|
||||
if (codec == 'flac') {
|
||||
return targetBitDepth <= 16 ? 's16' : 's32';
|
||||
}
|
||||
if (codec == 'alac') {
|
||||
return targetBitDepth <= 16 ? 's16p' : 's32p';
|
||||
}
|
||||
if (codec == 'pcm') {
|
||||
return targetBitDepth <= 16 ? 's16' : 's32';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void _appendLosslessAresampleFilter(
|
||||
List<String> arguments, {
|
||||
int? targetSampleRate,
|
||||
String? outputSampleFormat,
|
||||
LosslessConversionProcessing processing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) {
|
||||
final hasSampleRate = targetSampleRate != null && targetSampleRate > 0;
|
||||
final hasSampleFormat =
|
||||
outputSampleFormat != null && outputSampleFormat.trim().isNotEmpty;
|
||||
if (!hasSampleRate && !hasSampleFormat && !processing.hasDither) return;
|
||||
|
||||
final options = <String>[
|
||||
'resampler=${processing.normalizedResampler}',
|
||||
if (hasSampleRate) 'osr=$targetSampleRate',
|
||||
if (hasSampleFormat) 'osf=${outputSampleFormat.trim()}',
|
||||
if (processing.hasDither) 'dither_method=${processing.normalizedDither}',
|
||||
];
|
||||
|
||||
arguments
|
||||
..add('-af')
|
||||
..add('aresample=${options.join(':')}');
|
||||
}
|
||||
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
@@ -2349,6 +2398,8 @@ class FFmpegService {
|
||||
int? sourceBitDepth,
|
||||
LosslessConversionQuality losslessQuality =
|
||||
const LosslessConversionQuality(),
|
||||
LosslessConversionProcessing losslessProcessing =
|
||||
const LosslessConversionProcessing(),
|
||||
}) async {
|
||||
final format = targetFormat.toLowerCase();
|
||||
if (!const {
|
||||
@@ -2380,6 +2431,7 @@ class FFmpegService {
|
||||
coverPath: coverPath,
|
||||
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||
processing: losslessProcessing,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
@@ -2391,6 +2443,7 @@ class FFmpegService {
|
||||
artistTagMode: artistTagMode,
|
||||
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||
processing: losslessProcessing,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
@@ -2403,6 +2456,7 @@ class FFmpegService {
|
||||
sourceBitDepth: sourceBitDepth,
|
||||
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||
processing: losslessProcessing,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
@@ -2500,6 +2554,8 @@ class FFmpegService {
|
||||
String? coverPath,
|
||||
int? targetBitDepth,
|
||||
int? targetSampleRate,
|
||||
LosslessConversionProcessing processing =
|
||||
const LosslessConversionProcessing(),
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
||||
@@ -2539,6 +2595,7 @@ class FFmpegService {
|
||||
codec: 'alac',
|
||||
targetBitDepth: targetBitDepth,
|
||||
targetSampleRate: targetSampleRate,
|
||||
processing: processing,
|
||||
);
|
||||
arguments
|
||||
..add('-map_metadata')
|
||||
@@ -2553,7 +2610,9 @@ class FFmpegService {
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC'
|
||||
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
|
||||
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}',
|
||||
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}'
|
||||
'${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}'
|
||||
'${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}',
|
||||
);
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
@@ -2583,6 +2642,8 @@ class FFmpegService {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
int? targetBitDepth,
|
||||
int? targetSampleRate,
|
||||
LosslessConversionProcessing processing =
|
||||
const LosslessConversionProcessing(),
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
@@ -2624,6 +2685,7 @@ class FFmpegService {
|
||||
codec: 'flac',
|
||||
targetBitDepth: targetBitDepth,
|
||||
targetSampleRate: targetSampleRate,
|
||||
processing: processing,
|
||||
);
|
||||
arguments
|
||||
..add('-map_metadata')
|
||||
@@ -2642,7 +2704,9 @@ class FFmpegService {
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC'
|
||||
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
|
||||
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}',
|
||||
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}'
|
||||
'${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}'
|
||||
'${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}',
|
||||
);
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
@@ -2676,6 +2740,8 @@ class FFmpegService {
|
||||
int? sourceBitDepth,
|
||||
int? targetBitDepth,
|
||||
int? targetSampleRate,
|
||||
LosslessConversionProcessing processing =
|
||||
const LosslessConversionProcessing(),
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final isAiff = container == 'aiff';
|
||||
@@ -2697,22 +2763,24 @@ class FFmpegService {
|
||||
inputPath,
|
||||
'-map',
|
||||
'0:a',
|
||||
'-c:a',
|
||||
codec,
|
||||
if (targetSampleRate != null && targetSampleRate > 0) ...[
|
||||
'-ar',
|
||||
targetSampleRate.toString(),
|
||||
],
|
||||
'-map_metadata',
|
||||
'-1',
|
||||
outputPath,
|
||||
'-y',
|
||||
];
|
||||
_appendLosslessAresampleFilter(
|
||||
arguments,
|
||||
targetSampleRate: targetSampleRate,
|
||||
outputSampleFormat: _losslessOutputSampleFormat(
|
||||
codec: 'pcm',
|
||||
targetBitDepth: targetBitDepth,
|
||||
),
|
||||
processing: processing,
|
||||
);
|
||||
arguments.addAll(['-c:a', codec, '-map_metadata', '-1', outputPath, '-y']);
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
|
||||
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit'
|
||||
'${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''})',
|
||||
'${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''}'
|
||||
'${processing.hasDither ? ', dither=${processing.normalizedDither}' : ''}'
|
||||
'${processing.normalizedResampler != 'swr' ? ', resampler=${processing.normalizedResampler}' : ''})',
|
||||
);
|
||||
final result = await _executeWithArguments(arguments);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -19,6 +19,36 @@ const List<int> losslessConversionSampleRateOptions = [
|
||||
|
||||
const List<int> losslessConversionBitDepthOptions = [16, 24];
|
||||
|
||||
const List<String> losslessDitherOptions = [
|
||||
'none',
|
||||
'triangular',
|
||||
'triangular_hp',
|
||||
];
|
||||
|
||||
const List<String> losslessResamplerOptions = ['swr', 'soxr'];
|
||||
|
||||
class LosslessConversionProcessing {
|
||||
final String dither;
|
||||
final String resampler;
|
||||
|
||||
const LosslessConversionProcessing({
|
||||
this.dither = 'none',
|
||||
this.resampler = 'swr',
|
||||
});
|
||||
|
||||
String get normalizedDither {
|
||||
final normalized = dither.trim().toLowerCase();
|
||||
return losslessDitherOptions.contains(normalized) ? normalized : 'none';
|
||||
}
|
||||
|
||||
String get normalizedResampler {
|
||||
final normalized = resampler.trim().toLowerCase();
|
||||
return losslessResamplerOptions.contains(normalized) ? normalized : 'swr';
|
||||
}
|
||||
|
||||
bool get hasDither => normalizedDither != 'none';
|
||||
}
|
||||
|
||||
List<int> availableLosslessBitDepthOptions(int? sourceBitDepth) {
|
||||
if (sourceBitDepth == null || sourceBitDepth <= 0) {
|
||||
return losslessConversionBitDepthOptions;
|
||||
@@ -189,12 +219,29 @@ extension LosslessConversionLabelsL10n on AppLocalizations {
|
||||
originalQuality: trackConvertOriginalQuality,
|
||||
lossless: trackConvertLosslessSuffix,
|
||||
);
|
||||
|
||||
String losslessDitherOptionLabel(String dither) {
|
||||
switch (dither.trim().toLowerCase()) {
|
||||
case 'triangular':
|
||||
return trackConvertDitherTriangular;
|
||||
case 'triangular_hp':
|
||||
return trackConvertDitherTriangularHp;
|
||||
default:
|
||||
return trackConvertDitherNone;
|
||||
}
|
||||
}
|
||||
|
||||
String losslessResamplerOptionLabel(String resampler) {
|
||||
switch (resampler.trim().toLowerCase()) {
|
||||
case 'soxr':
|
||||
return trackConvertResamplerSoxr;
|
||||
default:
|
||||
return trackConvertResamplerSwr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String losslessBitDepthLabel(
|
||||
int? bitDepth, {
|
||||
required String originalLabel,
|
||||
}) {
|
||||
String losslessBitDepthLabel(int? bitDepth, {required String originalLabel}) {
|
||||
return bitDepth == null ? originalLabel : '$bitDepth-bit';
|
||||
}
|
||||
|
||||
@@ -216,10 +263,7 @@ String losslessQualityLabel(
|
||||
final parts = <String>[];
|
||||
if (quality.maxBitDepth != null) {
|
||||
parts.add(
|
||||
losslessBitDepthLabel(
|
||||
quality.maxBitDepth,
|
||||
originalLabel: originalLabel,
|
||||
),
|
||||
losslessBitDepthLabel(quality.maxBitDepth, originalLabel: originalLabel),
|
||||
);
|
||||
}
|
||||
if (quality.maxSampleRate != null) {
|
||||
@@ -250,11 +294,7 @@ String convertedAudioQualityLabel({
|
||||
return '$upper ${losslessBitDepthLabel(actualBitDepth, originalLabel: labels.original)}/${losslessSampleRateLabel(actualSampleRate, originalLabel: labels.original)}';
|
||||
}
|
||||
if (losslessQuality.hasCaps) {
|
||||
return '$upper ${losslessQualityLabel(
|
||||
losslessQuality,
|
||||
originalLabel: labels.original,
|
||||
originalQualityLabel: labels.originalQuality,
|
||||
)}';
|
||||
return '$upper ${losslessQualityLabel(losslessQuality, originalLabel: labels.original, originalQualityLabel: labels.originalQuality)}';
|
||||
}
|
||||
return '$upper ${labels.lossless}';
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class BatchConvertSheet extends StatefulWidget {
|
||||
String format,
|
||||
String bitrate,
|
||||
LosslessConversionQuality losslessQuality,
|
||||
LosslessConversionProcessing losslessProcessing,
|
||||
)
|
||||
onConvert;
|
||||
|
||||
@@ -42,6 +43,8 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
late String _selectedBitrate;
|
||||
int? _selectedMaxBitDepth;
|
||||
int? _selectedMaxSampleRate;
|
||||
String _selectedDither = 'none';
|
||||
String _selectedResampler = 'swr';
|
||||
|
||||
String _defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
@@ -132,6 +135,8 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
} else {
|
||||
_selectedMaxBitDepth = null;
|
||||
_selectedMaxSampleRate = null;
|
||||
_selectedDither = 'none';
|
||||
_selectedResampler = 'swr';
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -183,8 +188,10 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: _selectedMaxBitDepth == null,
|
||||
onTap: () =>
|
||||
setState(() => _selectedMaxBitDepth = null),
|
||||
onTap: () => setState(() {
|
||||
_selectedMaxBitDepth = null;
|
||||
_selectedDither = 'none';
|
||||
}),
|
||||
),
|
||||
...bitDepthOptions.map((depth) {
|
||||
return _choice(
|
||||
@@ -222,8 +229,10 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
originalLabel: labels.original,
|
||||
),
|
||||
selected: _selectedMaxSampleRate == null,
|
||||
onTap: () =>
|
||||
setState(() => _selectedMaxSampleRate = null),
|
||||
onTap: () => setState(() {
|
||||
_selectedMaxSampleRate = null;
|
||||
_selectedResampler = 'swr';
|
||||
}),
|
||||
),
|
||||
...sampleRateOptions.map((rate) {
|
||||
return _choice(
|
||||
@@ -243,6 +252,53 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLosslessTarget && _selectedMaxBitDepth != null)
|
||||
_card(
|
||||
cs,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionLabel(cs, context.l10n.trackConvertDithering),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: losslessDitherOptions.map((mode) {
|
||||
return _choice(
|
||||
cs,
|
||||
label: context.l10n.losslessDitherOptionLabel(mode),
|
||||
selected: mode == _selectedDither,
|
||||
onTap: () => setState(() => _selectedDither = mode),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLosslessTarget && _selectedMaxSampleRate != null)
|
||||
_card(
|
||||
cs,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionLabel(cs, context.l10n.trackConvertResampler),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: losslessResamplerOptions.map((mode) {
|
||||
return _choice(
|
||||
cs,
|
||||
label: context.l10n.losslessResamplerOptionLabel(mode),
|
||||
selected: mode == _selectedResampler,
|
||||
onTap: () =>
|
||||
setState(() => _selectedResampler = mode),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLosslessTarget)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
@@ -294,6 +350,10 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
maxBitDepth: _selectedMaxBitDepth,
|
||||
maxSampleRate: _selectedMaxSampleRate,
|
||||
),
|
||||
LosslessConversionProcessing(
|
||||
dither: _selectedDither,
|
||||
resampler: _selectedResampler,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
style: FilledButton.styleFrom(
|
||||
|
||||
Reference in New Issue
Block a user