mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
add network-dependent stream quality
Combine max bitrate and transcode format into one StreamQuality and store a separate value per network mode (Wi-Fi vs mobile data). NetworkMonitor reports metered vs unmetered via ConnectivityManager, SourceRepository selects and exposes the active quality, and PlaybackController reloads the window at the current position when it changes, so a network switch re-issues stream URLs with the new settings. Reset Coil's singleton in SubtracksApp.onCreate alongside Koin, since ArtworkSeedCache's background scope can call get() across Robolectric tests and poison the next Application.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.subtracks
|
||||
import android.app.Application
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.annotation.DelicateCoilApi
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
import com.subtracks.di.appModule
|
||||
@@ -14,9 +15,11 @@ import org.koin.core.context.stopKoin
|
||||
class SubtracksApp : Application() {
|
||||
private val http = OkHttpClient()
|
||||
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
stopKoin()
|
||||
SingletonImageLoader.reset()
|
||||
val koin =
|
||||
startKoin {
|
||||
modules(appModule(this@SubtracksApp, http))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.subtracks.data.net
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
enum class NetworkMode(
|
||||
val key: String,
|
||||
) {
|
||||
Wifi("wifi"),
|
||||
Mobile("mobile"),
|
||||
}
|
||||
|
||||
class NetworkMonitor(
|
||||
context: Context,
|
||||
) {
|
||||
private val connectivity = context.applicationContext.getSystemService(ConnectivityManager::class.java)
|
||||
|
||||
val mode: Flow<NetworkMode> =
|
||||
callbackFlow {
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = push()
|
||||
|
||||
override fun onLost(network: Network) = push()
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) = push()
|
||||
|
||||
private fun push() {
|
||||
this@callbackFlow.trySend(current())
|
||||
}
|
||||
}
|
||||
val registered = runCatching { connectivity?.registerDefaultNetworkCallback(callback) }.isSuccess
|
||||
this@callbackFlow.trySend(current())
|
||||
awaitClose { if (registered) runCatching { connectivity?.unregisterNetworkCallback(callback) } }
|
||||
}.distinctUntilChanged().conflate()
|
||||
|
||||
private fun current(): NetworkMode = if (connectivity?.isActiveNetworkMetered == true) NetworkMode.Mobile else NetworkMode.Wifi
|
||||
}
|
||||
@@ -4,9 +4,9 @@ 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 com.subtracks.data.net.NetworkMode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@@ -34,6 +34,11 @@ data class ListQuery(
|
||||
val starred: StarredFilter = StarredFilter.Any,
|
||||
)
|
||||
|
||||
data class StreamQuality(
|
||||
val maxBitrate: Int = 0,
|
||||
val format: String? = null,
|
||||
)
|
||||
|
||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||
|
||||
class UserPreferences(
|
||||
@@ -48,24 +53,32 @@ class UserPreferences(
|
||||
store.edit { prefs -> prefs[listQueryKey(tab)] = encode(query) }
|
||||
}
|
||||
|
||||
val maxBitrate: Flow<Int> = store.data.map { prefs -> prefs[MAX_BITRATE] ?: 0 }
|
||||
fun streamQuality(mode: NetworkMode): Flow<StreamQuality> =
|
||||
store.data.map { prefs -> decodeStreamQuality(prefs[streamQualityKey(mode)]) }
|
||||
|
||||
suspend fun setMaxBitrate(kbps: Int) {
|
||||
store.edit { prefs -> prefs[MAX_BITRATE] = kbps }
|
||||
suspend fun setStreamQuality(
|
||||
mode: NetworkMode,
|
||||
quality: StreamQuality,
|
||||
) {
|
||||
store.edit { prefs -> prefs[streamQualityKey(mode)] = encode(quality) }
|
||||
}
|
||||
|
||||
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 fun streamQualityKey(mode: NetworkMode) = stringPreferencesKey("stream_quality_${mode.key}")
|
||||
|
||||
private fun listQueryKey(tab: LibraryListTab) = stringPreferencesKey("list_query_${tab.key}")
|
||||
|
||||
private fun encode(query: ListQuery) = "${query.sort}|${if (query.descending) 1 else 0}|${query.starred.ordinal}"
|
||||
|
||||
private fun encode(quality: StreamQuality) = "${quality.maxBitrate}|${quality.format.orEmpty()}"
|
||||
|
||||
private fun decodeStreamQuality(stored: String?): StreamQuality {
|
||||
val parts = stored?.split("|") ?: return StreamQuality()
|
||||
return StreamQuality(
|
||||
maxBitrate = parts.getOrNull(0)?.toIntOrNull() ?: 0,
|
||||
format = parts.getOrNull(1)?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun decode(
|
||||
stored: String?,
|
||||
defaultSort: String,
|
||||
@@ -78,11 +91,6 @@ class UserPreferences(
|
||||
starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any },
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val MAX_BITRATE = intPreferencesKey("max_bitrate")
|
||||
val STREAM_FORMAT = stringPreferencesKey("stream_format")
|
||||
}
|
||||
}
|
||||
|
||||
fun createUserPreferences(context: Context): UserPreferences = UserPreferences(context.preferences)
|
||||
|
||||
@@ -5,6 +5,8 @@ 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.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
@@ -13,8 +15,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
@@ -25,6 +29,7 @@ class SourceRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val http: OkHttpClient,
|
||||
private val prefs: UserPreferences,
|
||||
networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi),
|
||||
private val showMessage: (String) -> Unit = {},
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -33,15 +38,20 @@ class SourceRepository(
|
||||
private var active: SubsonicMusicSource? = null
|
||||
private var activeSourceId: Long? = null
|
||||
|
||||
private val _quality = MutableStateFlow(StreamQuality())
|
||||
val quality: StateFlow<StreamQuality> = _quality
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
combine(
|
||||
db.sourcesDao().activeSubsonicConfig(),
|
||||
prefs.maxBitrate,
|
||||
prefs.streamFormat,
|
||||
) { config, maxBitrate, streamFormat -> Triple(config, maxBitrate, streamFormat) }
|
||||
.collect { (config, maxBitrate, streamFormat) ->
|
||||
active = config?.toMusicSource(maxBitrate, streamFormat)
|
||||
networkMode,
|
||||
prefs.streamQuality(NetworkMode.Wifi),
|
||||
prefs.streamQuality(NetworkMode.Mobile),
|
||||
) { config, mode, wifi, mobile -> config to (if (mode == NetworkMode.Wifi) wifi else mobile) }
|
||||
.collect { (config, quality) ->
|
||||
active = config?.toMusicSource(quality)
|
||||
_quality.value = quality
|
||||
val sourceChanged = config?.id != activeSourceId
|
||||
activeSourceId = config?.id
|
||||
if (sourceChanged && config != null && config.useTokenAuth) {
|
||||
@@ -129,7 +139,7 @@ class SourceRepository(
|
||||
|
||||
suspend fun activeMusicSource(): MusicSource? {
|
||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
|
||||
return config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())
|
||||
return config.toMusicSource(quality.value)
|
||||
}
|
||||
|
||||
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||
@@ -141,15 +151,12 @@ class SourceRepository(
|
||||
onTokenAuthUnsupported = { disableTokenAuth(id) },
|
||||
)
|
||||
|
||||
private fun SubsonicConfig.toMusicSource(
|
||||
maxBitrate: Int,
|
||||
streamFormat: String?,
|
||||
): SubsonicMusicSource =
|
||||
private fun SubsonicConfig.toMusicSource(quality: StreamQuality): SubsonicMusicSource =
|
||||
SubsonicMusicSource(
|
||||
id = id,
|
||||
client = toClient(),
|
||||
maxBitrate = maxBitrate,
|
||||
streamFormat = streamFormat,
|
||||
maxBitrate = quality.maxBitrate,
|
||||
streamFormat = quality.format,
|
||||
)
|
||||
|
||||
private fun disableTokenAuth(sourceId: Long) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.subtracks.data.db.createAndroidDatabase
|
||||
import com.subtracks.data.net.NetworkMonitor
|
||||
import com.subtracks.data.prefs.createUserPreferences
|
||||
import com.subtracks.data.repo.ArtworkSeedRepository
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
@@ -38,7 +39,8 @@ fun appModule(
|
||||
single { createAndroidDatabase(context) }
|
||||
single { http }
|
||||
single { createUserPreferences(context) }
|
||||
single { SourceRepository(get(), get(), get(), showMessage = toast) }
|
||||
single { NetworkMonitor(context.applicationContext) }
|
||||
single { SourceRepository(get(), get(), get(), networkMode = get<NetworkMonitor>().mode, showMessage = toast) }
|
||||
single { LibraryRepository(get(), get(), toast) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get()) }
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -133,6 +134,9 @@ class PlaybackController(
|
||||
if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
sourceRepository.quality.drop(1).collect { reloadQuality() }
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
@@ -699,6 +703,17 @@ class PlaybackController(
|
||||
refresh(position)
|
||||
}
|
||||
|
||||
private suspend fun reloadQuality() =
|
||||
startLock.withLock {
|
||||
val player = player ?: return@withLock
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val sourceId = queueSourceId
|
||||
if (sourceId == null || sourceRepository.activeSourceIdOnce() != sourceId) return@withLock
|
||||
val position = currentPosition() ?: return@withLock
|
||||
loadWindow(position, autoplay = player.playWhenReady && !player.isEnded, startPositionMs = player.currentPositionMs)
|
||||
}
|
||||
|
||||
private fun jumpTo(target: Long) {
|
||||
scope.launch {
|
||||
startLock.withLock {
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
@@ -42,13 +44,14 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
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 }
|
||||
private enum class SettingsDialog { WifiQuality, MobileQuality }
|
||||
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
@@ -58,17 +61,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()
|
||||
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
||||
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
||||
SettingsScreen(
|
||||
sources = sources,
|
||||
activeSourceId = activeSourceId,
|
||||
maxBitrate = maxBitrate,
|
||||
streamFormat = streamFormat,
|
||||
wifiQuality = wifiQuality,
|
||||
mobileQuality = mobileQuality,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
onDeleteSource = viewModel::deleteSource,
|
||||
onMaxBitrateChange = viewModel::setMaxBitrate,
|
||||
onStreamFormatChange = viewModel::setStreamFormat,
|
||||
onWifiQualityChange = viewModel::setWifiQuality,
|
||||
onMobileQualityChange = viewModel::setMobileQuality,
|
||||
onAddServer = onAddServer,
|
||||
onBack = onBack,
|
||||
)
|
||||
@@ -79,12 +82,12 @@ fun SettingsRoute(
|
||||
fun SettingsScreen(
|
||||
sources: List<Source>,
|
||||
activeSourceId: Long?,
|
||||
maxBitrate: Int,
|
||||
streamFormat: String?,
|
||||
wifiQuality: StreamQuality,
|
||||
mobileQuality: StreamQuality,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onDeleteSource: (Long) -> Unit,
|
||||
onMaxBitrateChange: (Int) -> Unit,
|
||||
onStreamFormatChange: (String?) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||
onAddServer: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -147,17 +150,17 @@ fun SettingsScreen(
|
||||
item { SectionHeader("Network") }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Maximum bitrate") },
|
||||
supportingContent = { Text(bitrateLabel(maxBitrate)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate },
|
||||
headlineContent = { Text("Wi-Fi") },
|
||||
supportingContent = { Text(qualityLabel(wifiQuality)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.WifiQuality },
|
||||
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 },
|
||||
headlineContent = { Text("Mobile data") },
|
||||
supportingContent = { Text(qualityLabel(mobileQuality)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.MobileQuality },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
@@ -166,28 +169,20 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
when (dialog) {
|
||||
SettingsDialog.Bitrate -> {
|
||||
ChoiceDialog(
|
||||
title = "Maximum bitrate",
|
||||
options = bitrateOptions.map { it to bitrateLabel(it) },
|
||||
selected = maxBitrate,
|
||||
onSelect = { value ->
|
||||
onMaxBitrateChange(value)
|
||||
dialog = null
|
||||
},
|
||||
SettingsDialog.WifiQuality -> {
|
||||
QualityDialog(
|
||||
title = "Wi-Fi",
|
||||
quality = wifiQuality,
|
||||
onSelect = onWifiQualityChange,
|
||||
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
|
||||
},
|
||||
SettingsDialog.MobileQuality -> {
|
||||
QualityDialog(
|
||||
title = "Mobile data",
|
||||
quality = mobileQuality,
|
||||
onSelect = onMobileQualityChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
@@ -198,39 +193,63 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
private fun qualityLabel(quality: StreamQuality): String = "${bitrateLabel(quality.maxBitrate)} · ${quality.format ?: "Server default"}"
|
||||
|
||||
private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps"
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceDialog(
|
||||
private fun QualityDialog(
|
||||
title: String,
|
||||
options: List<Pair<T, String>>,
|
||||
selected: T,
|
||||
onSelect: (T) -> Unit,
|
||||
quality: StreamQuality,
|
||||
onSelect: (StreamQuality) -> 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)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
ChoiceGroup(
|
||||
header = "Maximum bitrate",
|
||||
options = bitrateOptions.map { it to bitrateLabel(it) },
|
||||
selected = quality.maxBitrate,
|
||||
onSelect = { onSelect(quality.copy(maxBitrate = it)) },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChoiceGroup(
|
||||
header = "Preferred format",
|
||||
options = streamFormats.map { it to (it ?: "Use server default") },
|
||||
selected = quality.format,
|
||||
onSelect = { onSelect(quality.copy(format = it)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss) { Text("Done") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceGroup(
|
||||
header: String,
|
||||
options: List<Pair<T, String>>,
|
||||
selected: T,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
Text(header, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.subtracks.ui.settings
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -20,13 +22,15 @@ 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 wifiQuality: StateFlow<StreamQuality> =
|
||||
userPreferences
|
||||
.streamQuality(NetworkMode.Wifi)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
|
||||
|
||||
val streamFormat: StateFlow<String?> =
|
||||
userPreferences.streamFormat
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
val mobileQuality: StateFlow<StreamQuality> =
|
||||
userPreferences
|
||||
.streamQuality(NetworkMode.Mobile)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
|
||||
|
||||
fun selectSource(id: Long) {
|
||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||
@@ -36,11 +40,11 @@ class SettingsViewModel(
|
||||
viewModelScope.launch { sourceRepository.deleteSource(id) }
|
||||
}
|
||||
|
||||
fun setMaxBitrate(kbps: Int) {
|
||||
viewModelScope.launch { userPreferences.setMaxBitrate(kbps) }
|
||||
fun setWifiQuality(quality: StreamQuality) {
|
||||
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Wifi, quality) }
|
||||
}
|
||||
|
||||
fun setStreamFormat(format: String?) {
|
||||
viewModelScope.launch { userPreferences.setStreamFormat(format) }
|
||||
fun setMobileQuality(quality: StreamQuality) {
|
||||
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.data.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -35,6 +36,25 @@ class UserPreferencesTest {
|
||||
prefs.listQuery(LibraryListTab.Artists).first(),
|
||||
)
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamQualityRoundTripsPerNetworkMode() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(StreamQuality(), prefs.streamQuality(NetworkMode.Wifi).first())
|
||||
assertEquals(StreamQuality(), prefs.streamQuality(NetworkMode.Mobile).first())
|
||||
|
||||
prefs.setStreamQuality(NetworkMode.Wifi, StreamQuality(320, null))
|
||||
prefs.setStreamQuality(NetworkMode.Mobile, StreamQuality(96, "opus"))
|
||||
|
||||
assertEquals(StreamQuality(320, null), prefs.streamQuality(NetworkMode.Wifi).first())
|
||||
assertEquals(StreamQuality(96, "opus"), prefs.streamQuality(NetworkMode.Mobile).first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ 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.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
@@ -35,6 +38,7 @@ class PlaybackControllerTest {
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var prefs: UserPreferences
|
||||
private lateinit var sources: SourceRepository
|
||||
private lateinit var queues: QueueRepository
|
||||
private lateinit var handle: FakePlayerHandle
|
||||
@@ -48,7 +52,8 @@ class PlaybackControllerTest {
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
||||
prefs = fakeUserPreferences()
|
||||
sources = SourceRepository(db, OkHttpClient(), prefs)
|
||||
queues = QueueRepository(db)
|
||||
handle = FakePlayerHandle()
|
||||
controller = PlaybackController(sources, queues, FakePlayerConnection(handle), dispatcher = dispatcher)
|
||||
@@ -1015,6 +1020,25 @@ class PlaybackControllerTest {
|
||||
assertEquals(paused, controller.positionMs.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changingStreamQualityReloadsTheCurrentItemAtTheSamePosition() {
|
||||
seedAlbum(60, sourceId = 1)
|
||||
runBlocking { prefs.setStreamQuality(NetworkMode.Wifi, StreamQuality(64, null)) }
|
||||
await { sources.quality.value == StreamQuality(64, null) }
|
||||
|
||||
controller.playAlbum(1, "al1", 10)
|
||||
await { handle.operations.contains("play") }
|
||||
handle.positionMs = 42_000L
|
||||
val windows = handle.operations.count { it.startsWith("setWindow") }
|
||||
|
||||
runBlocking { prefs.setStreamQuality(NetworkMode.Wifi, StreamQuality(128, "opus")) }
|
||||
await { handle.operations.count { it.startsWith("setWindow") } > windows }
|
||||
|
||||
assertEquals("s11", handle.currentItem?.id)
|
||||
assertEquals(42_000L, handle.positionMs)
|
||||
assertEquals(listOf("prepare", "play"), handle.operations.takeLast(2))
|
||||
}
|
||||
|
||||
private fun seedAlbum(
|
||||
count: Int,
|
||||
sourceId: Long,
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.playback.RepeatMode
|
||||
@@ -303,12 +304,12 @@ class AppScreenshotTest {
|
||||
SettingsScreen(
|
||||
sources = Fixtures.sources,
|
||||
activeSourceId = 1,
|
||||
maxBitrate = 0,
|
||||
streamFormat = null,
|
||||
wifiQuality = StreamQuality(),
|
||||
mobileQuality = StreamQuality(192, "mp3"),
|
||||
onSelectSource = {},
|
||||
onDeleteSource = {},
|
||||
onMaxBitrateChange = {},
|
||||
onStreamFormatChange = {},
|
||||
onWifiQualityChange = {},
|
||||
onMobileQualityChange = {},
|
||||
onAddServer = {},
|
||||
onBack = {},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user