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:
austinried
2026-10-04 00:29:59 +00:00
parent 8ba0aa5192
commit 117e8a69fb
13 changed files with 249 additions and 97 deletions
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -3,6 +3,7 @@ package com.subtracks
import android.app.Application import android.app.Application
import coil3.ImageLoader import coil3.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.subtracks.data.repo.ArtworkSeedStore import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.di.appModule import com.subtracks.di.appModule
@@ -14,9 +15,11 @@ import org.koin.core.context.stopKoin
class SubtracksApp : Application() { class SubtracksApp : Application() {
private val http = OkHttpClient() private val http = OkHttpClient()
@OptIn(DelicateCoilApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
stopKoin() stopKoin()
SingletonImageLoader.reset()
val koin = val koin =
startKoin { startKoin {
modules(appModule(this@SubtracksApp, http)) 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.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 com.subtracks.data.net.NetworkMode
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -34,6 +34,11 @@ data class ListQuery(
val starred: StarredFilter = StarredFilter.Any, 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") private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
class UserPreferences( class UserPreferences(
@@ -48,24 +53,32 @@ class UserPreferences(
store.edit { prefs -> prefs[listQueryKey(tab)] = encode(query) } 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) { suspend fun setStreamQuality(
store.edit { prefs -> prefs[MAX_BITRATE] = kbps } mode: NetworkMode,
quality: StreamQuality,
) {
store.edit { prefs -> prefs[streamQualityKey(mode)] = encode(quality) }
} }
val streamFormat: Flow<String?> = store.data.map { prefs -> prefs[STREAM_FORMAT] } private fun streamQualityKey(mode: NetworkMode) = stringPreferencesKey("stream_quality_${mode.key}")
suspend fun setStreamFormat(format: String?) {
store.edit { prefs ->
if (format.isNullOrEmpty()) prefs.remove(STREAM_FORMAT) else prefs[STREAM_FORMAT] = format
}
}
private fun listQueryKey(tab: LibraryListTab) = stringPreferencesKey("list_query_${tab.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(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( private fun decode(
stored: String?, stored: String?,
defaultSort: String, defaultSort: String,
@@ -78,11 +91,6 @@ class UserPreferences(
starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any }, 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) 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.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.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.subsonic.SubsonicClient import com.subtracks.data.source.subsonic.SubsonicClient
@@ -13,8 +15,10 @@ 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf
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
@@ -25,6 +29,7 @@ class SourceRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val http: OkHttpClient, private val http: OkHttpClient,
private val prefs: UserPreferences, private val prefs: UserPreferences,
networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi),
private val showMessage: (String) -> Unit = {}, private val showMessage: (String) -> Unit = {},
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -33,15 +38,20 @@ class SourceRepository(
private var active: SubsonicMusicSource? = null private var active: SubsonicMusicSource? = null
private var activeSourceId: Long? = null private var activeSourceId: Long? = null
private val _quality = MutableStateFlow(StreamQuality())
val quality: StateFlow<StreamQuality> = _quality
init { init {
scope.launch { scope.launch {
combine( combine(
db.sourcesDao().activeSubsonicConfig(), db.sourcesDao().activeSubsonicConfig(),
prefs.maxBitrate, networkMode,
prefs.streamFormat, prefs.streamQuality(NetworkMode.Wifi),
) { config, maxBitrate, streamFormat -> Triple(config, maxBitrate, streamFormat) } prefs.streamQuality(NetworkMode.Mobile),
.collect { (config, maxBitrate, streamFormat) -> ) { config, mode, wifi, mobile -> config to (if (mode == NetworkMode.Wifi) wifi else mobile) }
active = config?.toMusicSource(maxBitrate, streamFormat) .collect { (config, quality) ->
active = config?.toMusicSource(quality)
_quality.value = quality
val sourceChanged = config?.id != activeSourceId val sourceChanged = config?.id != activeSourceId
activeSourceId = config?.id activeSourceId = config?.id
if (sourceChanged && config != null && config.useTokenAuth) { if (sourceChanged && config != null && config.useTokenAuth) {
@@ -129,7 +139,7 @@ class SourceRepository(
suspend fun activeMusicSource(): MusicSource? { suspend fun activeMusicSource(): MusicSource? {
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null 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 = private fun SubsonicConfig.toClient(): SubsonicClient =
@@ -141,15 +151,12 @@ class SourceRepository(
onTokenAuthUnsupported = { disableTokenAuth(id) }, onTokenAuthUnsupported = { disableTokenAuth(id) },
) )
private fun SubsonicConfig.toMusicSource( private fun SubsonicConfig.toMusicSource(quality: StreamQuality): SubsonicMusicSource =
maxBitrate: Int,
streamFormat: String?,
): SubsonicMusicSource =
SubsonicMusicSource( SubsonicMusicSource(
id = id, id = id,
client = toClient(), client = toClient(),
maxBitrate = maxBitrate, maxBitrate = quality.maxBitrate,
streamFormat = streamFormat, streamFormat = quality.format,
) )
private fun disableTokenAuth(sourceId: Long) { private fun disableTokenAuth(sourceId: Long) {
@@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.widget.Toast import android.widget.Toast
import com.subtracks.data.db.createAndroidDatabase import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.net.NetworkMonitor
import com.subtracks.data.prefs.createUserPreferences import com.subtracks.data.prefs.createUserPreferences
import com.subtracks.data.repo.ArtworkSeedRepository import com.subtracks.data.repo.ArtworkSeedRepository
import com.subtracks.data.repo.ArtworkSeedStore import com.subtracks.data.repo.ArtworkSeedStore
@@ -38,7 +39,8 @@ fun appModule(
single { createAndroidDatabase(context) } single { createAndroidDatabase(context) }
single { http } single { http }
single { createUserPreferences(context) } 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 { LibraryRepository(get(), get(), toast) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) } single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get()) } single { SyncManager(get(), get(), get()) }
@@ -16,6 +16,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -133,6 +134,9 @@ class PlaybackController(
if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop() if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop()
} }
} }
scope.launch {
sourceRepository.quality.drop(1).collect { reloadQuality() }
}
} }
fun close() { fun close() {
@@ -699,6 +703,17 @@ class PlaybackController(
refresh(position) 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) { private fun jumpTo(target: Long) {
scope.launch { scope.launch {
startLock.withLock { startLock.withLock {
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState 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.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
@@ -42,13 +44,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
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 bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "flac") private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "flac")
private enum class SettingsDialog { Bitrate, Format } private enum class SettingsDialog { WifiQuality, MobileQuality }
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
@@ -58,17 +61,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 wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
val streamFormat by viewModel.streamFormat.collectAsStateWithLifecycle() val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
SettingsScreen( SettingsScreen(
sources = sources, sources = sources,
activeSourceId = activeSourceId, activeSourceId = activeSourceId,
maxBitrate = maxBitrate, wifiQuality = wifiQuality,
streamFormat = streamFormat, mobileQuality = mobileQuality,
onSelectSource = viewModel::selectSource, onSelectSource = viewModel::selectSource,
onDeleteSource = viewModel::deleteSource, onDeleteSource = viewModel::deleteSource,
onMaxBitrateChange = viewModel::setMaxBitrate, onWifiQualityChange = viewModel::setWifiQuality,
onStreamFormatChange = viewModel::setStreamFormat, onMobileQualityChange = viewModel::setMobileQuality,
onAddServer = onAddServer, onAddServer = onAddServer,
onBack = onBack, onBack = onBack,
) )
@@ -79,12 +82,12 @@ fun SettingsRoute(
fun SettingsScreen( fun SettingsScreen(
sources: List<Source>, sources: List<Source>,
activeSourceId: Long?, activeSourceId: Long?,
maxBitrate: Int, wifiQuality: StreamQuality,
streamFormat: String?, mobileQuality: StreamQuality,
onSelectSource: (Long) -> Unit, onSelectSource: (Long) -> Unit,
onDeleteSource: (Long) -> Unit, onDeleteSource: (Long) -> Unit,
onMaxBitrateChange: (Int) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit,
onStreamFormatChange: (String?) -> Unit, onMobileQualityChange: (StreamQuality) -> Unit,
onAddServer: () -> Unit, onAddServer: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -147,17 +150,17 @@ fun SettingsScreen(
item { SectionHeader("Network") } item { SectionHeader("Network") }
item { item {
ListItem( ListItem(
headlineContent = { Text("Maximum bitrate") }, headlineContent = { Text("Wi-Fi") },
supportingContent = { Text(bitrateLabel(maxBitrate)) }, supportingContent = { Text(qualityLabel(wifiQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.Bitrate }, modifier = Modifier.clickable { dialog = SettingsDialog.WifiQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
item { item {
ListItem( ListItem(
headlineContent = { Text("Preferred stream format") }, headlineContent = { Text("Mobile data") },
supportingContent = { Text(streamFormat ?: "Use server default") }, supportingContent = { Text(qualityLabel(mobileQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.Format }, modifier = Modifier.clickable { dialog = SettingsDialog.MobileQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
@@ -166,28 +169,20 @@ fun SettingsScreen(
} }
when (dialog) { when (dialog) {
SettingsDialog.Bitrate -> { SettingsDialog.WifiQuality -> {
ChoiceDialog( QualityDialog(
title = "Maximum bitrate", title = "Wi-Fi",
options = bitrateOptions.map { it to bitrateLabel(it) }, quality = wifiQuality,
selected = maxBitrate, onSelect = onWifiQualityChange,
onSelect = { value ->
onMaxBitrateChange(value)
dialog = null
},
onDismiss = { dialog = null }, onDismiss = { dialog = null },
) )
} }
SettingsDialog.Format -> { SettingsDialog.MobileQuality -> {
ChoiceDialog( QualityDialog(
title = "Preferred stream format", title = "Mobile data",
options = streamFormats.map { it to (it ?: "Use server default") }, quality = mobileQuality,
selected = streamFormat, onSelect = onMobileQualityChange,
onSelect = { value ->
onStreamFormatChange(value)
dialog = null
},
onDismiss = { dialog = null }, onDismiss = { dialog = null },
) )
} }
@@ -198,21 +193,51 @@ 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" private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps"
@Composable @Composable
private fun <T> ChoiceDialog( private fun QualityDialog(
title: String, title: String,
options: List<Pair<T, String>>, quality: StreamQuality,
selected: T, onSelect: (StreamQuality) -> Unit,
onSelect: (T) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(title) }, title = { Text(title) },
text = { text = {
Column { 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("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) -> options.forEach { (value, label) ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -223,12 +248,6 @@ private fun <T> ChoiceDialog(
Text(label) Text(label)
} }
} }
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
} }
@Composable @Composable
@@ -3,6 +3,8 @@ 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.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences 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
@@ -20,13 +22,15 @@ 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> = val wifiQuality: StateFlow<StreamQuality> =
userPreferences.maxBitrate userPreferences
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) .streamQuality(NetworkMode.Wifi)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val streamFormat: StateFlow<String?> = val mobileQuality: StateFlow<StreamQuality> =
userPreferences.streamFormat userPreferences
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .streamQuality(NetworkMode.Mobile)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
fun selectSource(id: Long) { fun selectSource(id: Long) {
viewModelScope.launch { sourceRepository.selectSource(id) } viewModelScope.launch { sourceRepository.selectSource(id) }
@@ -36,11 +40,11 @@ class SettingsViewModel(
viewModelScope.launch { sourceRepository.deleteSource(id) } viewModelScope.launch { sourceRepository.deleteSource(id) }
} }
fun setMaxBitrate(kbps: Int) { fun setWifiQuality(quality: StreamQuality) {
viewModelScope.launch { userPreferences.setMaxBitrate(kbps) } viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Wifi, quality) }
} }
fun setStreamFormat(format: String?) { fun setMobileQuality(quality: StreamQuality) {
viewModelScope.launch { userPreferences.setStreamFormat(format) } viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) }
} }
} }
@@ -2,6 +2,7 @@ package com.subtracks.data.prefs
import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.net.NetworkMode
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -35,6 +36,25 @@ class UserPreferencesTest {
prefs.listQuery(LibraryListTab.Artists).first(), 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() file.delete()
} }
} }
@@ -11,6 +11,9 @@ 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.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences 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
@@ -35,6 +38,7 @@ class PlaybackControllerTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase private lateinit var db: SubtracksDatabase
private lateinit var prefs: UserPreferences
private lateinit var sources: SourceRepository private lateinit var sources: SourceRepository
private lateinit var queues: QueueRepository private lateinit var queues: QueueRepository
private lateinit var handle: FakePlayerHandle private lateinit var handle: FakePlayerHandle
@@ -48,7 +52,8 @@ class PlaybackControllerTest {
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences()) prefs = fakeUserPreferences()
sources = SourceRepository(db, OkHttpClient(), prefs)
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)
@@ -1015,6 +1020,25 @@ class PlaybackControllerTest {
assertEquals(paused, controller.positionMs.value) 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( private fun seedAlbum(
count: Int, count: Int,
sourceId: Long, sourceId: Long,
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode import com.subtracks.playback.RepeatMode
@@ -303,12 +304,12 @@ class AppScreenshotTest {
SettingsScreen( SettingsScreen(
sources = Fixtures.sources, sources = Fixtures.sources,
activeSourceId = 1, activeSourceId = 1,
maxBitrate = 0, wifiQuality = StreamQuality(),
streamFormat = null, mobileQuality = StreamQuality(192, "mp3"),
onSelectSource = {}, onSelectSource = {},
onDeleteSource = {}, onDeleteSource = {},
onMaxBitrateChange = {}, onWifiQualityChange = {},
onStreamFormatChange = {}, onMobileQualityChange = {},
onAddServer = {}, onAddServer = {},
onBack = {}, onBack = {},
) )
+1 -1
View File
@@ -86,7 +86,7 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality).
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet. The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet.