mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
choose the stream bitrate and format from settings
This commit is contained in:
@@ -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 = {},
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user