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.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -25,8 +26,24 @@ class UserPreferences(
store.edit { prefs -> prefs[ALBUM_SORT] = sort.name } 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 { private companion object {
val ALBUM_SORT = stringPreferencesKey("album_sort") 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.Source
import com.subtracks.data.model.SubsonicConfig import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.source.subsonic.SubsonicClient import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.data.sync.SyncService import com.subtracks.data.sync.SyncService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -12,6 +13,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -21,6 +24,7 @@ import com.subtracks.data.source.subsonic.SubsonicSource as SubsonicMusicSource
class SourceRepository( class SourceRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val http: OkHttpClient, private val http: OkHttpClient,
private val prefs: UserPreferences,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -29,9 +33,12 @@ class SourceRepository(
init { init {
scope.launch { scope.launch {
db.sourcesDao().activeSubsonicConfig().collect { config -> combine(
active = config?.toMusicSource() 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> = suspend fun sync(): Result<Unit> =
runCatching { runCatching {
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured") 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 = private fun SubsonicConfig.toMusicSource(
SubsonicMusicSource(id, client(address, username, password, useTokenAuth)) maxBitrate: Int,
streamFormat: String?,
): SubsonicMusicSource =
SubsonicMusicSource(
id = id,
client = client(address, username, password, useTokenAuth),
maxBitrate = maxBitrate,
streamFormat = streamFormat,
)
private fun client( private fun client(
address: String, address: String,
@@ -27,7 +27,7 @@ fun appModule(
single { createAndroidDatabase(context) } single { createAndroidDatabase(context) }
single { http } single { http }
single { createUserPreferences(context) } single { createUserPreferences(context) }
single { SourceRepository(get(), get()) } single { SourceRepository(get(), get(), get()) }
single { LibraryRepository(get(), get()) } single { LibraryRepository(get(), get()) }
single { SyncManager(get()) } single { SyncManager(get()) }
single { QueueRepository(get()) } single { QueueRepository(get()) }
@@ -35,7 +35,7 @@ fun appModule(
single { PlaybackController(get(), get(), get()) } single { PlaybackController(get(), get(), get()) }
viewModel { RootViewModel(get()) } viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get()) } viewModel { LibraryViewModel(get(), get(), get(), get()) }
viewModel { SettingsViewModel(get()) } viewModel { SettingsViewModel(get(), get()) }
viewModel { AddSourceViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
@@ -1,6 +1,8 @@
package com.subtracks.ui.settings package com.subtracks.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -23,9 +26,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -34,6 +42,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import org.koin.compose.viewmodel.koinViewModel 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 @Composable
fun SettingsRoute( fun SettingsRoute(
onAddServer: () -> Unit, onAddServer: () -> Unit,
@@ -42,11 +55,17 @@ fun SettingsRoute(
) { ) {
val sources by viewModel.sources.collectAsStateWithLifecycle() val sources by viewModel.sources.collectAsStateWithLifecycle()
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle() val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
val maxBitrate by viewModel.maxBitrate.collectAsStateWithLifecycle()
val streamFormat by viewModel.streamFormat.collectAsStateWithLifecycle()
SettingsScreen( SettingsScreen(
sources = sources, sources = sources,
activeSourceId = activeSourceId, activeSourceId = activeSourceId,
maxBitrate = maxBitrate,
streamFormat = streamFormat,
onSelectSource = viewModel::selectSource, onSelectSource = viewModel::selectSource,
onDeleteSource = viewModel::deleteSource, onDeleteSource = viewModel::deleteSource,
onMaxBitrateChange = viewModel::setMaxBitrate,
onStreamFormatChange = viewModel::setStreamFormat,
onAddServer = onAddServer, onAddServer = onAddServer,
onBack = onBack, onBack = onBack,
) )
@@ -57,12 +76,18 @@ fun SettingsRoute(
fun SettingsScreen( fun SettingsScreen(
sources: List<Source>, sources: List<Source>,
activeSourceId: Long?, activeSourceId: Long?,
maxBitrate: Int,
streamFormat: String?,
onSelectSource: (Long) -> Unit, onSelectSource: (Long) -> Unit,
onDeleteSource: (Long) -> Unit, onDeleteSource: (Long) -> Unit,
onMaxBitrateChange: (Int) -> Unit,
onStreamFormatChange: (String?) -> Unit,
onAddServer: () -> Unit, onAddServer: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -111,10 +136,92 @@ 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 @Composable
private fun SectionHeader(text: String) { private fun SectionHeader(text: String) {
Text( Text(
@@ -3,6 +3,7 @@ package com.subtracks.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -11,6 +12,7 @@ import kotlinx.coroutines.launch
class SettingsViewModel( class SettingsViewModel(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val userPreferences: UserPreferences,
) : ViewModel() { ) : ViewModel() {
val sources: StateFlow<List<Source>> = val sources: StateFlow<List<Source>> =
sourceRepository.sources().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) sourceRepository.sources().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -18,6 +20,14 @@ class SettingsViewModel(
val activeSourceId: StateFlow<Long?> = val activeSourceId: StateFlow<Long?> =
sourceRepository.activeSourceId().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) 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) { fun selectSource(id: Long) {
viewModelScope.launch { sourceRepository.selectSource(id) } viewModelScope.launch { sourceRepository.selectSource(id) }
} }
@@ -25,4 +35,12 @@ class SettingsViewModel(
fun deleteSource(id: Long) { fun deleteSource(id: Long) {
viewModelScope.launch { sourceRepository.deleteSource(id) } 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.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -28,7 +29,7 @@ class SourceRepositoryTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
repository = SourceRepository(db, OkHttpClient()) repository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
} }
@After @After
@@ -8,6 +8,8 @@ import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest import okhttp3.mockwebserver.RecordedRequest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -54,6 +56,31 @@ class SubsonicSourceTest {
assertEquals(listOf("s1", "s2"), source.getSongs().map { it.id }) 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() = private fun client() =
SubsonicClient( SubsonicClient(
baseUrl = server.url("/"), baseUrl = server.url("/"),
@@ -10,6 +10,7 @@ import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
@@ -42,7 +43,7 @@ class PlaybackControllerTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
sources = SourceRepository(db, OkHttpClient()) sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
queues = QueueRepository(db) queues = QueueRepository(db)
handle = FakePlayerHandle() handle = FakePlayerHandle()
controller = PlaybackController(sources, queues, FakePlayerConnection(handle), dispatcher = dispatcher) controller = PlaybackController(sources, queues, FakePlayerConnection(handle), dispatcher = dispatcher)
@@ -61,7 +62,7 @@ class PlaybackControllerTest {
seedAlbum(60, sourceId = 1) seedAlbum(60, sourceId = 1)
controller.playAlbum(1, "al1", 30) controller.playAlbum(1, "al1", 30)
await { handle.items.isNotEmpty() } await { handle.operations.contains("play") }
assertEquals(51, handle.itemCount) assertEquals(51, handle.itemCount)
assertEquals(25, handle.currentIndex) assertEquals(25, handle.currentIndex)
@@ -153,8 +153,12 @@ class AppScreenshotTest {
SettingsScreen( SettingsScreen(
sources = Fixtures.sources, sources = Fixtures.sources,
activeSourceId = 1, activeSourceId = 1,
maxBitrate = 0,
streamFormat = null,
onSelectSource = {}, onSelectSource = {},
onDeleteSource = {}, onDeleteSource = {},
onMaxBitrateChange = {},
onStreamFormatChange = {},
onAddServer = {}, onAddServer = {},
onBack = {}, 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) 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) 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. - (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. - 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. - Scrobbling and "now playing" back to the server.
- Optional download/caching. - Optional download/caching.