feat(android): lazy queue activation + loading spinner during UPnP load
android / Build + lint + test (push) Successful in 3m55s
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:
@@ -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,
|
||||
)
|
||||
|
||||
+47
-3
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user