choose the stream bitrate and format from settings

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 6e4e911f95
commit 659df47c14
11 changed files with 221 additions and 12 deletions
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
@@ -25,8 +26,24 @@ class UserPreferences(
store.edit { prefs -> prefs[ALBUM_SORT] = sort.name }
}
val maxBitrate: Flow<Int> = store.data.map { prefs -> prefs[MAX_BITRATE] ?: 0 }
suspend fun setMaxBitrate(kbps: Int) {
store.edit { prefs -> prefs[MAX_BITRATE] = kbps }
}
val streamFormat: Flow<String?> = store.data.map { prefs -> prefs[STREAM_FORMAT] }
suspend fun setStreamFormat(format: String?) {
store.edit { prefs ->
if (format.isNullOrEmpty()) prefs.remove(STREAM_FORMAT) else prefs[STREAM_FORMAT] = format
}
}
private companion object {
val ALBUM_SORT = stringPreferencesKey("album_sort")
val MAX_BITRATE = intPreferencesKey("max_bitrate")
val STREAM_FORMAT = stringPreferencesKey("stream_format")
}
}
@@ -5,6 +5,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.data.sync.SyncService
import kotlinx.coroutines.CoroutineScope
@@ -12,6 +13,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -21,6 +24,7 @@ import com.subtracks.data.source.subsonic.SubsonicSource as SubsonicMusicSource
class SourceRepository(
private val db: SubtracksDatabase,
private val http: OkHttpClient,
private val prefs: UserPreferences,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -29,9 +33,12 @@ class SourceRepository(
init {
scope.launch {
db.sourcesDao().activeSubsonicConfig().collect { config ->
active = config?.toMusicSource()
}
combine(
db.sourcesDao().activeSubsonicConfig(),
prefs.maxBitrate,
prefs.streamFormat,
) { config, maxBitrate, streamFormat -> config?.toMusicSource(maxBitrate, streamFormat) }
.collect { active = it }
}
}
@@ -111,11 +118,19 @@ class SourceRepository(
suspend fun sync(): Result<Unit> =
runCatching {
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured")
SyncService(db, config.toMusicSource()).sync()
SyncService(db, config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())).sync()
}
private fun SubsonicConfig.toMusicSource(): SubsonicMusicSource =
SubsonicMusicSource(id, client(address, username, password, useTokenAuth))
private fun SubsonicConfig.toMusicSource(
maxBitrate: Int,
streamFormat: String?,
): SubsonicMusicSource =
SubsonicMusicSource(
id = id,
client = client(address, username, password, useTokenAuth),
maxBitrate = maxBitrate,
streamFormat = streamFormat,
)
private fun client(
address: String,
@@ -27,7 +27,7 @@ fun appModule(
single { createAndroidDatabase(context) }
single { http }
single { createUserPreferences(context) }
single { SourceRepository(get(), get()) }
single { SourceRepository(get(), get(), get()) }
single { LibraryRepository(get(), get()) }
single { SyncManager(get()) }
single { QueueRepository(get()) }
@@ -35,7 +35,7 @@ fun appModule(
single { PlaybackController(get(), get(), get()) }
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get()) }
viewModel { SettingsViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
@@ -1,6 +1,8 @@
package com.subtracks.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +15,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -23,9 +26,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
@@ -34,6 +42,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Source
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 { Bitrate, Format }
@Composable
fun SettingsRoute(
onAddServer: () -> Unit,
@@ -42,11 +55,17 @@ fun SettingsRoute(
) {
val sources by viewModel.sources.collectAsStateWithLifecycle()
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
val maxBitrate by viewModel.maxBitrate.collectAsStateWithLifecycle()
val streamFormat by viewModel.streamFormat.collectAsStateWithLifecycle()
SettingsScreen(
sources = sources,
activeSourceId = activeSourceId,
maxBitrate = maxBitrate,
streamFormat = streamFormat,
onSelectSource = viewModel::selectSource,
onDeleteSource = viewModel::deleteSource,
onMaxBitrateChange = viewModel::setMaxBitrate,
onStreamFormatChange = viewModel::setStreamFormat,
onAddServer = onAddServer,
onBack = onBack,
)
@@ -57,12 +76,18 @@ fun SettingsRoute(
fun SettingsScreen(
sources: List<Source>,
activeSourceId: Long?,
maxBitrate: Int,
streamFormat: String?,
onSelectSource: (Long) -> Unit,
onDeleteSource: (Long) -> Unit,
onMaxBitrateChange: (Int) -> Unit,
onStreamFormatChange: (String?) -> Unit,
onAddServer: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
Scaffold(
modifier = modifier,
topBar = {
@@ -111,8 +136,90 @@ fun SettingsScreen(
}
}
}
item { SectionHeader("Network") }
item {
ListItem(
headlineContent = { Text("Maximum bitrate") },
supportingContent = { Text(bitrateLabel(maxBitrate)) },
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text("Preferred stream format") },
supportingContent = { Text(streamFormat ?: "Use server default") },
modifier = Modifier.clickable { dialog = SettingsDialog.Format },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
when (dialog) {
SettingsDialog.Bitrate -> {
ChoiceDialog(
title = "Maximum bitrate",
options = bitrateOptions.map { it to bitrateLabel(it) },
selected = maxBitrate,
onSelect = { value ->
onMaxBitrateChange(value)
dialog = null
},
onDismiss = { dialog = null },
)
}
SettingsDialog.Format -> {
ChoiceDialog(
title = "Preferred stream format",
options = streamFormats.map { it to (it ?: "Use server default") },
selected = streamFormat,
onSelect = { value ->
onStreamFormatChange(value)
dialog = null
},
onDismiss = { dialog = null },
)
}
null -> {
Unit
}
}
}
private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps"
@Composable
private fun <T> ChoiceDialog(
title: String,
options: List<Pair<T, String>>,
selected: T,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
options.forEach { (value, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { onSelect(value) },
) {
RadioButton(selected = value == selected, onClick = { onSelect(value) })
Spacer(Modifier.width(8.dp))
Text(label)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
@@ -3,6 +3,7 @@ package com.subtracks.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -11,6 +12,7 @@ import kotlinx.coroutines.launch
class SettingsViewModel(
private val sourceRepository: SourceRepository,
private val userPreferences: UserPreferences,
) : ViewModel() {
val sources: StateFlow<List<Source>> =
sourceRepository.sources().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -18,6 +20,14 @@ class SettingsViewModel(
val activeSourceId: StateFlow<Long?> =
sourceRepository.activeSourceId().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val maxBitrate: StateFlow<Int> =
userPreferences.maxBitrate
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
val streamFormat: StateFlow<String?> =
userPreferences.streamFormat
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun selectSource(id: Long) {
viewModelScope.launch { sourceRepository.selectSource(id) }
}
@@ -25,4 +35,12 @@ class SettingsViewModel(
fun deleteSource(id: Long) {
viewModelScope.launch { sourceRepository.deleteSource(id) }
}
fun setMaxBitrate(kbps: Int) {
viewModelScope.launch { userPreferences.setMaxBitrate(kbps) }
}
fun setStreamFormat(format: String?) {
viewModelScope.launch { userPreferences.setStreamFormat(format) }
}
}