From adb9367f1884a399574ef6f7c44fc906e13d1dae Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:18:02 +0000 Subject: [PATCH] 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. --- .../subtracks/data/prefs/UserPreferences.kt | 9 +++++++ .../subtracks/data/repo/SourceRepository.kt | 18 +++++++++++++- .../data/source/subsonic/SubsonicSource.kt | 23 +++++++++++------- .../subtracks/ui/settings/SettingsScreen.kt | 24 ++++++++++++++++++- .../ui/settings/SettingsViewModel.kt | 9 +++++++ .../data/repo/SourceRepositoryTest.kt | 24 +++++++++++++++++++ .../com/subtracks/ui/AppScreenshotTest.kt | 2 ++ 7 files changed, 98 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index cf260e19..a5e9f487 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -77,8 +77,17 @@ class UserPreferences( store.edit { prefs -> prefs[streamQualityKey(mode)] = encode(quality) } } + fun downloadQuality(): Flow = + 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 = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) } suspend fun setSyncConcurrency(value: Int) { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index c526ceb3..61ff61a8 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -49,7 +49,13 @@ class SourceRepository( private val _quality = MutableStateFlow(StreamQuality()) val quality: StateFlow = _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, diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index 3c57c7e2..8726d4e8 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -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)) diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 5682d2ed..e5cf016a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index af40eb62..ea86698a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -33,6 +33,11 @@ class SettingsViewModel( .streamQuality(NetworkMode.Mobile) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality()) + val downloadQuality: StateFlow = + userPreferences + .downloadQuality() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality()) + val syncConcurrency: StateFlow = 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) } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt index 3d7e71b6..4a324e1a 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -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 { diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index f2aaa96e..4a732bb5 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -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 = {},