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) }
}
}
@@ -0,0 +1,19 @@
package com.subtracks.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
fun fakeUserPreferences(): UserPreferences =
UserPreferences(
object : DataStore<Preferences> {
private val state = MutableStateFlow(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
transform(state.value).also { state.value = it }
},
)
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
@@ -28,7 +29,7 @@ class SourceRepositoryTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
repository = SourceRepository(db, OkHttpClient())
repository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
}
@After
@@ -8,6 +8,8 @@ import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -54,6 +56,31 @@ class SubsonicSourceTest {
assertEquals(listOf("s1", "s2"), source.getSongs().map { it.id })
}
@Test
fun streamUriRequestsTranscodingWhenABitrateIsSet() {
val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString()
assertTrue(uri, uri.contains("maxBitRate=128"))
assertTrue(uri, uri.contains("estimateContentLength=true"))
}
@Test
fun streamUriLeavesTheOriginalStreamAloneByDefault() {
val uri = SubsonicSource(1, client()).streamUri("s1").toString()
assertFalse(uri, uri.contains("maxBitRate"))
assertFalse(uri, uri.contains("format="))
assertFalse(uri, uri.contains("estimateContentLength"))
}
@Test
fun streamUriRequestsAPreferredFormat() {
val uri = SubsonicSource(1, client(), streamFormat = "opus").streamUri("s1").toString()
assertTrue(uri, uri.contains("format=opus"))
assertTrue(uri, uri.contains("estimateContentLength=true"))
}
private fun client() =
SubsonicClient(
baseUrl = server.url("/"),
@@ -10,6 +10,7 @@ import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.asCoroutineDispatcher
@@ -42,7 +43,7 @@ class PlaybackControllerTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
sources = SourceRepository(db, OkHttpClient())
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
queues = QueueRepository(db)
handle = FakePlayerHandle()
controller = PlaybackController(sources, queues, FakePlayerConnection(handle), dispatcher = dispatcher)
@@ -61,7 +62,7 @@ class PlaybackControllerTest {
seedAlbum(60, sourceId = 1)
controller.playAlbum(1, "al1", 30)
await { handle.items.isNotEmpty() }
await { handle.operations.contains("play") }
assertEquals(51, handle.itemCount)
assertEquals(25, handle.currentIndex)
@@ -153,8 +153,12 @@ class AppScreenshotTest {
SettingsScreen(
sources = Fixtures.sources,
activeSourceId = 1,
maxBitrate = 0,
streamFormat = null,
onSelectSource = {},
onDeleteSource = {},
onMaxBitrateChange = {},
onStreamFormatChange = {},
onAddServer = {},
onBack = {},
)
+1 -1
View File
@@ -33,7 +33,7 @@ Not done yet in this slice (deliberately): artist detail, cover-art tonal colour
- (done) Media3/ExoPlayer playback service, queue and now-playing UI: a `MediaSessionService` plus a mini player and a Now Playing screen.
- (done) Media notifications and headset/Bluetooth controls, from the media session.
- (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player.
- Streaming with `maxBitRate`/format taken from settings.
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
- Playing from the Songs tab, the queue view (add to queue / play next / reorder), and gapless format preferences.
- Scrobbling and "now playing" back to the server.
- Optional download/caching.