From 117e8a69fbf3bd180eb01e486779c63232a0b730 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:57:41 +0000 Subject: [PATCH] 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. --- app/src/main/AndroidManifest.xml | 1 + .../main/kotlin/com/subtracks/SubtracksApp.kt | 3 + .../com/subtracks/data/net/NetworkMonitor.kt | 48 +++++++ .../subtracks/data/prefs/UserPreferences.kt | 40 +++--- .../subtracks/data/repo/SourceRepository.kt | 33 +++-- .../main/kotlin/com/subtracks/di/AppModule.kt | 4 +- .../subtracks/playback/PlaybackController.kt | 15 +++ .../subtracks/ui/settings/SettingsScreen.kt | 121 ++++++++++-------- .../ui/settings/SettingsViewModel.kt | 24 ++-- .../data/prefs/UserPreferencesTest.kt | 20 +++ .../playback/PlaybackControllerTest.kt | 26 +++- .../com/subtracks/ui/AppScreenshotTest.kt | 9 +- docs/architecture.md | 2 +- 13 files changed, 249 insertions(+), 97 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/data/net/NetworkMonitor.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9abb4be9..c421969f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt index 6954061b..529f53de 100644 --- a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt +++ b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt @@ -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)) diff --git a/app/src/main/kotlin/com/subtracks/data/net/NetworkMonitor.kt b/app/src/main/kotlin/com/subtracks/data/net/NetworkMonitor.kt new file mode 100644 index 00000000..90b067d9 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/net/NetworkMonitor.kt @@ -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 = + 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 +} 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 68c6de02..1fa392b5 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -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 by preferencesDataStore("user_prefs") class UserPreferences( @@ -48,24 +53,32 @@ class UserPreferences( store.edit { prefs -> prefs[listQueryKey(tab)] = encode(query) } } - val maxBitrate: Flow = store.data.map { prefs -> prefs[MAX_BITRATE] ?: 0 } + fun streamQuality(mode: NetworkMode): Flow = + 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 = 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) 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 1a0532db..8b9ad78d 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,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 = 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 = _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) { diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index bacbb952..5f2b3d8b 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -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().mode, showMessage = toast) } single { LibraryRepository(get(), get(), toast) } single { ArtworkSeedRepository(get()) } single { SyncManager(get(), get(), get()) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 43a5c901..e8801b7b 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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 { 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 bec5ca50..32e9a749 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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, 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 ChoiceDialog( +private fun QualityDialog( title: String, - options: List>, - 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 ChoiceGroup( + header: String, + options: List>, + 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( 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 2752798a..d36d2bae 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,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 = sourceRepository.activeSourceId().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - val maxBitrate: StateFlow = - userPreferences.maxBitrate - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + val wifiQuality: StateFlow = + userPreferences + .streamQuality(NetworkMode.Wifi) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality()) - val streamFormat: StateFlow = - userPreferences.streamFormat - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val mobileQuality: StateFlow = + 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) } } } diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index 43042315..32aec46e 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -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() } } diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 77200dfc..7131c84e 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 7827f210..fa8ff2f6 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -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 = {}, ) diff --git a/docs/architecture.md b/docs/architecture.md index e577c137..b4763e74 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. -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.