diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index a3de8b5a..8fec550c 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -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 = store.data.map { prefs -> prefs[MAX_BITRATE] ?: 0 } + + suspend fun setMaxBitrate(kbps: Int) { + store.edit { prefs -> prefs[MAX_BITRATE] = kbps } + } + + val streamFormat: Flow = 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") } } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 24679e4f..26d07a6c 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -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 = 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, diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 61d1c4be..ccb7d0a4 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -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()) } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 5ee0e674..2ff7ef49 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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, 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(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 ChoiceDialog( + title: String, + options: List>, + 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 diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index 98cf599a..2752798a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -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> = sourceRepository.sources().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -18,6 +20,14 @@ class SettingsViewModel( val activeSourceId: StateFlow = sourceRepository.activeSourceId().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val maxBitrate: StateFlow = + userPreferences.maxBitrate + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + + val streamFormat: StateFlow = + 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) } + } } diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/FakeUserPreferences.kt b/app/src/test/kotlin/com/subtracks/data/prefs/FakeUserPreferences.kt new file mode 100644 index 00000000..cb9037af --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/prefs/FakeUserPreferences.kt @@ -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 { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(state.value).also { state.value = it } + }, + ) diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt index 3ea478ff..3cc8fc30 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -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 diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt index 8cca9ff2..e47a86bb 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt @@ -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("/"), diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 987a4a0b..a4282f1e 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 5d16820d..e9c834b5 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -153,8 +153,12 @@ class AppScreenshotTest { SettingsScreen( sources = Fixtures.sources, activeSourceId = 1, + maxBitrate = 0, + streamFormat = null, onSelectSource = {}, onDeleteSource = {}, + onMaxBitrateChange = {}, + onStreamFormatChange = {}, onAddServer = {}, onBack = {}, ) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index ebc7c1cf..d58f55e0 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -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.