react to active source changes

The active source becomes one immutable id+source pair, so cover art and
stream/download URLs can no longer read a torn state mid-switch. The root
navigation watches the active source id and, on a real switch, closes the
overlays and pops an open album/artist/playlist detail back to the library.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent d4f3b54574
commit fa5595db30
3 changed files with 139 additions and 17 deletions
@@ -40,11 +40,13 @@ class SourceRepository(
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Volatile
private var active: SubsonicMusicSource? = null
private data class ActiveSource(
val id: Long,
val source: SubsonicMusicSource,
)
@Volatile
private var activeSourceId: Long? = null
private var active: ActiveSource? = null
private val _quality = MutableStateFlow(StreamQuality())
val quality: StateFlow<StreamQuality> = _quality
@@ -87,10 +89,9 @@ class SourceRepository(
) { config, mode, wifi, mobile, concurrency ->
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
}.collect { (config, quality, concurrency) ->
active = config?.toMusicSource(quality, concurrency)
val sourceChanged = config?.id != active?.id
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
_quality.value = quality
val sourceChanged = config?.id != activeSourceId
activeSourceId = config?.id
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
scope.launch { runCatching { config.toClient().check("ping") } }
}
@@ -116,12 +117,12 @@ class SourceRepository(
coverArt: String?,
thumbnail: Boolean = false,
): CoverArtRef? {
val source = active ?: return null
val current = active ?: return null
if (coverArt == null) return null
val cacheKey = coverArtKey(source.id, coverArt, thumbnail)
artworkStore.uri(source.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
val cacheKey = coverArtKey(current.id, coverArt, thumbnail)
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
if (_offline.value) return null
val url = source.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
val url = current.source.coverArtUri(coverArt, thumbnail)?.toString() ?: return null
return CoverArtRef(url = url, cacheKey = cacheKey)
}
@@ -131,9 +132,8 @@ class SourceRepository(
thumbnail: Boolean,
): String? {
if (_offline.value) return null
val source = active ?: return null
if (source.id != sourceId) return null
return source.coverArtUri(coverArt, thumbnail)?.toString()
val current = active?.takeIf { it.id == sourceId } ?: return null
return current.source.coverArtUri(coverArt, thumbnail)?.toString()
}
fun streamUri(
@@ -142,7 +142,7 @@ class SourceRepository(
quality: StreamQuality,
): String? {
if (_offline.value) return null
val uri = active?.streamUri(songId)?.toString() ?: return null
val uri = active?.source?.streamUri(songId)?.toString() ?: return null
val length = declaredStreamLength(durationMs, quality) ?: return uri
return uri + streamLengthSuffix(length)
}
@@ -152,14 +152,14 @@ class SourceRepository(
songId: String,
): String? {
if (_offline.value) return null
val source = active?.takeIf { activeSourceId == sourceId } ?: return null
val current = active?.takeIf { it.id == sourceId } ?: return null
// A transcode preference downloads the stream, so the saved file is already the wanted
// quality rather than the original that would then have to be transcoded on every play.
val quality = downloadQuality
return if (quality.transcodes) {
source.streamUri(songId, quality.maxBitrate, quality.format).toString()
current.source.streamUri(songId, quality.maxBitrate, quality.format).toString()
} else {
source.downloadUri(songId).toString()
current.source.downloadUri(songId).toString()
}
}
@@ -131,6 +131,23 @@ private object Routes {
fun editServer(id: Long) = "edit-server/$id"
}
private val DETAIL_ROUTES =
setOf(Routes.ALBUM_DETAIL, Routes.ARTIST_DETAIL, Routes.PLAYLIST_DETAIL)
internal fun resetOnSourceSwitch(
previousSourceId: Long?,
currentSourceId: Long?,
route: String?,
closeNowPlaying: () -> Unit,
closeQueue: () -> Unit,
popDetail: () -> Unit,
) {
if (previousSourceId == null || currentSourceId == null || previousSourceId == currentSourceId) return
closeNowPlaying()
closeQueue()
if (route in DETAIL_ROUTES) popDetail()
}
internal data class BackStackKey(
val route: String?,
val argument: String?,
@@ -191,6 +208,25 @@ private fun MainNavigation() {
val contextMenuHost = remember { ContextMenuHost() }
var infoSong by remember { mutableStateOf<Song?>(null) }
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
LaunchedEffect(activeSourceId) {
val id = activeSourceId
resetOnSourceSwitch(
previousSourceId = previousSourceId,
currentSourceId = id,
route = currentRoute,
closeNowPlaying = {
settleJob?.cancel()
nowPlayingOpen = false
nowPlayingProgress = 0f
},
closeQueue = { showingQueue = false },
popDetail = { navController.popBackStack(Routes.LIBRARY, inclusive = false) },
)
if (id != null) previousSourceId = id
}
fun settleNowPlaying(open: Boolean) {
if (open) {
nowPlayingOpen = true
@@ -0,0 +1,86 @@
package com.subtracks.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SourceSwitchResetTest {
private var nowPlayingClosed = false
private var queueClosed = false
private var detailPopped = false
private fun reset(
previous: Long?,
current: Long?,
route: String?,
) {
nowPlayingClosed = false
queueClosed = false
detailPopped = false
resetOnSourceSwitch(
previousSourceId = previous,
currentSourceId = current,
route = route,
closeNowPlaying = { nowPlayingClosed = true },
closeQueue = { queueClosed = true },
popDetail = { detailPopped = true },
)
}
@Test
fun theFirstSourceResetsNothing() {
reset(previous = null, current = 1L, route = "album/{albumId}?coverArt={coverArt}")
assertFalse(nowPlayingClosed)
assertFalse(queueClosed)
assertFalse(detailPopped)
}
@Test
fun reselectingTheSameSourceResetsNothing() {
reset(previous = 1L, current = 1L, route = "settings")
assertFalse(nowPlayingClosed)
assertFalse(queueClosed)
assertFalse(detailPopped)
}
@Test
fun losingTheSourceResetsNothing() {
reset(previous = 1L, current = null, route = "settings")
assertFalse(nowPlayingClosed)
assertFalse(queueClosed)
assertFalse(detailPopped)
}
@Test
fun aSwitchClosesTheOverlays() {
reset(previous = 1L, current = 2L, route = "settings")
assertTrue(nowPlayingClosed)
assertTrue(queueClosed)
assertFalse(detailPopped)
}
@Test
fun aSwitchOnADetailPopsIt() {
reset(previous = 1L, current = 2L, route = "album/{albumId}?coverArt={coverArt}")
assertTrue(nowPlayingClosed)
assertTrue(queueClosed)
assertTrue(detailPopped)
}
@Test
fun everyDetailRoutePopsOnASwitch() {
listOf(
"album/{albumId}?coverArt={coverArt}",
"artist/{artistId}?coverArt={coverArt}",
"playlist/{playlistId}",
).forEach { route ->
reset(previous = 1L, current = 2L, route = route)
assertTrue("$route should be dismissed", detailPopped)
}
}
}