mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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.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,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
|
@Composable
|
||||||
|
|||||||
@@ -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 = {},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user