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 = {},