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
+22 -1
View File
@@ -3883,7 +3883,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -3920,6 +3920,21 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless-to-lossless conversion
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
);
/// Hint shown when converting between lossless formats
///
/// In en, this message translates to:
/// **'Lossless conversion — no quality loss'**
String get trackConvertLosslessHint;
/// Snackbar while converting
///
/// In en, this message translates to:
@@ -4290,6 +4305,12 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless batch conversion
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
String selectionBatchConvertConfirmMessageLossless(int count, String format);
/// Snackbar during batch conversion progress
///
/// In en, this message translates to:
+23
View File
@@ -2226,6 +2226,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Konvertiere Audio...';
@@ -2480,6 +2492,17 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Konvertiere $current von $total...';
+25 -1
View File
@@ -2176,7 +2176,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2199,6 +2200,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2452,6 +2465,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+25 -1
View File
@@ -2176,7 +2176,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2199,6 +2200,18 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2452,6 +2465,17 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+23
View File
@@ -2201,6 +2201,18 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2454,6 +2466,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+23
View File
@@ -2199,6 +2199,18 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2452,6 +2464,17 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+25 -1
View File
@@ -2183,7 +2183,8 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Konversi ke MP3, Opus, ALAC, atau FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2206,6 +2207,18 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertLosslessHint =>
'Konversi lossless — tanpa kehilangan kualitas';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2459,6 +2472,17 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+23
View File
@@ -2186,6 +2186,18 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'オーディオを変換中...';
@@ -2439,6 +2451,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+23
View File
@@ -2179,6 +2179,18 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2432,6 +2444,17 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+23
View File
@@ -2199,6 +2199,18 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2452,6 +2464,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+25 -1
View File
@@ -2176,7 +2176,8 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2199,6 +2200,18 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2452,6 +2465,17 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+23
View File
@@ -2252,6 +2252,18 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Конвертация аудио...';
@@ -2511,6 +2523,17 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Конвертация $current из $total...';
+23
View File
@@ -2211,6 +2211,18 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2464,6 +2476,17 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+25 -1
View File
@@ -2176,7 +2176,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2199,6 +2200,18 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2452,6 +2465,17 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
+29 -1
View File
@@ -2873,7 +2873,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2908,6 +2908,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3259,6 +3275,18 @@
}
}
},
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
+17 -1
View File
@@ -2809,7 +2809,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2844,6 +2844,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"