From 491998e5c504fcc5b5b5205891d75dfa2b2fd94b Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 16 Jul 2026 08:27:24 +0700 Subject: [PATCH] feat(convert): optionally retain original files --- lib/l10n/app_localizations.dart | 27 +++++ lib/l10n/app_localizations_ar.dart | 26 +++++ lib/l10n/app_localizations_de.dart | 26 +++++ lib/l10n/app_localizations_en.dart | 26 +++++ lib/l10n/app_localizations_es.dart | 26 +++++ lib/l10n/app_localizations_fr.dart | 26 +++++ lib/l10n/app_localizations_hi.dart | 26 +++++ lib/l10n/app_localizations_id.dart | 20 ++++ lib/l10n/app_localizations_ja.dart | 26 +++++ lib/l10n/app_localizations_ko.dart | 26 +++++ lib/l10n/app_localizations_nl.dart | 26 +++++ lib/l10n/app_localizations_pt.dart | 26 +++++ lib/l10n/app_localizations_ru.dart | 26 +++++ lib/l10n/app_localizations_tr.dart | 26 +++++ lib/l10n/app_localizations_uk.dart | 26 +++++ lib/l10n/app_localizations_zh.dart | 26 +++++ lib/l10n/arb/app_en.arb | 32 ++++++ lib/l10n/arb/app_id.arb | 34 +++++- lib/screens/track_metadata_convert.dart | 97 +++++++++------- lib/screens/track_metadata_screen.dart | 2 +- lib/services/batch_track_actions.dart | 111 ++++++++++--------- lib/services/conversion_library_service.dart | 54 +++++++++ lib/services/library_database.dart | 23 ++-- lib/utils/audio_conversion_utils.dart | 21 ++++ lib/widgets/batch_convert_sheet.dart | 20 +++- test/audio_conversion_utils_test.dart | 30 +++++ 26 files changed, 722 insertions(+), 113 deletions(-) create mode 100644 lib/services/conversion_library_service.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d516be6f..35900be9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4238,6 +4238,18 @@ abstract class AppLocalizations { /// **'Bitrate'** String get trackConvertBitrate; + /// Toggle to preserve the source file during conversion + /// + /// In en, this message translates to: + /// **'Keep original file'** + String get trackConvertKeepOriginal; + + /// Description for preserving the source file during conversion + /// + /// In en, this message translates to: + /// **'Add the converted file as a separate library entry'** + String get trackConvertKeepOriginalDescription; + /// Confirmation dialog title /// /// In en, this message translates to: @@ -4263,6 +4275,15 @@ abstract class AppLocalizations { String targetFormat, ); + /// Confirmation message when the source file will be preserved + /// + /// In en, this message translates to: + /// **'Convert from {sourceFormat} to {targetFormat}?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'** + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ); + /// Hint shown when converting between lossless formats /// /// In en, this message translates to: @@ -4651,6 +4672,12 @@ abstract class AppLocalizations { /// **'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); + /// Batch conversion confirmation when source files will be preserved + /// + /// In en, this message translates to: + /// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}?\n\nOriginal files will be kept and converted files will be added as separate library entries.'** + String selectionBatchConvertConfirmKeepOriginal(int count, String format); + /// Snackbar after batch conversion completes /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 864db241..5edbd6b2 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -2379,6 +2379,13 @@ class AppLocalizationsAr extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2399,6 +2406,14 @@ class AppLocalizationsAr extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2678,6 +2693,17 @@ class AppLocalizationsAr extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e35f1532..9a08cef0 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2408,6 +2408,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Konvertierung bestätigen'; @@ -2428,6 +2435,14 @@ class AppLocalizationsDe extends AppLocalizations { return 'Konvertieren von $sourceFormat in $targetFormat? (kein Qualitätsverlust)\n\nDie Originaldatei wird nach der Konvertierung gelöscht.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Verlustfreie Konvertierung kein Qualitätsverlust'; @@ -2710,6 +2725,17 @@ class AppLocalizationsDe extends AppLocalizations { return 'Konvertiere $count $_temp0 in $format? (kein Qualitätsverlust)\n\nOriginaldateien werden nach der Konvertierung gelöscht.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return '$success von $total Titeln in $format konvertiert'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1e6e5a30..db692d60 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2379,6 +2379,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2399,6 +2406,14 @@ class AppLocalizationsEn extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2678,6 +2693,17 @@ class AppLocalizationsEn extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c67415b4..ba1c1453 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2379,6 +2379,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2399,6 +2406,14 @@ class AppLocalizationsEs extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2678,6 +2693,17 @@ class AppLocalizationsEs extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 1872863d..be7719da 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2439,6 +2439,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertBitrate => 'Débit binaire'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirmer la conversion'; @@ -2459,6 +2466,14 @@ class AppLocalizationsFr extends AppLocalizations { return 'Convertir de $sourceFormat vers $targetFormat ? (Sans perte — aucune perte de qualité)\n\nLe fichier d\'origine sera supprimé après la conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Conversion sans perte — aucune perte de qualité'; @@ -2746,6 +2761,17 @@ class AppLocalizationsFr extends AppLocalizations { return 'Convertir $count $_temp0 au format $format ? (Sans perte — aucune perte de qualité)\n\nLes fichiers d\'origine seront supprimés après la conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return '$success pistes sur $total ont été converties au format $format'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 1577a306..410e79c5 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2379,6 +2379,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2399,6 +2406,14 @@ class AppLocalizationsHi extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2678,6 +2693,17 @@ class AppLocalizationsHi extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a59a3569..fa80b934 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2388,6 +2388,13 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Pertahankan file asli'; + + @override + String get trackConvertKeepOriginalDescription => + 'Tambahkan file hasil konversi sebagai entri library terpisah'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2408,6 +2415,14 @@ class AppLocalizationsId extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Konversi dari $sourceFormat ke $targetFormat?\n\nFile asli akan dipertahankan dan file hasil konversi akan ditambahkan sebagai entri library terpisah.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2687,6 +2702,11 @@ class AppLocalizationsId extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + return 'Konversi $count lagu ke $format?\n\nFile asli akan dipertahankan dan file hasil konversi akan ditambahkan sebagai entri library terpisah.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index bf349724..22f12cf0 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2368,6 +2368,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackConvertBitrate => 'ビットレート'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => '変換を確認'; @@ -2388,6 +2395,14 @@ class AppLocalizationsJa extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2667,6 +2682,17 @@ class AppLocalizationsJa extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a62e4b6a..815a1843 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2315,6 +2315,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertBitrate => '비트레이트'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => '변환 확인'; @@ -2335,6 +2342,14 @@ class AppLocalizationsKo extends AppLocalizations { return '$sourceFormat에서 $targetFormat으로 변환하시겠습니까? (무손실 — 음질 손실 없음)\n\n변환 후에 원본 파일이 삭제됩니다'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => '무손실 변환 — 음질 손실 없음'; @@ -2611,6 +2626,17 @@ class AppLocalizationsKo extends AppLocalizations { return '$count 개의 $_temp0을 $format으로 변환하시겠습니까? (무손실 — 음질 손실 없음)\n\n변환 후 원본 파일이 삭제됩니다'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return '$total 개 중 $success 개를 $format로 변환 완료'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index fac46bd0..a6079dab 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2379,6 +2379,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2399,6 +2406,14 @@ class AppLocalizationsNl extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2678,6 +2693,17 @@ class AppLocalizationsNl extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 1fb90dc1..f25745bd 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2379,6 +2379,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2399,6 +2406,14 @@ class AppLocalizationsPt extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2678,6 +2693,17 @@ class AppLocalizationsPt extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0da15e45..f7ae2224 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2409,6 +2409,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackConvertBitrate => 'Битрейт'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Подтвердить конвертацию'; @@ -2429,6 +2436,14 @@ class AppLocalizationsRu extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Конвертация без потери качества'; @@ -2708,6 +2723,17 @@ class AppLocalizationsRu extends AppLocalizations { return 'Конвертировать $count $_temp0 в $format? (Без потери качества)\n\nОригинальные файлы будут удалены после конвертации.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Конвертировано $success треков $total в $format'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 29e352ca..ae2e9d90 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2405,6 +2405,13 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Dönüştürmeyi Onayla'; @@ -2425,6 +2432,14 @@ class AppLocalizationsTr extends AppLocalizations { return '$sourceFormat formatından $targetFormat formatına dönüştürmek ister misiniz? (Kayıpsız — kalite kaybı yok)\n\nDönüştürme işleminden sonra orijinal dosya silinecektir.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Kayıpsız dönüştürme — kalite kaybı yok'; @@ -2705,6 +2720,17 @@ class AppLocalizationsTr extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8b5591d3..3fa535a9 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2413,6 +2413,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get trackConvertBitrate => 'Бітрейт'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Підтвердити конверсію'; @@ -2433,6 +2440,14 @@ class AppLocalizationsUk extends AppLocalizations { return 'Конвертувати з $sourceFormat у $targetFormat? (Lossless — без втрати якості)\n\nОригінальний файл буде видалено після конвертації.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless конвертація — без втрати якості'; @@ -2716,6 +2731,17 @@ class AppLocalizationsUk extends AppLocalizations { return 'Конвертувати $count $_temp0 у $format? (Lossless — без втрати якості)\n\nОригінальні файли будуть видалені після конвертації.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Конвертовано $success з $total треків у $format'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9581450b..0f9c43e7 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2379,6 +2379,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackConvertBitrate => 'Bitrate'; + @override + String get trackConvertKeepOriginal => 'Keep original file'; + + @override + String get trackConvertKeepOriginalDescription => + 'Add the converted file as a separate library entry'; + @override String get trackConvertConfirmTitle => 'Confirm Conversion'; @@ -2399,6 +2406,14 @@ class AppLocalizationsZh extends AppLocalizations { return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'; } + @override + String trackConvertConfirmKeepOriginal( + String sourceFormat, + String targetFormat, + ) { + return 'Convert from $sourceFormat to $targetFormat?\n\nThe original file will be kept and the converted file will be added as a separate library entry.'; + } + @override String get trackConvertLosslessHint => 'Lossless conversion — no quality loss'; @@ -2678,6 +2693,17 @@ class AppLocalizationsZh extends AppLocalizations { return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'; } + @override + String selectionBatchConvertConfirmKeepOriginal(int count, String format) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format?\n\nOriginal files will be kept and converted files will be added as separate library entries.'; + } + @override String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index cfda0e95..52632382 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3140,6 +3140,14 @@ "@trackConvertBitrate": { "description": "Label for bitrate selection" }, + "trackConvertKeepOriginal": "Keep original file", + "@trackConvertKeepOriginal": { + "description": "Toggle to preserve the source file during conversion" + }, + "trackConvertKeepOriginalDescription": "Add the converted file as a separate library entry", + "@trackConvertKeepOriginalDescription": { + "description": "Description for preserving the source file during conversion" + }, "trackConvertConfirmTitle": "Confirm Conversion", "@trackConvertConfirmTitle": { "description": "Confirmation dialog title" @@ -3171,6 +3179,18 @@ } } }, + "trackConvertConfirmKeepOriginal": "Convert from {sourceFormat} to {targetFormat}?\n\nThe original file will be kept and the converted file will be added as a separate library entry.", + "@trackConvertConfirmKeepOriginal": { + "description": "Confirmation message when the source file will be preserved", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + } + } + }, "trackConvertLosslessHint": "Lossless conversion — no quality loss", "@trackConvertLosslessHint": { "description": "Hint shown when converting between lossless formats" @@ -3547,6 +3567,18 @@ } } }, + "selectionBatchConvertConfirmKeepOriginal": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}?\n\nOriginal files will be kept and converted files will be added as separate library entries.", + "@selectionBatchConvertConfirmKeepOriginal": { + "description": "Batch conversion confirmation when source files will be preserved", + "placeholders": { + "count": { + "type": "int" + }, + "format": { + "type": "String" + } + } + }, "selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}", "@selectionBatchConvertSuccess": { "description": "Snackbar after batch conversion completes", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 8251b281..77a353fe 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -5883,5 +5883,37 @@ "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", "albumFolderAlbumOnly": "Album Saja", "nowPlayingOpenInExternalPlayer": "Open in external player", - "cueSplitButton": "Split into Tracks" + "cueSplitButton": "Split into Tracks", + "trackConvertKeepOriginal": "Pertahankan file asli", + "@trackConvertKeepOriginal": { + "description": "Toggle untuk mempertahankan file sumber saat konversi" + }, + "trackConvertKeepOriginalDescription": "Tambahkan file hasil konversi sebagai entri library terpisah", + "@trackConvertKeepOriginalDescription": { + "description": "Deskripsi opsi mempertahankan file sumber saat konversi" + }, + "trackConvertConfirmKeepOriginal": "Konversi dari {sourceFormat} ke {targetFormat}?\n\nFile asli akan dipertahankan dan file hasil konversi akan ditambahkan sebagai entri library terpisah.", + "@trackConvertConfirmKeepOriginal": { + "description": "Konfirmasi konversi ketika file sumber dipertahankan", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + } + } + }, + "selectionBatchConvertConfirmKeepOriginal": "Konversi {count} lagu ke {format}?\n\nFile asli akan dipertahankan dan file hasil konversi akan ditambahkan sebagai entri library terpisah.", + "@selectionBatchConvertConfirmKeepOriginal": { + "description": "Konfirmasi konversi batch ketika file sumber dipertahankan", + "placeholders": { + "count": { + "type": "int" + }, + "format": { + "type": "String" + } + } + } } diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 4ef41bc8..e5f24b66 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -263,17 +263,25 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { bitrate, ); }, - onConvert: (format, bitrate, losslessQuality, losslessProcessing) { - Navigator.pop(sheetContext); - _confirmAndConvert( - context: this.context, - sourceFormat: currentFormat, - targetFormat: format, - bitrate: bitrate, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - ); - }, + onConvert: + ( + format, + bitrate, + losslessQuality, + losslessProcessing, + keepOriginal, + ) { + Navigator.pop(sheetContext); + _confirmAndConvert( + context: this.context, + sourceFormat: currentFormat, + targetFormat: format, + bitrate: bitrate, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + keepOriginal: keepOriginal, + ); + }, ), ); } @@ -738,6 +746,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { const LosslessConversionQuality(), LosslessConversionProcessing losslessProcessing = const LosslessConversionProcessing(), + bool keepOriginal = false, }) { final isLossless = isLosslessConversionTarget(targetFormat); showDialog( @@ -746,7 +755,12 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { return AlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( - isLossless && losslessQuality.hasCaps + keepOriginal + ? dialogContext.l10n.trackConvertConfirmKeepOriginal( + sourceFormat, + targetFormat, + ) + : isLossless && losslessQuality.hasCaps ? dialogContext.l10n.trackConvertConfirmMessageLosslessCapped( sourceFormat, targetFormat, @@ -784,6 +798,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { bitrate: bitrate, losslessQuality: losslessQuality, losslessProcessing: losslessProcessing, + keepOriginal: keepOriginal, ); }, child: Text(dialogContext.l10n.trackConvertFormat), @@ -801,6 +816,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { const LosslessConversionQuality(), LosslessConversionProcessing losslessProcessing = const LosslessConversionProcessing(), + bool keepOriginal = false, }) async { if (_isConverting) return; _setState(() => _isConverting = true); @@ -874,7 +890,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { metadata: metadata, coverPath: coverPath, artistTagMode: ref.read(settingsProvider).artistTagMode, - deleteOriginal: !isSaf, + deleteOriginal: !isSaf && !keepOriginal, sourceBitDepth: bitDepth, losslessQuality: losslessQuality, losslessProcessing: losslessProcessing, @@ -991,14 +1007,13 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { return; } - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; + final newFileName = convertedOutputFileName( + originalFileName: oldFileName, + targetFormat: targetFormat, + keepOriginal: keepOriginal, + ); final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -1026,7 +1041,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { return; } - if (!isSameContentUri(cleanFilePath, safUri)) { + if (!keepOriginal && !isSameContentUri(cleanFilePath, safUri)) { final deletedOriginal = await PlatformBridge.safDelete( cleanFilePath, ).catchError((_) => false); @@ -1038,19 +1053,16 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { } if (!_isLocalItem) { - await HistoryDatabase.instance.updateFilePath( - _downloadItem!.id, - safUri, - newSafFileName: newFileName, + await ConversionLibraryService.persistHistoryConversion( + source: _downloadItem!, + newFilePath: safUri, newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, + newSafFileName: newFileName, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } else { @@ -1061,6 +1073,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, ); await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -1075,18 +1088,15 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { } } else { if (!_isLocalItem) { - await HistoryDatabase.instance.updateFilePath( - _downloadItem!.id, - newPath, + await ConversionLibraryService.persistHistoryConversion( + source: _downloadItem!, + newFilePath: newPath, newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, ); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } else { @@ -1097,6 +1107,7 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, ); await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index e0cdd414..7d505dd1 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:spotiflac_android/services/history_database.dart'; +import 'package:spotiflac_android/services/conversion_library_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:path_provider/path_provider.dart'; diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index 22a0e075..da4a1b64 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -8,8 +8,8 @@ import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/conversion_library_service.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; -import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; @@ -86,20 +86,22 @@ Future showBatchConvertSheet( confirmLabel: sheetConfirmLabel, sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths), sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates), - onConvert: (format, bitrate, losslessQuality, losslessProcessing) { - didStartConversion = true; - Navigator.pop(sheetContext); - _performBatchConversion( - context, - ref, - selectedItems: selectedItems, - targetFormat: format, - bitrate: bitrate, - losslessQuality: losslessQuality, - losslessProcessing: losslessProcessing, - onExitSelectionMode: onExitSelectionMode, - ); - }, + onConvert: + (format, bitrate, losslessQuality, losslessProcessing, keepOriginal) { + didStartConversion = true; + Navigator.pop(sheetContext); + _performBatchConversion( + context, + ref, + selectedItems: selectedItems, + targetFormat: format, + bitrate: bitrate, + losslessQuality: losslessQuality, + losslessProcessing: losslessProcessing, + keepOriginal: keepOriginal, + onExitSelectionMode: onExitSelectionMode, + ); + }, ), ); @@ -115,6 +117,7 @@ Future _performBatchConversion( LosslessConversionQuality losslessQuality = const LosslessConversionQuality(), LosslessConversionProcessing losslessProcessing = const LosslessConversionProcessing(), + bool keepOriginal = false, required VoidCallback onExitSelectionMode, }) async { final convertibleItems = []; @@ -150,7 +153,12 @@ Future _performBatchConversion( builder: (ctx) => AlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( - isLossless && losslessQuality.hasCaps + keepOriginal + ? context.l10n.selectionBatchConvertConfirmKeepOriginal( + convertibleItems.length, + targetFormat, + ) + : isLossless && losslessQuality.hasCaps ? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped( convertibleItems.length, targetFormat, @@ -188,7 +196,6 @@ Future _performBatchConversion( int successCount = 0; final total = convertibleItems.length; - final historyDb = HistoryDatabase.instance; final settings = ref.read(settingsProvider); final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; @@ -266,7 +273,7 @@ Future _performBatchConversion( metadata: metadata, coverPath: coverPath, artistTagMode: settings.artistTagMode, - deleteOriginal: !isSaf, + deleteOriginal: !isSaf && !keepOriginal, sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth, losslessQuality: losslessQuality, losslessProcessing: losslessProcessing, @@ -326,14 +333,13 @@ Future _performBatchConversion( final relativeDir = hi.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { final oldFileName = hi.safFileName ?? ''; - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; + final newFileName = convertedOutputFileName( + originalFileName: oldFileName, + targetFormat: targetFormat, + keepOriginal: keepOriginal, + ); final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -355,25 +361,22 @@ Future _performBatchConversion( continue; } - if (!isSameContentUri(item.filePath, safUri)) { + if (!keepOriginal && !isSameContentUri(item.filePath, safUri)) { try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} } - await historyDb.updateFilePath( - hi.id, - safUri, - newSafFileName: newFileName, + await ConversionLibraryService.persistHistoryConversion( + source: hi, + newFilePath: safUri, newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, + newSafFileName: newFileName, ); } try { @@ -423,14 +426,13 @@ Future _performBatchConversion( } if (treeUri != null && oldFileName.isNotEmpty) { - final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 - ? oldFileName.substring(0, dotIdx) - : oldFileName; final convTarget = convertTargetExtAndMime(targetFormat); - final newExt = convTarget.ext; final mimeType = convTarget.mime; - final newFileName = '$baseName$newExt'; + final newFileName = convertedOutputFileName( + originalFileName: oldFileName, + targetFormat: targetFormat, + keepOriginal: keepOriginal, + ); final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -452,7 +454,7 @@ Future _performBatchConversion( continue; } - if (!isSameContentUri(item.filePath, safUri)) { + if (!keepOriginal && !isSameContentUri(item.filePath, safUri)) { try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} @@ -464,6 +466,7 @@ Future _performBatchConversion( bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, ); } @@ -476,18 +479,15 @@ Future _performBatchConversion( } catch (_) {} } } else if (item.historyItem != null) { - await historyDb.updateFilePath( - item.historyItem!.id, - newPath, + await ConversionLibraryService.persistHistoryConversion( + source: item.historyItem!, + newFilePath: newPath, newQuality: newQuality, - newFormat: normalizedConvertedAudioFormat(targetFormat), - newBitrate: convertedAudioBitrateKbps( - targetFormat: targetFormat, - bitrate: bitrate, - ), - newBitDepth: convertedBitDepth, - newSampleRate: convertedSampleRate, - clearAudioSpecs: !isLosslessOutput, + targetFormat: targetFormat, + bitrate: bitrate, + bitDepth: convertedBitDepth, + sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, ); } else if (item.localItem != null) { await LibraryDatabase.instance.replaceWithConvertedItem( @@ -497,6 +497,7 @@ Future _performBatchConversion( bitrate: bitrate, bitDepth: convertedBitDepth, sampleRate: convertedSampleRate, + keepOriginal: keepOriginal, ); } diff --git a/lib/services/conversion_library_service.dart b/lib/services/conversion_library_service.dart new file mode 100644 index 00000000..901ad118 --- /dev/null +++ b/lib/services/conversion_library_service.dart @@ -0,0 +1,54 @@ +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/services/history_database.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; + +class ConversionLibraryService { + const ConversionLibraryService._(); + + static Future persistHistoryConversion({ + required DownloadHistoryItem source, + required String newFilePath, + required String newQuality, + required String targetFormat, + required String bitrate, + required int? bitDepth, + required int? sampleRate, + required bool keepOriginal, + String? newSafFileName, + }) async { + final convertedBitrate = convertedAudioBitrateKbps( + targetFormat: targetFormat, + bitrate: bitrate, + ); + final isLossless = isLosslessConversionTarget(targetFormat); + + if (!keepOriginal) { + await HistoryDatabase.instance.updateFilePath( + source.id, + newFilePath, + newSafFileName: newSafFileName, + newQuality: newQuality, + newFormat: normalizedConvertedAudioFormat(targetFormat), + newBitrate: convertedBitrate, + newBitDepth: bitDepth, + newSampleRate: sampleRate, + clearAudioSpecs: !isLossless, + ); + return; + } + + final converted = source.toJson() + ..['id'] = convertedLibraryItemId(source.id, newFilePath) + ..['filePath'] = newFilePath + ..['downloadedAt'] = DateTime.now().toIso8601String() + ..['quality'] = newQuality + ..['format'] = normalizedConvertedAudioFormat(targetFormat) + ..['bitrate'] = convertedBitrate + ..['bitDepth'] = isLossless ? bitDepth : null + ..['sampleRate'] = isLossless ? sampleRate : null; + if (newSafFileName != null) { + converted['safFileName'] = newSafFileName; + } + await HistoryDatabase.instance.upsert(converted); + } +} diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 2404d01a..834be0d2 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1783,6 +1783,7 @@ class LibraryDatabase { required String bitrate, int? bitDepth, int? sampleRate, + bool keepOriginal = false, }) async { final db = await database; final stat = await fileStat(newFilePath); @@ -1810,16 +1811,18 @@ class LibraryDatabase { } await db.transaction((txn) async { - await txn.delete( - 'library_path_keys', - where: 'item_id = ?', - whereArgs: [item.id], - ); - await txn.delete( - 'library', - where: 'id = ? OR file_path = ?', - whereArgs: [item.id, item.filePath], - ); + if (!keepOriginal) { + await txn.delete( + 'library_path_keys', + where: 'item_id = ?', + whereArgs: [item.id], + ); + await txn.delete( + 'library', + where: 'id = ? OR file_path = ?', + whereArgs: [item.id, item.filePath], + ); + } await txn.insert( 'library', _jsonToDbRow(updated), diff --git a/lib/utils/audio_conversion_utils.dart b/lib/utils/audio_conversion_utils.dart index cf15d916..d7de0dd8 100644 --- a/lib/utils/audio_conversion_utils.dart +++ b/lib/utils/audio_conversion_utils.dart @@ -332,6 +332,27 @@ String normalizedConvertedAudioFormat(String targetFormat) { } } +String convertedOutputFileName({ + required String originalFileName, + required String targetFormat, + required bool keepOriginal, +}) { + final dotIndex = originalFileName.lastIndexOf('.'); + final baseName = dotIndex > 0 + ? originalFileName.substring(0, dotIndex) + : originalFileName; + final suffix = keepOriginal ? '_converted' : ''; + return '$baseName$suffix${convertTargetExtAndMime(targetFormat).ext}'; +} + +String convertedLibraryItemId(String sourceId, String filePath) { + var hash = 5381; + for (final codeUnit in filePath.codeUnits) { + hash = (((hash << 5) + hash) ^ codeUnit) & 0x7fffffff; + } + return '$sourceId:converted:${hash.toRadixString(16)}'; +} + int? convertedAudioBitrateKbps({ required String targetFormat, required String bitrate, diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index efe5b346..308fb6c5 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -25,6 +25,7 @@ class BatchConvertSheet extends StatefulWidget { String bitrate, LosslessConversionQuality losslessQuality, LosslessConversionProcessing losslessProcessing, + bool keepOriginal, ) onConvert; @@ -55,6 +56,7 @@ class _BatchConvertSheetState extends State { int? _selectedMaxSampleRate; String _selectedDither = 'none'; String _selectedResampler = 'swr'; + bool _keepOriginal = false; String _defaultBitrateForFormat(String format) { if (format == 'Opus') return '128k'; @@ -84,9 +86,7 @@ class _BatchConvertSheetState extends State { ); return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: DraggableScrollableSheet( initialChildSize: 0.85, minChildSize: 0.5, @@ -370,6 +370,19 @@ class _BatchConvertSheetState extends State { ), ), + _card( + cs, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.trackConvertKeepOriginal), + subtitle: Text( + context.l10n.trackConvertKeepOriginalDescription, + ), + value: _keepOriginal, + onChanged: (value) => setState(() => _keepOriginal = value), + ), + ), + const SizedBox(height: 8), SizedBox( width: double.infinity, @@ -385,6 +398,7 @@ class _BatchConvertSheetState extends State { dither: _selectedDither, resampler: _selectedResampler, ), + _keepOriginal, ), icon: const Icon(Icons.swap_horiz), style: FilledButton.styleFrom( diff --git a/test/audio_conversion_utils_test.dart b/test/audio_conversion_utils_test.dart index 61835d91..59120ea7 100644 --- a/test/audio_conversion_utils_test.dart +++ b/test/audio_conversion_utils_test.dart @@ -45,4 +45,34 @@ void main() { expect(swrHighPass.hasDither, isTrue); }); }); + + group('kept conversion output identity', () { + test('uses a distinct filename when the original is retained', () { + expect( + convertedOutputFileName( + originalFileName: 'Track.flac', + targetFormat: 'FLAC', + keepOriginal: true, + ), + 'Track_converted.flac', + ); + expect( + convertedOutputFileName( + originalFileName: 'Track.flac', + targetFormat: 'MP3', + keepOriginal: false, + ), + 'Track.mp3', + ); + }); + + test('derives stable but path-specific converted item IDs', () { + final first = convertedLibraryItemId('source', '/music/Track.flac'); + final repeated = convertedLibraryItemId('source', '/music/Track.flac'); + final other = convertedLibraryItemId('source', '/music/Track.m4a'); + + expect(first, repeated); + expect(first, isNot(other)); + }); + }); }