mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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 = {},
|
||||
|
||||
Reference in New Issue
Block a user