diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 29ab2d02..391b7e5d 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -743,15 +743,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) { return AudioQuality{}, err } - buf := make([]byte, 24) + buf := make([]byte, 32) if _, err := f.ReadAt(buf, sampleOffset); err != nil { return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err) } - sampleRate := int(buf[22])<<8 | int(buf[23]) - bitDepth := 16 - if atomType == "alac" { - bitDepth = 24 + // AudioSampleEntry layout from the box type field: + // [0:4] type ("mp4a"/"alac") + // [4:10] SampleEntry.reserved + // [10:12] data_reference_index + // [12:20] reserved[8] + // [20:22] channelcount + // [22:24] samplesize (bit depth) + // [24:26] pre_defined + // [26:28] reserved + // [28:32] samplerate (16.16 fixed-point) + sampleRate := int(buf[28])<<8 | int(buf[29]) + bitDepth := int(buf[22])<<8 | int(buf[23]) + if bitDepth <= 0 { + bitDepth = 16 + if atomType == "alac" { + bitDepth = 24 + } } return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil @@ -874,7 +887,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string if bestIdx >= 0 { absolute := readPos - int64(len(tail)) + int64(bestIdx) - if absolute+24 > fileSize { + if absolute+32 > fileSize { return 0, "", fmt.Errorf("audio info not found in M4A file") } return absolute, bestType, nil diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 01af801f..15451886 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c3ee5083..f0137de1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 180810f3..041c5bd1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index cea3915b..99849885 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 8270c7c2..cec6eae9 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 1767c438..9a9ad518 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 67a9c2fc..902d49cd 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e406ba35..31270670 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index b8085a70..76f1b737 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index c54e044a..d509e1e7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ac0f577d..ef87eca7 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 76814d38..8bbb8a74 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index f24d9f88..bbf6ef14 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d9440cff..465f196c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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...'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6da406d0..7f4e1acb 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index bab66bf2..ff764ec8 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 55b1b71c..f320aa40 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -912,6 +912,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ) { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + bool isLosslessTarget = false; showModalBottomSheet( context: context, @@ -923,7 +924,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -960,51 +961,75 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1057,12 +1082,19 @@ class _DownloadedAlbumScreenState extends ConsumerState { : item.filePath.toLowerCase(); final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; - if (ext != null && ext != targetFormat) selected.add(item); + if (ext == null || ext == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selected.add(item); } if (selected.isEmpty) { @@ -1074,16 +1106,22 @@ class _DownloadedAlbumScreenState extends ConsumerState { return; } + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selected.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -1103,8 +1141,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { int successCount = 0; final total = selected.length; final historyDb = HistoryDatabase.instance; - final newQuality = - '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final newQuality = (targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC') + ? '${targetFormat.toUpperCase()} Lossless' + : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -1207,13 +1247,27 @@ class _DownloadedAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 122a4bab..1b662324 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1131,6 +1131,7 @@ class _LocalAlbumScreenState extends ConsumerState { ) { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + bool isLosslessTarget = false; showModalBottomSheet( context: context, @@ -1142,7 +1143,7 @@ class _LocalAlbumScreenState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -1179,51 +1180,75 @@ class _LocalAlbumScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1276,6 +1301,8 @@ class _LocalAlbumScreenState extends ConsumerState { final fmt = item.format!.toLowerCase(); if (fmt == 'flac') { currentFormat = 'FLAC'; + } else if (fmt == 'm4a') { + currentFormat = 'M4A'; } else if (fmt == 'mp3') { currentFormat = 'MP3'; } else if (fmt == 'opus' || fmt == 'ogg') { @@ -1287,15 +1314,20 @@ class _LocalAlbumScreenState extends ConsumerState { final lower = item.filePath.toLowerCase(); if (lower.endsWith('.flac')) { currentFormat = 'FLAC'; + } else if (lower.endsWith('.m4a')) { + currentFormat = 'M4A'; } else if (lower.endsWith('.mp3')) { currentFormat = 'MP3'; } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { currentFormat = 'Opus'; } } - if (currentFormat != null && currentFormat != targetFormat) { - selected.add(item); - } + if (currentFormat == null || currentFormat == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selected.add(item); } if (selected.isEmpty) { @@ -1307,16 +1339,22 @@ class _LocalAlbumScreenState extends ConsumerState { return; } + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selected.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selected.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -1481,13 +1519,27 @@ class _LocalAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 52ca3309..64f9c0e3 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4757,6 +4757,7 @@ class _QueueTabState extends ConsumerState { ) async { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + bool isLosslessTarget = false; var didStartConversion = false; _hideSelectionOverlay(); @@ -4772,7 +4773,7 @@ class _QueueTabState extends ConsumerState { return StatefulBuilder( builder: (context, setSheetState) { final colorScheme = Theme.of(context).colorScheme; - final formats = ['MP3', 'Opus']; + final formats = ['ALAC', 'FLAC', 'MP3', 'Opus']; final bitrates = ['128k', '192k', '256k', '320k']; return SafeArea( @@ -4809,51 +4810,75 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + if (isLosslessTarget) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -4929,14 +4954,19 @@ class _QueueTabState extends ConsumerState { } final ext = nameToCheck.endsWith('.flac') ? 'FLAC' + : nameToCheck.endsWith('.m4a') + ? 'M4A' : nameToCheck.endsWith('.mp3') ? 'MP3' : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' : null; - if (ext != null && ext != targetFormat) { - selectedItems.add(item); - } + if (ext == null || ext == targetFormat) continue; + // Skip lossy sources when target is lossless (pointless re-encoding) + final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; + if (isLosslessTarget && !isLosslessSource) continue; + selectedItems.add(item); } if (selectedItems.isEmpty) { @@ -4949,16 +4979,22 @@ class _QueueTabState extends ConsumerState { } // Confirm + final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - context.l10n.selectionBatchConvertConfirmMessage( - selectedItems.length, - targetFormat, - bitrate, - ), + isLossless + ? context.l10n.selectionBatchConvertConfirmMessageLossless( + selectedItems.length, + targetFormat, + ) + : context.l10n.selectionBatchConvertConfirmMessage( + selectedItems.length, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -4978,8 +5014,10 @@ class _QueueTabState extends ConsumerState { int successCount = 0; final total = selectedItems.length; final historyDb = HistoryDatabase.instance; - final newQuality = - '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final newQuality = (targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC') + ? '${targetFormat.toUpperCase()} Lossless' + : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -5093,13 +5131,27 @@ class _QueueTabState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' - ? '.opus' - : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 85e803b6..744acf54 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2662,6 +2662,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool get _isConvertibleFormat { final lower = cleanFilePath.toLowerCase(); return lower.endsWith('.flac') || + lower.endsWith('.m4a') || lower.endsWith('.mp3') || lower.endsWith('.opus') || lower.endsWith('.ogg'); @@ -2692,6 +2693,7 @@ class _TrackMetadataScreenState extends ConsumerState { } final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; + if (lower.endsWith('.m4a')) return 'M4A'; if (lower.endsWith('.mp3')) return 'MP3'; if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; if (lower.endsWith('.cue')) return 'CUE'; @@ -2749,8 +2751,12 @@ class _TrackMetadataScreenState extends ConsumerState { } String _buildConvertedQualityLabel(String targetFormat, String bitrate) { + final upper = targetFormat.toUpperCase(); + if (upper == 'ALAC' || upper == 'FLAC') { + return '$upper Lossless'; + } final normalizedBitrate = bitrate.trim().toLowerCase(); - return '${targetFormat.toUpperCase()} $normalizedBitrate'; + return '$upper $normalizedBitrate'; } String? _extractLossyBitrateLabel(String? quality) { @@ -2790,17 +2796,27 @@ class _TrackMetadataScreenState extends ConsumerState { void _showConvertSheet(BuildContext context) { final currentFormat = _currentFileFormat; - // Available target formats (exclude current) - final formats = [ - 'MP3', - 'Opus', - ].where((f) => f != currentFormat).toList(); + final isLosslessSource = + currentFormat == 'FLAC' || currentFormat == 'M4A'; + + // Build available target formats based on source + final formats = []; if (currentFormat == 'FLAC') { - // FLAC can convert to both + formats.addAll(['ALAC', 'MP3', 'Opus']); + } else if (currentFormat == 'M4A') { + formats.addAll(['FLAC', 'MP3', 'Opus']); + } else if (currentFormat == 'MP3') { + formats.add('Opus'); + } else if (currentFormat == 'Opus') { + formats.add('MP3'); + } else { + formats.addAll(['MP3', 'Opus']); } String selectedFormat = formats.first; String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k'; + bool isLosslessTarget = + selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; showModalBottomSheet( context: context, @@ -2849,53 +2865,79 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - Row( - children: formats.map((format) { - final isSelected = format == selectedFormat; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(format), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setSheetState(() { - selectedFormat = format; - // Reset bitrate to default for format - selectedBitrate = format == 'Opus' - ? '128k' - : '320k'; - }); - } - }, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - - Text( - context.l10n.trackConvertBitrate, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), Wrap( spacing: 8, - children: bitrates.map((br) { - final isSelected = br == selectedBitrate; + children: formats.map((format) { + final isSelected = format == selectedFormat; return ChoiceChip( - label: Text(br), + label: Text(format), selected: isSelected, onSelected: (selected) { if (selected) { - setSheetState(() => selectedBitrate = br); + setSheetState(() { + selectedFormat = format; + isLosslessTarget = + format == 'ALAC' || format == 'FLAC'; + if (!isLosslessTarget) { + selectedBitrate = + format == 'Opus' ? '128k' : '320k'; + } + }); } }, ); }).toList(), ), + + // Only show bitrate for lossy targets + if (!isLosslessTarget) ...[ + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + ], + + // Show lossless indicator + if (isLosslessTarget && isLosslessSource) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + context.l10n.trackConvertLosslessHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ), + ], const SizedBox(height: 24), SizedBox( @@ -2917,7 +2959,9 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), child: Text( - '$currentFormat -> $selectedFormat @ $selectedBitrate', + isLosslessTarget + ? '$currentFormat -> $selectedFormat (Lossless)' + : '$currentFormat -> $selectedFormat @ $selectedBitrate', ), ), ), @@ -3402,17 +3446,25 @@ class _TrackMetadataScreenState extends ConsumerState { required String targetFormat, required String bitrate, }) { + final isLossless = + targetFormat.toUpperCase() == 'ALAC' || + targetFormat.toUpperCase() == 'FLAC'; showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( - dialogContext.l10n.trackConvertConfirmMessage( - sourceFormat, - targetFormat, - bitrate, - ), + isLossless + ? dialogContext.l10n.trackConvertConfirmMessageLossless( + sourceFormat, + targetFormat, + ) + : dialogContext.l10n.trackConvertConfirmMessage( + sourceFormat, + targetFormat, + bitrate, + ), ), actions: [ TextButton( @@ -3561,11 +3613,27 @@ class _TrackMetadataScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + String newExt; + String mimeType; + switch (targetFormat.toLowerCase()) { + case 'opus': + newExt = '.opus'; + mimeType = 'audio/opus'; + break; + case 'alac': + newExt = '.m4a'; + mimeType = 'audio/mp4'; + break; + case 'flac': + newExt = '.flac'; + mimeType = 'audio/flac'; + break; + default: // mp3 + newExt = '.mp3'; + mimeType = 'audio/mpeg'; + break; + } final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' - ? 'audio/opus' - : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index b9aa6603..cb533cb0 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1209,7 +1209,8 @@ class FFmpegService { } /// Unified audio format conversion with full metadata + cover preservation. - /// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format). + /// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC. + /// ALAC and FLAC targets are lossless (bitrate parameter is ignored). /// Returns the new file path on success, null on failure. static Future convertAudioFormat({ required String inputPath, @@ -1220,11 +1221,30 @@ class FFmpegService { bool deleteOriginal = true, }) async { final format = targetFormat.toLowerCase(); - if (format != 'mp3' && format != 'opus') { + if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) { _log.e('Unsupported target format: $targetFormat'); return null; } + // Lossless targets: dedicated single-pass methods + if (format == 'alac') { + return _convertToAlac( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: deleteOriginal, + ); + } + if (format == 'flac') { + return _convertToFlac( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: deleteOriginal, + ); + } + + // Lossy targets: MP3 / Opus final extension = format == 'opus' ? '.opus' : '.mp3'; final outputPath = _buildOutputPath(inputPath, extension); @@ -1296,6 +1316,197 @@ class FFmpegService { return outputPath; } + /// Convert any audio format to ALAC (Apple Lossless) in an M4A container. + /// Metadata and cover art are embedded in a single FFmpeg pass. + static Future _convertToAlac({ + required String inputPath, + required Map metadata, + String? coverPath, + bool deleteOriginal = true, + }) async { + final outputPath = _buildOutputPath(inputPath, '.m4a'); + + final cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$inputPath" '); + + // Cover art as second input for M4A attached picture + final hasCover = coverPath != null && + coverPath.trim().isNotEmpty && + await File(coverPath).exists(); + if (hasCover) { + cmdBuffer.write('-i "$coverPath" '); + } + + cmdBuffer.write('-map 0:a '); + if (hasCover) { + cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); + } + cmdBuffer.write('-c:a alac '); + cmdBuffer.write('-map_metadata -1 '); + + // Embed M4A metadata tags + final m4aTags = _convertToM4aTags(metadata); + for (final entry in m4aTags.entries) { + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + } + + cmdBuffer.write('"$outputPath" -y'); + + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC', + ); + final result = await _execute(cmdBuffer.toString()); + + if (!result.success) { + _log.e('ALAC conversion failed: ${result.output}'); + return null; + } + + if (deleteOriginal) { + try { + await File(inputPath).delete(); + _log.i( + 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', + ); + } catch (e) { + _log.w('Failed to delete original: $e'); + } + } + + return outputPath; + } + + /// Convert any audio format to FLAC. + /// Metadata (Vorbis comments) and cover art (METADATA_BLOCK_PICTURE) are + /// embedded in a single FFmpeg pass. + static Future _convertToFlac({ + required String inputPath, + required Map metadata, + String? coverPath, + bool deleteOriginal = true, + }) async { + final outputPath = _buildOutputPath(inputPath, '.flac'); + + final cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$inputPath" '); + cmdBuffer.write('-map 0:a '); + cmdBuffer.write('-c:a flac -compression_level 8 '); + cmdBuffer.write('-map_metadata -1 '); + + // Embed Vorbis comments + for (final entry in metadata.entries) { + if (entry.value.trim().isEmpty) continue; + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + } + + // Embed cover art via METADATA_BLOCK_PICTURE (same approach as Opus) + if (coverPath != null && coverPath.trim().isNotEmpty) { + try { + if (await File(coverPath).exists()) { + final pictureBlock = await _createMetadataBlockPicture(coverPath); + if (pictureBlock != null) { + final escapedBlock = pictureBlock.replaceAll('"', '\\"'); + cmdBuffer.write( + '-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ', + ); + _log.d( + 'Created METADATA_BLOCK_PICTURE for FLAC (${pictureBlock.length} chars)', + ); + } + } + } catch (e) { + _log.e('Error creating METADATA_BLOCK_PICTURE for FLAC: $e'); + } + } + + cmdBuffer.write('"$outputPath" -y'); + + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC', + ); + final result = await _execute(cmdBuffer.toString()); + + if (!result.success) { + _log.e('FLAC conversion failed: ${result.output}'); + return null; + } + + if (deleteOriginal) { + try { + await File(inputPath).delete(); + _log.i( + 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', + ); + } catch (e) { + _log.w('Failed to delete original: $e'); + } + } + + return outputPath; + } + + /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. + static Map _convertToM4aTags( + Map metadata, + ) { + final m4aMap = {}; + + for (final entry in metadata.entries) { + final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + final value = entry.value; + if (value.trim().isEmpty) continue; + + switch (key) { + case 'TITLE': + m4aMap['title'] = value; + break; + case 'ARTIST': + m4aMap['artist'] = value; + break; + case 'ALBUM': + m4aMap['album'] = value; + break; + case 'ALBUMARTIST': + m4aMap['album_artist'] = value; + break; + case 'TRACKNUMBER': + case 'TRACK': + case 'TRCK': + m4aMap['track'] = value; + break; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + m4aMap['disc'] = value; + break; + case 'DATE': + case 'YEAR': + m4aMap['date'] = value; + break; + case 'GENRE': + m4aMap['genre'] = value; + break; + case 'COMPOSER': + m4aMap['composer'] = value; + break; + case 'COMMENT': + m4aMap['comment'] = value; + break; + case 'COPYRIGHT': + m4aMap['copyright'] = value; + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + m4aMap['lyrics'] = value; + break; + } + } + + return m4aMap; + } + static Map _convertToId3Tags( Map vorbisMetadata, ) {