mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-07 05:07:59 +02:00
feat: cap bit depth/sample rate on lossless conversion + WAV/AIFF
- LosslessConversionQuality model with bit depth/sample rate caps, applied only when they reduce source quality - FFmpegService probes sample rate and appends codec-specific args (-ar, -sample_fmt, -bits_per_raw_sample) for FLAC/ALAC/WAV/AIFF - Batch + single-track convert sheets expose quality cap options - Persist real converted bit depth/sample rate to history/library DB - track_metadata: recognize and convert to WAV/AIFF targets - convertedAudioQualityLabel reflects actual output quality
This commit is contained in:
@@ -5499,6 +5499,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
) async {
|
) async {
|
||||||
final itemsById = {for (final item in allItems) item.id: item};
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
final sourceFormats = <String>{};
|
final sourceFormats = <String>{};
|
||||||
|
final sourceBitDepths = <int?>[];
|
||||||
|
final sourceSampleRates = <int?>[];
|
||||||
for (final id in _selectedIds) {
|
for (final id in _selectedIds) {
|
||||||
final item = itemsById[id];
|
final item = itemsById[id];
|
||||||
if (item == null) continue;
|
if (item == null) continue;
|
||||||
@@ -5508,6 +5510,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
fileName: item.historyItem?.safFileName,
|
fileName: item.historyItem?.safFileName,
|
||||||
);
|
);
|
||||||
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
if (sourceFormat != null) sourceFormats.add(sourceFormat);
|
||||||
|
sourceBitDepths.add(
|
||||||
|
item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
||||||
|
);
|
||||||
|
sourceSampleRates.add(
|
||||||
|
item.historyItem?.sampleRate ?? item.localItem?.sampleRate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final formats = audioConversionTargetFormats
|
final formats = audioConversionTargetFormats
|
||||||
@@ -5546,13 +5554,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
formats: formats,
|
formats: formats,
|
||||||
title: sheetTitle,
|
title: sheetTitle,
|
||||||
confirmLabel: sheetConfirmLabel,
|
confirmLabel: sheetConfirmLabel,
|
||||||
onConvert: (format, bitrate) {
|
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||||
|
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||||
|
onConvert: (format, bitrate, losslessQuality) {
|
||||||
didStartConversion = true;
|
didStartConversion = true;
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_performBatchConversion(
|
_performBatchConversion(
|
||||||
allItems: allItems,
|
allItems: allItems,
|
||||||
targetFormat: format,
|
targetFormat: format,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -5585,6 +5596,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
required List<UnifiedLibraryItem> allItems,
|
required List<UnifiedLibraryItem> allItems,
|
||||||
required String targetFormat,
|
required String targetFormat,
|
||||||
required String bitrate,
|
required String bitrate,
|
||||||
|
LosslessConversionQuality losslessQuality =
|
||||||
|
const LosslessConversionQuality(),
|
||||||
}) async {
|
}) async {
|
||||||
final itemsById = {for (final item in allItems) item.id: item};
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
final selectedItems = <UnifiedLibraryItem>[];
|
final selectedItems = <UnifiedLibraryItem>[];
|
||||||
@@ -5621,7 +5634,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
isLossless
|
isLossless && losslessQuality.hasCaps
|
||||||
|
? 'Convert ${selectedItems.length} tracks to $targetFormat (${losslessQualityLabel(losslessQuality)})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.'
|
||||||
|
: isLossless
|
||||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
selectedItems.length,
|
selectedItems.length,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
@@ -5650,9 +5665,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
final total = selectedItems.length;
|
final total = selectedItems.length;
|
||||||
final historyDb = HistoryDatabase.instance;
|
final historyDb = HistoryDatabase.instance;
|
||||||
final newQuality = isLosslessConversionTarget(targetFormat)
|
|
||||||
? '${targetFormat.toUpperCase()} Lossless'
|
|
||||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
@@ -5733,6 +5745,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
artistTagMode: settings.artistTagMode,
|
artistTagMode: settings.artistTagMode,
|
||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
|
sourceBitDepth:
|
||||||
|
item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
@@ -5750,6 +5765,42 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final sourceBitDepth =
|
||||||
|
item.historyItem?.bitDepth ?? item.localItem?.bitDepth;
|
||||||
|
final sourceSampleRate =
|
||||||
|
item.historyItem?.sampleRate ?? item.localItem?.sampleRate;
|
||||||
|
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
||||||
|
int? convertedBitDepth;
|
||||||
|
int? convertedSampleRate;
|
||||||
|
if (isLosslessOutput) {
|
||||||
|
try {
|
||||||
|
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
||||||
|
newPath,
|
||||||
|
);
|
||||||
|
if (convertedMetadata['error'] == null) {
|
||||||
|
convertedBitDepth = readPositiveAudioInt(
|
||||||
|
convertedMetadata['bit_depth'],
|
||||||
|
);
|
||||||
|
convertedSampleRate = readPositiveAudioInt(
|
||||||
|
convertedMetadata['sample_rate'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
convertedBitDepth ??= losslessQuality.effectiveBitDepth(
|
||||||
|
sourceBitDepth,
|
||||||
|
);
|
||||||
|
convertedSampleRate ??= losslessQuality.effectiveSampleRate(
|
||||||
|
sourceSampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final newQuality = convertedAudioQualityLabel(
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
actualBitDepth: convertedBitDepth,
|
||||||
|
actualSampleRate: convertedSampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
if (isSaf && item.historyItem != null) {
|
if (isSaf && item.historyItem != null) {
|
||||||
final hi = item.historyItem!;
|
final hi = item.historyItem!;
|
||||||
final treeUri = hi.downloadTreeUri;
|
final treeUri = hi.downloadTreeUri;
|
||||||
@@ -5801,7 +5852,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
),
|
),
|
||||||
clearAudioSpecs: true,
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -5890,6 +5943,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
newFilePath: safUri,
|
newFilePath: safUri,
|
||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
bitDepth: convertedBitDepth,
|
||||||
|
sampleRate: convertedSampleRate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5911,7 +5966,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
),
|
),
|
||||||
clearAudioSpecs: true,
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
);
|
);
|
||||||
} else if (item.localItem != null) {
|
} else if (item.localItem != null) {
|
||||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||||
@@ -5919,6 +5976,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
newFilePath: newPath,
|
newFilePath: newPath,
|
||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
bitDepth: convertedBitDepth,
|
||||||
|
sampleRate: convertedSampleRate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -775,6 +775,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? get _localCoverPath =>
|
String? get _localCoverPath =>
|
||||||
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||||
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
||||||
@@ -1219,9 +1220,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text(playNext ? 'Playing next' : 'Added to queue')),
|
||||||
content: Text(playNext ? 'Playing next' : 'Added to queue'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3568,6 +3567,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return lower.endsWith('.flac') ||
|
return lower.endsWith('.flac') ||
|
||||||
lower.endsWith('.m4a') ||
|
lower.endsWith('.m4a') ||
|
||||||
lower.endsWith('.aac') ||
|
lower.endsWith('.aac') ||
|
||||||
|
lower.endsWith('.wav') ||
|
||||||
|
lower.endsWith('.aiff') ||
|
||||||
|
lower.endsWith('.aif') ||
|
||||||
lower.endsWith('.mp3') ||
|
lower.endsWith('.mp3') ||
|
||||||
lower.endsWith('.opus') ||
|
lower.endsWith('.opus') ||
|
||||||
lower.endsWith('.ogg');
|
lower.endsWith('.ogg');
|
||||||
@@ -3613,12 +3615,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
case 'opus':
|
case 'opus':
|
||||||
case 'ogg':
|
case 'ogg':
|
||||||
return 'Opus';
|
return 'Opus';
|
||||||
|
case 'wav':
|
||||||
|
case 'wave':
|
||||||
|
return 'WAV';
|
||||||
|
case 'aiff':
|
||||||
|
case 'aif':
|
||||||
|
case 'aifc':
|
||||||
|
return 'AIFF';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final lower = cleanFilePath.toLowerCase();
|
final lower = cleanFilePath.toLowerCase();
|
||||||
if (lower.endsWith('.flac')) return 'FLAC';
|
if (lower.endsWith('.flac')) return 'FLAC';
|
||||||
if (lower.endsWith('.m4a')) return 'M4A';
|
if (lower.endsWith('.m4a')) return 'M4A';
|
||||||
if (lower.endsWith('.aac')) return 'AAC';
|
if (lower.endsWith('.aac')) return 'AAC';
|
||||||
|
if (lower.endsWith('.wav')) return 'WAV';
|
||||||
|
if (lower.endsWith('.aiff') || lower.endsWith('.aif')) return 'AIFF';
|
||||||
if (lower.endsWith('.mp3')) return 'MP3';
|
if (lower.endsWith('.mp3')) return 'MP3';
|
||||||
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
|
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
|
||||||
if (lower.endsWith('.cue')) return 'CUE';
|
if (lower.endsWith('.cue')) return 'CUE';
|
||||||
@@ -3701,15 +3712,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildConvertedQualityLabel(String targetFormat, String bitrate) {
|
|
||||||
final upper = targetFormat.toUpperCase();
|
|
||||||
if (isLosslessConversionTarget(targetFormat)) {
|
|
||||||
return '$upper Lossless';
|
|
||||||
}
|
|
||||||
final normalizedBitrate = bitrate.trim().toLowerCase();
|
|
||||||
return '$upper $normalizedBitrate';
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _extractLossyBitrateLabel(String? quality) {
|
String? _extractLossyBitrateLabel(String? quality) {
|
||||||
if (quality == null || quality.isEmpty) return null;
|
if (quality == null || quality.isEmpty) return null;
|
||||||
final match = RegExp(
|
final match = RegExp(
|
||||||
@@ -3808,6 +3810,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
|
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
|
||||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||||
|
int? selectedMaxBitDepth;
|
||||||
|
int? selectedMaxSampleRate;
|
||||||
|
final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth);
|
||||||
|
final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate);
|
||||||
|
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -3875,9 +3881,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected
|
color: selected
|
||||||
? Colors.transparent
|
? Colors.transparent
|
||||||
: colorScheme.outlineVariant.withValues(
|
: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||||
alpha: 0.6,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -3949,8 +3953,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
isLosslessTarget =
|
isLosslessTarget =
|
||||||
isLosslessConversionTarget(format);
|
isLosslessConversionTarget(format);
|
||||||
if (!isLosslessTarget) {
|
if (!isLosslessTarget) {
|
||||||
selectedBitrate =
|
selectedBitrate = defaultBitrateForFormat(
|
||||||
defaultBitrateForFormat(format);
|
format,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedMaxBitDepth = null;
|
||||||
|
selectedMaxSampleRate = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -3974,9 +3982,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return choice(
|
return choice(
|
||||||
label: br,
|
label: br,
|
||||||
selected: br == selectedBitrate,
|
selected: br == selectedBitrate,
|
||||||
onTap: () => setSheetState(
|
onTap: () =>
|
||||||
() => selectedBitrate = br,
|
setSheetState(() => selectedBitrate = br),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
@@ -3984,6 +3991,70 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
if (isLosslessTarget && bitDepthOptions.isNotEmpty)
|
||||||
|
card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
sectionLabel('Bit depth'),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
choice(
|
||||||
|
label: losslessBitDepthLabel(null),
|
||||||
|
selected: selectedMaxBitDepth == null,
|
||||||
|
onTap: () => setSheetState(
|
||||||
|
() => selectedMaxBitDepth = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...bitDepthOptions.map((depth) {
|
||||||
|
return choice(
|
||||||
|
label: losslessBitDepthLabel(depth),
|
||||||
|
selected: depth == selectedMaxBitDepth,
|
||||||
|
onTap: () => setSheetState(
|
||||||
|
() => selectedMaxBitDepth = depth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
|
||||||
|
card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
sectionLabel('Sample rate'),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
choice(
|
||||||
|
label: losslessSampleRateLabel(null),
|
||||||
|
selected: selectedMaxSampleRate == null,
|
||||||
|
onTap: () => setSheetState(
|
||||||
|
() => selectedMaxSampleRate = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...sampleRateOptions.map((rate) {
|
||||||
|
return choice(
|
||||||
|
label: losslessSampleRateLabel(rate),
|
||||||
|
selected: rate == selectedMaxSampleRate,
|
||||||
|
onTap: () => setSheetState(
|
||||||
|
() => selectedMaxSampleRate = rate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
if (isLosslessTarget && isLosslessSource)
|
if (isLosslessTarget && isLosslessSource)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -4008,7 +4079,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.trackConvertLosslessHint,
|
selectedMaxBitDepth == null &&
|
||||||
|
selectedMaxSampleRate == null
|
||||||
|
? context.l10n.trackConvertLosslessHint
|
||||||
|
: 'Lossless output with ${losslessQualityLabel(LosslessConversionQuality(maxBitDepth: selectedMaxBitDepth, maxSampleRate: selectedMaxSampleRate))} cap',
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
?.copyWith(color: colorScheme.primary),
|
?.copyWith(color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
@@ -4028,6 +4102,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
sourceFormat: currentFormat,
|
sourceFormat: currentFormat,
|
||||||
targetFormat: selectedFormat,
|
targetFormat: selectedFormat,
|
||||||
bitrate: selectedBitrate,
|
bitrate: selectedBitrate,
|
||||||
|
losslessQuality: LosslessConversionQuality(
|
||||||
|
maxBitDepth: selectedMaxBitDepth,
|
||||||
|
maxSampleRate: selectedMaxSampleRate,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.swap_horiz),
|
icon: const Icon(Icons.swap_horiz),
|
||||||
@@ -4039,7 +4117,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
isLosslessTarget
|
isLosslessTarget
|
||||||
? '$currentFormat → $selectedFormat (Lossless)'
|
? '$currentFormat → $selectedFormat (${losslessQualityLabel(LosslessConversionQuality(maxBitDepth: selectedMaxBitDepth, maxSampleRate: selectedMaxSampleRate))})'
|
||||||
: '$currentFormat → $selectedFormat @ $selectedBitrate',
|
: '$currentFormat → $selectedFormat @ $selectedBitrate',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -4510,6 +4588,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
required String sourceFormat,
|
required String sourceFormat,
|
||||||
required String targetFormat,
|
required String targetFormat,
|
||||||
required String bitrate,
|
required String bitrate,
|
||||||
|
LosslessConversionQuality losslessQuality =
|
||||||
|
const LosslessConversionQuality(),
|
||||||
}) {
|
}) {
|
||||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
@@ -4518,7 +4598,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(dialogContext.l10n.trackConvertConfirmTitle),
|
title: Text(dialogContext.l10n.trackConvertConfirmTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
isLossless
|
isLossless && losslessQuality.hasCaps
|
||||||
|
? 'Convert $sourceFormat to $targetFormat (${losslessQualityLabel(losslessQuality)})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.'
|
||||||
|
: isLossless
|
||||||
? dialogContext.l10n.trackConvertConfirmMessageLossless(
|
? dialogContext.l10n.trackConvertConfirmMessageLossless(
|
||||||
sourceFormat,
|
sourceFormat,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
@@ -4540,6 +4622,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_performConversion(
|
_performConversion(
|
||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Text(dialogContext.l10n.trackConvertFormat),
|
child: Text(dialogContext.l10n.trackConvertFormat),
|
||||||
@@ -4553,6 +4636,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
Future<void> _performConversion({
|
Future<void> _performConversion({
|
||||||
required String targetFormat,
|
required String targetFormat,
|
||||||
required String bitrate,
|
required String bitrate,
|
||||||
|
LosslessConversionQuality losslessQuality =
|
||||||
|
const LosslessConversionQuality(),
|
||||||
}) async {
|
}) async {
|
||||||
if (_isConverting) return;
|
if (_isConverting) return;
|
||||||
setState(() => _isConverting = true);
|
setState(() => _isConverting = true);
|
||||||
@@ -4626,6 +4711,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
|
sourceBitDepth: bitDepth,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
@@ -4649,7 +4736,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate);
|
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
||||||
|
int? convertedBitDepth;
|
||||||
|
int? convertedSampleRate;
|
||||||
|
if (isLosslessOutput) {
|
||||||
|
try {
|
||||||
|
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
||||||
|
newPath,
|
||||||
|
);
|
||||||
|
if (convertedMetadata['error'] == null) {
|
||||||
|
convertedBitDepth = readPositiveAudioInt(
|
||||||
|
convertedMetadata['bit_depth'],
|
||||||
|
);
|
||||||
|
convertedSampleRate = readPositiveAudioInt(
|
||||||
|
convertedMetadata['sample_rate'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
convertedBitDepth ??= losslessQuality.effectiveBitDepth(bitDepth);
|
||||||
|
convertedSampleRate ??= losslessQuality.effectiveSampleRate(sampleRate);
|
||||||
|
}
|
||||||
|
final newQuality = convertedAudioQualityLabel(
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
losslessQuality: losslessQuality,
|
||||||
|
actualBitDepth: convertedBitDepth,
|
||||||
|
actualSampleRate: convertedSampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
String? treeUri;
|
String? treeUri;
|
||||||
@@ -4771,7 +4884,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
),
|
),
|
||||||
clearAudioSpecs: true,
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
);
|
);
|
||||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
} else {
|
} else {
|
||||||
@@ -4780,6 +4895,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
newFilePath: safUri,
|
newFilePath: safUri,
|
||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
bitDepth: convertedBitDepth,
|
||||||
|
sampleRate: convertedSampleRate,
|
||||||
);
|
);
|
||||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
}
|
}
|
||||||
@@ -4803,7 +4920,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
),
|
),
|
||||||
clearAudioSpecs: true,
|
newBitDepth: convertedBitDepth,
|
||||||
|
newSampleRate: convertedSampleRate,
|
||||||
|
clearAudioSpecs: !isLosslessOutput,
|
||||||
);
|
);
|
||||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
} else {
|
} else {
|
||||||
@@ -4812,6 +4931,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
newFilePath: newPath,
|
newFilePath: newPath,
|
||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
bitDepth: convertedBitDepth,
|
||||||
|
sampleRate: convertedSampleRate,
|
||||||
);
|
);
|
||||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||||
|
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
@@ -111,6 +112,16 @@ class DownloadDecryptionDescriptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ResolvedLosslessConversionQuality {
|
||||||
|
final int? targetBitDepth;
|
||||||
|
final int? targetSampleRate;
|
||||||
|
|
||||||
|
const _ResolvedLosslessConversionQuality({
|
||||||
|
this.targetBitDepth,
|
||||||
|
this.targetSampleRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class FFmpegService {
|
class FFmpegService {
|
||||||
static const int _commandLogPreviewLength = 300;
|
static const int _commandLogPreviewLength = 300;
|
||||||
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
||||||
@@ -306,6 +317,24 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<int?> probeSampleRate(String filePath) async {
|
||||||
|
try {
|
||||||
|
final session = await FFprobeKit.getMediaInformation(filePath);
|
||||||
|
final info = session.getMediaInformation();
|
||||||
|
if (info == null) return null;
|
||||||
|
for (final stream in info.getStreams()) {
|
||||||
|
final props = stream.getAllProperties() ?? const <String, dynamic>{};
|
||||||
|
if (props['codec_type']?.toString() != 'audio') continue;
|
||||||
|
final value = int.tryParse(props['sample_rate']?.toString() ?? '');
|
||||||
|
if (value != null && value > 0) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Sample rate probe failed for $filePath: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` when [filePath] starts with the native FLAC magic bytes
|
/// Returns `true` when [filePath] starts with the native FLAC magic bytes
|
||||||
/// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4
|
/// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4
|
||||||
/// container that carries a `.flac` extension or claims codec=flac.
|
/// container that carries a `.flac` extension or claims codec=flac.
|
||||||
@@ -328,6 +357,80 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<_ResolvedLosslessConversionQuality> _resolveLosslessQuality({
|
||||||
|
required String inputPath,
|
||||||
|
required LosslessConversionQuality quality,
|
||||||
|
int? sourceBitDepth,
|
||||||
|
}) async {
|
||||||
|
final probedBitDepth =
|
||||||
|
sourceBitDepth ??
|
||||||
|
(quality.maxBitDepth != null ? await probeBitDepth(inputPath) : null);
|
||||||
|
final probedSampleRate = quality.maxSampleRate != null
|
||||||
|
? await probeSampleRate(inputPath)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
int? targetBitDepth;
|
||||||
|
if (quality.maxBitDepth != null &&
|
||||||
|
(probedBitDepth == null || probedBitDepth > quality.maxBitDepth!)) {
|
||||||
|
targetBitDepth = quality.maxBitDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? targetSampleRate;
|
||||||
|
if (quality.maxSampleRate != null &&
|
||||||
|
(probedSampleRate == null ||
|
||||||
|
probedSampleRate > quality.maxSampleRate!)) {
|
||||||
|
targetSampleRate = quality.maxSampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ResolvedLosslessConversionQuality(
|
||||||
|
targetBitDepth: targetBitDepth,
|
||||||
|
targetSampleRate: targetSampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _appendLosslessCodecQualityArguments(
|
||||||
|
List<String> arguments, {
|
||||||
|
required String codec,
|
||||||
|
int? targetBitDepth,
|
||||||
|
int? targetSampleRate,
|
||||||
|
}) {
|
||||||
|
if (targetSampleRate != null && targetSampleRate > 0) {
|
||||||
|
arguments
|
||||||
|
..add('-ar')
|
||||||
|
..add(targetSampleRate.toString());
|
||||||
|
}
|
||||||
|
if (targetBitDepth == null || targetBitDepth <= 0) return;
|
||||||
|
|
||||||
|
if (codec == 'flac') {
|
||||||
|
if (targetBitDepth <= 16) {
|
||||||
|
arguments
|
||||||
|
..add('-sample_fmt')
|
||||||
|
..add('s16');
|
||||||
|
} else if (targetBitDepth <= 24) {
|
||||||
|
arguments
|
||||||
|
..add('-sample_fmt')
|
||||||
|
..add('s32')
|
||||||
|
..add('-bits_per_raw_sample')
|
||||||
|
..add('24');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codec == 'alac') {
|
||||||
|
if (targetBitDepth <= 16) {
|
||||||
|
arguments
|
||||||
|
..add('-sample_fmt')
|
||||||
|
..add('s16p');
|
||||||
|
} else if (targetBitDepth <= 24) {
|
||||||
|
arguments
|
||||||
|
..add('-sample_fmt')
|
||||||
|
..add('s32p')
|
||||||
|
..add('-bits_per_raw_sample')
|
||||||
|
..add('24');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
|
|
||||||
@@ -2048,7 +2151,10 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
|
_appendMappedMetadataToArguments(
|
||||||
|
arguments,
|
||||||
|
_convertToM4aTags(metadata),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOV muxer accepts codecs the MP4 muxer rejects (e.g. AC-4). The default
|
// MOV muxer accepts codecs the MP4 muxer rejects (e.g. AC-4). The default
|
||||||
@@ -2228,8 +2334,10 @@ class FFmpegService {
|
|||||||
|
|
||||||
/// Unified audio format conversion with full metadata + cover preservation.
|
/// Unified audio format conversion with full metadata + cover preservation.
|
||||||
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF.
|
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF.
|
||||||
/// ALAC, FLAC, WAV and AIFF targets are lossless (bitrate parameter is ignored).
|
/// ALAC, FLAC, WAV and AIFF targets are lossless codecs (bitrate parameter
|
||||||
/// [sourceBitDepth] (when known) preserves 24-bit resolution for WAV/AIFF.
|
/// is ignored). [losslessQuality] can cap bit depth/sample rate, and caps are
|
||||||
|
/// only applied when they reduce the source quality.
|
||||||
|
/// [sourceBitDepth] (when known) avoids an extra probe.
|
||||||
static Future<String?> convertAudioFormat({
|
static Future<String?> convertAudioFormat({
|
||||||
required String inputPath,
|
required String inputPath,
|
||||||
required String targetFormat,
|
required String targetFormat,
|
||||||
@@ -2239,6 +2347,8 @@ class FFmpegService {
|
|||||||
String artistTagMode = artistTagModeJoined,
|
String artistTagMode = artistTagModeJoined,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
int? sourceBitDepth,
|
int? sourceBitDepth,
|
||||||
|
LosslessConversionQuality losslessQuality =
|
||||||
|
const LosslessConversionQuality(),
|
||||||
}) async {
|
}) async {
|
||||||
final format = targetFormat.toLowerCase();
|
final format = targetFormat.toLowerCase();
|
||||||
if (!const {
|
if (!const {
|
||||||
@@ -2255,11 +2365,21 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final resolvedLosslessQuality = isLosslessConversionTarget(format)
|
||||||
|
? await _resolveLosslessQuality(
|
||||||
|
inputPath: inputPath,
|
||||||
|
quality: losslessQuality,
|
||||||
|
sourceBitDepth: sourceBitDepth,
|
||||||
|
)
|
||||||
|
: const _ResolvedLosslessConversionQuality();
|
||||||
|
|
||||||
if (format == 'alac') {
|
if (format == 'alac') {
|
||||||
return _convertToAlac(
|
return _convertToAlac(
|
||||||
inputPath: inputPath,
|
inputPath: inputPath,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
|
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||||
|
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||||
deleteOriginal: deleteOriginal,
|
deleteOriginal: deleteOriginal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2269,6 +2389,8 @@ class FFmpegService {
|
|||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
artistTagMode: artistTagMode,
|
artistTagMode: artistTagMode,
|
||||||
|
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||||
|
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||||
deleteOriginal: deleteOriginal,
|
deleteOriginal: deleteOriginal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2279,6 +2401,8 @@ class FFmpegService {
|
|||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
container: format == 'wav' ? 'wav' : 'aiff',
|
container: format == 'wav' ? 'wav' : 'aiff',
|
||||||
sourceBitDepth: sourceBitDepth,
|
sourceBitDepth: sourceBitDepth,
|
||||||
|
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||||
|
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||||
deleteOriginal: deleteOriginal,
|
deleteOriginal: deleteOriginal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2374,6 +2498,8 @@ class FFmpegService {
|
|||||||
required String inputPath,
|
required String inputPath,
|
||||||
required Map<String, String> metadata,
|
required Map<String, String> metadata,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
|
int? targetBitDepth,
|
||||||
|
int? targetSampleRate,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
||||||
@@ -2407,7 +2533,14 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
arguments
|
arguments
|
||||||
..add('-c:a')
|
..add('-c:a')
|
||||||
..add('alac')
|
..add('alac');
|
||||||
|
_appendLosslessCodecQualityArguments(
|
||||||
|
arguments,
|
||||||
|
codec: 'alac',
|
||||||
|
targetBitDepth: targetBitDepth,
|
||||||
|
targetSampleRate: targetSampleRate,
|
||||||
|
);
|
||||||
|
arguments
|
||||||
..add('-map_metadata')
|
..add('-map_metadata')
|
||||||
..add('-1');
|
..add('-1');
|
||||||
|
|
||||||
@@ -2418,7 +2551,9 @@ class FFmpegService {
|
|||||||
..add('-y');
|
..add('-y');
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC'
|
||||||
|
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
|
||||||
|
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}',
|
||||||
);
|
);
|
||||||
final result = await _executeWithArguments(arguments);
|
final result = await _executeWithArguments(arguments);
|
||||||
|
|
||||||
@@ -2446,6 +2581,8 @@ class FFmpegService {
|
|||||||
required Map<String, String> metadata,
|
required Map<String, String> metadata,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
String artistTagMode = artistTagModeJoined,
|
String artistTagMode = artistTagModeJoined,
|
||||||
|
int? targetBitDepth,
|
||||||
|
int? targetSampleRate,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
@@ -2481,7 +2618,14 @@ class FFmpegService {
|
|||||||
..add('-c:a')
|
..add('-c:a')
|
||||||
..add('flac')
|
..add('flac')
|
||||||
..add('-compression_level')
|
..add('-compression_level')
|
||||||
..add('8')
|
..add('8');
|
||||||
|
_appendLosslessCodecQualityArguments(
|
||||||
|
arguments,
|
||||||
|
codec: 'flac',
|
||||||
|
targetBitDepth: targetBitDepth,
|
||||||
|
targetSampleRate: targetSampleRate,
|
||||||
|
);
|
||||||
|
arguments
|
||||||
..add('-map_metadata')
|
..add('-map_metadata')
|
||||||
..add('0');
|
..add('0');
|
||||||
|
|
||||||
@@ -2496,7 +2640,9 @@ class FFmpegService {
|
|||||||
..add('-y');
|
..add('-y');
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC'
|
||||||
|
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
|
||||||
|
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}',
|
||||||
);
|
);
|
||||||
final result = await _executeWithArguments(arguments);
|
final result = await _executeWithArguments(arguments);
|
||||||
|
|
||||||
@@ -2528,11 +2674,13 @@ class FFmpegService {
|
|||||||
required String container, // 'wav' or 'aiff'
|
required String container, // 'wav' or 'aiff'
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
int? sourceBitDepth,
|
int? sourceBitDepth,
|
||||||
|
int? targetBitDepth,
|
||||||
|
int? targetSampleRate,
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final isAiff = container == 'aiff';
|
final isAiff = container == 'aiff';
|
||||||
final outputPath = _buildOutputPath(inputPath, isAiff ? '.aiff' : '.wav');
|
final outputPath = _buildOutputPath(inputPath, isAiff ? '.aiff' : '.wav');
|
||||||
var depth = sourceBitDepth;
|
var depth = targetBitDepth ?? sourceBitDepth;
|
||||||
if (depth == null || depth <= 0) {
|
if (depth == null || depth <= 0) {
|
||||||
depth = await probeBitDepth(inputPath);
|
depth = await probeBitDepth(inputPath);
|
||||||
}
|
}
|
||||||
@@ -2542,18 +2690,29 @@ class FFmpegService {
|
|||||||
: (use24 ? 'pcm_s24le' : 'pcm_s16le');
|
: (use24 ? 'pcm_s24le' : 'pcm_s16le');
|
||||||
|
|
||||||
final arguments = <String>[
|
final arguments = <String>[
|
||||||
'-v', 'error', '-hide_banner',
|
'-v',
|
||||||
'-i', inputPath,
|
'error',
|
||||||
'-map', '0:a',
|
'-hide_banner',
|
||||||
'-c:a', codec,
|
'-i',
|
||||||
'-map_metadata', '-1',
|
inputPath,
|
||||||
|
'-map',
|
||||||
|
'0:a',
|
||||||
|
'-c:a',
|
||||||
|
codec,
|
||||||
|
if (targetSampleRate != null && targetSampleRate > 0) ...[
|
||||||
|
'-ar',
|
||||||
|
targetSampleRate.toString(),
|
||||||
|
],
|
||||||
|
'-map_metadata',
|
||||||
|
'-1',
|
||||||
outputPath,
|
outputPath,
|
||||||
'-y',
|
'-y',
|
||||||
];
|
];
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
|
||||||
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit)',
|
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit'
|
||||||
|
'${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''})',
|
||||||
);
|
);
|
||||||
final result = await _executeWithArguments(arguments);
|
final result = await _executeWithArguments(arguments);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -1735,6 +1735,8 @@ class LibraryDatabase {
|
|||||||
required String newFilePath,
|
required String newFilePath,
|
||||||
required String targetFormat,
|
required String targetFormat,
|
||||||
required String bitrate,
|
required String bitrate,
|
||||||
|
int? bitDepth,
|
||||||
|
int? sampleRate,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final stat = await fileStat(newFilePath);
|
final stat = await fileStat(newFilePath);
|
||||||
@@ -1755,6 +1757,10 @@ class LibraryDatabase {
|
|||||||
normalizedFormat == 'opus' ||
|
normalizedFormat == 'opus' ||
|
||||||
normalizedFormat == 'aac') {
|
normalizedFormat == 'aac') {
|
||||||
updated['bitDepth'] = null;
|
updated['bitDepth'] = null;
|
||||||
|
updated['sampleRate'] = null;
|
||||||
|
} else {
|
||||||
|
updated['bitDepth'] = bitDepth ?? item.bitDepth;
|
||||||
|
updated['sampleRate'] = sampleRate ?? item.sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction((txn) async {
|
await db.transaction((txn) async {
|
||||||
|
|||||||
@@ -8,6 +8,67 @@ const List<String> audioConversionTargetFormats = [
|
|||||||
'Opus',
|
'Opus',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const List<int> losslessConversionSampleRateOptions = [
|
||||||
|
192000,
|
||||||
|
96000,
|
||||||
|
48000,
|
||||||
|
44100,
|
||||||
|
];
|
||||||
|
|
||||||
|
const List<int> losslessConversionBitDepthOptions = [16, 24];
|
||||||
|
|
||||||
|
List<int> availableLosslessBitDepthOptions(int? sourceBitDepth) {
|
||||||
|
if (sourceBitDepth == null || sourceBitDepth <= 0) {
|
||||||
|
return losslessConversionBitDepthOptions;
|
||||||
|
}
|
||||||
|
return losslessConversionBitDepthOptions
|
||||||
|
.where((depth) => depth < sourceBitDepth)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> availableLosslessSampleRateOptions(int? sourceSampleRate) {
|
||||||
|
if (sourceSampleRate == null || sourceSampleRate <= 0) {
|
||||||
|
return losslessConversionSampleRateOptions;
|
||||||
|
}
|
||||||
|
return losslessConversionSampleRateOptions
|
||||||
|
.where((rate) => rate < sourceSampleRate)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
int? lowestKnownPositiveInt(Iterable<int?> values) {
|
||||||
|
int? lowest;
|
||||||
|
for (final value in values) {
|
||||||
|
if (value == null || value <= 0) continue;
|
||||||
|
if (lowest == null || value < lowest) {
|
||||||
|
lowest = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lowest;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LosslessConversionQuality {
|
||||||
|
final int? maxBitDepth;
|
||||||
|
final int? maxSampleRate;
|
||||||
|
|
||||||
|
const LosslessConversionQuality({this.maxBitDepth, this.maxSampleRate});
|
||||||
|
|
||||||
|
bool get hasCaps => maxBitDepth != null || maxSampleRate != null;
|
||||||
|
|
||||||
|
int? effectiveBitDepth(int? sourceBitDepth) {
|
||||||
|
if (maxBitDepth == null) return sourceBitDepth;
|
||||||
|
if (sourceBitDepth == null || sourceBitDepth <= 0) return maxBitDepth;
|
||||||
|
return sourceBitDepth > maxBitDepth! ? maxBitDepth : sourceBitDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? effectiveSampleRate(int? sourceSampleRate) {
|
||||||
|
if (maxSampleRate == null) return sourceSampleRate;
|
||||||
|
if (sourceSampleRate == null || sourceSampleRate <= 0) {
|
||||||
|
return maxSampleRate;
|
||||||
|
}
|
||||||
|
return sourceSampleRate > maxSampleRate! ? maxSampleRate : sourceSampleRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool isLosslessConversionTarget(String targetFormat) {
|
bool isLosslessConversionTarget(String targetFormat) {
|
||||||
final normalized = targetFormat.trim().toLowerCase();
|
final normalized = targetFormat.trim().toLowerCase();
|
||||||
return normalized == 'alac' ||
|
return normalized == 'alac' ||
|
||||||
@@ -107,6 +168,60 @@ String? _convertibleAudioFormatLabel(String? rawFormat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String losslessBitDepthLabel(int? bitDepth) {
|
||||||
|
return bitDepth == null ? 'Original' : '$bitDepth-bit';
|
||||||
|
}
|
||||||
|
|
||||||
|
String losslessSampleRateLabel(int? sampleRate) {
|
||||||
|
if (sampleRate == null) return 'Original';
|
||||||
|
final khz = sampleRate / 1000;
|
||||||
|
final precision = sampleRate % 1000 == 0 ? 0 : 1;
|
||||||
|
return '${khz.toStringAsFixed(precision)} kHz';
|
||||||
|
}
|
||||||
|
|
||||||
|
String losslessQualityLabel(LosslessConversionQuality quality) {
|
||||||
|
final parts = <String>[];
|
||||||
|
if (quality.maxBitDepth != null) {
|
||||||
|
parts.add(losslessBitDepthLabel(quality.maxBitDepth));
|
||||||
|
}
|
||||||
|
if (quality.maxSampleRate != null) {
|
||||||
|
parts.add(losslessSampleRateLabel(quality.maxSampleRate));
|
||||||
|
}
|
||||||
|
return parts.isEmpty ? 'Original quality' : parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String convertedAudioQualityLabel({
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
LosslessConversionQuality losslessQuality = const LosslessConversionQuality(),
|
||||||
|
int? actualBitDepth,
|
||||||
|
int? actualSampleRate,
|
||||||
|
}) {
|
||||||
|
final upper = targetFormat.toUpperCase();
|
||||||
|
if (isLosslessConversionTarget(targetFormat)) {
|
||||||
|
if (actualBitDepth != null &&
|
||||||
|
actualBitDepth > 0 &&
|
||||||
|
actualSampleRate != null &&
|
||||||
|
actualSampleRate > 0) {
|
||||||
|
return '$upper ${losslessBitDepthLabel(actualBitDepth)}/${losslessSampleRateLabel(actualSampleRate)}';
|
||||||
|
}
|
||||||
|
if (losslessQuality.hasCaps) {
|
||||||
|
return '$upper ${losslessQualityLabel(losslessQuality)}';
|
||||||
|
}
|
||||||
|
return '$upper Lossless';
|
||||||
|
}
|
||||||
|
return '$upper ${bitrate.trim().toLowerCase()}';
|
||||||
|
}
|
||||||
|
|
||||||
|
int? readPositiveAudioInt(Object? value) {
|
||||||
|
if (value is num) {
|
||||||
|
final intValue = value.toInt();
|
||||||
|
return intValue > 0 ? intValue : null;
|
||||||
|
}
|
||||||
|
final parsed = int.tryParse(value?.toString() ?? '');
|
||||||
|
return parsed != null && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
String normalizedConvertedAudioFormat(String targetFormat) {
|
String normalizedConvertedAudioFormat(String targetFormat) {
|
||||||
return targetFormat.trim().toLowerCase();
|
return targetFormat.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ class BatchConvertSheet extends StatefulWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final String confirmLabel;
|
final String confirmLabel;
|
||||||
final void Function(String format, String bitrate) onConvert;
|
final int? sourceBitDepth;
|
||||||
|
final int? sourceSampleRate;
|
||||||
|
final void Function(
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
LosslessConversionQuality losslessQuality,
|
||||||
|
)
|
||||||
|
onConvert;
|
||||||
|
|
||||||
const BatchConvertSheet({
|
const BatchConvertSheet({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -19,6 +26,8 @@ class BatchConvertSheet extends StatefulWidget {
|
|||||||
required this.confirmLabel,
|
required this.confirmLabel,
|
||||||
required this.onConvert,
|
required this.onConvert,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
|
this.sourceBitDepth,
|
||||||
|
this.sourceSampleRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -31,6 +40,8 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
late String _selectedFormat;
|
late String _selectedFormat;
|
||||||
late bool _isLosslessTarget;
|
late bool _isLosslessTarget;
|
||||||
late String _selectedBitrate;
|
late String _selectedBitrate;
|
||||||
|
int? _selectedMaxBitDepth;
|
||||||
|
int? _selectedMaxSampleRate;
|
||||||
|
|
||||||
String _defaultBitrateForFormat(String format) {
|
String _defaultBitrateForFormat(String format) {
|
||||||
if (format == 'Opus') return '128k';
|
if (format == 'Opus') return '128k';
|
||||||
@@ -51,6 +62,12 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final bitDepthOptions = availableLosslessBitDepthOptions(
|
||||||
|
widget.sourceBitDepth,
|
||||||
|
);
|
||||||
|
final sampleRateOptions = availableLosslessSampleRateOptions(
|
||||||
|
widget.sourceSampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -111,6 +128,9 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
_selectedBitrate = _defaultBitrateForFormat(
|
_selectedBitrate = _defaultBitrateForFormat(
|
||||||
format,
|
format,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
_selectedMaxBitDepth = null;
|
||||||
|
_selectedMaxSampleRate = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -144,6 +164,72 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
if (_isLosslessTarget && bitDepthOptions.isNotEmpty)
|
||||||
|
_card(
|
||||||
|
cs,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_sectionLabel(cs, 'Bit depth'),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_choice(
|
||||||
|
cs,
|
||||||
|
label: losslessBitDepthLabel(null),
|
||||||
|
selected: _selectedMaxBitDepth == null,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _selectedMaxBitDepth = null),
|
||||||
|
),
|
||||||
|
...bitDepthOptions.map((depth) {
|
||||||
|
return _choice(
|
||||||
|
cs,
|
||||||
|
label: losslessBitDepthLabel(depth),
|
||||||
|
selected: depth == _selectedMaxBitDepth,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _selectedMaxBitDepth = depth),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_isLosslessTarget && sampleRateOptions.isNotEmpty)
|
||||||
|
_card(
|
||||||
|
cs,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_sectionLabel(cs, 'Sample rate'),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_choice(
|
||||||
|
cs,
|
||||||
|
label: losslessSampleRateLabel(null),
|
||||||
|
selected: _selectedMaxSampleRate == null,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _selectedMaxSampleRate = null),
|
||||||
|
),
|
||||||
|
...sampleRateOptions.map((rate) {
|
||||||
|
return _choice(
|
||||||
|
cs,
|
||||||
|
label: losslessSampleRateLabel(rate),
|
||||||
|
selected: rate == _selectedMaxSampleRate,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _selectedMaxSampleRate = rate),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
if (_isLosslessTarget)
|
if (_isLosslessTarget)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -162,7 +248,10 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.trackConvertLosslessHint,
|
_selectedMaxBitDepth == null &&
|
||||||
|
_selectedMaxSampleRate == null
|
||||||
|
? context.l10n.trackConvertLosslessHint
|
||||||
|
: 'Lossless output with ${losslessQualityLabel(LosslessConversionQuality(maxBitDepth: _selectedMaxBitDepth, maxSampleRate: _selectedMaxSampleRate))} cap',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall?.copyWith(color: cs.primary),
|
).textTheme.bodySmall?.copyWith(color: cs.primary),
|
||||||
@@ -176,8 +265,14 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () =>
|
onPressed: () => widget.onConvert(
|
||||||
widget.onConvert(_selectedFormat, _selectedBitrate),
|
_selectedFormat,
|
||||||
|
_selectedBitrate,
|
||||||
|
LosslessConversionQuality(
|
||||||
|
maxBitDepth: _selectedMaxBitDepth,
|
||||||
|
maxSampleRate: _selectedMaxSampleRate,
|
||||||
|
),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.swap_horiz),
|
icon: const Icon(Icons.swap_horiz),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
|||||||
Reference in New Issue
Block a user