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) } 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 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) } fun syncConcurrency(): Flow<Int> = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) }
suspend fun setSyncConcurrency(value: Int) { suspend fun setSyncConcurrency(value: Int) {
@@ -49,7 +49,13 @@ class SourceRepository(
private val _quality = MutableStateFlow(StreamQuality()) private val _quality = MutableStateFlow(StreamQuality())
val quality: StateFlow<StreamQuality> = _quality val quality: StateFlow<StreamQuality> = _quality
@Volatile
private var downloadQuality = StreamQuality()
init { init {
scope.launch {
prefs.downloadQuality().collect { downloadQuality = it }
}
scope.launch { scope.launch {
combine( combine(
db.sourcesDao().activeSubsonicConfig(), db.sourcesDao().activeSubsonicConfig(),
@@ -120,7 +126,17 @@ class SourceRepository(
fun downloadUri( fun downloadUri(
sourceId: Long, sourceId: Long,
songId: String, 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( suspend fun addSource(
name: String, name: String,
@@ -137,15 +137,20 @@ class SubsonicSource(
} }
}.flowOn(Dispatchers.IO).buffer(1) }.flowOn(Dispatchers.IO).buffer(1)
fun streamUri(songId: String) = fun streamUri(songId: String) = streamUri(songId, maxBitrate, streamFormat)
client.uri(
"stream", fun streamUri(
buildMap { songId: String,
put("id", songId) maxBitrate: Int,
if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString()) format: String?,
streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) } ) = 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)) 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 bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "flac") 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 @Composable
fun SettingsRoute( fun SettingsRoute(
@@ -69,17 +69,20 @@ fun SettingsRoute(
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle() val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle() val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle() val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
SettingsScreen( SettingsScreen(
sources = sources, sources = sources,
activeSourceId = activeSourceId, activeSourceId = activeSourceId,
wifiQuality = wifiQuality, wifiQuality = wifiQuality,
mobileQuality = mobileQuality, mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency, syncConcurrency = syncConcurrency,
downloadQuality = downloadQuality,
onSelectSource = viewModel::selectSource, onSelectSource = viewModel::selectSource,
onEditServer = onEditServer, onEditServer = onEditServer,
onWifiQualityChange = viewModel::setWifiQuality, onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality, onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency, onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onDownloadQualityChange = viewModel::setDownloadQuality,
onAddServer = onAddServer, onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads, onOpenDownloads = onOpenDownloads,
onBack = onBack, onBack = onBack,
@@ -94,11 +97,13 @@ fun SettingsScreen(
wifiQuality: StreamQuality, wifiQuality: StreamQuality,
mobileQuality: StreamQuality, mobileQuality: StreamQuality,
syncConcurrency: Int, syncConcurrency: Int,
downloadQuality: StreamQuality,
onSelectSource: (Long) -> Unit, onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit, onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit, onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit, onSyncConcurrencyChange: (Int) -> Unit,
onDownloadQualityChange: (StreamQuality) -> Unit,
onAddServer: () -> Unit, onAddServer: () -> Unit,
onOpenDownloads: () -> Unit, onOpenDownloads: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
@@ -186,6 +191,14 @@ fun SettingsScreen(
) )
} }
item { SectionHeader("Downloads") } 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 { item {
ListItem( ListItem(
headlineContent = { Text("Manage downloads") }, 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 -> { SettingsDialog.SyncConcurrency -> {
ConcurrencyDialog( ConcurrencyDialog(
selected = syncConcurrency, selected = syncConcurrency,
@@ -33,6 +33,11 @@ class SettingsViewModel(
.streamQuality(NetworkMode.Mobile) .streamQuality(NetworkMode.Mobile)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val downloadQuality: StateFlow<StreamQuality> =
userPreferences
.downloadQuality()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val syncConcurrency: StateFlow<Int> = val syncConcurrency: StateFlow<Int> =
userPreferences userPreferences
.syncConcurrency() .syncConcurrency()
@@ -50,6 +55,10 @@ class SettingsViewModel(
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) } viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) }
} }
fun setDownloadQuality(quality: StreamQuality) {
viewModelScope.launch { userPreferences.setDownloadQuality(quality) }
}
fun setSyncConcurrency(value: Int) { fun setSyncConcurrency(value: Int) {
viewModelScope.launch { userPreferences.setSyncConcurrency(value) } 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.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -63,6 +64,29 @@ class SourceRepositoryTest {
db.close() 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 @Test
fun addingASourceMakesItTheSingleActiveOne() = fun addingASourceMakesItTheSingleActiveOne() =
runTest { runTest {
@@ -343,11 +343,13 @@ class AppScreenshotTest {
wifiQuality = StreamQuality(), wifiQuality = StreamQuality(),
mobileQuality = StreamQuality(192, "mp3"), mobileQuality = StreamQuality(192, "mp3"),
syncConcurrency = 4, syncConcurrency = 4,
downloadQuality = StreamQuality(128, "opus"),
onSelectSource = {}, onSelectSource = {},
onEditServer = {}, onEditServer = {},
onWifiQualityChange = {}, onWifiQualityChange = {},
onMobileQualityChange = {}, onMobileQualityChange = {},
onSyncConcurrencyChange = {}, onSyncConcurrencyChange = {},
onDownloadQualityChange = {},
onAddServer = {}, onAddServer = {},
onOpenDownloads = {}, onOpenDownloads = {},
onBack = {}, onBack = {},