diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 771dadae..ed4597b5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -216,6 +216,22 @@ object NativeDownloadFinalizer { checkCancelled(shouldCancel) runPostProcessing(context, effectiveInput, state, shouldCancel) checkCancelled(shouldCancel) + try { + finalizeAutoConversion( + context, + effectiveInput, + state, + shouldCancel, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Auto-conversion is best effort: a completed source file + // must remain usable if FFmpeg or metadata embedding fails. + Log.w(TAG, "Automatic conversion failed; keeping source: ${e.message}") + result.put("auto_conversion_warning", e.message ?: "conversion failed") + } + checkCancelled(shouldCancel) val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel) if (replayGain != null) result.put("replaygain", replayGain) checkCancelled(shouldCancel) @@ -547,15 +563,22 @@ object NativeDownloadFinalizer { if (requestQuality(input) != "HIGH") return if (!looksLikeM4a(state.filePath, state.fileName)) return + val autoTarget = NativeFinalizationPolicy.autoConversionTarget( + enabled = input.request.optBoolean("auto_convert_downloads", false), + format = input.request.optString("auto_convert_format", ""), + bitrate = input.request.optString("auto_convert_bitrate", ""), + ) val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" } - val format = when { + val format = autoTarget?.codec ?: when { tidalHighFormat.startsWith("opus") -> "opus" tidalHighFormat.startsWith("aac") || tidalHighFormat.startsWith("m4a") -> "aac" else -> "mp3" } val metadataFormat = if (format == "aac") "m4a" else format val displayFormat = if (format == "aac") "AAC" else format.uppercase(Locale.ROOT) - val bitrate = if (tidalHighFormat.contains("_")) { + val bitrate = if (autoTarget != null) { + "${autoTarget.bitrateKbps}k" + } else if (tidalHighFormat.contains("_")) { "${tidalHighFormat.substringAfterLast("_")}k" } else { if (format == "opus") "128k" else "320k" @@ -598,6 +621,111 @@ object NativeDownloadFinalizer { state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps" state.bitDepth = null state.sampleRate = null + state.bitrateKbps = bitrate.removeSuffix("k").toIntOrNull() + state.audioCodec = format + } + + private fun finalizeAutoConversion( + context: Context, + input: FinalizeInput, + state: FinalizeState, + shouldCancel: () -> Boolean, + ) { + val target = NativeFinalizationPolicy.autoConversionTarget( + enabled = input.request.optBoolean("auto_convert_downloads", false), + format = input.request.optString("auto_convert_format", ""), + bitrate = input.request.optString("auto_convert_bitrate", ""), + ) ?: return + if ( + NativeFinalizationPolicy.autoConversionAlreadySatisfied( + target, + state.audioCodec, + state.bitrateKbps, + ) + ) return + + val localInput = materializeForFFmpeg(context, input, state) + val sourceWasSaf = state.filePath.startsWith("content://") + val sameLocalExtension = !sourceWasSaf && + normalizeExt(File(localInput).extension) == target.extension + val output = if (sameLocalExtension) { + buildOutputPath(localInput, target.extension) + } else { + uniqueAutoConversionOutputPath(localInput, target.extension) + } + val stagedOutput = stagedConversionPath(output) + val bitrate = "${target.bitrateKbps}k" + var adoptedOutput = false + try { + val command = when (target.codec) { + "opus" -> "-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(stagedOutput)} -y" + "aac" -> "-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(stagedOutput)} -y" + else -> "-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(stagedOutput)} -y" + } + val conversion = runFFmpeg(command, shouldCancel) + if (!conversion.first || !File(stagedOutput).exists()) { + throw IllegalStateException("automatic conversion failed: ${conversion.second}") + } + if (!promoteStagedConversion(stagedOutput, output)) { + throw IllegalStateException("failed to promote automatic conversion output") + } + + val metadataFormat = if (target.codec == "aac") "m4a" else target.codec + embedBasicMetadata(context, output, input, metadataFormat) + + if (sameLocalExtension) { + replaceSameFormatLocalOutput(localInput, output) + state.filePath = localInput + state.fileName = File(localInput).name + } else { + replaceStatePath(context, input, state, output, deleteOld = true) + } + adoptedOutput = true + } finally { + if (!adoptedOutput) { + File(stagedOutput).delete() + File(output).delete() + } + if (sourceWasSaf) File(localInput).delete() + } + + state.quality = "${if (target.codec == "aac") "AAC" else target.codec.uppercase(Locale.ROOT)} ${target.bitrateKbps}kbps" + state.bitDepth = null + state.sampleRate = null + state.bitrateKbps = target.bitrateKbps + state.audioCodec = target.codec + } + + private fun replaceSameFormatLocalOutput(inputPath: String, convertedPath: String) { + val source = File(inputPath) + val converted = File(convertedPath) + val backup = File("$inputPath.spotiflac-backup-${System.nanoTime()}") + if (!source.renameTo(backup)) { + throw IllegalStateException("failed to stage original for same-format conversion") + } + try { + if (!converted.renameTo(source)) { + throw IllegalStateException("failed to publish same-format conversion") + } + backup.delete() + } catch (e: Exception) { + if (!source.exists()) backup.renameTo(source) + throw e + } + } + + private fun uniqueAutoConversionOutputPath(inputPath: String, extension: String): String { + val source = File(inputPath) + val requested = File(source.parentFile, "${source.nameWithoutExtension}$extension") + if (!requested.exists()) return requested.absolutePath + for (index in 2..Int.MAX_VALUE) { + val candidate = File( + source.parentFile, + "${source.nameWithoutExtension} ($index)$extension", + ) + if (!candidate.exists()) return candidate.absolutePath + } + throw IllegalStateException("could not allocate automatic conversion output") } private fun finalizeContainerConversion( @@ -754,7 +882,14 @@ object NativeDownloadFinalizer { } else { ext } - if (fileExt != ".flac" && fileExt != ".m4a" && fileExt != ".mp4") return null + if ( + fileExt != ".flac" && + fileExt != ".m4a" && + fileExt != ".mp4" && + fileExt != ".mp3" && + fileExt != ".opus" && + fileExt != ".ogg" + ) return null val scanPath = if (state.filePath.startsWith("content://")) { SafDownloadHandler.copyContentUriToTemp(context, state.filePath) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt index 86c1b80b..f3dfb4c0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -11,6 +11,47 @@ import kotlin.math.roundToInt * finalizer's I/O-heavy orchestration. */ internal object NativeFinalizationPolicy { + data class AutoConversionTarget( + val codec: String, + val extension: String, + val bitrateKbps: Int, + ) + + fun autoConversionTarget( + enabled: Boolean, + format: String?, + bitrate: String?, + ): AutoConversionTarget? { + if (!enabled) return null + val codec = when (format?.trim()?.lowercase(Locale.ROOT)) { + "aac", "m4a" -> "aac" + "opus" -> "opus" + else -> "mp3" + } + val normalizedBitrate = Regex("(\\d+)") + .find(bitrate.orEmpty()) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?.takeIf { it in setOf(128, 192, 256, 320) } + ?: 320 + val extension = when (codec) { + "aac" -> ".m4a" + "opus" -> ".opus" + else -> ".mp3" + } + return AutoConversionTarget(codec, extension, normalizedBitrate) + } + + fun autoConversionAlreadySatisfied( + target: AutoConversionTarget, + audioCodec: String?, + bitrateKbps: Int?, + ): Boolean { + return normalizeAudioCodec(audioCodec) == target.codec && + bitrateKbps == target.bitrateKbps + } + fun normalizeAudioCodec(codec: String?): String? { val normalized = normalizeOptional(codec) ?.lowercase(Locale.ROOT) diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt index 70c1a1a8..e09fdb0b 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -7,6 +7,41 @@ import org.junit.Assert.assertTrue import org.junit.Test class NativeFinalizationPolicyTest { + @Test + fun automaticConversionSettingsAreNormalizedAndComparable() { + val target = checkNotNull( + NativeFinalizationPolicy.autoConversionTarget( + enabled = true, + format = "M4A", + bitrate = "256 kbps", + ), + ) + assertEquals("aac", target.codec) + assertEquals(".m4a", target.extension) + assertEquals(256, target.bitrateKbps) + assertTrue( + NativeFinalizationPolicy.autoConversionAlreadySatisfied( + target, + audioCodec = "mp4a", + bitrateKbps = 256, + ), + ) + assertFalse( + NativeFinalizationPolicy.autoConversionAlreadySatisfied( + target, + audioCodec = "aac", + bitrateKbps = 128, + ), + ) + assertNull( + NativeFinalizationPolicy.autoConversionTarget( + enabled = false, + format = "opus", + bitrate = "128k", + ), + ) + } + @Test fun matchesSharedCrossPipelineQualityCases() { val stream = checkNotNull( diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index aaa0241f..d4758d57 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2764,6 +2764,60 @@ abstract class AppLocalizations { /// **'Lossy Format'** String get downloadLossyFormat; + /// Toggle for automatic post-download audio conversion + /// + /// In en, this message translates to: + /// **'Auto-convert after download'** + String get downloadAutoConvert; + + /// Explanation of safe automatic post-download conversion + /// + /// In en, this message translates to: + /// **'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'** + String get downloadAutoConvertSubtitle; + + /// Automatic conversion output format setting + /// + /// In en, this message translates to: + /// **'Output format'** + String get downloadAutoConvertFormat; + + /// Automatic conversion format picker explanation + /// + /// In en, this message translates to: + /// **'Choose the lossy format used for newly completed downloads.'** + String get downloadAutoConvertFormatSubtitle; + + /// Automatic conversion bitrate setting + /// + /// In en, this message translates to: + /// **'Output quality'** + String get downloadAutoConvertBitrate; + + /// Automatic conversion bitrate picker explanation + /// + /// In en, this message translates to: + /// **'Higher bitrates preserve more detail but create larger files.'** + String get downloadAutoConvertBitrateSubtitle; + + /// MP3 automatic conversion option description + /// + /// In en, this message translates to: + /// **'Best compatibility across players and devices'** + String get downloadAutoConvertMp3Subtitle; + + /// M4A AAC automatic conversion option description + /// + /// In en, this message translates to: + /// **'Efficient AAC audio in an M4A container'** + String get downloadAutoConvertM4aSubtitle; + + /// Opus automatic conversion option description + /// + /// In en, this message translates to: + /// **'Best efficiency for modern players'** + String get downloadAutoConvertOpusSubtitle; + /// Title of the lossy format picker bottom sheet /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 140cf693..4f5b240b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1515,6 +1515,39 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadLossyFormat => 'Verlustbehaftetes Format'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Verlustbehaftetes 320kbps-Format'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b790b9f6..150cc862 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1494,6 +1494,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadLossyFormat => 'Lossy Format'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Lossy 320kbps Format'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f667ed1e..37905322 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1494,6 +1494,39 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadLossyFormat => 'Lossy Format'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Lossy 320kbps Format'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index ccbc9c83..ee18ae93 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1533,6 +1533,39 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadLossyFormat => 'Format avec perte'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Format avec perte à 320 kbps'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a522787e..d4528889 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1501,6 +1501,39 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadLossyFormat => 'Format Lossy'; + @override + String get downloadAutoConvert => 'Konversi otomatis setelah unduh'; + + @override + String get downloadAutoConvertSubtitle => + 'Ubah unduhan yang selesai ke format lossy yang lebih kecil. File asli hanya diganti setelah konversi berhasil.'; + + @override + String get downloadAutoConvertFormat => 'Format keluaran'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Pilih format lossy untuk unduhan yang baru selesai.'; + + @override + String get downloadAutoConvertBitrate => 'Kualitas keluaran'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Bitrate lebih tinggi menjaga lebih banyak detail, tetapi ukuran file juga lebih besar.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Kompatibilitas terbaik di berbagai pemutar dan perangkat'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Audio AAC yang efisien dalam container M4A'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Efisiensi terbaik untuk pemutar modern'; + @override String get downloadLossy320Format => 'Format Lossy 320kbps'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b1008ee3..f470be0a 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1485,6 +1485,39 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadLossyFormat => 'Lossy Format'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Lossy 320kbps Format'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 7be832a4..e03fe49f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1464,6 +1464,39 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadLossyFormat => '손실 압축 형식'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => '손실 압축 320kbps 형식'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4c92b07d..c4595786 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1494,6 +1494,39 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadLossyFormat => 'Lossy Format'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Lossy 320kbps Format'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4d48f8c2..630359bb 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1505,6 +1505,39 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadLossyFormat => 'Формат с потерями'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Формат с потерями 320 кбит/с'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index f2505e5f..084efce3 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1507,6 +1507,39 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadLossyFormat => 'Kayıplı Format'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Kayıplı 320kbps Formatı'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index fa799746..c5849055 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1513,6 +1513,39 @@ class AppLocalizationsUk extends AppLocalizations { @override String get downloadLossyFormat => 'Формат із втратами'; + @override + String get downloadAutoConvert => 'Auto-convert after download'; + + @override + String get downloadAutoConvertSubtitle => + 'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'; + + @override + String get downloadAutoConvertFormat => 'Output format'; + + @override + String get downloadAutoConvertFormatSubtitle => + 'Choose the lossy format used for newly completed downloads.'; + + @override + String get downloadAutoConvertBitrate => 'Output quality'; + + @override + String get downloadAutoConvertBitrateSubtitle => + 'Higher bitrates preserve more detail but create larger files.'; + + @override + String get downloadAutoConvertMp3Subtitle => + 'Best compatibility across players and devices'; + + @override + String get downloadAutoConvertM4aSubtitle => + 'Efficient AAC audio in an M4A container'; + + @override + String get downloadAutoConvertOpusSubtitle => + 'Best efficiency for modern players'; + @override String get downloadLossy320Format => 'Формат із втратами 320 кбіт/с'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 34582816..4dc77467 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1991,6 +1991,42 @@ "@downloadLossyFormat": { "description": "Setting title to pick output format for lossy downloads" }, + "downloadAutoConvert": "Auto-convert after download", + "@downloadAutoConvert": { + "description": "Toggle for automatic post-download audio conversion" + }, + "downloadAutoConvertSubtitle": "Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.", + "@downloadAutoConvertSubtitle": { + "description": "Explanation of safe automatic post-download conversion" + }, + "downloadAutoConvertFormat": "Output format", + "@downloadAutoConvertFormat": { + "description": "Automatic conversion output format setting" + }, + "downloadAutoConvertFormatSubtitle": "Choose the lossy format used for newly completed downloads.", + "@downloadAutoConvertFormatSubtitle": { + "description": "Automatic conversion format picker explanation" + }, + "downloadAutoConvertBitrate": "Output quality", + "@downloadAutoConvertBitrate": { + "description": "Automatic conversion bitrate setting" + }, + "downloadAutoConvertBitrateSubtitle": "Higher bitrates preserve more detail but create larger files.", + "@downloadAutoConvertBitrateSubtitle": { + "description": "Automatic conversion bitrate picker explanation" + }, + "downloadAutoConvertMp3Subtitle": "Best compatibility across players and devices", + "@downloadAutoConvertMp3Subtitle": { + "description": "MP3 automatic conversion option description" + }, + "downloadAutoConvertM4aSubtitle": "Efficient AAC audio in an M4A container", + "@downloadAutoConvertM4aSubtitle": { + "description": "M4A AAC automatic conversion option description" + }, + "downloadAutoConvertOpusSubtitle": "Best efficiency for modern players", + "@downloadAutoConvertOpusSubtitle": { + "description": "Opus automatic conversion option description" + }, "downloadLossy320Format": "Lossy 320kbps Format", "@downloadLossy320Format": { "description": "Title of the lossy format picker bottom sheet" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index c3df424c..fa1fcdd0 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4253,6 +4253,42 @@ "@downloadLossyFormat": { "description": "Setting title to pick output format for lossy downloads" }, + "downloadAutoConvert": "Konversi otomatis setelah unduh", + "@downloadAutoConvert": { + "description": "Toggle for automatic post-download audio conversion" + }, + "downloadAutoConvertSubtitle": "Ubah unduhan yang selesai ke format lossy yang lebih kecil. File asli hanya diganti setelah konversi berhasil.", + "@downloadAutoConvertSubtitle": { + "description": "Explanation of safe automatic post-download conversion" + }, + "downloadAutoConvertFormat": "Format keluaran", + "@downloadAutoConvertFormat": { + "description": "Automatic conversion output format setting" + }, + "downloadAutoConvertFormatSubtitle": "Pilih format lossy untuk unduhan yang baru selesai.", + "@downloadAutoConvertFormatSubtitle": { + "description": "Automatic conversion format picker explanation" + }, + "downloadAutoConvertBitrate": "Kualitas keluaran", + "@downloadAutoConvertBitrate": { + "description": "Automatic conversion bitrate setting" + }, + "downloadAutoConvertBitrateSubtitle": "Bitrate lebih tinggi menjaga lebih banyak detail, tetapi ukuran file juga lebih besar.", + "@downloadAutoConvertBitrateSubtitle": { + "description": "Automatic conversion bitrate picker explanation" + }, + "downloadAutoConvertMp3Subtitle": "Kompatibilitas terbaik di berbagai pemutar dan perangkat", + "@downloadAutoConvertMp3Subtitle": { + "description": "MP3 automatic conversion option description" + }, + "downloadAutoConvertM4aSubtitle": "Audio AAC yang efisien dalam container M4A", + "@downloadAutoConvertM4aSubtitle": { + "description": "M4A AAC automatic conversion option description" + }, + "downloadAutoConvertOpusSubtitle": "Efisiensi terbaik untuk pemutar modern", + "@downloadAutoConvertOpusSubtitle": { + "description": "Opus automatic conversion option description" + }, "snackbarAlreadyInLibrary": "\"{trackName}\" sudah ada di perpustakaan Anda", "@snackbarAlreadyInLibrary": { "description": "Snackbar - track already exists in local library", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 66fc7657..e0836f6c 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -69,6 +69,9 @@ class AppSettings { final String lyricsMode; final String tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128' + final bool autoConvertDownloads; + final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus' + final String autoConvertBitrate; // '128k', '192k', '256k', or '320k' final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE final bool autoExportFailedDownloads; @@ -142,7 +145,7 @@ class AppSettings { this.historyViewMode = 'grid', this.historyFilterMode = 'all', this.defaultLibraryView = 'last', - this.libraryQualityLabelMode = libraryQualityLabelBitrate, + this.libraryQualityLabelMode = AppSettings.libraryQualityLabelBitrate, this.askQualityBeforeDownload = true, this.enableLogging = false, this.useExtensionProviders = true, @@ -160,6 +163,9 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.tidalHighFormat = 'mp3_320', + this.autoConvertDownloads = false, + this.autoConvertFormat = 'mp3', + this.autoConvertBitrate = '320k', this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', @@ -235,6 +241,9 @@ class AppSettings { String? locale, String? lyricsMode, String? tidalHighFormat, + bool? autoConvertDownloads, + String? autoConvertFormat, + String? autoConvertBitrate, bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, @@ -323,6 +332,9 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, + autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads, + autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat, + autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 39f48932..108778ac 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -60,6 +60,9 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', + autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false, + autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3', + autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k', useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, @@ -145,6 +148,9 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'tidalHighFormat': instance.tidalHighFormat, + 'autoConvertDownloads': instance.autoConvertDownloads, + 'autoConvertFormat': instance.autoConvertFormat, + 'autoConvertBitrate': instance.autoConvertBitrate, 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index caae88cf..a072a46e 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -28,6 +28,7 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/progress_stream_poller.dart'; @@ -556,6 +557,11 @@ class DownloadQueueNotifier extends Notifier { embedReplayGain: settings.embedReplayGain, postProcessingEnabled: postProcessingEnabled, tidalHighFormat: settings.tidalHighFormat, + autoConvertDownloads: settings.autoConvertDownloads, + autoConvertFormat: normalizeAutoConvertFormat(settings.autoConvertFormat), + autoConvertBitrate: normalizeAutoConvertBitrate( + settings.autoConvertBitrate, + ), trackNumber: normalizedTrackNumber, playlistPosition: _validPlaylistPosition(item), discNumber: normalizedDiscNumber, diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index 70b773af..038f8344 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -23,6 +23,20 @@ class _QualityVariantFileOutcome { }); } +class _AutoConversionOutcome { + final String filePath; + final String? fileName; + final String quality; + final bool converted; + + const _AutoConversionOutcome({ + required this.filePath, + required this.fileName, + required this.quality, + required this.converted, + }); +} + /// AC-4 repair only applies to MP4 containers; decrypt can also emit raw /// FLAC, which the native MP4 box parser would reject as corrupt. bool _isMp4Container(String path) { @@ -31,6 +45,160 @@ bool _isMp4Container(String path) { } extension _DownloadQueueFinalization on DownloadQueueNotifier { + Future<_AutoConversionOutcome> _autoConvertDownloadedFile({ + required String itemId, + required String filePath, + required String? fileName, + required String currentQuality, + required AppSettings settings, + required Track track, + required Map result, + required String downloadService, + required String storageMode, + String? downloadTreeUri, + String? safRelativeDir, + }) async { + if (!settings.autoConvertDownloads) { + return _AutoConversionOutcome( + filePath: filePath, + fileName: fileName, + quality: currentQuality, + converted: false, + ); + } + + final targetFormat = normalizeAutoConvertFormat(settings.autoConvertFormat); + final targetBitrate = normalizeAutoConvertBitrate( + settings.autoConvertBitrate, + ); + final targetBitrateKbps = autoConvertBitrateKbps(targetBitrate); + if (autoConversionAlreadySatisfied( + filePath: filePath, + fileName: fileName, + targetFormat: targetFormat, + targetBitrate: targetBitrate, + quality: currentQuality, + bitrateKbps: readPositiveBitrateKbps( + result['bitrate'] ?? result['actual_bitrate'], + ), + )) { + return _AutoConversionOutcome( + filePath: filePath, + fileName: fileName, + quality: currentQuality, + converted: false, + ); + } + + final baseFileName = (fileName?.trim().isNotEmpty == true + ? fileName!.trim() + : File(filePath).uri.pathSegments.last); + final convertedFileName = convertedOutputFileName( + originalFileName: baseFileName, + targetFormat: targetFormat, + ); + final convertedQuality = + '${displayFormatForLossyFormat(targetFormat)} ${targetBitrateKbps}kbps'; + + Future embedConvertedMetadata(String convertedPath) async { + if (!settings.embedMetadata) return; + try { + await _embedMetadataToFile( + convertedPath, + track, + format: metadataFormatForLossyFormat(targetFormat), + genre: result['genre'] as String?, + label: result['label'] as String?, + copyright: result['copyright'] as String?, + downloadService: downloadService, + writeExternalLrc: storageMode != 'saf', + ); + } catch (e) { + // The audio conversion itself is still valid. Preserve the converted + // file if an optional tag/cover write fails. + _log.w('Automatic conversion metadata embed failed: $e'); + result['auto_conversion_metadata_warning'] = e.toString(); + } + } + + try { + updateItemStatus(itemId, DownloadStatus.finalizing, progress: 0.97); + String? convertedPath; + String? publishedFileName = convertedFileName; + if (storageMode == 'saf' && isContentUri(filePath)) { + if (downloadTreeUri == null || downloadTreeUri.isEmpty) { + throw StateError('Missing SAF tree for automatic conversion'); + } + convertedPath = await _replaceSafFileVia( + uri: filePath, + treeUri: downloadTreeUri, + relativeDir: safRelativeDir ?? '', + avoidOverwrite: + convertedFileName.toLowerCase() != baseFileName.toLowerCase(), + onPublishedFileName: (value) => publishedFileName = value, + op: (tempPath, addCleanup) async { + final output = await FFmpegService.convertAudioFormat( + inputPath: tempPath, + targetFormat: targetFormat, + bitrate: targetBitrate, + metadata: const {}, + deleteOriginal: false, + ); + if (output == null) return null; + addCleanup(output); + await embedConvertedMetadata(output); + return (output, convertedFileName); + }, + ); + } else { + convertedPath = await FFmpegService.convertAudioFormat( + inputPath: filePath, + targetFormat: targetFormat, + bitrate: targetBitrate, + metadata: const {}, + deleteOriginal: true, + ); + if (convertedPath != null) { + publishedFileName = File(convertedPath).uri.pathSegments.last; + await embedConvertedMetadata(convertedPath); + } + } + + if (convertedPath == null || convertedPath.isEmpty) { + throw StateError('FFmpeg returned no automatic conversion output'); + } + + result['file_path'] = convertedPath; + result['file_name'] = publishedFileName; + result['audio_codec'] = targetFormat; + result['format'] = targetFormat; + result['bitrate'] = targetBitrateKbps; + result.remove('actual_bit_depth'); + result.remove('actual_sample_rate'); + _log.i( + 'Automatic conversion completed: ${autoConvertFormatLabel(targetFormat)} @ $targetBitrate', + ); + return _AutoConversionOutcome( + filePath: convertedPath, + fileName: publishedFileName, + quality: convertedQuality, + converted: true, + ); + } catch (e) { + // A successful download remains usable when the optional conversion + // fails. Conversion helpers only remove the source after atomic output + // promotion, so returning the original path is safe here. + result['auto_conversion_warning'] = e.toString(); + _log.w('Automatic conversion failed; keeping downloaded source: $e'); + return _AutoConversionOutcome( + filePath: filePath, + fileName: fileName, + quality: currentQuality, + converted: false, + ); + } + } + /// Builds the [DownloadHistoryItem] shared by the native-worker and inline /// completion paths. Fields whose source/derivation legitimately differs /// between the two callers (SAF location, probed vs. raw audio metadata, @@ -709,7 +877,12 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { return filePath; } - final tidalHighFormat = settings.tidalHighFormat; + final tidalHighFormat = settings.autoConvertDownloads + ? autoConvertLossySetting( + format: settings.autoConvertFormat, + bitrate: settings.autoConvertBitrate, + ) + : settings.tidalHighFormat; final format = lossyFormatForSetting(tidalHighFormat); final newExt = lossyExtensionForFormat(format); final displayFormat = displayFormatForLossyFormat(format); @@ -761,6 +934,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { } result['file_name'] = newFileName; result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; + result['audio_codec'] = format; + result['format'] = format; + result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last); + result.remove('actual_bit_depth'); + result.remove('actual_sample_rate'); return newUri; } @@ -775,6 +953,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { } await embedConvertedMetadata(convertedPath); result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; + result['audio_codec'] = format; + result['format'] = format; + result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last); + result.remove('actual_bit_depth'); + result.remove('actual_sample_rate'); return convertedPath; } diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 891971b1..4d307905 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -1212,6 +1212,30 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { if (postProcessedPath != null && postProcessedPath.isNotEmpty) { filePath = postProcessedPath; } + final autoConvertOutcome = await _autoConvertDownloadedFile( + itemId: item.id, + filePath: filePath, + fileName: result['file_name'] as String? ?? context.safFileName, + currentQuality: actualQuality, + settings: settings, + track: trackToDownload, + result: result, + downloadService: context.item.service, + storageMode: context.storageMode, + downloadTreeUri: context.downloadTreeUri, + safRelativeDir: context.safRelativeDir, + ); + filePath = autoConvertOutcome.filePath; + actualQuality = autoConvertOutcome.quality; + if (autoConvertOutcome.fileName != null) { + result['file_name'] = autoConvertOutcome.fileName; + } + if (autoConvertOutcome.converted) { + actualBitDepth = null; + actualSampleRate = null; + actualFormat = normalizeAutoConvertFormat(settings.autoConvertFormat); + actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate); + } await _writeNativeWorkerReplayGain( context: context, settings: settings, diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 9a4d14ab..70a13203 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -713,6 +713,27 @@ class _DownloadRun { } } + final autoConvertInput = filePath; + if (!wasExisting && autoConvertInput != null) { + final outcome = await n._autoConvertDownloadedFile( + itemId: item.id, + filePath: autoConvertInput, + fileName: finalSafFileName ?? result['file_name'] as String?, + currentQuality: actualQuality, + settings: settings, + track: trackToDownload, + result: result, + downloadService: item.service, + storageMode: effectiveSafMode ? 'saf' : 'app', + downloadTreeUri: settings.downloadTreeUri, + safRelativeDir: effectiveOutputDir, + ); + filePath = outcome.filePath; + finalSafFileName = outcome.fileName; + actualQuality = outcome.quality; + if (outcome.converted) probedFinalMetadata = null; + } + final variantInput = filePath; if (variantInput != null && item.preserveQualityVariant) { final variantOutcome = await n._finalizeQualityVariantFilename( @@ -911,7 +932,12 @@ class _DownloadRun { } Future _convertSafM4aToLossy(String currentFilePath) async { - final tidalHighFormat = settings.tidalHighFormat; + final tidalHighFormat = settings.autoConvertDownloads + ? autoConvertLossySetting( + format: settings.autoConvertFormat, + bitrate: settings.autoConvertBitrate, + ) + : settings.tidalHighFormat; _log.i( 'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...', ); @@ -972,6 +998,11 @@ class _DownloadRun { ? '${tidalHighFormat.split('_').last}kbps' : '320kbps'; actualQuality = '$displayFormat $bitrateDisplay'; + result['audio_codec'] = format; + result['format'] = format; + result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last); + result.remove('actual_bit_depth'); + result.remove('actual_sample_rate'); } else if (convertFailed) { _log.w('M4A to $format conversion failed, keeping M4A file'); actualQuality = 'AAC 320kbps'; @@ -1115,7 +1146,12 @@ class _DownloadRun { } Future _convertLocalM4aToLossy(String currentFilePath) async { - final tidalHighFormat = settings.tidalHighFormat; + final tidalHighFormat = settings.autoConvertDownloads + ? autoConvertLossySetting( + format: settings.autoConvertFormat, + bitrate: settings.autoConvertBitrate, + ) + : settings.tidalHighFormat; _log.i( 'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...', ); @@ -1138,6 +1174,11 @@ class _DownloadRun { ? '${tidalHighFormat.split('_').last}kbps' : '320kbps'; actualQuality = '$displayFormat $bitrateDisplay'; + result['audio_codec'] = format; + result['format'] = format; + result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last); + result.remove('actual_bit_depth'); + result.remove('actual_sample_rate'); _log.i('Successfully converted M4A to $format: $convertedPath'); _log.i('Embedding metadata to $format...'); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 26ad7d0c..10ee3f6e 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -162,6 +163,12 @@ class SettingsNotifier extends Notifier { libraryQualityLabelMode: _normalizeLibraryQualityLabelMode( loaded.libraryQualityLabelMode, ), + autoConvertFormat: normalizeAutoConvertFormat( + loaded.autoConvertFormat, + ), + autoConvertBitrate: normalizeAutoConvertBitrate( + loaded.autoConvertBitrate, + ), defaultService: loaded.defaultService, searchProvider: loaded.searchProvider, extensionVerificationBrowserMode: @@ -735,6 +742,25 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAutoConvertDownloads(bool enabled) { + state = state.copyWith(autoConvertDownloads: enabled); + _saveSettings(); + } + + void setAutoConvertFormat(String format) { + state = state.copyWith( + autoConvertFormat: normalizeAutoConvertFormat(format), + ); + _saveSettings(); + } + + void setAutoConvertBitrate(String bitrate) { + state = state.copyWith( + autoConvertBitrate: normalizeAutoConvertBitrate(bitrate), + ); + _saveSettings(); + } + void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 57812eac..85911f3a 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -6,6 +6,8 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -109,10 +111,7 @@ class _DownloadSettingsPageState extends ConsumerState { onTap: () => ref .read(settingsProvider.notifier) .setAudioQuality(quality.id), - showDivider: - quality != qualityOptions.last || - (usesTidalCompatibilityOptions && - settings.audioQuality == 'HIGH'), + showDivider: true, ), if (usesTidalCompatibilityOptions && settings.audioQuality == 'HIGH') @@ -128,9 +127,46 @@ class _DownloadSettingsPageState extends ConsumerState { ref, settings.tidalHighFormat, ), - showDivider: false, + showDivider: true, ), ], + SettingsSwitchItem( + icon: Icons.auto_fix_high_outlined, + title: context.l10n.downloadAutoConvert, + subtitle: context.l10n.downloadAutoConvertSubtitle, + value: settings.autoConvertDownloads, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setAutoConvertDownloads(value), + showDivider: settings.autoConvertDownloads, + ), + if (settings.autoConvertDownloads) ...[ + SettingsItem( + icon: Icons.audio_file_outlined, + title: context.l10n.downloadAutoConvertFormat, + subtitle: autoConvertFormatLabel( + settings.autoConvertFormat, + ), + onTap: () => _showAutoConvertFormatPicker( + context, + ref, + settings.autoConvertFormat, + ), + ), + SettingsItem( + icon: Icons.speed_outlined, + title: context.l10n.downloadAutoConvertBitrate, + subtitle: normalizeAutoConvertBitrate( + settings.autoConvertBitrate, + ).replaceAll('k', ' kbps'), + onTap: () => _showAutoConvertBitratePicker( + context, + ref, + settings.autoConvertBitrate, + ), + showDivider: false, + ), + ], ], ), ), @@ -497,6 +533,91 @@ class _DownloadSettingsPageState extends ConsumerState { ); } + void _showAutoConvertFormatPicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final normalizedCurrent = normalizeAutoConvertFormat(current); + showAppBottomSheet( + context: context, + useRootNavigator: true, + title: context.l10n.downloadAutoConvertFormat, + subtitle: context.l10n.downloadAutoConvertFormatSubtitle, + builder: (sheetContext) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final format in autoConvertFormats) + ListTile( + leading: Icon( + format == 'opus' + ? Icons.graphic_eq + : format == 'aac' + ? Icons.album_outlined + : Icons.audiotrack, + ), + title: Text(autoConvertFormatLabel(format)), + subtitle: Text(switch (format) { + 'aac' => context.l10n.downloadAutoConvertM4aSubtitle, + 'opus' => context.l10n.downloadAutoConvertOpusSubtitle, + _ => context.l10n.downloadAutoConvertMp3Subtitle, + }), + trailing: normalizedCurrent == format + ? Icon( + Icons.check, + color: Theme.of(sheetContext).colorScheme.primary, + ) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setAutoConvertFormat(format); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + } + + void _showAutoConvertBitratePicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final normalizedCurrent = normalizeAutoConvertBitrate(current); + showAppBottomSheet( + context: context, + useRootNavigator: true, + title: context.l10n.downloadAutoConvertBitrate, + subtitle: context.l10n.downloadAutoConvertBitrateSubtitle, + builder: (sheetContext) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final bitrate in autoConvertBitrates) + ListTile( + leading: const Icon(Icons.speed_outlined), + title: Text(bitrate.replaceAll('k', ' kbps')), + trailing: normalizedCurrent == bitrate + ? Icon( + Icons.check, + color: Theme.of(sheetContext).colorScheme.primary, + ) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setAutoConvertBitrate(bitrate); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + } + void _showNetworkModePicker( BuildContext context, WidgetRef ref, diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 66e80d02..50de51fa 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -321,6 +321,19 @@ class SettingsSearchCatalog { title: l10n.downloadLossyFormat, keywords: const ['mp3', 'aac', 'opus'], ), + SettingsSearchEntry( + icon: Icons.auto_fix_high_outlined, + title: l10n.downloadAutoConvert, + subtitle: l10n.downloadAutoConvertSubtitle, + keywords: const [ + 'automatic conversion', + 'mp3', + 'm4a', + 'aac', + 'opus', + 'bitrate', + ], + ), SettingsSearchEntry( icon: Icons.wifi, title: l10n.settingsDownloadNetwork, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index cdeca4d1..3d484d35 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -20,6 +20,9 @@ class DownloadRequestPayload { final bool embedReplayGain; final bool postProcessingEnabled; final String tidalHighFormat; + final bool autoConvertDownloads; + final String autoConvertFormat; + final String autoConvertBitrate; final int trackNumber; final int playlistPosition; final int discNumber; @@ -73,6 +76,9 @@ class DownloadRequestPayload { this.embedReplayGain = false, this.postProcessingEnabled = false, this.tidalHighFormat = 'mp3_320', + this.autoConvertDownloads = false, + this.autoConvertFormat = 'mp3', + this.autoConvertBitrate = '320k', this.trackNumber = 0, this.playlistPosition = 0, this.discNumber = 0, @@ -128,6 +134,9 @@ class DownloadRequestPayload { 'embed_replaygain': embedReplayGain, 'post_processing_enabled': postProcessingEnabled, 'tidal_high_format': tidalHighFormat, + 'auto_convert_downloads': autoConvertDownloads, + 'auto_convert_format': autoConvertFormat, + 'auto_convert_bitrate': autoConvertBitrate, 'track_number': trackNumber, 'playlist_position': playlistPosition, 'disc_number': discNumber, @@ -187,6 +196,9 @@ class DownloadRequestPayload { embedReplayGain: embedReplayGain, postProcessingEnabled: postProcessingEnabled, tidalHighFormat: tidalHighFormat, + autoConvertDownloads: autoConvertDownloads, + autoConvertFormat: autoConvertFormat, + autoConvertBitrate: autoConvertBitrate, trackNumber: trackNumber, playlistPosition: playlistPosition, discNumber: discNumber, diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 1022761f..c7395b75 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -285,6 +285,69 @@ String displayFormatForLossyFormat(String format) { return format == 'aac' ? 'AAC' : format.toUpperCase(); } +const List autoConvertFormats = ['mp3', 'aac', 'opus']; +const List autoConvertBitrates = ['128k', '192k', '256k', '320k']; + +String normalizeAutoConvertFormat(String value) { + final normalized = value.trim().toLowerCase(); + if (normalized == 'm4a') return 'aac'; + return autoConvertFormats.contains(normalized) ? normalized : 'mp3'; +} + +String normalizeAutoConvertBitrate(String value) { + final match = RegExp(r'(\d+)').firstMatch(value); + final normalized = match == null ? '' : '${match.group(1)}k'; + return autoConvertBitrates.contains(normalized) ? normalized : '320k'; +} + +int autoConvertBitrateKbps(String value) { + return int.parse(normalizeAutoConvertBitrate(value).replaceAll('k', '')); +} + +String autoConvertLossySetting({ + required String format, + required String bitrate, +}) { + final normalizedFormat = normalizeAutoConvertFormat(format); + final normalizedBitrate = autoConvertBitrateKbps(bitrate); + return '${normalizedFormat}_$normalizedBitrate'; +} + +String autoConvertFormatLabel(String format) { + return switch (normalizeAutoConvertFormat(format)) { + 'aac' => 'M4A (AAC)', + 'opus' => 'Opus', + _ => 'MP3', + }; +} + +bool autoConversionAlreadySatisfied({ + required String? filePath, + String? fileName, + required String targetFormat, + required String targetBitrate, + String? quality, + int? bitrateKbps, +}) { + final sourceFormat = audioFormatForPath(filePath, fileName: fileName); + final normalizedTarget = normalizeAutoConvertFormat(targetFormat); + final matchesFormat = switch (normalizedTarget) { + 'aac' => sourceFormat == 'AAC' || sourceFormat == 'M4A', + 'opus' => sourceFormat == 'OPUS', + _ => sourceFormat == 'MP3', + }; + if (!matchesFormat) return false; + + final targetKbps = autoConvertBitrateKbps(targetBitrate); + if (bitrateKbps != null && bitrateKbps > 0) { + return bitrateKbps == targetKbps; + } + return RegExp( + '\\b$targetKbps\\s*kbps\\b', + caseSensitive: false, + ).hasMatch(quality ?? ''); +} + String? resolveDisplayQuality({ required String? filePath, String? fileName, diff --git a/test/audio_conversion_utils_test.dart b/test/audio_conversion_utils_test.dart index b9275f5f..e6418602 100644 --- a/test/audio_conversion_utils_test.dart +++ b/test/audio_conversion_utils_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; void main() { group('same-format lossless conversion', () { @@ -85,4 +86,38 @@ void main() { expect(first, isNot(other)); }); }); + + group('automatic download conversion settings', () { + test('normalizes supported formats and bitrates', () { + expect(normalizeAutoConvertFormat('M4A'), 'aac'); + expect(normalizeAutoConvertFormat('unexpected'), 'mp3'); + expect(normalizeAutoConvertBitrate('256 kbps'), '256k'); + expect(normalizeAutoConvertBitrate('999k'), '320k'); + expect( + autoConvertLossySetting(format: 'opus', bitrate: '192k'), + 'opus_192', + ); + }); + + test('skips only an output that already matches format and bitrate', () { + expect( + autoConversionAlreadySatisfied( + filePath: '/music/Track.mp3', + targetFormat: 'mp3', + targetBitrate: '320k', + quality: 'MP3 320kbps', + ), + isTrue, + ); + expect( + autoConversionAlreadySatisfied( + filePath: '/music/Track.m4a', + targetFormat: 'aac', + targetBitrate: '128k', + bitrateKbps: 256, + ), + isFalse, + ); + }); + }); } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 72fd3aa7..4d4e4aab 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -671,6 +671,9 @@ void main() { expect(settings.deduplicateDownloads, isTrue); expect(settings.allowQualityVariants, isFalse); expect(settings.nativeDownloadWorkerEnabled, isFalse); + expect(settings.autoConvertDownloads, isFalse); + expect(settings.autoConvertFormat, 'mp3'); + expect(settings.autoConvertBitrate, '320k'); expect( settings.libraryQualityLabelMode, AppSettings.libraryQualityLabelBitrate, @@ -733,6 +736,9 @@ void main() { deduplicateDownloads: false, allowQualityVariants: true, nativeDownloadWorkerEnabled: true, + autoConvertDownloads: true, + autoConvertFormat: 'opus', + autoConvertBitrate: '192k', libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth, ); @@ -760,6 +766,9 @@ void main() { expect(decoded.deduplicateDownloads, isFalse); expect(decoded.allowQualityVariants, isTrue); expect(decoded.nativeDownloadWorkerEnabled, isTrue); + expect(decoded.autoConvertDownloads, isTrue); + expect(decoded.autoConvertFormat, 'opus'); + expect(decoded.autoConvertBitrate, '192k'); }); }); @@ -808,6 +817,9 @@ void main() { embedReplayGain: true, postProcessingEnabled: true, tidalHighFormat: 'opus_256', + autoConvertDownloads: true, + autoConvertFormat: 'opus', + autoConvertBitrate: '192k', trackNumber: 7, playlistPosition: 3, discNumber: 2, @@ -859,6 +871,9 @@ void main() { 'embed_replaygain': true, 'post_processing_enabled': true, 'tidal_high_format': 'opus_256', + 'auto_convert_downloads': true, + 'auto_convert_format': 'opus', + 'auto_convert_bitrate': '192k', 'track_number': 7, 'playlist_position': 3, 'disc_number': 2, @@ -913,6 +928,9 @@ void main() { expect(updated.filenameFormat, payload.filenameFormat); expect(updated.allowQualityVariant, payload.allowQualityVariant); expect(updated.qualityVariant, payload.qualityVariant); + expect(updated.autoConvertDownloads, payload.autoConvertDownloads); + expect(updated.autoConvertFormat, payload.autoConvertFormat); + expect(updated.autoConvertBitrate, payload.autoConvertBitrate); expect( updated.qualityVariantCollisionOnly, payload.qualityVariantCollisionOnly,