feat(download): add automatic lossy conversion

This commit is contained in:
zarzet
2026-08-11 15:19:45 +07:00
parent a59c749089
commit 5237aed25e
30 changed files with 1272 additions and 12 deletions
@@ -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)
@@ -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)
@@ -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(
+54
View File
@@ -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:
+33
View File
@@ -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';
+33
View File
@@ -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';
+33
View File
@@ -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';
+33
View File
@@ -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';
+33
View File
@@ -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';
+33
View File
@@ -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';
+33
View File
@@ -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 형식';
+33
View File
@@ -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';
+33
View File
@@ -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 кбит/с';
+33
View File
@@ -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ı';
+33
View File
@@ -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 кбіт/с';
+36
View File
@@ -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"
+36
View File
@@ -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",
+13 -1
View File
@@ -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,
+6
View File
@@ -60,6 +60,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$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,
@@ -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<DownloadQueueState> {
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,
@@ -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<String, dynamic> 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<void> 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;
}
@@ -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,
@@ -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<void> _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<void> _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...');
+26
View File
@@ -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<AppSettings> {
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<AppSettings> {
_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();
@@ -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<DownloadSettingsPage> {
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<DownloadSettingsPage> {
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<DownloadSettingsPage> {
);
}
void _showAutoConvertFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final normalizedCurrent = normalizeAutoConvertFormat(current);
showAppBottomSheet<void>(
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<void>(
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,
@@ -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,
@@ -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,
+63
View File
@@ -285,6 +285,69 @@ String displayFormatForLossyFormat(String format) {
return format == 'aac' ? 'AAC' : format.toUpperCase();
}
const List<String> autoConvertFormats = ['mp3', 'aac', 'opus'];
const List<String> 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,
+35
View File
@@ -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,
);
});
});
}
+18
View File
@@ -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,