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
@@ -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 = {},
)