feat(android): lazy queue activation + loading spinner during UPnP load
android / Build + lint + test (push) Successful in 3m55s

Part A: split loadQueueOnSonos into an initial phase (tracks[0..currentIndex]
only, then SetAV+Seek+Play) plus a background extendQueueOnSonos coroutine
that appends the remainder after activation. Reduces the UPnP activation
block from ~17s (100 tracks serial) to ~200ms (1 track at currentIndex=0).
Background extension cancels cleanly when activeUpnpHolder.active changes.

Part B: add PlayerUiState.isUpnpLoading (target set, active null). Projected
inline in onEvents so it stays consistent with the rest of the snapshot, plus
a separate combine(target, active) collector that updates uiState between
player-event fires. NowPlayingScreen.TransportRow and MiniPlayer.MiniRow
replace the play/pause icon with a CircularProgressIndicator while loading
and disable the button tap to prevent premature commands to the Sonos queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:56:06 -04:00
parent 9c0013f4b6
commit 87ad7f4dc2
5 changed files with 113 additions and 13 deletions
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -372,6 +373,19 @@ class PlayerController @Inject constructor(
// awaitReady, the Player.Listener is wired too.
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
startPositionPolling(controller)
// Keep isUpnpLoading current between player-event fires: holder state
// changes (setTarget / set(active)) are independent of Media3 events, so
// onEvents alone would lag behind by up to one event cycle. This collector
// runs for the process lifetime alongside the position poller.
scope.launch {
combine(
activeUpnpHolder.target,
activeUpnpHolder.active,
) { target, active -> target != null && active == null }
.collect { isLoading ->
uiStateInternal.value = uiStateInternal.value.copy(isUpnpLoading = isLoading)
}
}
controller.addListener(
object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
@@ -412,6 +426,8 @@ class PlayerController @Inject constructor(
?.mediaMetadata
?.extras
?.getString(MINSTREL_SOURCE_KEY)
val isUpnpLoading = activeUpnpHolder.target.value != null &&
activeUpnpHolder.active.value == null
uiStateInternal.value =
PlayerUiState(
currentTrack = current,
@@ -430,6 +446,7 @@ class PlayerController @Inject constructor(
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
else -> RepeatMode.OFF
},
isUpnpLoading = isUpnpLoading,
)
}
},
@@ -28,4 +28,6 @@ data class PlayerUiState(
val currentSource: String? = null,
val shuffleEnabled: Boolean = false,
val repeatMode: RepeatMode = RepeatMode.OFF,
/** True while the UPnP initial-batch load is in progress (target set, active not yet wired). */
val isUpnpLoading: Boolean = false,
)
@@ -303,8 +303,13 @@ class OutputPickerController @Inject constructor(
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
Timber.w("UPnP select: add %d tracks to queue", queue.size)
queue.forEachIndexed { idx, ref ->
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
@@ -321,7 +326,46 @@ class OutputPickerController @Inject constructor(
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: done")
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
tracks.forEachIndexed { i, ref ->
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
return
}
runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}.onFailure { Timber.w(it, "UPnP extend: append failed at offset %d", i) }
}
Timber.w("UPnP extend: done (%d tracks appended)", tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? {
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -164,6 +165,7 @@ fun MiniPlayer(
MiniRow(
track = track,
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
isLiked = isLiked,
onExpandClick = onExpandClick,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
private fun MiniRow(
track: TrackRef,
isPlaying: Boolean,
isUpnpLoading: Boolean,
isLiked: Boolean,
onExpandClick: () -> Unit,
onPlayPause: () -> Unit,
@@ -248,15 +251,39 @@ private fun MiniRow(
}
LikeButton(liked = isLiked, onToggle = onToggleLike)
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
TransportButton(
icon = if (isPlaying) Lucide.Pause else Lucide.Play,
description = if (isPlaying) "Pause" else "Play",
MiniPlayPauseButton(
isPlaying = isPlaying,
isUpnpLoading = isUpnpLoading,
onClick = onPlayPause,
)
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext)
}
}
@Composable
private fun MiniPlayPauseButton(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(MINI_PLAY_PAUSE_SPINNER_DP.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
private const val MINI_PLAY_PAUSE_SPINNER_DP = 24
@Composable
private fun TransportButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -424,6 +425,7 @@ private fun PlaybackControlsBlock(
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
@@ -704,6 +706,7 @@ private fun ScrubTrack(fraction: Float, accent: Color) {
@Composable
private fun TransportRow(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onPrev: () -> Unit,
onPlayPause: () -> Unit,
onNext: () -> Unit,
@@ -722,13 +725,20 @@ private fun TransportRow(
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
strokeWidth = 3.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
}
}
IconButton(onClick = onNext) {
Icon(