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.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -372,6 +373,19 @@ class PlayerController @Inject constructor(
|
|||||||
// awaitReady, the Player.Listener is wired too.
|
// awaitReady, the Player.Listener is wired too.
|
||||||
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
|
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
|
||||||
startPositionPolling(controller)
|
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(
|
controller.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||||
@@ -412,6 +426,8 @@ class PlayerController @Inject constructor(
|
|||||||
?.mediaMetadata
|
?.mediaMetadata
|
||||||
?.extras
|
?.extras
|
||||||
?.getString(MINSTREL_SOURCE_KEY)
|
?.getString(MINSTREL_SOURCE_KEY)
|
||||||
|
val isUpnpLoading = activeUpnpHolder.target.value != null &&
|
||||||
|
activeUpnpHolder.active.value == null
|
||||||
uiStateInternal.value =
|
uiStateInternal.value =
|
||||||
PlayerUiState(
|
PlayerUiState(
|
||||||
currentTrack = current,
|
currentTrack = current,
|
||||||
@@ -430,6 +446,7 @@ class PlayerController @Inject constructor(
|
|||||||
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
|
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
|
||||||
else -> RepeatMode.OFF
|
else -> RepeatMode.OFF
|
||||||
},
|
},
|
||||||
|
isUpnpLoading = isUpnpLoading,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,4 +28,6 @@ data class PlayerUiState(
|
|||||||
val currentSource: String? = null,
|
val currentSource: String? = null,
|
||||||
val shuffleEnabled: Boolean = false,
|
val shuffleEnabled: Boolean = false,
|
||||||
val repeatMode: RepeatMode = RepeatMode.OFF,
|
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)
|
Timber.w("UPnP select: clear queue on %s", route.name)
|
||||||
transport.removeAllTracksFromQueue()
|
transport.removeAllTracksFromQueue()
|
||||||
Timber.w("UPnP select: add %d tracks to queue", queue.size)
|
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
|
||||||
queue.forEachIndexed { idx, ref ->
|
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)
|
val token = streamTokens.mint(ref.id)
|
||||||
transport.addURIToQueue(
|
transport.addURIToQueue(
|
||||||
uri = token.url,
|
uri = token.url,
|
||||||
@@ -321,7 +326,46 @@ class OutputPickerController @Inject constructor(
|
|||||||
transport.seekToTrack(currentIndex + 1)
|
transport.seekToTrack(currentIndex + 1)
|
||||||
Timber.w("UPnP select: Play")
|
Timber.w("UPnP select: Play")
|
||||||
transport.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? {
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -164,6 +165,7 @@ fun MiniPlayer(
|
|||||||
MiniRow(
|
MiniRow(
|
||||||
track = track,
|
track = track,
|
||||||
isPlaying = state.isPlaying,
|
isPlaying = state.isPlaying,
|
||||||
|
isUpnpLoading = state.isUpnpLoading,
|
||||||
isLiked = isLiked,
|
isLiked = isLiked,
|
||||||
onExpandClick = onExpandClick,
|
onExpandClick = onExpandClick,
|
||||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||||
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
|
|||||||
private fun MiniRow(
|
private fun MiniRow(
|
||||||
track: TrackRef,
|
track: TrackRef,
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
|
isUpnpLoading: Boolean,
|
||||||
isLiked: Boolean,
|
isLiked: Boolean,
|
||||||
onExpandClick: () -> Unit,
|
onExpandClick: () -> Unit,
|
||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
@@ -248,15 +251,39 @@ private fun MiniRow(
|
|||||||
}
|
}
|
||||||
LikeButton(liked = isLiked, onToggle = onToggleLike)
|
LikeButton(liked = isLiked, onToggle = onToggleLike)
|
||||||
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
|
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
|
||||||
TransportButton(
|
MiniPlayPauseButton(
|
||||||
icon = if (isPlaying) Lucide.Pause else Lucide.Play,
|
isPlaying = isPlaying,
|
||||||
description = if (isPlaying) "Pause" else "Play",
|
isUpnpLoading = isUpnpLoading,
|
||||||
onClick = onPlayPause,
|
onClick = onPlayPause,
|
||||||
)
|
)
|
||||||
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext)
|
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
|
@Composable
|
||||||
private fun TransportButton(
|
private fun TransportButton(
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
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.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -424,6 +425,7 @@ private fun PlaybackControlsBlock(
|
|||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
TransportRow(
|
TransportRow(
|
||||||
isPlaying = state.isPlaying,
|
isPlaying = state.isPlaying,
|
||||||
|
isUpnpLoading = state.isUpnpLoading,
|
||||||
onPrev = viewModel::skipToPrevious,
|
onPrev = viewModel::skipToPrevious,
|
||||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||||
onNext = viewModel::skipToNext,
|
onNext = viewModel::skipToNext,
|
||||||
@@ -704,6 +706,7 @@ private fun ScrubTrack(fraction: Float, accent: Color) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun TransportRow(
|
private fun TransportRow(
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
|
isUpnpLoading: Boolean,
|
||||||
onPrev: () -> Unit,
|
onPrev: () -> Unit,
|
||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
@@ -722,13 +725,20 @@ private fun TransportRow(
|
|||||||
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
|
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onPlayPause) {
|
IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
|
||||||
Icon(
|
if (isUpnpLoading) {
|
||||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
CircularProgressIndicator(
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||||
tint = actionColors.primary,
|
strokeWidth = 3.dp,
|
||||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.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) {
|
IconButton(onClick = onNext) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
Reference in New Issue
Block a user