fix: an app update is offered only when the app changed; casting no longer sticks on "loading" when the same queue plays again (#6455, #6456)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / integration (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped

ci/version.sh --apk names the APK from android/ alone, and the APK lane
calls it. The app offers an update when the server's APK name differs from
its own, so a name derived from the whole repo offered every phone a rebuild
of the app it already had on each server-only deploy. The image keeps the
union set: it bundles the APK under client/.

Casting (#6456):
- setMediaItems with the tracks already queued (play the queued album from
  another track) cleared the cast and waited for a resync that the id-keyed
  collector never ran. It is now a move within the queue: Seek TRACK_NR on a
  Sonos, start that track on a one-track renderer; the play() goes through.
- A queue replaced through setMediaItems could take the Sonos incremental
  diff, which cleared target without restoring active, so the app thought it
  was back on the phone while the speaker played on, and the chosen start
  track was ignored. The diff now runs only while the route is still active;
  a replaced queue reloads in full at its start index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 10:08:02 -04:00
co-authored by Claude Opus 5.5
parent fa75bd08d7
commit 3fa5d9fc3a
5 changed files with 172 additions and 39 deletions
@@ -216,24 +216,56 @@ class MinstrelForwardingPlayer(
// drops via isLoadingUpnp() = true; the OutputPickerController
// observes the uiState.queue change and runs the resync (re-clears
// Sonos's native queue + AddURIToQueue the new tracks + Play).
//
// The SAME tracks set again (play on the album already queued, from
// another track) is not a new queue: the renderer already holds it, and
// the uiState collector -- which keys on the track ids -- would never see
// a change to resync on, leaving the cast stuck "loading". It is a move
// within the queue, so the renderer is pointed at the new track and the
// play() that follows goes through.
override fun setMediaItems(mediaItems: List<MediaItem>) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems, resetPosition)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems, startIndex, startPositionMs)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
private fun markPendingResyncIfRemote() {
val wasActive = holder.active.value ?: return
private fun holdsSameItems(mediaItems: List<MediaItem>): Boolean =
mediaItems.size == delegate.mediaItemCount &&
mediaItems.indices.all { mediaItems[it].mediaId == delegate.getMediaItemAt(it).mediaId }
private fun afterQueueSet(sameQueue: Boolean) {
val active = holder.active.value ?: return
if (!sameQueue) {
markPendingResync(active)
return
}
val index = delegate.currentMediaItemIndex
val positionMs = delegate.currentPosition.coerceAtLeast(0L)
Timber.w(
"setMediaItems with the queue %s already holds -- moving to track %d",
active.routeName,
index + 1,
)
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> startSingleTrack(active, index, positionMs)
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, index, positionMs)
}
}
private fun markPendingResync(wasActive: ActiveUpnp) {
Timber.w(
"setMediaItems while UPnP active (%s) -- marking pending resync",
wasActive.routeName,
@@ -332,11 +364,15 @@ class MinstrelForwardingPlayer(
super.seekTo(mediaItemIndex, positionMs)
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, mediaItemIndex, positionMs)
}
}
private fun seekNativeQueue(active: ActiveUpnp, index: Int, positionMs: Long) {
sendNativeQueueCommand(active) {
active.avTransport.seekToTrack(index + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}
@@ -383,23 +383,27 @@ class OutputPickerController @Inject constructor(
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
return@withLock
}
val handledIncrementally = runCatching {
// The incremental diff is for a queue changed under a cast that kept
// running (play next, an append, a removal): it leaves the playing
// track alone. A queue REPLACED through setMediaItems has already
// cleared active, and the listener asked for its start track and for
// it to play -- both of which the diff would ignore, and with active
// never restored the app would think it had gone back to the phone
// while the speaker played on. That case reloads in full below.
val stillActive = activeUpnpHolder.active.value?.routeId == routeId
val handledIncrementally = stillActive && runCatching {
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false
}
if (handledIncrementally) {
// Active was never cleared on the incremental path; clear any
// target that markPendingResyncIfRemote set (it didn't, for
// incremental cases that don't go through setMediaItems, but
// belt-and-suspenders).
activeUpnpHolder.setTarget(null)
return@withLock
}
// Full rebuild: ensure active is cleared so transport calls drop
// (markPendingResyncIfRemote may already have done this on the
// setMediaItems path).
// (the forwarding player has already done this on the setMediaItems
// path).
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(routeId)