add a download quality preference

Downloads always fetched the original file. The setting takes the same choices
as the stream quality, and when it transcodes the download uses the stream
endpoint with those parameters instead, so the stored file is already the
wanted encoding rather than the original that would be transcoded on each play.

A preference change applied before the source is ready falls back to original,
and the URI is built from the same cached source the stream URL uses.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 26b8963205
commit adb9367f18
7 changed files with 98 additions and 11 deletions
@@ -77,8 +77,17 @@ class UserPreferences(
store.edit { prefs -> prefs[streamQualityKey(mode)] = encode(quality) }
}
fun downloadQuality(): Flow<StreamQuality> =
store.data.map { prefs -> prefs[downloadQualityKey]?.let(::decodeStreamQuality) ?: StreamQuality() }
suspend fun setDownloadQuality(quality: StreamQuality) {
store.edit { prefs -> prefs[downloadQualityKey] = encode(quality) }
}
private fun streamQualityKey(mode: NetworkMode) = stringPreferencesKey("stream_quality_${mode.key}")
private val downloadQualityKey = stringPreferencesKey("download_quality")
fun syncConcurrency(): Flow<Int> = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) }
suspend fun setSyncConcurrency(value: Int) {
@@ -49,7 +49,13 @@ class SourceRepository(
private val _quality = MutableStateFlow(StreamQuality())
val quality: StateFlow<StreamQuality> = _quality
@Volatile
private var downloadQuality = StreamQuality()
init {
scope.launch {
prefs.downloadQuality().collect { downloadQuality = it }
}
scope.launch {
combine(
db.sourcesDao().activeSubsonicConfig(),
@@ -120,7 +126,17 @@ class SourceRepository(
fun downloadUri(
sourceId: Long,
songId: String,
): String? = if (activeSourceId == sourceId) active?.downloadUri(songId)?.toString() else null
): String? {
val source = active?.takeIf { activeSourceId == sourceId } ?: return null
// A transcode preference downloads the stream, so the saved file is already the wanted
// quality rather than the original that would then have to be transcoded on every play.
val quality = downloadQuality
return if (quality.transcodes) {
source.streamUri(songId, quality.maxBitrate, quality.format)?.toString()
} else {
source.downloadUri(songId)?.toString()
}
}
suspend fun addSource(
name: String,
@@ -137,15 +137,20 @@ class SubsonicSource(
}
}.flowOn(Dispatchers.IO).buffer(1)
fun streamUri(songId: String) =
client.uri(
"stream",
buildMap {
put("id", songId)
if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString())
streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) }
},
)
fun streamUri(songId: String) = streamUri(songId, maxBitrate, streamFormat)
fun streamUri(
songId: String,
maxBitrate: Int,
format: String?,
) = client.uri(
"stream",
buildMap {
put("id", songId)
if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString())
format?.takeIf { it.isNotEmpty() }?.let { put("format", it) }
},
)
fun downloadUri(songId: String) = client.uri("download", mapOf("id" to songId))
@@ -54,7 +54,7 @@ import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "flac")
private enum class SettingsDialog { WifiQuality, MobileQuality, SyncConcurrency }
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency }
@Composable
fun SettingsRoute(
@@ -69,17 +69,20 @@ fun SettingsRoute(
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
SettingsScreen(
sources = sources,
activeSourceId = activeSourceId,
wifiQuality = wifiQuality,
mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency,
downloadQuality = downloadQuality,
onSelectSource = viewModel::selectSource,
onEditServer = onEditServer,
onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onDownloadQualityChange = viewModel::setDownloadQuality,
onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads,
onBack = onBack,
@@ -94,11 +97,13 @@ fun SettingsScreen(
wifiQuality: StreamQuality,
mobileQuality: StreamQuality,
syncConcurrency: Int,
downloadQuality: StreamQuality,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit,
onDownloadQualityChange: (StreamQuality) -> Unit,
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onBack: () -> Unit,
@@ -186,6 +191,14 @@ fun SettingsScreen(
)
}
item { SectionHeader("Downloads") }
item {
ListItem(
headlineContent = { Text("Download quality") },
supportingContent = { Text(qualityLabel(downloadQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.DownloadQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text("Manage downloads") },
@@ -217,6 +230,15 @@ fun SettingsScreen(
)
}
SettingsDialog.DownloadQuality -> {
QualityDialog(
title = "Download quality",
quality = downloadQuality,
onSelect = onDownloadQualityChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.SyncConcurrency -> {
ConcurrencyDialog(
selected = syncConcurrency,
@@ -33,6 +33,11 @@ class SettingsViewModel(
.streamQuality(NetworkMode.Mobile)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val downloadQuality: StateFlow<StreamQuality> =
userPreferences
.downloadQuality()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val syncConcurrency: StateFlow<Int> =
userPreferences
.syncConcurrency()
@@ -50,6 +55,10 @@ class SettingsViewModel(
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) }
}
fun setDownloadQuality(quality: StreamQuality) {
viewModelScope.launch { userPreferences.setDownloadQuality(quality) }
}
fun setSyncConcurrency(value: Int) {
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
}
@@ -9,6 +9,7 @@ import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.Dispatchers
@@ -63,6 +64,29 @@ class SourceRepositoryTest {
db.close()
}
@Test
fun aTranscodedDownloadQualityDownloadsTheStreamInstead() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val sourceId = repository.activeSourceId().first()!!
withTimeout(5_000) { while (repository.downloadUri(sourceId, "s1") == null) delay(10) }
assertTrue(repository.downloadUri(sourceId, "s1")!!.contains("/rest/download"))
prefs.setDownloadQuality(StreamQuality(maxBitrate = 128, format = "opus"))
var transcoded: String? = null
withTimeout(5_000) {
while (transcoded == null) {
delay(10)
transcoded = repository.downloadUri(sourceId, "s1")?.takeIf { it.contains("/rest/stream") }
}
}
assertTrue(transcoded!!.contains("maxBitRate=128"))
assertTrue(transcoded!!.contains("format=opus"))
}
@Test
fun addingASourceMakesItTheSingleActiveOne() =
runTest {
@@ -343,11 +343,13 @@ class AppScreenshotTest {
wifiQuality = StreamQuality(),
mobileQuality = StreamQuality(192, "mp3"),
syncConcurrency = 4,
downloadQuality = StreamQuality(128, "opus"),
onSelectSource = {},
onEditServer = {},
onWifiQualityChange = {},
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onDownloadQualityChange = {},
onAddServer = {},
onOpenDownloads = {},
onBack = {},