From 87ad7f4dc2e365db21e95f25cde57c09c0e9efd8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:56:06 -0400 Subject: [PATCH] feat(android): lazy queue activation + loading spinner during UPnP load 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 --- .../minstrel/player/PlayerController.kt | 17 +++++++ .../minstrel/player/PlayerUiState.kt | 2 + .../player/output/OutputPickerController.kt | 50 +++++++++++++++++-- .../minstrel/player/ui/MiniPlayer.kt | 33 ++++++++++-- .../minstrel/player/ui/NowPlayingScreen.kt | 24 ++++++--- 5 files changed, 113 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index ff44c4bd..180e85b3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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, ) } }, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt index fedaf877..4a101ec0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt @@ -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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 971b96af..382bd1c4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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, + 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? { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index b2061931..1e378ff5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index ac21a6ef..7fb99d3d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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(