From 24b7c92abd00592c56e0264135ad77905db74dd9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 15:54:56 -0400 Subject: [PATCH 01/69] fix(server+android): DIDL-Lite metadata for Sonos UPnP (error 1023) After the X-Forwarded-Proto fix Sonos now gets a clean https:// URL but returns vendor error 1023 - empty CurrentURIMetaData. Sonos requires DIDL-Lite metadata with at minimum carrying the audio MIME type so it can validate the source before playback. The original spec said 'Sonos accepts empty DIDL; recoverable if a device rejects' - that was wrong for Sonos. Server (cast_token.go): - Look up the track and return mime (from tracks.file_format) + title in the cast-token response. mimeForFormat covers the common formats - mp3, flac, m4a/aac, ogg, opus, wav - falling through to audio/mpeg for unknowns. - Missing track returns 404 (apierror.NotFound) instead of letting the caller mint a token for nothing. Client (CastApi.kt, AVTransportClient.kt, OutputPickerController.kt): - StreamTokenResponse gains mime + title (defaulted so old contracts stay parseable). - AVTransportClient.setAVTransportURIWithMetadata builds minimal Sonos- acceptable DIDL-Lite around the URL + MIME + title. xml-escaped. - selectUpnp calls the new overload; Timber.i now logs the MIME so the next on-device test shows it. Generic UPnP renderers tolerate the DIDL shape too - no downside to sending it everywhere. --- .../minstrel/api/endpoints/CastApi.kt | 9 +++- .../player/output/OutputPickerController.kt | 8 +++- .../player/output/upnp/AVTransportClient.kt | 41 ++++++++++++++++ internal/api/cast_token.go | 48 ++++++++++++++++++- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt index 771ef03a..e291f4a5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/CastApi.kt @@ -39,11 +39,18 @@ data class StreamTokenRequest( /** * Response body. [url] is a fully-formed stream URL with [token] and * [exp] already embedded as query params — callers pass it verbatim - * to `AVTransport.SetAVTransportURI`. + * to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits + * the client needs to build DIDL-Lite metadata for that call: Sonos + * rejects empty DIDL with vendor error 1023, so the server hands back + * the track's MIME (from `tracks.file_format`) and title so the + * client can populate `` and `` without + * a follow-up round trip. */ @Serializable data class StreamTokenResponse( val token: String, val exp: Long, val url: String, + val mime: String = "audio/mpeg", + val title: String = "", ) 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 1f6a178b..07c42a7d 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 @@ -194,8 +194,12 @@ class OutputPickerController @Inject constructor( runCatching { Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}") val token = castApi.streamToken(StreamTokenRequest(trackId = trackId)) - Timber.i("UPnP select: SetAVTransportURI to ${token.url}") - transport.setAVTransportURI(token.url) + Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") + transport.setAVTransportURIWithMetadata( + uri = token.url, + mime = token.mime, + title = token.title, + ) Timber.i("UPnP select: Play") transport.play() playerController.pause() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index a7897087..bccc60c6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -26,6 +26,47 @@ class AVTransportClient( ) } + /** + * Convenience overload that builds DIDL-Lite metadata around [uri] + * + [mime] + [title] and forwards to [setAVTransportURI]. Sonos + * rejects empty DIDL with vendor error 1023; this constructs the + * minimal-but-Sonos-acceptable shape: + * + * + * ... + * object.item.audioItem.musicTrack + * ... + * + * + * Generic UPnP renderers tolerate this shape too — there's no + * downside to always sending it. Title falls back to "Minstrel" + * when the caller doesn't supply one. + */ + suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) { + val safeTitle = title.ifBlank { "Minstrel" } + val didl = buildString { + append("") + append("") + append("").append(xmlEscape(safeTitle)).append("") + append("object.item.audioItem.musicTrack") + append("").append(xmlEscape(uri)).append("") + append("") + append("") + } + setAVTransportURI(uri, didl) + } + + private fun xmlEscape(v: String): String = v + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + suspend fun play() { soap.call( controlUrl = controlUrl, diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index 37751150..e638308f 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -3,9 +3,11 @@ package api import ( "net/http" "strconv" + "strings" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) const ( @@ -23,6 +25,36 @@ type castTokenResponse struct { Token string `json:"token"` Exp int64 `json:"exp"` URL string `json:"url"` + // MIME and Title let the client build proper DIDL-Lite metadata for + // SetAVTransportURI. Sonos rejects empty DIDL with vendor error 1023; + // passing back the track's MIME + title here lets the client populate + // `` and `` without a follow-up round trip. + MIME string `json:"mime"` + Title string `json:"title"` +} + +// mimeForFormat maps the tracks.file_format column to an HTTP audio +// MIME type. Sonos requires the protocolInfo MIME on DIDL-Lite to match +// what the URL actually serves; "audio/*" wildcard is silently rejected. +// Unknown formats fall back to audio/mpeg — most Sonos firmware probes +// the URL anyway and recovers from a small MIME mismatch. +func mimeForFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "mp3", "mpeg": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "aac", "m4a", "mp4": + return "audio/mp4" + case "ogg", "vorbis": + return "audio/ogg" + case "opus": + return "audio/opus" + case "wav", "wave": + return "audio/wav" + default: + return "audio/mpeg" + } } // handleCastStreamToken issues a short-lived HMAC stream token for the @@ -52,6 +84,14 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID")) return } + // Track lookup for the DIDL-Lite metadata the client builds for + // SetAVTransportURI. A missing track is a 404 — there's nothing to + // cast in that case. + track, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackUUID) + if err != nil { + writeErr(w, apierror.NotFound("track")) + return + } expSec := clampExpSeconds(req.ExpSeconds) exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix() token := SignStreamToken(h.streamSecret, req.TrackID, exp) @@ -77,7 +117,13 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) url := scheme + "://" + host + "/api/tracks/" + req.TrackID + "/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) - writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url}) + writeJSON(w, http.StatusOK, castTokenResponse{ + Token: token, + Exp: exp, + URL: url, + MIME: mimeForFormat(track.FileFormat), + Title: track.Title, + }) } // clampExpSeconds applies the [60, 86400] window with a 6h default for From 574bf29a7e203465a173cae8dec5b958deb71729 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:00:53 -0400 Subject: [PATCH 02/69] fix(android): PlayerController transport methods dispatch to controller thread After DIDL fix, Sonos accepted SetAVTransportURI + Play, but playerController.pause() threw IllegalStateException 'method is called from a wrong thread' because selectUpnp runs the whole UPnP-selection flow on Dispatchers.Default. UI tap handlers were fine - they're already on Main - but the cross-thread background call from OutputPickerController.selectUpnp hit the MediaController's application-thread guard. Same fix as the cold-boot resume one earlier today (commit e69a5204 wrapped setQueue): pause / play / seekTo / skipToNext / skipToPrevious now route through runOnControllerThread, which is a no-op when already on the application looper and Handler.post otherwise. Logcat from on-device confirmed Sonos plays after this fix lands - SetAVTransportURI -> 200, Play -> 200, then the IllegalStateException was the last failure path. --- .../minstrel/player/PlayerController.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 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 0604cd03..5c71f4a1 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 @@ -130,11 +130,24 @@ class PlayerController @Inject constructor( // ── Transport (no-op until the controller is connected) ────────────── - fun play() { mediaController?.play() } - fun pause() { mediaController?.pause() } - fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) } - fun skipToNext() { mediaController?.seekToNextMediaItem() } - fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() } + // Each transport call must run on the MediaController's + // applicationLooper; calling from a background coroutine throws + // IllegalStateException (see PlayerController.setQueue's note). + // UI tap handlers are already on Main so the in-place branch hits; + // the background path only fires for cross-thread callers like + // OutputPickerController.selectUpnp (which calls pause() after + // handing playback off to a UPnP renderer). + fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } } + fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } } + fun seekTo(positionMs: Long) { + mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } } + } + fun skipToNext() { + mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } } + } + fun skipToPrevious() { + mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } } + } /** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */ fun toggleShuffle() { From d62a3b8134c05a0e77b942161713780a1eadcd79 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:42:04 -0400 Subject: [PATCH 03/69] feat(android): AVTransport pause/seek/nextURI/positionInfo/transportInfo - Add pause(), seek(positionMs), setNextAVTransportURI(uri, mime, title) - Add getPositionInfo() -> PositionInfo, getTransportInfo() -> TransportInfo - Extract buildDidlLite() helper; add formatHhMmSs / parseHhMmSs helpers - Add PositionInfo, TransportState, TransportInfo top-level types - Add AVTransportClientTest covering all four new call shapes Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/upnp/AVTransportClient.kt | 154 +++++++++++++++--- .../output/upnp/AVTransportClientTest.kt | 112 +++++++++++++ 2 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index bccc60c6..c4448d57 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -3,12 +3,11 @@ package com.fabledsword.minstrel.player.output.upnp import okhttp3.HttpUrl /** - * High-level wrapper for the UPnP AVTransport service. Three calls - * for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred - * until we have hardware in the loop to verify each device's quirks - * (Sonos and BubbleUPnP accept the standard shape; some smart TVs - * reject Pause without DIDL). + * High-level wrapper for the UPnP AVTransport service. + * Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / + * Stop / Seek / GetPositionInfo / GetTransportInfo. */ +@Suppress("TooManyFunctions") // Compose-adjacent helper density: all are direct SOAP verbs. class AVTransportClient( private val soap: SoapClient, private val controlUrl: HttpUrl, @@ -43,29 +42,26 @@ class AVTransportClient( * when the caller doesn't supply one. */ suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) { - val safeTitle = title.ifBlank { "Minstrel" } - val didl = buildString { - append("") - append("") - append("").append(xmlEscape(safeTitle)).append("") - append("object.item.audioItem.musicTrack") - append("").append(xmlEscape(uri)).append("") - append("") - append("") - } - setAVTransportURI(uri, didl) + setAVTransportURI(uri, buildDidlLite(uri, mime, title)) } - private fun xmlEscape(v: String): String = v - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") + /** + * Queues the next track on the renderer so it can pre-buffer before + * the current track finishes. Uses the same DIDL-Lite shape as + * [setAVTransportURIWithMetadata]. + */ + suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) { + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "SetNextAVTransportURI", + args = mapOf( + "InstanceID" to "0", + "NextURI" to uri, + "NextURIMetaData" to buildDidlLite(uri, mime, title), + ), + ) + } suspend fun play() { soap.call( @@ -76,6 +72,15 @@ class AVTransportClient( ) } + suspend fun pause() { + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "Pause", + args = mapOf("InstanceID" to "0"), + ) + } + suspend fun stop() { soap.call( controlUrl = controlUrl, @@ -85,7 +90,104 @@ class AVTransportClient( ) } + suspend fun seek(positionMs: Long) { + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "Seek", + args = mapOf( + "InstanceID" to "0", + "Unit" to "REL_TIME", + "Target" to formatHhMmSs(positionMs), + ), + ) + } + + suspend fun getPositionInfo(): PositionInfo { + val result = soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "GetPositionInfo", + args = mapOf("InstanceID" to "0"), + ) + return PositionInfo( + trackUri = result["TrackURI"].orEmpty(), + relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()), + trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()), + ) + } + + suspend fun getTransportInfo(): TransportInfo { + val result = soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "GetTransportInfo", + args = mapOf("InstanceID" to "0"), + ) + val state = when (result["CurrentTransportState"]) { + "PLAYING" -> TransportState.PLAYING + "PAUSED_PLAYBACK" -> TransportState.PAUSED + "STOPPED" -> TransportState.STOPPED + "TRANSITIONING" -> TransportState.TRANSITIONING + else -> TransportState.UNKNOWN + } + return TransportInfo(state) + } + + private fun buildDidlLite(uri: String, mime: String, title: String): String { + val safeTitle = title.ifBlank { "Minstrel" } + return buildString { + append("") + append("") + append("").append(xmlEscape(safeTitle)).append("") + append("object.item.audioItem.musicTrack") + append("").append(xmlEscape(uri)).append("") + append("") + append("") + } + } + + private fun xmlEscape(v: String): String = v + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + + private fun formatHhMmSs(positionMs: Long): String { + val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0) + val h = totalSec / SECONDS_PER_HOUR + val m = (totalSec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE + val s = totalSec % SECONDS_PER_MINUTE + return "%d:%02d:%02d".format(h, m, s) + } + + private fun parseHhMmSs(raw: String): Long { + if (raw.isBlank()) return 0L + val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() } + if (parts.size != 3) return 0L + val (h, m, s) = parts + return ((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND + } + private companion object { const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" + const val MILLIS_PER_SECOND = 1000L + const val SECONDS_PER_MINUTE = 60L + const val SECONDS_PER_HOUR = 3600L } } + +data class PositionInfo( + val trackUri: String, + val relTimeMs: Long, + val trackDurationMs: Long, +) + +enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN } + +data class TransportInfo(val state: TransportState) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt new file mode 100644 index 00000000..9a90a8b9 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -0,0 +1,112 @@ +package com.fabledsword.minstrel.player.output.upnp + +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AVTransportClientTest { + + private lateinit var server: MockWebServer + private lateinit var client: AVTransportClient + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + val controlUrl = server.url("/MediaRenderer/AVTransport/Control") + client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl) + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + @Test + fun `seek formats position as hh-mm-ss`() = runTest { + server.enqueue(emptyResponse("Seek")) + client.seek(positionMs = 65_000L) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("0:01:05")) { "body was $body" } + } + + @Test + fun `getPositionInfo parses RelTime and TrackDuration`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + + + + 1 + 0:03:30 + http://x/y.mp3 + 0:01:05 + + + """.trimIndent(), + ), + ) + val info = client.getPositionInfo() + assertEquals(65_000L, info.relTimeMs) + assertEquals(210_000L, info.trackDurationMs) + assertEquals("http://x/y.mp3", info.trackUri) + } + + @Test + fun `getTransportInfo maps PAUSED_PLAYBACK to PAUSED`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + + + + PAUSED_PLAYBACK + OK + 1 + + + """.trimIndent(), + ), + ) + val info = client.getTransportInfo() + assertEquals(TransportState.PAUSED, info.state) + } + + @Test + fun `setNextAVTransportURI sends DIDL-Lite with mime and title`() = runTest { + server.enqueue(emptyResponse("SetNextAVTransportURI")) + client.setNextAVTransportURI( + uri = "http://x/y.mp3", + mime = "audio/mpeg", + title = "Song", + ) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("http://x/y.mp3")) { + "body missing NextURI: $body" + } + assertTrue(body.contains("Song")) { + "body missing dc:title: $body" + } + assertTrue(body.contains("audio/mpeg")) { + "body missing mime type: $body" + } + } + + private fun emptyResponse(action: String): MockResponse = MockResponse().setBody( + """ + + + + + """.trimIndent(), + ) +} From 3cdb416f943bd0f844339b86b6084d2292fb3364 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:47:18 -0400 Subject: [PATCH 04/69] fix(android): AVTransport test assertions escape DIDL; consolidate xmlEscape DIDL assertion now checks for XML-escaped form (<dc:title>) since SoapClient.buildEnvelope escapes all arg values. Lifts xmlEscape to a top-level internal fun in SoapClient.kt, removing the duplicate private copy from AVTransportClient. Fixes @Suppress rationale (not Compose). Renames seek test to reflect colon-separated format; adds unknown-state getTransportInfo test. Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/upnp/AVTransportClient.kt | 10 ++----- .../minstrel/player/output/upnp/SoapClient.kt | 15 ++++++----- .../output/upnp/AVTransportClientTest.kt | 27 ++++++++++++++++--- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index c4448d57..0eb581f0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -7,7 +7,8 @@ import okhttp3.HttpUrl * Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / * Stop / Seek / GetPositionInfo / GetTransportInfo. */ -@Suppress("TooManyFunctions") // Compose-adjacent helper density: all are direct SOAP verbs. +// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping. +@Suppress("TooManyFunctions") class AVTransportClient( private val soap: SoapClient, private val controlUrl: HttpUrl, @@ -151,13 +152,6 @@ class AVTransportClient( } } - private fun xmlEscape(v: String): String = v - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - private fun formatHhMmSs(positionMs: Long): String { val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0) val h = totalSec / SECONDS_PER_HOUR diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt index 4bf6ab51..7418f9eb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -69,13 +69,6 @@ class SoapClient( append("") } - private fun xmlEscape(v: String): String = v - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - private fun parseResponseArgs(body: String, action: String): Map { val parser = XmlPullParserFactory.newInstance().newPullParser().apply { setInput(body.reader()) @@ -138,3 +131,11 @@ class SoapClient( */ class SoapFaultException(val code: String, val description: String) : Exception("SOAP fault $code: $description") + +/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */ +internal fun xmlEscape(v: String): String = v + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 9a90a8b9..57ab15bb 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -29,7 +29,7 @@ class AVTransportClientTest { } @Test - fun `seek formats position as hh-mm-ss`() = runTest { + fun `seek sends Target in HH MM SS format`() = runTest { server.enqueue(emptyResponse("Seek")) client.seek(positionMs = 65_000L) val body = server.takeRequest().body.readUtf8() @@ -81,6 +81,27 @@ class AVTransportClientTest { assertEquals(TransportState.PAUSED, info.state) } + @Test + fun `getTransportInfo maps unknown state to UNKNOWN`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + + + + BUFFERING_PLAYBACK + OK + 1 + + + """.trimIndent(), + ), + ) + val info = client.getTransportInfo() + assertEquals(TransportState.UNKNOWN, info.state) + } + @Test fun `setNextAVTransportURI sends DIDL-Lite with mime and title`() = runTest { server.enqueue(emptyResponse("SetNextAVTransportURI")) @@ -93,8 +114,8 @@ class AVTransportClientTest { assertTrue(body.contains("http://x/y.mp3")) { "body missing NextURI: $body" } - assertTrue(body.contains("Song")) { - "body missing dc:title: $body" + assertTrue(body.contains("<dc:title>Song</dc:title>")) { + "title missing: $body" } assertTrue(body.contains("audio/mpeg")) { "body missing mime type: $body" From 8c0c4c860025ce4440b816bbfeb574681b863642 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:48:40 -0400 Subject: [PATCH 05/69] feat(android): RenderingControl get/set volume --- .../output/upnp/RenderingControlClient.kt | 44 +++++++++++++++ .../output/upnp/RenderingControlClientTest.kt | 56 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClient.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClient.kt new file mode 100644 index 00000000..a5f20c9a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClient.kt @@ -0,0 +1,44 @@ +package com.fabledsword.minstrel.player.output.upnp + +import okhttp3.HttpUrl + +/** + * RenderingControl service wrapper for hardware-volume routing while a + * UPnP route is active. GetVolume seeds an in-memory cache; SetVolume + * is invoked by NowPlayingScreen's volume-key interceptor. Clamps to + * the UPnP-standard 0..100 range. + */ +class RenderingControlClient( + private val soap: SoapClient, + private val controlUrl: HttpUrl, +) { + suspend fun getVolume(channel: String = "Master"): Int { + val args = soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "GetVolume", + args = mapOf("InstanceID" to "0", "Channel" to channel), + ) + return args["CurrentVolume"]?.toIntOrNull() ?: 0 + } + + suspend fun setVolume(volume: Int, channel: String = "Master") { + val clamped = volume.coerceIn(VOLUME_MIN, VOLUME_MAX) + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "SetVolume", + args = mapOf( + "InstanceID" to "0", + "Channel" to channel, + "DesiredVolume" to clamped.toString(), + ), + ) + } + + private companion object { + const val SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1" + const val VOLUME_MIN = 0 + const val VOLUME_MAX = 100 + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt new file mode 100644 index 00000000..55d6763a --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt @@ -0,0 +1,56 @@ +package com.fabledsword.minstrel.player.output.upnp + +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RenderingControlClientTest { + + private lateinit var server: MockWebServer + private lateinit var client: RenderingControlClient + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + client = RenderingControlClient(SoapClient(OkHttpClient()), server.url("/RC")) + } + + @AfterEach + fun tearDown() { server.shutdown() } + + @Test + fun `getVolume parses CurrentVolume`() = runTest { + server.enqueue(MockResponse().setBody( + """ + + + + 42 + + + """.trimIndent())) + assertEquals(42, client.getVolume()) + } + + @Test + fun `setVolume clamps and sends DesiredVolume`() = runTest { + server.enqueue(MockResponse().setBody( + """ + + + + + """.trimIndent())) + client.setVolume(150) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("100")) { body } + } +} From 799d50024cd01b020255000843d01d34a111f95a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:51:07 -0400 Subject: [PATCH 06/69] test(android): RenderingControl lower clamp + GetVolume request body checks Co-Authored-By: Claude Sonnet 4.6 --- .../output/upnp/RenderingControlClientTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt index 55d6763a..76e78610 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt @@ -37,6 +37,13 @@ class RenderingControlClientTest { """.trimIndent())) assertEquals(42, client.getVolume()) + val request = server.takeRequest() + val body = request.body.readUtf8() + assertTrue(body.contains("Master")) { body } + assertEquals( + "\"urn:schemas-upnp-org:service:RenderingControl:1#GetVolume\"", + request.getHeader("SOAPACTION"), + ) } @Test @@ -53,4 +60,19 @@ class RenderingControlClientTest { val body = server.takeRequest().body.readUtf8() assertTrue(body.contains("100")) { body } } + + @Test + fun `setVolume clamps below VOLUME_MIN to zero`() = runTest { + server.enqueue(MockResponse().setBody( + """ + + + + + """.trimIndent())) + client.setVolume(-5) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("0")) { body } + } } From bfcb9c42a0be397b24e2511427510e2183a486a9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:53:29 -0400 Subject: [PATCH 07/69] feat(android): Sonos ZoneGroupTopology client + parser Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/upnp/sonos/SonosTopology.kt | 77 +++++++++++++++++++ .../upnp/sonos/ZoneGroupTopologyClient.kt | 30 ++++++++ .../output/upnp/sonos/SonosTopologyTest.kt | 74 ++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt new file mode 100644 index 00000000..46ef494e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt @@ -0,0 +1,77 @@ +package com.fabledsword.minstrel.player.output.upnp.sonos + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory + +/** + * One Sonos zone group as exposed by ZoneGroupTopology.GetZoneGroupState. + * Stereo pairs and multi-speaker groups all appear as one group with + * multiple members; one member is the coordinator we send SOAP to. + */ +data class SonosZoneGroup( + val coordinatorUdn: String, + val name: String, + val members: List, +) + +data class SonosZoneMember( + val udn: String, + val roomName: String, + val location: String?, + val channelMapSet: String?, +) + +object SonosTopology { + + fun parse(xml: String): List { + return runCatching { parseStrict(xml) }.getOrDefault(emptyList()) + } + + private fun parseStrict(xml: String): List { + val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(xml.reader()) + } + val groups = mutableListOf() + var currentCoordinator: String? = null + var currentMembers: MutableList? = null + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + when (parser.eventType) { + XmlPullParser.START_TAG -> when (parser.name) { + TAG_ZONE_GROUP -> { + currentCoordinator = parser.getAttributeValue(null, ATTR_COORDINATOR) + currentMembers = mutableListOf() + } + TAG_ZONE_GROUP_MEMBER -> currentMembers?.add(memberOf(parser)) + } + XmlPullParser.END_TAG -> if (parser.name == TAG_ZONE_GROUP) { + val members = currentMembers ?: emptyList() + val coord = currentCoordinator + if (coord != null && members.isNotEmpty()) { + val name = members.firstOrNull { it.udn == coord }?.roomName + ?: members.first().roomName + groups.add(SonosZoneGroup(coord, name, members)) + } + currentCoordinator = null + currentMembers = null + } + } + parser.next() + } + return groups + } + + private fun memberOf(parser: XmlPullParser): SonosZoneMember = SonosZoneMember( + udn = parser.getAttributeValue(null, ATTR_UUID).orEmpty(), + roomName = parser.getAttributeValue(null, ATTR_ZONE_NAME).orEmpty(), + location = parser.getAttributeValue(null, ATTR_LOCATION), + channelMapSet = parser.getAttributeValue(null, ATTR_CHANNEL_MAP_SET), + ) + + private const val TAG_ZONE_GROUP = "ZoneGroup" + private const val TAG_ZONE_GROUP_MEMBER = "ZoneGroupMember" + private const val ATTR_COORDINATOR = "Coordinator" + private const val ATTR_UUID = "UUID" + private const val ATTR_ZONE_NAME = "ZoneName" + private const val ATTR_LOCATION = "Location" + private const val ATTR_CHANNEL_MAP_SET = "ChannelMapSet" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt new file mode 100644 index 00000000..684b7389 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt @@ -0,0 +1,30 @@ +package com.fabledsword.minstrel.player.output.upnp.sonos + +import com.fabledsword.minstrel.player.output.upnp.SoapClient +import okhttp3.HttpUrl + +/** + * Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a + * standard UPnP service, exposed on port 1400 at + * /ZoneGroupTopology/Control. GetZoneGroupState returns the full + * network topology as one XML doc wrapped inside a SOAP arg. + */ +class ZoneGroupTopologyClient( + private val soap: SoapClient, + private val controlUrl: HttpUrl, +) { + suspend fun getZoneGroupState(): List { + val args = soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "GetZoneGroupState", + args = emptyMap(), + ) + val inner = args["ZoneGroupState"].orEmpty() + return SonosTopology.parse(inner) + } + + private companion object { + const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1" + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt new file mode 100644 index 00000000..2c5511f6 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt @@ -0,0 +1,74 @@ +package com.fabledsword.minstrel.player.output.upnp.sonos + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class SonosTopologyTest { + + @Test + fun `single zone group with one member`() { + val xml = """ + + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals(1, groups.size) + val g = groups.first() + assertEquals("RINCON_A", g.coordinatorUdn) + assertEquals("Kitchen", g.name) + assertEquals(1, g.members.size) + } + + @Test + fun `stereo pair collapses to one group with two members`() { + val xml = """ + + + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals(1, groups.size) + val g = groups.first() + assertEquals("RINCON_L", g.coordinatorUdn) + assertEquals("Living Room", g.name) + assertEquals(2, g.members.size) + assertNotNull(g.members[0].channelMapSet) + } + + @Test + fun `multi-speaker group lists coordinator name`() { + val xml = """ + + + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals("Office", groups.first().name) + } + + @Test + fun `malformed xml returns empty list`() { + assertEquals(emptyList(), SonosTopology.parse(" Date: Wed, 3 Jun 2026 16:59:31 -0400 Subject: [PATCH 08/69] feat(android): UPnP in-place updates + Sonos topology aggregation --- .../player/output/upnp/DeviceDescription.kt | 5 ++ .../output/upnp/UpnpDiscoveryController.kt | 57 ++++++++++++++++++- .../minstrel/player/output/upnp/UpnpRoute.kt | 1 + .../output/upnp/DeviceDescriptionTest.kt | 31 ++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt index e672deb6..4dd49569 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt @@ -22,10 +22,12 @@ data class DeviceDescription( val modelName: String, val avTransportControlUrl: HttpUrl, val renderingControlUrl: HttpUrl?, + val zoneGroupTopologyControlUrl: HttpUrl? = null, ) { companion object { private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1" + private const val ZGT_SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1" private const val TAG_SERVICE = "service" private const val TAG_UDN = "UDN" @@ -58,6 +60,7 @@ data class DeviceDescription( modelName = acc.modelName, avTransportControlUrl = avt, renderingControlUrl = acc.rcControlUrl, + zoneGroupTopologyControlUrl = acc.zgtControlUrl, ) } @@ -91,6 +94,7 @@ data class DeviceDescription( when (acc.serviceType) { AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved RC_SERVICE_TYPE -> acc.rcControlUrl = resolved + ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved } acc.inService = false } @@ -115,6 +119,7 @@ data class DeviceDescription( var modelName: String = "" var avtControlUrl: HttpUrl? = null var rcControlUrl: HttpUrl? = null + var zgtControlUrl: HttpUrl? = null var inService: Boolean = false var serviceType: String = "" var serviceControlUrl: String = "" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 33427cd0..ff54cc4b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -2,6 +2,8 @@ package com.fabledsword.minstrel.player.output.upnp import android.content.Context import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup +import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -44,6 +46,9 @@ class UpnpDiscoveryController @Inject constructor( private val routesInternal = MutableStateFlow>(emptyList()) val routes: StateFlow> = routesInternal.asStateFlow() + private val sonosTopologyInternal = MutableStateFlow>(emptyList()) + val sonosTopology: StateFlow> = sonosTopologyInternal.asStateFlow() + init { ssdp.start(appScope) // appScope is process-lifetime (SupervisorJob + Dispatchers.Default), @@ -88,8 +93,55 @@ class UpnpDiscoveryController @Inject constructor( private suspend fun handleDiscovery(locationUrl: String) { val route = fetchRoute(locationUrl) ?: return - routesInternal.value = - routesInternal.value.filterNot { it.id == route.id } + route + upsertRoute(route) + if (route.manufacturer.contains("Sonos", ignoreCase = true)) { + refreshSonosTopology(route) + } + } + + private fun upsertRoute(route: UpnpRoute) { + val current = routesInternal.value + val idx = current.indexOfFirst { it.id == route.id } + routesInternal.value = if (idx < 0) { + current + route + } else { + current.toMutableList().also { it[idx] = route } + } + } + + private suspend fun refreshSonosTopology(anySonos: UpnpRoute) { + val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: return + val groups = runCatching { + ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState() + }.getOrNull() ?: return + sonosTopologyInternal.value = groups + } + + /** + * Returns the coordinator UDN's full route for the group [udn] belongs + * to, or null if topology hasn't loaded / the udn is in no group. UDN + * normalization strips the "uuid:" prefix because DeviceDescription's + * carries it but Sonos's ZoneGroupState Coordinator attr does not. + */ + fun coordinatorRouteFor(udn: String): UpnpRoute? { + val bare = udn.removePrefix("uuid:") + val coord = sonosTopologyInternal.value + .firstOrNull { g -> g.members.any { it.udn.removePrefix("uuid:") == bare } } + ?.coordinatorUdn ?: return null + return routesInternal.value.firstOrNull { + it.id.removePrefix("uuid:") == coord.removePrefix("uuid:") + } + } + + /** + * UDNs of every NON-coordinator Sonos group member. The picker + * controller suppresses these from the visible list. + */ + fun nonCoordinatorMemberUdns(): Set { + return sonosTopologyInternal.value.flatMap { g -> + g.members.map { it.udn.removePrefix("uuid:") } + .filter { it != g.coordinatorUdn.removePrefix("uuid:") } + }.toSet() } /** @@ -111,6 +163,7 @@ class UpnpDiscoveryController @Inject constructor( modelName = desc.modelName, avTransportControlUrl = desc.avTransportControlUrl, renderingControlUrl = desc.renderingControlUrl, + zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl, ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt index 029d300b..63022a1b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt @@ -24,4 +24,5 @@ data class UpnpRoute( val modelName: String, val avTransportControlUrl: HttpUrl, val renderingControlUrl: HttpUrl?, + val zoneGroupTopologyControlUrl: HttpUrl? = null, ) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt index e633415c..8b05d772 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt @@ -81,6 +81,37 @@ class DeviceDescriptionTest { assertNull(DeviceDescription.parse(xml, base)) } + @Test + fun `parses ZoneGroupTopology control URL when present`() { + val xml = """ + + + + uuid:RINCON_XYZ + Office + Sonos, Inc. + Sonos One + + + urn:schemas-upnp-org:service:AVTransport:1 + /MediaRenderer/AVTransport/Control + + + urn:schemas-upnp-org:service:ZoneGroupTopology:1 + /ZoneGroupTopology/Control + + + + + """.trimIndent() + val desc = DeviceDescription.parse(xml, base) + assertNotNull(desc) + assertEquals( + "http://192.168.1.50:1400/ZoneGroupTopology/Control", + desc.zoneGroupTopologyControlUrl?.toString(), + ) + } + @Test fun `handles missing optional fields`() { val xml = """ From 9002cf5559816c2a53753480b234a4b3288a3e7f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:05:31 -0400 Subject: [PATCH 09/69] refactor(android): UPnP discovery cleanup -- bareUdn helper, Timber, suppress, kdoc Co-Authored-By: Claude Sonnet 4.6 --- .../output/upnp/UpnpDiscoveryController.kt | 23 ++++++++++++------- .../minstrel/player/output/upnp/UpnpRoute.kt | 8 +++---- .../output/upnp/DeviceDescriptionTest.kt | 1 + 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index ff54cc4b..12f02b2c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -1,3 +1,4 @@ +@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density package com.fabledsword.minstrel.player.output.upnp import android.content.Context @@ -15,6 +16,7 @@ import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -113,7 +115,9 @@ class UpnpDiscoveryController @Inject constructor( val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: return val groups = runCatching { ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState() - }.getOrNull() ?: return + } + .onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) } + .getOrNull() ?: return sonosTopologyInternal.value = groups } @@ -124,13 +128,11 @@ class UpnpDiscoveryController @Inject constructor( * carries it but Sonos's ZoneGroupState Coordinator attr does not. */ fun coordinatorRouteFor(udn: String): UpnpRoute? { - val bare = udn.removePrefix("uuid:") + val bare = udn.bareUdn() val coord = sonosTopologyInternal.value - .firstOrNull { g -> g.members.any { it.udn.removePrefix("uuid:") == bare } } + .firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } } ?.coordinatorUdn ?: return null - return routesInternal.value.firstOrNull { - it.id.removePrefix("uuid:") == coord.removePrefix("uuid:") - } + return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() } } /** @@ -139,8 +141,8 @@ class UpnpDiscoveryController @Inject constructor( */ fun nonCoordinatorMemberUdns(): Set { return sonosTopologyInternal.value.flatMap { g -> - g.members.map { it.udn.removePrefix("uuid:") } - .filter { it != g.coordinatorUdn.removePrefix("uuid:") } + g.members.map { it.udn.bareUdn() } + .filter { it != g.coordinatorUdn.bareUdn() } }.toSet() } @@ -208,3 +210,8 @@ class UpnpDiscoveryController @Inject constructor( val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""") } } + +// uuid: prefix normalization — DeviceDescription carries it; Sonos +// ZoneGroupState Coordinator/UUID attrs don't. Helper centralizes the strip +// so a future change to the convention isn't a silent compare-always-false bug. +private fun String.bareUdn(): String = removePrefix("uuid:") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt index 63022a1b..b1ffbbba 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt @@ -7,10 +7,10 @@ import okhttp3.HttpUrl * Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP / * SSDP details so the picker UI consumes a narrow domain shape. * - * Generic UPnP only for THIS slice — Sonos-specific grouping value-adds - * (group join/leave, zone topology) live in a separate Sonos extension - * scoped in - * docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md. + * Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the + * ZoneGroupTopology service in their device description; non-Sonos devices + * leave it null. The discovery controller uses that URL to aggregate stereo + * pairs and multi-speaker groups into single picker rows. * * [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the * raw `` straight from the device description — callers diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt index 8b05d772..2668d066 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt @@ -58,6 +58,7 @@ class DeviceDescriptionTest { "http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control", desc.renderingControlUrl?.toString(), ) + assertNull(desc.zoneGroupTopologyControlUrl) } @Test From 9a7d3b2d3050aea66826066e0e9086b1f9122e19 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:06:33 -0400 Subject: [PATCH 10/69] feat(android): ActiveUpnpHolder singleton for picker -> player handoff Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/ActiveUpnpHolder.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt new file mode 100644 index 00000000..c92201c6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.player.output + +import com.fabledsword.minstrel.player.output.upnp.AVTransportClient +import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Shared singleton handle to the currently-active UPnP route's transport + * + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer] + * from [OutputPickerController] -- the picker writes; the forwarding + * player reads. Null = no UPnP active (local ExoPlayer path). + */ +data class ActiveUpnp( + val routeId: String, + val routeName: String, + val avTransport: AVTransportClient, + val rendering: RenderingControlClient?, +) + +@Singleton +class ActiveUpnpHolder @Inject constructor() { + private val internal = MutableStateFlow(null) + val active: StateFlow = internal.asStateFlow() + fun set(active: ActiveUpnp?) { internal.value = active } +} From 3aee2276bc64560fb90a612bbe5ac64be139954c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:08:04 -0400 Subject: [PATCH 11/69] feat(android): RemotePlayerState container for UPnP-synthesized player state Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/RemotePlayerState.kt | 65 ++++++++++++++++++ .../minstrel/player/RemotePlayerStateTest.kt | 66 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt new file mode 100644 index 00000000..3745e3b9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -0,0 +1,65 @@ +package com.fabledsword.minstrel.player + +/** + * Synthesized state for the UPnP route -- what the ForwardingPlayer + * exposes via Player.getCurrentPosition / isPlaying / etc. when the + * remote leg is active. Not a Player; a container. + * + * Updates flow in from: + * - 1Hz GetPositionInfo poll -> applyPositionInfo() + * - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}() + * - Error paths -> applyError() (drop fallback) or recordPollFailure() + * + * The poll-failure counter implements the rolling-3 drop heuristic: 3 + * consecutive poll failures = remote considered dropped (returns true + * from recordPollFailure for the caller to surface). Success resets it. + */ +class RemotePlayerState { + + @Volatile var positionMs: Long = 0L; private set + @Volatile var durationMs: Long = 0L; private set + @Volatile var isPlaying: Boolean = false; private set + @Volatile var currentTrackUri: String = ""; private set + @Volatile var lastError: Throwable? = null; private set + + private var consecutivePollFailures: Int = 0 + + fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String) { + this.positionMs = positionMs + this.durationMs = durationMs + this.currentTrackUri = trackUri + } + + fun applyTransportPlaying() { isPlaying = true } + fun applyTransportPaused() { isPlaying = false } + fun applyTransportStopped() { + isPlaying = false + positionMs = 0L + } + + fun applyError(t: Throwable) { + isPlaying = false + lastError = t + } + + /** Returns true when the rolling threshold trips this call. */ + fun recordPollFailure(): Boolean { + consecutivePollFailures += 1 + return consecutivePollFailures >= DROP_THRESHOLD + } + + fun recordPollSuccess() { consecutivePollFailures = 0 } + + fun reset() { + positionMs = 0L + durationMs = 0L + isPlaying = false + currentTrackUri = "" + lastError = null + consecutivePollFailures = 0 + } + + private companion object { + const val DROP_THRESHOLD = 3 + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt new file mode 100644 index 00000000..1ff51e9c --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.player + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RemotePlayerStateTest { + + @Test + fun `starts in idle state`() { + val state = RemotePlayerState() + assertFalse(state.isPlaying) + assertEquals(0L, state.positionMs) + assertEquals(0L, state.durationMs) + } + + @Test + fun `applyPositionInfo updates position and duration`() { + val state = RemotePlayerState() + state.applyPositionInfo(positionMs = 65_000L, durationMs = 210_000L, trackUri = "x") + assertEquals(65_000L, state.positionMs) + assertEquals(210_000L, state.durationMs) + assertEquals("x", state.currentTrackUri) + } + + @Test + fun `applyTransportPlaying flips isPlaying true`() { + val state = RemotePlayerState() + state.applyTransportPlaying() + assertTrue(state.isPlaying) + } + + @Test + fun `applyTransportPaused flips isPlaying false`() { + val state = RemotePlayerState().apply { applyTransportPlaying() } + state.applyTransportPaused() + assertFalse(state.isPlaying) + } + + @Test + fun `applyError resets to idle and records error`() { + val state = RemotePlayerState().apply { applyTransportPlaying() } + val ex = RuntimeException("disconnected") + state.applyError(ex) + assertFalse(state.isPlaying) + assertEquals(ex, state.lastError) + } + + @Test + fun `recordPollFailure trips after threshold`() { + val state = RemotePlayerState() + assertFalse(state.recordPollFailure()) + assertFalse(state.recordPollFailure()) + assertTrue(state.recordPollFailure()) + } + + @Test + fun `recordPollSuccess clears the failure counter`() { + val state = RemotePlayerState() + state.recordPollFailure() + state.recordPollFailure() + state.recordPollSuccess() + assertFalse(state.recordPollFailure()) + } +} From ab6c3a13540e705aae261c225ece1104618d1ec0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:12:11 -0400 Subject: [PATCH 12/69] feat(android): MinstrelForwardingPlayer wraps ExoPlayer with UPnP branching Task 7 of UPnP transport-parity slice. Introduces the central ForwardingPlayer that branches between local ExoPlayer and the active UPnP renderer: - MinstrelForwardingPlayer wraps the delegate Player; play/pause/seek and the next/previous transport calls translate to AVTransport SOAP when an ActiveUpnp is set, otherwise forward to super. Position + isPlaying + duration + playbackState reads pull from RemotePlayerState while remote. - 1Hz poll loop drives GetPositionInfo + GetTransportInfo, feeding RemotePlayerState; the rolling-3 failure heuristic fires onDrop on the looper for the factory to surface as a snackbar. - StreamTokenProvider extracts the CastApi.create() Retrofit wiring into a Hilt singleton so the service-side player and the controller-side picker share one CastApi instance. - OutputPickerController constructor swaps Retrofit for StreamTokenProvider + ActiveUpnpHolder (the holder is wired now for Task 8). selectUpnp now mints via streamTokens.mint(trackId). Co-Authored-By: Claude Opus 4.7 --- .../player/MinstrelForwardingPlayer.kt | 204 ++++++++++++++++++ .../minstrel/player/StreamTokenProvider.kt | 24 +++ .../player/output/OutputPickerController.kt | 12 +- 3 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt new file mode 100644 index 00000000..1f88e18a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -0,0 +1,204 @@ +@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API. + +package com.fabledsword.minstrel.player + +import android.os.Handler +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.Player +import com.fabledsword.minstrel.player.output.ActiveUpnp +import com.fabledsword.minstrel.player.output.ActiveUpnpHolder +import com.fabledsword.minstrel.player.output.upnp.TransportState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Integration point for UPnP transport parity. Wraps the local + * ExoPlayer; every transport method either forwards (local route + * active -- the default) or translates into AVTransport SOAP + + * [RemotePlayerState] updates (UPnP route active). + * + * Created inside [MinstrelPlayerService]; runs on the service's main + * looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is + * active, the MediaSession's reads of [Player.isPlaying] and + * [Player.getCurrentPosition] pull from [RemotePlayerState]; the + * wrapped ExoPlayer stays paused at the position it had when the + * route was selected. + * + * Drop heuristic: 3 consecutive poll failures fire [onDrop]. The + * factory wraps that callback into a SharedFlow consumed by the + * NowPlaying surface as a snackbar. + */ +class MinstrelForwardingPlayer( + private val delegate: Player, + private val holder: ActiveUpnpHolder, + private val remoteState: RemotePlayerState, + private val tokens: StreamTokenProvider, + private val onDrop: (routeName: String) -> Unit, +) : ForwardingPlayer(delegate) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val handler = Handler(delegate.applicationLooper) + private var pollJob: Job? = null + + init { + scope.launch { + holder.active.collect { active -> onActiveChanged(active) } + } + } + + private fun isRemote(): Boolean = holder.active.value != null + + override fun play() { + val active = holder.active.value + if (active == null) { + super.play() + } else { + scope.launch { + runCatching { active.avTransport.play() } + .onSuccess { remoteState.applyTransportPlaying() } + .onFailure { handleSoapFailure(active, it) } + } + } + } + + override fun pause() { + val active = holder.active.value + if (active == null) { + super.pause() + } else { + scope.launch { + runCatching { active.avTransport.pause() } + .onSuccess { remoteState.applyTransportPaused() } + .onFailure { handleSoapFailure(active, it) } + } + } + } + + override fun seekTo(positionMs: Long) { + val active = holder.active.value + if (active == null) { + super.seekTo(positionMs) + } else { + remoteState.applyPositionInfo( + positionMs = positionMs, + durationMs = remoteState.durationMs, + trackUri = remoteState.currentTrackUri, + ) + scope.launch { + runCatching { active.avTransport.seek(positionMs) } + .onFailure { handleSoapFailure(active, it) } + } + } + } + + override fun seekToNextMediaItem() { + val active = holder.active.value + super.seekToNextMediaItem() + if (active != null) { + scope.launch { syncCurrentItemToRemote(active) } + } + } + + override fun seekToPreviousMediaItem() { + val active = holder.active.value + super.seekToPreviousMediaItem() + if (active != null) { + scope.launch { syncCurrentItemToRemote(active) } + } + } + + override fun getCurrentPosition(): Long = + if (isRemote()) remoteState.positionMs else super.getCurrentPosition() + + override fun getDuration(): Long = + if (isRemote()) remoteState.durationMs else super.getDuration() + + override fun isPlaying(): Boolean = + if (isRemote()) remoteState.isPlaying else super.isPlaying() + + override fun getPlaybackState(): Int = + if (isRemote()) Player.STATE_READY else super.getPlaybackState() + + override fun release() { + pollJob?.cancel() + scope.cancel() + super.release() + } + + private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) { + val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return + val token = runCatching { tokens.mint(mediaId) }.getOrNull() ?: return + runCatching { + active.avTransport.setAVTransportURIWithMetadata( + uri = token.url, + mime = token.mime, + title = token.title, + ) + active.avTransport.play() + remoteState.applyTransportPlaying() + }.onFailure { handleSoapFailure(active, it) } + } + + private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) { + Timber.w(t, "UPnP transport call failed on %s", active.routeName) + remoteState.applyError(t) + handler.post { onDrop(active.routeName) } + } + + private fun onActiveChanged(active: ActiveUpnp?) { + pollJob?.cancel() + if (active != null) { + pollJob = scope.launch { pollLoop(active) } + } else { + remoteState.reset() + } + } + + private suspend fun pollLoop(active: ActiveUpnp) { + while (scope.isActive && holder.active.value?.routeId == active.routeId) { + val outcome = runCatching { + val info = active.avTransport.getPositionInfo() + remoteState.applyPositionInfo( + positionMs = info.relTimeMs, + durationMs = info.trackDurationMs, + trackUri = info.trackUri, + ) + val transport = active.avTransport.getTransportInfo() + when (transport.state) { + TransportState.PLAYING -> remoteState.applyTransportPlaying() + TransportState.PAUSED -> remoteState.applyTransportPaused() + TransportState.STOPPED -> remoteState.applyTransportStopped() + TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit + } + } + if (outcome.isSuccess) { + remoteState.recordPollSuccess() + } else if (remoteState.recordPollFailure()) { + handler.post { onDrop(active.routeName) } + return + } + delay(POLL_INTERVAL_MS) + } + } + + /** Run [block] on the application looper and bring its result back. */ + private suspend fun handlerEvaluate(block: () -> T): T? { + val deferred = CompletableDeferred() + handler.post { + deferred.complete(runCatching(block).getOrNull()) + } + return deferred.await() + } + + private companion object { + const val POLL_INTERVAL_MS = 1_000L + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt new file mode 100644 index 00000000..ffff44bf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt @@ -0,0 +1,24 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.api.endpoints.CastApi +import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest +import com.fabledsword.minstrel.api.endpoints.StreamTokenResponse +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Mints stream tokens for a given track id. Pulled into its own Hilt + * singleton so [MinstrelForwardingPlayer] (service-side) and + * [com.fabledsword.minstrel.player.output.OutputPickerController] + * (controller-side) don't each construct their own [CastApi] from + * Retrofit. The shared Retrofit instance is unchanged. + */ +@Singleton +class StreamTokenProvider @Inject constructor(retrofit: Retrofit) { + private val api: CastApi = retrofit.create() + + suspend fun mint(trackId: String): StreamTokenResponse = + api.streamToken(StreamTokenRequest(trackId = trackId)) +} 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 07c42a7d..76e64315 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 @@ -4,10 +4,9 @@ import android.content.Context import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter -import com.fabledsword.minstrel.api.endpoints.CastApi -import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.StreamTokenProvider import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -17,8 +16,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import retrofit2.Retrofit -import retrofit2.create import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -65,10 +62,9 @@ class OutputPickerController @Inject constructor( @ApplicationScope private val scope: CoroutineScope, private val upnpDiscovery: UpnpDiscoveryController, private val playerController: PlayerController, - retrofit: Retrofit, + private val streamTokens: StreamTokenProvider, + private val activeUpnpHolder: ActiveUpnpHolder, ) { - private val castApi: CastApi = retrofit.create() - private val mediaRouter = MediaRouter.getInstance(context) private val selector = MediaRouteSelector.Builder() @@ -193,7 +189,7 @@ class OutputPickerController @Inject constructor( } runCatching { Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}") - val token = castApi.streamToken(StreamTokenRequest(trackId = trackId)) + val token = streamTokens.mint(trackId) Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") transport.setAVTransportURIWithMetadata( uri = token.url, From 85cea8d5595bfc1c3158decc281fbcc07e55ca08 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:18:53 -0400 Subject: [PATCH 13/69] fix(android): UPnP forwarding -- route mint failures through drop, no double drop Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 27 ++++++++++++++----- .../minstrel/player/RemotePlayerState.kt | 2 +- .../player/output/OutputPickerController.kt | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 1f88e18a..543e58a4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -101,18 +101,30 @@ class MinstrelForwardingPlayer( override fun seekToNextMediaItem() { val active = holder.active.value - super.seekToNextMediaItem() - if (active != null) { - scope.launch { syncCurrentItemToRemote(active) } + if (active == null) { + super.seekToNextMediaItem() + return } + // Advance the local cursor first so syncCurrentItemToRemote can read + // the new currentMediaItem on the application looper. ExoPlayer stays + // paused while UPnP is active (playWhenReady=false invariant), so this + // does not produce local audio. + super.seekToNextMediaItem() + scope.launch { syncCurrentItemToRemote(active) } } override fun seekToPreviousMediaItem() { val active = holder.active.value - super.seekToPreviousMediaItem() - if (active != null) { - scope.launch { syncCurrentItemToRemote(active) } + if (active == null) { + super.seekToPreviousMediaItem() + return } + // Advance the local cursor first so syncCurrentItemToRemote can read + // the new currentMediaItem on the application looper. ExoPlayer stays + // paused while UPnP is active (playWhenReady=false invariant), so this + // does not produce local audio. + super.seekToPreviousMediaItem() + scope.launch { syncCurrentItemToRemote(active) } } override fun getCurrentPosition(): Long = @@ -135,8 +147,8 @@ class MinstrelForwardingPlayer( private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) { val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return - val token = runCatching { tokens.mint(mediaId) }.getOrNull() ?: return runCatching { + val token = tokens.mint(mediaId) active.avTransport.setAVTransportURIWithMetadata( uri = token.url, mime = token.mime, @@ -148,6 +160,7 @@ class MinstrelForwardingPlayer( } private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) { + pollJob?.cancel() Timber.w(t, "UPnP transport call failed on %s", active.routeName) remoteState.applyError(t) handler.post { onDrop(active.routeName) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 3745e3b9..9f43f444 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -22,7 +22,7 @@ class RemotePlayerState { @Volatile var currentTrackUri: String = ""; private set @Volatile var lastError: Throwable? = null; private set - private var consecutivePollFailures: Int = 0 + @Volatile private var consecutivePollFailures: Int = 0 fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String) { this.positionMs = positionMs 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 76e64315..e064db0b 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 @@ -45,7 +45,7 @@ data class RouteSnapshot( * - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in, * wired, Bluetooth) * - [OutputRoute.Protocol.UPNP] — mint a signed stream token via - * [CastApi.streamToken], drive the discovered renderer with + * [StreamTokenProvider.mint], drive the discovered renderer with * AVTransport.SetAVTransportURI + Play, pause local playback so * audio yields to the network speaker * - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] — From b29875fd308d6fceec37a973b9ff36d76aea18b5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:24:08 -0400 Subject: [PATCH 14/69] feat(android): UPnP selection state, disconnect, BuiltIn-pinned alphabetical sort OutputPickerController now owns selection state for the UPnP leg and runs the disconnect flow when the user picks a system route while a renderer is active. - Inject OkHttpClient + RemotePlayerState so we can build a RenderingControlClient at selection time and capture the last-known remote position on disconnect. - selectUpnp publishes ActiveUpnp(routeId, routeName, avTransport, rendering) to ActiveUpnpHolder, marks the route id in selectedUpnpRouteIdInternal, and honors Sonos topology by routing through coordinatorRouteFor before SOAP. - selectSystem now does the disconnect: AVTransport.Stop -> clear the holder -> seek local ExoPlayer to the remembered position -> resume if the remote was playing. - routesState combines 4 sources (system, UPnP, Sonos topology, upnp-selected id). Non-coordinator Sonos members are filtered out of the visible list. current resolves from the merged list when a UPnP route is selected; otherwise from the system snapshot. - sortRoutes drops the current-first rule -- BuiltIn "Phone speaker" pins to the top, everything else lowercase-alphabetical. Selection state moves to the radio-button indicator in the picker row. - RemotePlayerState gets @Singleton + @Inject constructor() so Hilt can provide the shared instance to both the picker and the forthcoming MinstrelForwardingPlayer. --- .../minstrel/player/RemotePlayerState.kt | 6 +- .../player/output/OutputPickerController.kt | 108 ++++++++++++++---- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 9f43f444..1bae6460 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -1,5 +1,8 @@ package com.fabledsword.minstrel.player +import javax.inject.Inject +import javax.inject.Singleton + /** * Synthesized state for the UPnP route -- what the ForwardingPlayer * exposes via Player.getCurrentPosition / isPlaying / etc. when the @@ -14,7 +17,8 @@ package com.fabledsword.minstrel.player * consecutive poll failures = remote considered dropped (returns true * from recordPollFailure for the caller to surface). Success resets it. */ -class RemotePlayerState { +@Singleton +class RemotePlayerState @Inject constructor() { @Volatile var positionMs: Long = 0L; private set @Volatile var durationMs: Long = 0L; private set 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 e064db0b..130b0bbf 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 @@ -6,8 +6,12 @@ import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.StreamTokenProvider +import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient +import com.fabledsword.minstrel.player.output.upnp.SoapClient import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController +import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -23,9 +28,9 @@ import javax.inject.Singleton /** * Snapshot of the audio output route state. [current] is the live * route audio is being delivered to. [available] is every route the - * picker knows about — MediaRouter system routes merged with - * UPnP/DLNA renderers discovered on the LAN — sorted current-first - * then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other). + * picker knows about -- MediaRouter system routes merged with + * UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn + * "Phone speaker" pinned first, everything else alphabetical. */ data class RouteSnapshot( val current: OutputRoute, @@ -64,6 +69,8 @@ class OutputPickerController @Inject constructor( private val playerController: PlayerController, private val streamTokens: StreamTokenProvider, private val activeUpnpHolder: ActiveUpnpHolder, + private val remoteState: RemotePlayerState, + private val okHttp: OkHttpClient, ) { private val mediaRouter = MediaRouter.getInstance(context) @@ -78,14 +85,34 @@ class OutputPickerController @Inject constructor( */ private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter()) + private val selectedUpnpRouteIdInternal = MutableStateFlow(null) + private var lastKnownRemotePositionMs: Long = 0L + val routesState: StateFlow = combine( systemRoutesInternal, upnpDiscovery.routes, - ) { sys, upnp -> - val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) } - RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged)) + upnpDiscovery.sonosTopology, + selectedUpnpRouteIdInternal, + ) { sys, upnp, topology, upnpSelected -> + val suppressed = nonCoordinatorMemberUdns(topology) + val visibleUpnp = upnp + .filter { it.id.removePrefix("uuid:") !in suppressed } + .map { OutputRoute.fromUpnpRoute(it) } + val merged = sys.available + visibleUpnp + val current = if (upnpSelected != null) { + merged.firstOrNull { it.id == upnpSelected } ?: sys.current + } else { + sys.current + } + RouteSnapshot(current = current, available = sortRoutes(merged)) }.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value) + private fun nonCoordinatorMemberUdns(topology: List): Set = + topology.flatMap { g -> + g.members.map { it.udn.removePrefix("uuid:") } + .filter { it != g.coordinatorUdn.removePrefix("uuid:") } + }.toSet() + private val callback = object : MediaRouter.Callback() { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() @@ -159,6 +186,20 @@ class OutputPickerController @Inject constructor( } private fun selectSystem(route: OutputRoute) { + val wasUpnp = selectedUpnpRouteIdInternal.value + if (wasUpnp != null) { + val active = activeUpnpHolder.active.value + lastKnownRemotePositionMs = remoteState.positionMs + val wasPlayingRemote = remoteState.isPlaying + scope.launch { + runCatching { active?.avTransport?.stop() } + .onFailure { Timber.w(it, "UPnP Stop failed during disconnect") } + activeUpnpHolder.set(null) + selectedUpnpRouteIdInternal.value = null + playerController.seekTo(lastKnownRemotePositionMs) + if (wasPlayingRemote) playerController.play() + } + } val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return mediaRouter.selectRoute(target) } @@ -179,16 +220,32 @@ class OutputPickerController @Inject constructor( Timber.w("UPnP select skipped: no currentTrack (start playback first)") return } - val transport = upnpDiscovery.transportFor(route.id) + // Honor Sonos topology: pick the coordinator's route when the user + // tapped a group row. Suppression in routesState already keeps the + // visible row at the coordinator's id, so this is identity in the + // common case -- defensive for follow-up flows. + val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id) + ?.let { OutputRoute.fromUpnpRoute(it) } ?: route + val transport = upnpDiscovery.transportFor(effectiveRoute.id) if (transport == null) { Timber.w( - "UPnP select skipped: no transport for route id=${route.id} " + + "UPnP select skipped: no transport for route id=${effectiveRoute.id} " + "(route disappeared or id mismatch with discovery list)", ) return } + val rendering = renderingClientFor(effectiveRoute.id) + selectedUpnpRouteIdInternal.value = effectiveRoute.id + activeUpnpHolder.set( + ActiveUpnp( + routeId = effectiveRoute.id, + routeName = effectiveRoute.name, + avTransport = transport, + rendering = rendering, + ), + ) runCatching { - Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}") + Timber.i("UPnP select: mint token for track=$trackId, route=${effectiveRoute.name}") val token = streamTokens.mint(trackId) Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") transport.setAVTransportURIWithMetadata( @@ -201,10 +258,19 @@ class OutputPickerController @Inject constructor( playerController.pause() Timber.i("UPnP select: done") }.onFailure { e -> - Timber.w(e, "UPnP select failed for route ${route.id}") + Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}") + activeUpnpHolder.set(null) + selectedUpnpRouteIdInternal.value = null } } + private fun renderingClientFor(routeId: String): RenderingControlClient? { + val rcUrl = upnpDiscovery.routes.value + .firstOrNull { it.id == routeId } + ?.renderingControlUrl ?: return null + return RenderingControlClient(SoapClient(okHttp), rcUrl) + } + private fun refresh() { systemRoutesInternal.value = snapshotFromRouter() } @@ -214,24 +280,16 @@ class OutputPickerController @Inject constructor( .filter { it.matchesSelector(selector) } .map { OutputRoute.fromRouteInfo(it) } val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute) - return RouteSnapshot(current = current, available = sortRoutes(current, all)) + return RouteSnapshot(current = current, available = sortRoutes(all)) } /** - * Selected first, then Bluetooth, then Wired, then BuiltIn, then - * Other (UPnP renderers fall in Other). Keeps the active output at - * the top + likely-wanted alternatives next + fallback last. + * BuiltIn "Phone speaker" pinned first; everything else + * alphabetical. Selection state is conveyed by the radio button + * indicator in the picker row, not by sort order. */ - private fun sortRoutes(current: OutputRoute, all: List): List { - val rank: (OutputRoute) -> Int = { route -> - when { - route.id == current.id -> 0 - route.kind == OutputRoute.Kind.Bluetooth -> 1 - route.kind == OutputRoute.Kind.Wired -> 2 - route.kind == OutputRoute.Kind.BuiltIn -> 3 - else -> 4 - } - } - return all.sortedBy(rank) + private fun sortRoutes(all: List): List { + val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn } + return builtIn + rest.sortedBy { it.name.lowercase() } } } From e9dd3e4d2aeea2c5a7b2099ad58b12ecacfcaaea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:31:40 -0400 Subject: [PATCH 15/69] fix(android): OutputPicker -- local capture, selectUpnp mutex, shared dedup helper, suppress Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/OutputPickerController.kt | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) 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 130b0bbf..1160653f 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 @@ -1,3 +1,4 @@ +@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count package com.fabledsword.minstrel.player.output import android.content.Context @@ -11,7 +12,6 @@ import com.fabledsword.minstrel.player.StreamTokenProvider import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient import com.fabledsword.minstrel.player.output.upnp.SoapClient import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController -import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +20,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject @@ -86,17 +88,17 @@ class OutputPickerController @Inject constructor( private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter()) private val selectedUpnpRouteIdInternal = MutableStateFlow(null) - private var lastKnownRemotePositionMs: Long = 0L + private val selectUpnpMutex = Mutex() val routesState: StateFlow = combine( systemRoutesInternal, upnpDiscovery.routes, upnpDiscovery.sonosTopology, selectedUpnpRouteIdInternal, - ) { sys, upnp, topology, upnpSelected -> - val suppressed = nonCoordinatorMemberUdns(topology) + ) { sys, upnp, _, upnpSelected -> + val suppressed = upnpDiscovery.nonCoordinatorMemberUdns() val visibleUpnp = upnp - .filter { it.id.removePrefix("uuid:") !in suppressed } + .filter { it.id.removePrefix("uuid:") !in suppressed } // suppressed set is bare UDNs .map { OutputRoute.fromUpnpRoute(it) } val merged = sys.available + visibleUpnp val current = if (upnpSelected != null) { @@ -107,12 +109,6 @@ class OutputPickerController @Inject constructor( RouteSnapshot(current = current, available = sortRoutes(merged)) }.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value) - private fun nonCoordinatorMemberUdns(topology: List): Set = - topology.flatMap { g -> - g.members.map { it.udn.removePrefix("uuid:") } - .filter { it != g.coordinatorUdn.removePrefix("uuid:") } - }.toSet() - private val callback = object : MediaRouter.Callback() { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() @@ -189,14 +185,14 @@ class OutputPickerController @Inject constructor( val wasUpnp = selectedUpnpRouteIdInternal.value if (wasUpnp != null) { val active = activeUpnpHolder.active.value - lastKnownRemotePositionMs = remoteState.positionMs + val capturedPositionMs = remoteState.positionMs val wasPlayingRemote = remoteState.isPlaying scope.launch { runCatching { active?.avTransport?.stop() } .onFailure { Timber.w(it, "UPnP Stop failed during disconnect") } activeUpnpHolder.set(null) selectedUpnpRouteIdInternal.value = null - playerController.seekTo(lastKnownRemotePositionMs) + playerController.seekTo(capturedPositionMs) if (wasPlayingRemote) playerController.play() } } @@ -214,11 +210,11 @@ class OutputPickerController @Inject constructor( * the cause in logcat (OkHttp's logger doesn't cover our own * deserialize / SOAP-parse code paths). */ - private suspend fun selectUpnp(route: OutputRoute) { + private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock { val trackId = playerController.uiState.value.currentTrack?.id if (trackId == null) { Timber.w("UPnP select skipped: no currentTrack (start playback first)") - return + return@withLock } // Honor Sonos topology: pick the coordinator's route when the user // tapped a group row. Suppression in routesState already keeps the @@ -232,7 +228,7 @@ class OutputPickerController @Inject constructor( "UPnP select skipped: no transport for route id=${effectiveRoute.id} " + "(route disappeared or id mismatch with discovery list)", ) - return + return@withLock } val rendering = renderingClientFor(effectiveRoute.id) selectedUpnpRouteIdInternal.value = effectiveRoute.id From b2bfe9655908b9a5f3856b0d352e465460c78bd4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:33:01 -0400 Subject: [PATCH 16/69] ui(android): LazyColumn stable keys for in-place output picker row continuity Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/OutputPickerSheet.kt | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt index 173fcb46..4a9039c3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt @@ -4,8 +4,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -69,12 +72,19 @@ fun OutputPickerSheet( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(vertical = 8.dp), ) - snapshot.available.forEach { route -> - RouteRow( - route = route, - isSelected = route.id == snapshot.current.id, - onClick = { onRouteSelected(route) }, - ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = ROUTE_LIST_MAX_HEIGHT_DP.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(items = snapshot.available, key = { it.id }) { route -> + RouteRow( + route = route, + isSelected = route.id == snapshot.current.id, + onClick = { onRouteSelected(route) }, + ) + } } if (permissionDenied) { PermissionHintRow() @@ -198,3 +208,4 @@ private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) { private const val ROW_ICON_DP = 24 private const val HINT_ICON_DP = 20 +private const val ROUTE_LIST_MAX_HEIGHT_DP = 400 From 2f4d67d3c8736f5cc26b7563c1464d4e577efefc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:34:35 -0400 Subject: [PATCH 17/69] feat(android): PlayerFactory wraps ExoPlayer in ForwardingPlayer + drop events Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/PlayerFactory.kt | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 8bfe3ccb..e7222217 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -3,6 +3,7 @@ package com.fabledsword.minstrel.player import android.content.Context import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.Player import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.cache.CacheDataSink import androidx.media3.datasource.cache.CacheDataSource @@ -12,14 +13,19 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import com.fabledsword.minstrel.cache.audiocache.CacheConfig +import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import okhttp3.OkHttpClient import java.io.File import javax.inject.Inject import javax.inject.Singleton /** - * Builds the process-singleton ExoPlayer with our shared OkHttp + + * Builds the process-singleton player with our shared OkHttp + * SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls * `build()` once during onCreate. * @@ -31,12 +37,22 @@ import javax.inject.Singleton * (sizeBytes cap = rollingCap); our policy layer in the worker layers * the 2-bucket protection on top by feeding `removeSpan` only for * unprotected tracks. + * + * `build()` returns a [MinstrelForwardingPlayer] wrapping the internal + * ExoPlayer. When the UPnP route drops (3 consecutive poll failures or + * a SOAP failure), [dropEvents] emits the route name so the NowPlaying + * surface can show a snackbar. The MutableSharedFlow uses DROP_OLDEST + * with capacity=1 so a burst of failures during a single tear-down + * surfaces as one event rather than queueing N. */ @Singleton class PlayerFactory @Inject constructor( @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient, private val cacheConfig: CacheConfig, + private val activeUpnpHolder: ActiveUpnpHolder, + private val streamTokens: StreamTokenProvider, + private val remoteState: RemotePlayerState, ) { private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } @@ -46,7 +62,28 @@ class PlayerFactory @Inject constructor( StandaloneDatabaseProvider(context), ) - fun build(): ExoPlayer { + // MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst + // of drop events (rapid SOAP failures during a single tear-down) surfaces + // as one snackbar rather than queueing N. + private val dropEventsInternal = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val dropEvents: SharedFlow = dropEventsInternal.asSharedFlow() + + fun build(): Player { + val exo = buildExoPlayer() + return MinstrelForwardingPlayer( + delegate = exo, + holder = activeUpnpHolder, + remoteState = remoteState, + tokens = streamTokens, + onDrop = { name -> emitDrop(name) }, + ) + } + + private fun buildExoPlayer(): ExoPlayer { val httpDataSource = OkHttpDataSource.Factory(okHttpClient) val cacheDataSource = CacheDataSource.Factory() .setCache(simpleCache) @@ -71,4 +108,8 @@ class PlayerFactory @Inject constructor( .setHandleAudioBecomingNoisy(true) .build() } + + private fun emitDrop(routeName: String) { + dropEventsInternal.tryEmit(routeName) + } } From 70b29567fbdc3af1c32dee9faf1c6d1a7d34a163 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:36:35 -0400 Subject: [PATCH 18/69] refactor(android): service holds Player not ExoPlayer for ForwardingPlayer wrap Co-Authored-By: Claude Sonnet 4.6 --- .../com/fabledsword/minstrel/player/MinstrelPlayerService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index ae4f3eb3..8c867142 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -72,7 +72,7 @@ class MinstrelPlayerService : MediaSessionService() { override fun onCreate() { super.onCreate() - val player = playerFactory.build() + val player: Player = playerFactory.build() val callback = LikeMediaCallback(likesRepository, serviceScope) val session = MediaSession.Builder(this, player) .setSessionActivity(buildNowPlayingPendingIntent()) From 29309d9bfb82dee7653c8bfaca838429679ce565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:43:55 -0400 Subject: [PATCH 19/69] ui(android): drop snackbar + hardware volume keys when UPnP route active Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/PlayerController.kt | 9 ++ .../player/output/OutputPickerViewModel.kt | 4 + .../minstrel/player/ui/NowPlayingScreen.kt | 100 +++++++++++++++--- .../minstrel/player/ui/PlayerViewModel.kt | 2 + 4 files changed, 102 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 5c71f4a1..908b7e3e 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 @@ -20,6 +20,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow @@ -59,7 +60,15 @@ class PlayerController @Inject constructor( @ApplicationContext private val context: Context, @ApplicationScope private val scope: CoroutineScope, private val radio: RadioController, + private val playerFactory: PlayerFactory, ) { + + /** + * UPnP drop events surfaced from [PlayerFactory.dropEvents]. NowPlaying + * collects this into its snackbar host so a transport / poll failure + * during UPnP playback shows "Disconnected from " to the user. + */ + val dropEvents: SharedFlow = playerFactory.dropEvents private val sessionToken = SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java)) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt index 19362d60..6e3cd81a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt @@ -24,6 +24,7 @@ import javax.inject.Inject @HiltViewModel class OutputPickerViewModel @Inject constructor( private val controller: OutputPickerController, + private val activeUpnpHolder: ActiveUpnpHolder, ) : ViewModel() { val routes: StateFlow = controller.routesState @@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor( initialValue = controller.routesState.value, ) + val activeUpnp: StateFlow = activeUpnpHolder.active + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + private val sheetVisibleInternal = MutableStateFlow(false) val sheetVisible: StateFlow = sheetVisibleInternal.asStateFlow() 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 4115a767..72a648c7 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 @@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement @@ -50,10 +51,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -74,11 +84,13 @@ import com.composables.icons.lucide.Shuffle import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipForward import com.fabledsword.minstrel.player.RepeatMode +import com.fabledsword.minstrel.player.output.ActiveUpnp import com.fabledsword.minstrel.player.output.DeviceChip import com.fabledsword.minstrel.player.output.OutputPickerSheet import com.fabledsword.minstrel.player.output.OutputPickerViewModel import com.fabledsword.minstrel.player.output.OutputRoute import com.fabledsword.minstrel.player.output.RouteSnapshot +import kotlinx.coroutines.launch import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER @@ -129,27 +141,30 @@ fun NowPlayingScreen( snackbarHostState.showSnackbar(msg) } } + LaunchedEffect(Unit) { + viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") } + } val track = state.currentTrack if (track == null) { - // Session torn down (queue finished + auto-stop, or user cleared - // the queue from elsewhere). Pop back to whichever shell screen - // launched NowPlaying rather than stranding the user on an - // EmptyState with no escape. A short delay swallows the - // momentary null during MediaController IPC bind on cold-mount. - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(POP_GRACE_MS) - if (viewModel.uiState.value.currentTrack == null) { - navController.popBackStack() - } - } + NowPlayingNullTrackGuard(navController, viewModel) return } + val outputViewModel: OutputPickerViewModel = hiltViewModel() + val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle() + val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp) + val focusRequester = remember { FocusRequester() } + LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() } val dominant = rememberDominantColor(track.coverUrl) val dismissConnection = rememberDragDismissConnection( onDismiss = { navController.popBackStack() }, ) Scaffold( - modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection), + modifier = Modifier + .fillMaxSize() + .nestedScroll(dismissConnection) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent(onKeyEvent), topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) }, snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = Color.Transparent, @@ -166,11 +181,32 @@ fun NowPlayingScreen( navController = navController, viewModel = viewModel, trackActionsViewModel = trackActionsViewModel, + outputViewModel = outputViewModel, ) } } } +/** + * Null-track guard extracted from [NowPlayingScreen] to keep that + * function under detekt's LongMethod ceiling. Session torn down + * (queue finished + auto-stop, or user cleared the queue from + * elsewhere). Pops back after a short grace delay so a momentary + * null during MediaController IPC bind on cold-mount doesn't flash. + */ +@Composable +private fun NowPlayingNullTrackGuard( + navController: NavHostController, + viewModel: PlayerViewModel, +) { + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(POP_GRACE_MS) + if (viewModel.uiState.value.currentTrack == null) { + navController.popBackStack() + } + } +} + @Composable private fun dominantGradient(top: Color): Brush { val base = MaterialTheme.colorScheme.background @@ -270,6 +306,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) { ) } +@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic @Composable private fun NowPlayingBody( inner: androidx.compose.foundation.layout.PaddingValues, @@ -278,10 +315,10 @@ private fun NowPlayingBody( navController: NavHostController, viewModel: PlayerViewModel, trackActionsViewModel: TrackActionsViewModel, + outputViewModel: OutputPickerViewModel, ) { val isLiked by trackActionsViewModel.isLikedFlow(track.id) .collectAsStateWithLifecycle(initialValue = false) - val outputViewModel: OutputPickerViewModel = hiltViewModel() val routes by outputViewModel.routes.collectAsStateWithLifecycle() val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle() val permissionDenied = rememberBluetoothPermissionState(sheetVisible) @@ -703,3 +740,40 @@ private fun TransportRow( } } } + +/** + * Returns a key-event handler that intercepts volume-up/down when a UPnP + * route is active and routes the step through [ActiveUpnp.rendering]. + * Volume is cached locally so rapid key presses don't each wait on a + * getVolume() round-trip. Returns false (not consumed) for every event + * when no UPnP route is active so the system handles volume normally. + */ +@Composable +private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean { + val scope = rememberCoroutineScope() + val cache = remember { VolumeCache() } + return remember(activeUpnp) { + handler@{ event: KeyEvent -> + val rc = activeUpnp?.rendering ?: return@handler false + if (event.type != KeyEventType.KeyDown) return@handler false + val delta = when (event.key) { + Key.VolumeUp -> VOLUME_KEY_STEP + Key.VolumeDown -> -VOLUME_KEY_STEP + else -> return@handler false + } + scope.launch { + val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0 + val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT) + cache.value = next + runCatching { rc.setVolume(next) } + } + true + } + } +} + +private class VolumeCache(var value: Int? = null) + +private const val VOLUME_KEY_STEP = 5 +private const val VOLUME_MIN_PERCENT = 0 +private const val VOLUME_MAX_PERCENT = 100 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt index b9a32e3f..d7a4952a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor( ) : ViewModel() { val uiState: StateFlow = controller.uiState + val dropEvents: SharedFlow = controller.dropEvents fun play() = controller.play() fun pause() = controller.pause() From 81794e24757ea3c40de64d67dd85cb5d08bd4a8a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:52:00 -0400 Subject: [PATCH 20/69] fix(android): UPnP volume cache keyed to active route id --- .../java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 72a648c7..ac21a6ef 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 @@ -751,7 +751,7 @@ private fun TransportRow( @Composable private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean { val scope = rememberCoroutineScope() - val cache = remember { VolumeCache() } + val cache = remember(activeUpnp?.routeId) { VolumeCache() } return remember(activeUpnp) { handler@{ event: KeyEvent -> val rc = activeUpnp?.rendering ?: return@handler false From 1ab21d81ca2e69491cd257412bc4a53652ca748c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:53:10 -0400 Subject: [PATCH 21/69] feat(android): SetNextAVTransportURI pre-queue for gap-free UPnP advance Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 543e58a4..3b35f103 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -52,6 +52,15 @@ class MinstrelForwardingPlayer( scope.launch { holder.active.collect { active -> onActiveChanged(active) } } + delegate.addListener(object : Player.Listener { + override fun onMediaItemTransition( + mediaItem: androidx.media3.common.MediaItem?, + reason: Int, + ) { + val active = holder.active.value ?: return + scope.launch { preQueueNext(active) } + } + }) } private fun isRemote(): Boolean = holder.active.value != null @@ -159,6 +168,34 @@ class MinstrelForwardingPlayer( }.onFailure { handleSoapFailure(active, it) } } + /** + * Speculative pre-queue of the next track so Sonos can advance gap-free. + * Failures (token mint OR SetNextAVTransportURI rejection) silently fall + * back to the poll-driven advance path -- the pollLoop detects STOPPED + + * 0:00 RelTime and calls syncCurrentItemToRemote on whatever the new + * currentMediaItem is. The only consequence of failure here is a sub-second + * audible gap at track boundary, not a broken route. + */ + private suspend fun preQueueNext(active: ActiveUpnp) { + val nextMediaId = handlerEvaluate { nextItemMediaId() } ?: return + val token = runCatching { tokens.mint(nextMediaId) }.getOrNull() ?: return + runCatching { + active.avTransport.setNextAVTransportURI( + uri = token.url, + mime = token.mime, + title = token.title, + ) + }.onFailure { + Timber.w(it, "SetNextAVTransportURI failed (poll-driven advance will fall back)") + } + } + + private fun nextItemMediaId(): String? { + val nextIdx = delegate.nextMediaItemIndex + if (nextIdx == androidx.media3.common.C.INDEX_UNSET) return null + return delegate.getMediaItemAt(nextIdx).mediaId + } + private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) { pollJob?.cancel() Timber.w(t, "UPnP transport call failed on %s", active.routeName) From edffdec2b2bbaab4d2da2c8d814c4111fd33aacf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 18:07:20 -0400 Subject: [PATCH 22/69] fix(android): UPnP drop falls back to local; pollLoop detects natural advance Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 27 +++++++++++++++++++ .../player/output/OutputPickerController.kt | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 3b35f103..8aaedd0e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -207,6 +207,10 @@ class MinstrelForwardingPlayer( pollJob?.cancel() if (active != null) { pollJob = scope.launch { pollLoop(active) } + // Pre-queue the next track immediately so Sonos can advance + // gap-free into the second track without waiting for the first + // onMediaItemTransition event (which only fires on user skips). + scope.launch { preQueueNext(active) } } else { remoteState.reset() } @@ -215,6 +219,10 @@ class MinstrelForwardingPlayer( private suspend fun pollLoop(active: ActiveUpnp) { while (scope.isActive && holder.active.value?.routeId == active.routeId) { val outcome = runCatching { + // Capture before applyPositionInfo so we can detect a URI + // change that Sonos made via the pre-queued SetNextAVTransportURI + // (natural auto-advance without any user skip action). + val previousTrackUri = remoteState.currentTrackUri val info = active.avTransport.getPositionInfo() remoteState.applyPositionInfo( positionMs = info.relTimeMs, @@ -228,6 +236,25 @@ class MinstrelForwardingPlayer( TransportState.STOPPED -> remoteState.applyTransportStopped() TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit } + // Detect remote-side natural advance: Sonos auto-advanced + // through SetNextAVTransportURI so the URI changed without + // any local user action. Advance the local queue cursor so + // the onMediaItemTransition listener fires preQueueNext to + // set up the next-next URI on Sonos, keeping the chain going. + // Both guards must be non-blank: previousTrackUri="" on the + // very first poll (before any state arrives) and we must not + // treat that initial empty-to-URI transition as an advance. + if (info.trackUri.isNotBlank() && + previousTrackUri.isNotBlank() && + info.trackUri != previousTrackUri + ) { + // Run on the application looper. ExoPlayer is paused + // while UPnP is active (playWhenReady=false invariant) so + // the cursor advances without producing local audio. The + // delegate's onMediaItemTransition listener (wired in init) + // then calls preQueueNext to load the next-next track. + handler.post { delegate.seekToNextMediaItem() } + } } if (outcome.isSuccess) { remoteState.recordPollSuccess() 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 1160653f..da21cd22 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 @@ -7,6 +7,7 @@ import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.PlayerFactory import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.StreamTokenProvider import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient @@ -69,6 +70,7 @@ class OutputPickerController @Inject constructor( @ApplicationScope private val scope: CoroutineScope, private val upnpDiscovery: UpnpDiscoveryController, private val playerController: PlayerController, + private val playerFactory: PlayerFactory, private val streamTokens: StreamTokenProvider, private val activeUpnpHolder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, @@ -139,6 +141,31 @@ class OutputPickerController @Inject constructor( // without forcing Bluetooth scans. (There is no // CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.) mediaRouter.addCallback(selector, callback) + // When MinstrelForwardingPlayer reports the active UPnP route has + // dropped (3+ consecutive poll failures or a transport SOAP exception), + // clear the UPnP selection state and fall back to local ExoPlayer at + // the last-known remote position. This mirrors selectSystem's disconnect + // path but skips the Stop SOAP since the device is already unreachable. + scope.launch { + playerFactory.dropEvents.collect { handleRemoteDrop() } + } + } + + /** + * Called when the active UPnP route drops unexpectedly (poll-failure + * threshold or SOAP exception). Captures the last remote position + + * play state, clears UPnP selection, and resumes local ExoPlayer at + * the same point. Skips the Stop SOAP (device already unreachable). + * The snackbar is handled independently by the NowPlaying surface + * collecting the same [PlayerFactory.dropEvents] via PlayerController. + */ + private fun handleRemoteDrop() { + val capturedPositionMs = remoteState.positionMs + val wasPlayingRemote = remoteState.isPlaying + activeUpnpHolder.set(null) + selectedUpnpRouteIdInternal.value = null + playerController.seekTo(capturedPositionMs) + if (wasPlayingRemote) playerController.play() } /** From c556388a6bf4fa819ef842b4ac205ffe970c3d84 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 19:47:19 -0400 Subject: [PATCH 23/69] fix(android): UPnP activation order -- pause local before holder.set; sync+preQueue sequential; diagnostic Timber.w - OutputPickerController.selectUpnp: pause ExoPlayer BEFORE setting activeUpnpHolder so ForwardingPlayer.pause() routes to ExoPlayer, not SOAP; remove now-redundant playerController.pause() from inside runCatching; bump activation Timber.i -> Timber.w for release logcat - MinstrelForwardingPlayer: remove Player.Listener onMediaItemTransition that raced with seekToNext/Prev override's syncCurrentItemToRemote; seekToNext/Prev now launch sync -> preQueueNext sequentially in one coroutine; remove early preQueueNext from onActiveChanged (raced with selectUpnp SOAP); move initial pre-queue to pollLoop, fires once trackUri lands confirming Sonos accepted SetAV+Play - Extract pollOnce from pollLoop to stay within detekt LongMethod=60; natural-advance branch now calls preQueueNext explicitly (no listener) Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 137 ++++++++++-------- .../player/output/OutputPickerController.kt | 15 +- 2 files changed, 89 insertions(+), 63 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 8aaedd0e..6099fb51 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -52,15 +52,10 @@ class MinstrelForwardingPlayer( scope.launch { holder.active.collect { active -> onActiveChanged(active) } } - delegate.addListener(object : Player.Listener { - override fun onMediaItemTransition( - mediaItem: androidx.media3.common.MediaItem?, - reason: Int, - ) { - val active = holder.active.value ?: return - scope.launch { preQueueNext(active) } - } - }) + // No Player.Listener -- the seekToNextMediaItem/seekToPreviousMediaItem + // overrides and pollLoop natural-advance branch each call preQueueNext + // explicitly. A listener-based path raced with the override's launched + // syncCurrentItemToRemote, producing non-deterministic SOAP order. } private fun isRemote(): Boolean = holder.active.value != null @@ -117,9 +112,13 @@ class MinstrelForwardingPlayer( // Advance the local cursor first so syncCurrentItemToRemote can read // the new currentMediaItem on the application looper. ExoPlayer stays // paused while UPnP is active (playWhenReady=false invariant), so this - // does not produce local audio. + // does not produce local audio. After the sync lands, preQueueNext + // primes the next-next track on Sonos -- sequential, not raced. super.seekToNextMediaItem() - scope.launch { syncCurrentItemToRemote(active) } + scope.launch { + syncCurrentItemToRemote(active) + preQueueNext(active) + } } override fun seekToPreviousMediaItem() { @@ -128,12 +127,12 @@ class MinstrelForwardingPlayer( super.seekToPreviousMediaItem() return } - // Advance the local cursor first so syncCurrentItemToRemote can read - // the new currentMediaItem on the application looper. ExoPlayer stays - // paused while UPnP is active (playWhenReady=false invariant), so this - // does not produce local audio. + // Same as seekToNextMediaItem: sync then pre-queue in one coroutine. super.seekToPreviousMediaItem() - scope.launch { syncCurrentItemToRemote(active) } + scope.launch { + syncCurrentItemToRemote(active) + preQueueNext(active) + } } override fun getCurrentPosition(): Long = @@ -206,59 +205,33 @@ class MinstrelForwardingPlayer( private fun onActiveChanged(active: ActiveUpnp?) { pollJob?.cancel() if (active != null) { + Timber.w("UPnP active: %s -- pollLoop starting", active.routeName) pollJob = scope.launch { pollLoop(active) } - // Pre-queue the next track immediately so Sonos can advance - // gap-free into the second track without waiting for the first - // onMediaItemTransition event (which only fires on user skips). - scope.launch { preQueueNext(active) } + // Do NOT pre-queue here: selectUpnp's SetAV+Play SOAP may not have + // landed yet. Initial pre-queue fires from pollLoop once the first + // successful poll confirms Sonos accepted the track (trackUri lands). } else { remoteState.reset() } } private suspend fun pollLoop(active: ActiveUpnp) { + var initialPreQueueDone = false while (scope.isActive && holder.active.value?.routeId == active.routeId) { - val outcome = runCatching { - // Capture before applyPositionInfo so we can detect a URI - // change that Sonos made via the pre-queued SetNextAVTransportURI - // (natural auto-advance without any user skip action). - val previousTrackUri = remoteState.currentTrackUri - val info = active.avTransport.getPositionInfo() - remoteState.applyPositionInfo( - positionMs = info.relTimeMs, - durationMs = info.trackDurationMs, - trackUri = info.trackUri, - ) - val transport = active.avTransport.getTransportInfo() - when (transport.state) { - TransportState.PLAYING -> remoteState.applyTransportPlaying() - TransportState.PAUSED -> remoteState.applyTransportPaused() - TransportState.STOPPED -> remoteState.applyTransportStopped() - TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit - } - // Detect remote-side natural advance: Sonos auto-advanced - // through SetNextAVTransportURI so the URI changed without - // any local user action. Advance the local queue cursor so - // the onMediaItemTransition listener fires preQueueNext to - // set up the next-next URI on Sonos, keeping the chain going. - // Both guards must be non-blank: previousTrackUri="" on the - // very first poll (before any state arrives) and we must not - // treat that initial empty-to-URI transition as an advance. - if (info.trackUri.isNotBlank() && - previousTrackUri.isNotBlank() && - info.trackUri != previousTrackUri - ) { - // Run on the application looper. ExoPlayer is paused - // while UPnP is active (playWhenReady=false invariant) so - // the cursor advances without producing local audio. The - // delegate's onMediaItemTransition listener (wired in init) - // then calls preQueueNext to load the next-next track. - handler.post { delegate.seekToNextMediaItem() } - } - } + val outcome = runCatching { pollOnce(active) } if (outcome.isSuccess) { remoteState.recordPollSuccess() + // Initial pre-queue fires once Sonos confirms it accepted the + // SetAV+Play from selectUpnp (trackUri lands in the first + // successful poll). Firing from onActiveChanged would race + // with selectUpnp's SOAP calls. + if (!initialPreQueueDone && remoteState.currentTrackUri.isNotBlank()) { + Timber.w("UPnP initial pre-queue for %s", active.routeName) + preQueueNext(active) + initialPreQueueDone = true + } } else if (remoteState.recordPollFailure()) { + Timber.w("UPnP drop threshold tripped for %s", active.routeName) handler.post { onDrop(active.routeName) } return } @@ -266,6 +239,54 @@ class MinstrelForwardingPlayer( } } + /** + * One poll tick: read position + transport state from Sonos, apply to + * [remoteState], and detect natural-advance (URI change driven by a + * previously queued SetNextAVTransportURI). Extracted from [pollLoop] + * to keep that method within detekt's LongMethod=60 limit. + */ + private suspend fun pollOnce(active: ActiveUpnp) { + // Capture before applyPositionInfo so we can detect a URI + // change that Sonos made via the pre-queued SetNextAVTransportURI + // (natural auto-advance without any user skip action). + val previousTrackUri = remoteState.currentTrackUri + val info = active.avTransport.getPositionInfo() + remoteState.applyPositionInfo( + positionMs = info.relTimeMs, + durationMs = info.trackDurationMs, + trackUri = info.trackUri, + ) + val transport = active.avTransport.getTransportInfo() + when (transport.state) { + TransportState.PLAYING -> remoteState.applyTransportPlaying() + TransportState.PAUSED -> remoteState.applyTransportPaused() + TransportState.STOPPED -> remoteState.applyTransportStopped() + TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit + } + // Detect remote-side natural advance: Sonos auto-advanced + // through SetNextAVTransportURI so the URI changed without + // any local user action. Advance the local queue cursor and + // call preQueueNext explicitly to set up the next-next URI on + // Sonos. Both guards must be non-blank: previousTrackUri="" on + // the very first poll and we must not treat that initial + // empty-to-URI transition as an advance. + if (info.trackUri.isNotBlank() && + previousTrackUri.isNotBlank() && + info.trackUri != previousTrackUri + ) { + Timber.w( + "UPnP natural-advance detected: %s -> %s", + previousTrackUri, + info.trackUri, + ) + // Run on the application looper. ExoPlayer is paused + // while UPnP is active (playWhenReady=false invariant) so + // the cursor advances without producing local audio. + handler.post { delegate.seekToNextMediaItem() } + preQueueNext(active) + } + } + /** Run [block] on the application looper and bring its result back. */ private suspend fun handlerEvaluate(block: () -> T): T? { val deferred = CompletableDeferred() 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 da21cd22..096a3ef5 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 @@ -258,6 +258,12 @@ class OutputPickerController @Inject constructor( return@withLock } val rendering = renderingClientFor(effectiveRoute.id) + // Pause local FIRST while holder.active is still null so the pause() + // call routes to ExoPlayer, not SOAP. After holder.set() below, + // ForwardingPlayer.pause() would translate this into AVTransport.Pause() + // -- the opposite of what we want, since we are about to tell Sonos + // to start playing. + playerController.pause() selectedUpnpRouteIdInternal.value = effectiveRoute.id activeUpnpHolder.set( ActiveUpnp( @@ -268,18 +274,17 @@ class OutputPickerController @Inject constructor( ), ) runCatching { - Timber.i("UPnP select: mint token for track=$trackId, route=${effectiveRoute.name}") + Timber.w("UPnP select: mint token for track=$trackId, route=${effectiveRoute.name}") val token = streamTokens.mint(trackId) - Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") + Timber.w("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") transport.setAVTransportURIWithMetadata( uri = token.url, mime = token.mime, title = token.title, ) - Timber.i("UPnP select: Play") + Timber.w("UPnP select: Play") transport.play() - playerController.pause() - Timber.i("UPnP select: done") + Timber.w("UPnP select: done") }.onFailure { e -> Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}") activeUpnpHolder.set(null) From 673f98487f25833bb0bb2a584c222e4a0530a0eb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 19:53:41 -0400 Subject: [PATCH 24/69] fix(android): UPnP local pause via delegate -- no SOAP race Move local ExoPlayer pause from OutputPickerController.selectUpnp into MinstrelForwardingPlayer.onActiveChanged (handler.post { delegate.pause() }). This guarantees the pause hits ExoPlayer before the holder is live, eliminating the async race that caused SOAP fault 701 on Sonos when pause() was dispatched via playerController after holder.active was already set. Also adds per-poll Timber.w before initialPreQueueDone for diagnostics. Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/MinstrelForwardingPlayer.kt | 15 +++++++++++++++ .../player/output/OutputPickerController.kt | 13 +++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 6099fb51..34cec946 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -206,6 +206,13 @@ class MinstrelForwardingPlayer( pollJob?.cancel() if (active != null) { Timber.w("UPnP active: %s -- pollLoop starting", active.routeName) + // Pause the wrapped ExoPlayer so we are not playing local audio + // simultaneously with the remote renderer. handler.post targets the + // application looper, so this runs on the same thread that processes + // our override calls -- no race with the pause() override branching + // to SOAP (holder.active is already non-null by the time this post + // fires, but delegate.pause() bypasses the override entirely). + handler.post { delegate.pause() } pollJob = scope.launch { pollLoop(active) } // Do NOT pre-queue here: selectUpnp's SetAV+Play SOAP may not have // landed yet. Initial pre-queue fires from pollLoop once the first @@ -225,6 +232,14 @@ class MinstrelForwardingPlayer( // SetAV+Play from selectUpnp (trackUri lands in the first // successful poll). Firing from onActiveChanged would race // with selectUpnp's SOAP calls. + if (!initialPreQueueDone) { + Timber.w( + "UPnP poll OK: route=%s trackUri=%s posMs=%d", + active.routeName, + remoteState.currentTrackUri, + remoteState.positionMs, + ) + } if (!initialPreQueueDone && remoteState.currentTrackUri.isNotBlank()) { Timber.w("UPnP initial pre-queue for %s", active.routeName) preQueueNext(active) 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 096a3ef5..e72973fc 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 @@ -258,12 +258,13 @@ class OutputPickerController @Inject constructor( return@withLock } val rendering = renderingClientFor(effectiveRoute.id) - // Pause local FIRST while holder.active is still null so the pause() - // call routes to ExoPlayer, not SOAP. After holder.set() below, - // ForwardingPlayer.pause() would translate this into AVTransport.Pause() - // -- the opposite of what we want, since we are about to tell Sonos - // to start playing. - playerController.pause() + // Local pause is handled by MinstrelForwardingPlayer.onActiveChanged + // via handler.post { delegate.pause() } -- called immediately after + // holder.set() fires. That path bypasses the SOAP-routing override, so + // there is no race between pausing ExoPlayer and starting the remote + // renderer. Do NOT call playerController.pause() here: it dispatches + // through Handler.post on the application looper, meaning it runs after + // holder.active is non-null and the override would route it to SOAP. selectedUpnpRouteIdInternal.value = effectiveRoute.id activeUpnpHolder.set( ActiveUpnp( From 75132a2afe21a3fc154c219b966b00610755a372 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:12:15 -0400 Subject: [PATCH 25/69] diag(android): log raw SOAP response body for first 3 UPnP polls Add optional onRawResponse callback to SoapClient; loggingSoapClient factory emits the first 6 GetPositionInfo/GetTransportInfo bodies (3 poll cycles) at WARN so release logs capture them. Wire into transportFor so every AVTransportClient for a new UPnP session logs. Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/output/upnp/SoapClient.kt | 26 +++++++++++++++++++ .../output/upnp/UpnpDiscoveryController.kt | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt index 7418f9eb..d58e33e5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.player.output.upnp +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.HttpUrl @@ -8,6 +9,7 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserFactory +import timber.log.Timber /** * Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than @@ -25,6 +27,7 @@ import org.xmlpull.v1.XmlPullParserFactory */ class SoapClient( private val okHttp: OkHttpClient, + private val onRawResponse: ((action: String, body: String) -> Unit)? = null, ) { suspend fun call( controlUrl: HttpUrl, @@ -41,6 +44,7 @@ class SoapClient( .build() okHttp.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() + onRawResponse?.invoke(action, body) if (!response.isSuccessful) { throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body)) } @@ -132,6 +136,28 @@ class SoapClient( class SoapFaultException(val code: String, val description: String) : Exception("SOAP fault $code: $description") +private const val MAX_DIAGNOSTIC_RESPONSES = 6 // 3 polls x 2 action types +private const val MAX_BODY_LOG_CHARS = 2048 + +/** + * Returns a [SoapClient] that logs the raw response body for the first + * [MAX_DIAGNOSTIC_RESPONSES] GetPositionInfo / GetTransportInfo calls (i.e. + * the first 3 poll cycles of a UPnP session). After that the callback is a + * no-op so there is no persistent log spam. Logged at WARN so release builds + * capture it without a separate log-level override. + */ +internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient { + val counter = AtomicInteger(0) + return SoapClient(okHttp) { action, body -> + if (action != "GetPositionInfo" && action != "GetTransportInfo") return@SoapClient + val n = counter.incrementAndGet() + if (n <= MAX_DIAGNOSTIC_RESPONSES) { + Timber.w("UPnP %s response #%d (%s): %s", + label, n, action, body.take(MAX_BODY_LOG_CHARS)) + } + } +} + /** XML-escapes a string value for embedding as text content inside a SOAP envelope. */ internal fun xmlEscape(v: String): String = v .replace("&", "&") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 12f02b2c..d8e688c7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -90,7 +90,7 @@ class UpnpDiscoveryController @Inject constructor( */ fun transportFor(routeId: String): AVTransportClient? { val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null - return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl) + return AVTransportClient(loggingSoapClient(okHttp, route.name), route.avTransportControlUrl) } private suspend fun handleDiscovery(locationUrl: String) { From 8652b86f40ac8dc1c42dff4b54db30ee97bf47a0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:20:25 -0400 Subject: [PATCH 26/69] fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/upnp/AVTransportClient.kt | 29 ++++++++++-- .../output/upnp/UpnpDiscoveryController.kt | 7 ++- .../output/upnp/AVTransportClientTest.kt | 46 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index 0eb581f0..fba29910 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -12,6 +12,7 @@ import okhttp3.HttpUrl class AVTransportClient( private val soap: SoapClient, private val controlUrl: HttpUrl, + private val applySonosUriFix: Boolean = false, ) { suspend fun setAVTransportURI(uri: String, metadata: String = "") { soap.call( @@ -43,7 +44,9 @@ class AVTransportClient( * when the caller doesn't supply one. */ suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) { - setAVTransportURI(uri, buildDidlLite(uri, mime, title)) + val safeTitle = title.ifBlank { "Minstrel" } + val effectiveUri = maybeTransformForSonos(uri) + setAVTransportURI(effectiveUri, buildDidlLite(effectiveUri, mime, safeTitle)) } /** @@ -52,14 +55,16 @@ class AVTransportClient( * [setAVTransportURIWithMetadata]. */ suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) { + val safeTitle = title.ifBlank { "Minstrel" } + val effectiveUri = maybeTransformForSonos(uri) soap.call( controlUrl = controlUrl, serviceType = SERVICE_TYPE, action = "SetNextAVTransportURI", args = mapOf( "InstanceID" to "0", - "NextURI" to uri, - "NextURIMetaData" to buildDidlLite(uri, mime, title), + "NextURI" to effectiveUri, + "NextURIMetaData" to buildDidlLite(effectiveUri, mime, safeTitle), ), ) } @@ -135,6 +140,24 @@ class AVTransportClient( return TransportInfo(state) } + /** + * Sonos firmware 6.4.2+ silently refuses plain http(s):// URIs in + * SetAVTransportURI -- the SOAP call returns 200 OK but the URI never + * loads. The documented fix (from SoCo et al.) is to replace the + * scheme with x-rincon-mp3radio:// for Sonos targets. Generic UPnP + * renderers (Yamaha, Bose, generic DLNA) reject this scheme, so the + * transform is conditional on the manufacturer. + * + * The transformed URI goes both in the SOAP CurrentURI / NextURI arg + * AND inside the DIDL-Lite `` element so the two stay consistent. + */ + private fun maybeTransformForSonos(uri: String): String { + if (!applySonosUriFix) return uri + return uri + .replaceFirst("https://", "x-rincon-mp3radio://") + .replaceFirst("http://", "x-rincon-mp3radio://") + } + private fun buildDidlLite(uri: String, mime: String, title: String): String { val safeTitle = title.ifBlank { "Minstrel" } return buildString { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index d8e688c7..45521e9c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -90,7 +90,12 @@ class UpnpDiscoveryController @Inject constructor( */ fun transportFor(routeId: String): AVTransportClient? { val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null - return AVTransportClient(loggingSoapClient(okHttp, route.name), route.avTransportControlUrl) + val isSonos = route.manufacturer.contains("Sonos", ignoreCase = true) + return AVTransportClient( + loggingSoapClient(okHttp, route.name), + route.avTransportControlUrl, + applySonosUriFix = isSonos, + ) } private suspend fun handleDiscovery(locationUrl: String) { diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 57ab15bb..194d3ca7 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class AVTransportClientTest { @@ -122,6 +123,51 @@ class AVTransportClientTest { } } + @Test + fun `setAVTransportURIWithMetadata transforms https to x-rincon-mp3radio when Sonos flag set`() = + runTest { + val sonosClient = AVTransportClient( + SoapClient(OkHttpClient()), + server.url("/MediaRenderer/AVTransport/Control"), + applySonosUriFix = true, + ) + server.enqueue(emptyResponse("SetAVTransportURI")) + sonosClient.setAVTransportURIWithMetadata( + uri = "https://example.com/track.mp3", + mime = "audio/mpeg", + title = "Song", + ) + val body = server.takeRequest().body.readUtf8() + assertTrue( + body.contains( + "x-rincon-mp3radio://example.com/track.mp3", + ), + ) { "missing transformed CurrentURI: $body" } + assertTrue(body.contains("x-rincon-mp3radio://example.com/track.mp3")) { + "transformed URI not present in body: $body" + } + assertFalse(body.contains("https://example.com/track.mp3")) { + "untransformed URI should not appear: $body" + } + } + + @Test + fun `setAVTransportURIWithMetadata leaves https untransformed by default`() = runTest { + server.enqueue(emptyResponse("SetAVTransportURI")) + client.setAVTransportURIWithMetadata( + uri = "https://example.com/track.mp3", + mime = "audio/mpeg", + title = "Song", + ) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("https://example.com/track.mp3")) { + "CurrentURI should be untransformed: $body" + } + assertFalse(body.contains("x-rincon-mp3radio")) { + "Sonos scheme should not appear without flag: $body" + } + } + private fun emptyResponse(action: String): MockResponse = MockResponse().setBody( """ From 2c61d7a333f688be2b985ec0f787c56d0dd96bfb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:29:08 -0400 Subject: [PATCH 27/69] fix(android): Sonos UDN comparison strips _MR/_MS suffix -- coordinator routing Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/OutputPickerController.kt | 3 +- .../output/upnp/UpnpDiscoveryController.kt | 33 +++++++++++--- .../player/output/upnp/BareUdnTest.kt | 44 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/BareUdnTest.kt 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 e72973fc..51ce71fe 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 @@ -13,6 +13,7 @@ import com.fabledsword.minstrel.player.StreamTokenProvider import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient import com.fabledsword.minstrel.player.output.upnp.SoapClient import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController +import com.fabledsword.minstrel.player.output.upnp.bareUdn import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -100,7 +101,7 @@ class OutputPickerController @Inject constructor( ) { sys, upnp, _, upnpSelected -> val suppressed = upnpDiscovery.nonCoordinatorMemberUdns() val visibleUpnp = upnp - .filter { it.id.removePrefix("uuid:") !in suppressed } // suppressed set is bare UDNs + .filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs .map { OutputRoute.fromUpnpRoute(it) } val merged = sys.available + visibleUpnp val current = if (upnpSelected != null) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 45521e9c..0cb06f15 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -117,12 +117,23 @@ class UpnpDiscoveryController @Inject constructor( } private suspend fun refreshSonosTopology(anySonos: UpnpRoute) { - val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: return + val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run { + Timber.w( + "Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled", + anySonos.id, + ) + return + } val groups = runCatching { ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState() } .onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) } .getOrNull() ?: return + Timber.w( + "Sonos topology refreshed for %s: %d group(s)", + anySonos.id, + groups.size, + ) sonosTopologyInternal.value = groups } @@ -216,7 +227,19 @@ class UpnpDiscoveryController @Inject constructor( } } -// uuid: prefix normalization — DeviceDescription carries it; Sonos -// ZoneGroupState Coordinator/UUID attrs don't. Helper centralizes the strip -// so a future change to the convention isn't a silent compare-always-false bug. -private fun String.bareUdn(): String = removePrefix("uuid:") +/** + * Normalize a Sonos UDN to its bare RINCON form for cross-comparison. + * + * Three places use UDN strings with different conventions: + * - DeviceDescription's tag carries the `uuid:` prefix. + * - Sonos exposes one UDN per embedded device. The MediaRenderer adds + * a `_MR` suffix and the MediaServer adds `_MS`. + * - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with + * neither the `uuid:` prefix nor any device suffix. + * + * To compare any pair of those, strip the prefix and the suffix so + * everything reduces to the underlying speaker identity. + */ +internal fun String.bareUdn(): String = removePrefix("uuid:") + .removeSuffix("_MR") + .removeSuffix("_MS") diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/BareUdnTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/BareUdnTest.kt new file mode 100644 index 00000000..28187353 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/BareUdnTest.kt @@ -0,0 +1,44 @@ +package com.fabledsword.minstrel.player.output.upnp + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class BareUdnTest { + + @Test + fun `strips uuid prefix`() { + assertEquals("RINCON_ABC", "uuid:RINCON_ABC".bareUdn()) + } + + @Test + fun `strips MR suffix`() { + assertEquals("RINCON_ABC", "RINCON_ABC_MR".bareUdn()) + } + + @Test + fun `strips MS suffix`() { + assertEquals("RINCON_ABC", "RINCON_ABC_MS".bareUdn()) + } + + @Test + fun `strips both uuid prefix and MR suffix`() { + assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MR".bareUdn()) + } + + @Test + fun `strips both uuid prefix and MS suffix`() { + assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MS".bareUdn()) + } + + @Test + fun `leaves already bare UDN unchanged`() { + assertEquals("RINCON_ABC", "RINCON_ABC".bareUdn()) + } + + @Test + fun `MR suffix comparison crosses MediaRenderer vs ZGT response`() { + val routeId = "uuid:RINCON_5CAAFD794B6401400_MR" + val zgtMemberUdn = "RINCON_5CAAFD794B6401400" + assertEquals(routeId.bareUdn(), zgtMemberUdn.bareUdn()) + } +} From 96594ba52bbdf1354b2338ac75f25b723409b843 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:40:52 -0400 Subject: [PATCH 28/69] fix(android): Sonos ZGT robust extraction -- escaped + nested element fallback + diag --- .../minstrel/player/output/upnp/SoapClient.kt | 70 ++++++++++++++++--- .../output/upnp/UpnpDiscoveryController.kt | 5 +- .../player/output/upnp/sonos/SonosTopology.kt | 14 +++- .../upnp/sonos/ZoneGroupTopologyClient.kt | 7 ++ .../output/upnp/sonos/SonosTopologyTest.kt | 35 ++++++++++ 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt index d58e33e5..938f2ef4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -99,7 +99,7 @@ class SoapClient( } else { if (inResponse) { val name = parser.name - val text = runCatching { parser.nextText() }.getOrDefault("") + val text = readElementContent(parser, name) args[name] = text } inResponse @@ -109,6 +109,51 @@ class SoapClient( else -> inResponse } + /** + * Read the content of the currently-started element. Try nextText() first + * (works when the content is text -- escaped XML included). If that throws + * (because the content is nested elements), manually walk to the matching + * END_TAG, accumulating text and re-serializing child elements. + * + * Some Sonos firmware sends the GetZoneGroupState payload as nested XML + * elements without escaping; this fallback recovers that path. + */ + private fun readElementContent(parser: XmlPullParser, tagName: String): String { + return runCatching { parser.nextText() }.getOrElse { + readUntilEndTag(parser, tagName) + } + } + + private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String { + val sb = StringBuilder() + var depth = 1 + while (depth > 0) { + when (parser.next()) { + XmlPullParser.START_TAG -> { + sb.append('<').append(parser.name) + for (i in 0 until parser.attributeCount) { + sb.append(' ') + .append(parser.getAttributeName(i)) + .append("=\"") + .append(parser.getAttributeValue(i)) + .append('"') + } + sb.append('>') + depth += 1 + } + XmlPullParser.END_TAG -> { + depth -= 1 + if (depth == 0 && parser.name == tagName) break + sb.append("') + } + XmlPullParser.TEXT -> sb.append(parser.text.orEmpty()) + XmlPullParser.END_DOCUMENT -> break + else -> Unit + } + } + return sb.toString() + } + private fun faultCodeOf(body: String): String = extractBetween(body, "", "") ?: "unknown" @@ -141,23 +186,32 @@ private const val MAX_BODY_LOG_CHARS = 2048 /** * Returns a [SoapClient] that logs the raw response body for the first - * [MAX_DIAGNOSTIC_RESPONSES] GetPositionInfo / GetTransportInfo calls (i.e. - * the first 3 poll cycles of a UPnP session). After that the callback is a - * no-op so there is no persistent log spam. Logged at WARN so release builds - * capture it without a separate log-level override. + * [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS] + * (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the + * callback is a no-op so there is no persistent log spam. Logged at WARN + * so release builds capture it without a separate log-level override. */ internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient { val counter = AtomicInteger(0) return SoapClient(okHttp) { action, body -> - if (action != "GetPositionInfo" && action != "GetTransportInfo") return@SoapClient + if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient val n = counter.incrementAndGet() if (n <= MAX_DIAGNOSTIC_RESPONSES) { - Timber.w("UPnP %s response #%d (%s): %s", - label, n, action, body.take(MAX_BODY_LOG_CHARS)) + Timber.w( + "UPnP %s response #%d (%s): %s", + label, n, action, + body.take(MAX_BODY_LOG_CHARS), + ) } } } +private val DIAGNOSTIC_ACTIONS = setOf( + "GetPositionInfo", + "GetTransportInfo", + "GetZoneGroupState", +) + /** XML-escapes a string value for embedding as text content inside a SOAP envelope. */ internal fun xmlEscape(v: String): String = v .replace("&", "&") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 0cb06f15..b43da397 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -125,7 +125,10 @@ class UpnpDiscoveryController @Inject constructor( return } val groups = runCatching { - ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState() + ZoneGroupTopologyClient( + loggingSoapClient(okHttp, "ZGT-${anySonos.id}"), + zgtUrl, + ).getZoneGroupState() } .onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) } .getOrNull() ?: return diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt index 46ef494e..56d276f1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt @@ -24,9 +24,21 @@ data class SonosZoneMember( object SonosTopology { fun parse(xml: String): List { - return runCatching { parseStrict(xml) }.getOrDefault(emptyList()) + val effective = if (xml.contains("<ZoneGroup")) { + unescapeXmlEntities(xml) + } else { + xml + } + return runCatching { parseStrict(effective) }.getOrDefault(emptyList()) } + private fun unescapeXmlEntities(s: String): String = s + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") // must be last to avoid double-decoding + private fun parseStrict(xml: String): List { val parser = XmlPullParserFactory.newInstance().newPullParser().apply { setInput(xml.reader()) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt index 684b7389..35cc0665 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt @@ -2,6 +2,7 @@ package com.fabledsword.minstrel.player.output.upnp.sonos import com.fabledsword.minstrel.player.output.upnp.SoapClient import okhttp3.HttpUrl +import timber.log.Timber /** * Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a @@ -21,10 +22,16 @@ class ZoneGroupTopologyClient( args = emptyMap(), ) val inner = args["ZoneGroupState"].orEmpty() + Timber.w( + "ZGT extracted ZoneGroupState (%d chars): %s", + inner.length, + inner.take(ZGT_LOG_TRUNCATE_CHARS), + ) return SonosTopology.parse(inner) } private companion object { const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1" + const val ZGT_LOG_TRUNCATE_CHARS = 2048 } } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt index 2c5511f6..23caef85 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt @@ -71,4 +71,39 @@ class SonosTopologyTest { fun `malformed xml returns empty list`() { assertEquals(emptyList(), SonosTopology.parse(" + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals(1, groups.size) + } + + @Test + fun `parses escaped ZoneGroupState wrapped in entities`() { + val xml = """ + <ZoneGroupState> + <ZoneGroups> + <ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1"> + <ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen" + Location="http://192.168.1.10:1400/xml/device_description.xml"/> + </ZoneGroup> + </ZoneGroups> + </ZoneGroupState> + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals(1, groups.size) + } } From 8fe3308afd6ac23b3cbefa4742c83b893d3389df Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:45:00 -0400 Subject: [PATCH 29/69] fix(android): detekt -- parseHhMmSs ReturnCount/MagicNumber + readUntilEndTag jumps Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/upnp/AVTransportClient.kt | 11 +++--- .../minstrel/player/output/upnp/SoapClient.kt | 34 ++++++++++++------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index fba29910..89c06644 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -184,11 +184,13 @@ class AVTransportClient( } private fun parseHhMmSs(raw: String): Long { - if (raw.isBlank()) return 0L val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() } - if (parts.size != 3) return 0L - val (h, m, s) = parts - return ((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND + return if (parts.size == EXPECTED_HMS_PARTS) { + val (h, m, s) = parts + ((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND + } else { + 0L + } } private companion object { @@ -196,6 +198,7 @@ class AVTransportClient( const val MILLIS_PER_SECOND = 1000L const val SECONDS_PER_MINUTE = 60L const val SECONDS_PER_HOUR = 3600L + const val EXPECTED_HMS_PARTS = 3 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt index 938f2ef4..31365683 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -127,33 +127,41 @@ class SoapClient( private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String { val sb = StringBuilder() var depth = 1 - while (depth > 0) { + var done = false + while (!done && depth > 0) { when (parser.next()) { XmlPullParser.START_TAG -> { - sb.append('<').append(parser.name) - for (i in 0 until parser.attributeCount) { - sb.append(' ') - .append(parser.getAttributeName(i)) - .append("=\"") - .append(parser.getAttributeValue(i)) - .append('"') - } - sb.append('>') + appendStartTag(sb, parser) depth += 1 } XmlPullParser.END_TAG -> { depth -= 1 - if (depth == 0 && parser.name == tagName) break - sb.append("') + if (depth == 0 && parser.name == tagName) { + done = true + } else { + sb.append("') + } } XmlPullParser.TEXT -> sb.append(parser.text.orEmpty()) - XmlPullParser.END_DOCUMENT -> break + XmlPullParser.END_DOCUMENT -> done = true else -> Unit } } return sb.toString() } + private fun appendStartTag(sb: StringBuilder, parser: XmlPullParser) { + sb.append('<').append(parser.name) + for (i in 0 until parser.attributeCount) { + sb.append(' ') + .append(parser.getAttributeName(i)) + .append("=\"") + .append(parser.getAttributeValue(i)) + .append('"') + } + sb.append('>') + } + private fun faultCodeOf(body: String): String = extractBetween(body, "", "") ?: "unknown" From 5c99341b341c442a6e0a4bcf50896f96a863a550 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:51:28 -0400 Subject: [PATCH 30/69] fix(android): test assertions use JUnit Jupiter for lazy-message Supplier Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/output/upnp/AVTransportClientTest.kt | 6 +++--- .../player/output/upnp/RenderingControlClientTest.kt | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 194d3ca7..9c15adc4 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -7,9 +7,9 @@ import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue class AVTransportClientTest { diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt index 76e78610..3fcdf6f8 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/RenderingControlClientTest.kt @@ -7,8 +7,8 @@ import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue class RenderingControlClientTest { From 487d1bd430b40ced3bde9c9a2fed13de85ec0e62 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:58:59 -0400 Subject: [PATCH 31/69] fix(android): SOAP/UPnP parsers enable processNamespaces -- correct name extraction Co-Authored-By: Claude Sonnet 4.6 --- .../fabledsword/minstrel/player/output/upnp/DeviceDescription.kt | 1 + .../com/fabledsword/minstrel/player/output/upnp/SoapClient.kt | 1 + .../minstrel/player/output/upnp/sonos/SonosTopology.kt | 1 + 3 files changed, 3 insertions(+) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt index 4dd49569..4dcb8f94 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt @@ -45,6 +45,7 @@ data class DeviceDescription( */ fun parse(xml: String, base: HttpUrl): DeviceDescription? { val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) setInput(xml.reader()) } val acc = ParseState() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt index 31365683..4a25fde3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -75,6 +75,7 @@ class SoapClient( private fun parseResponseArgs(body: String, action: String): Map { val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) setInput(body.reader()) } val responseTag = "${action}Response" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt index 56d276f1..a823de2d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt @@ -41,6 +41,7 @@ object SonosTopology { private fun parseStrict(xml: String): List { val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) setInput(xml.reader()) } val groups = mutableListOf() From 8a1203c4a1e0f4c6deb9ccc7b8783721494fbd06 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 21:31:31 -0400 Subject: [PATCH 32/69] fix(android): revert x-rincon-mp3radio Sonos URI -- plain https for music tracks Co-Authored-By: Claude Sonnet 4.6 --- .../player/output/upnp/AVTransportClient.kt | 27 ++------------- .../output/upnp/UpnpDiscoveryController.kt | 2 -- .../output/upnp/AVTransportClientTest.kt | 34 ++----------------- 3 files changed, 6 insertions(+), 57 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index 89c06644..b7dfa85d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -12,7 +12,6 @@ import okhttp3.HttpUrl class AVTransportClient( private val soap: SoapClient, private val controlUrl: HttpUrl, - private val applySonosUriFix: Boolean = false, ) { suspend fun setAVTransportURI(uri: String, metadata: String = "") { soap.call( @@ -45,8 +44,7 @@ class AVTransportClient( */ suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) { val safeTitle = title.ifBlank { "Minstrel" } - val effectiveUri = maybeTransformForSonos(uri) - setAVTransportURI(effectiveUri, buildDidlLite(effectiveUri, mime, safeTitle)) + setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle)) } /** @@ -56,15 +54,14 @@ class AVTransportClient( */ suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) { val safeTitle = title.ifBlank { "Minstrel" } - val effectiveUri = maybeTransformForSonos(uri) soap.call( controlUrl = controlUrl, serviceType = SERVICE_TYPE, action = "SetNextAVTransportURI", args = mapOf( "InstanceID" to "0", - "NextURI" to effectiveUri, - "NextURIMetaData" to buildDidlLite(effectiveUri, mime, safeTitle), + "NextURI" to uri, + "NextURIMetaData" to buildDidlLite(uri, mime, safeTitle), ), ) } @@ -140,24 +137,6 @@ class AVTransportClient( return TransportInfo(state) } - /** - * Sonos firmware 6.4.2+ silently refuses plain http(s):// URIs in - * SetAVTransportURI -- the SOAP call returns 200 OK but the URI never - * loads. The documented fix (from SoCo et al.) is to replace the - * scheme with x-rincon-mp3radio:// for Sonos targets. Generic UPnP - * renderers (Yamaha, Bose, generic DLNA) reject this scheme, so the - * transform is conditional on the manufacturer. - * - * The transformed URI goes both in the SOAP CurrentURI / NextURI arg - * AND inside the DIDL-Lite `` element so the two stay consistent. - */ - private fun maybeTransformForSonos(uri: String): String { - if (!applySonosUriFix) return uri - return uri - .replaceFirst("https://", "x-rincon-mp3radio://") - .replaceFirst("http://", "x-rincon-mp3radio://") - } - private fun buildDidlLite(uri: String, mime: String, title: String): String { val safeTitle = title.ifBlank { "Minstrel" } return buildString { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index b43da397..1989f904 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -90,11 +90,9 @@ class UpnpDiscoveryController @Inject constructor( */ fun transportFor(routeId: String): AVTransportClient? { val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null - val isSonos = route.manufacturer.contains("Sonos", ignoreCase = true) return AVTransportClient( loggingSoapClient(okHttp, route.name), route.avTransportControlUrl, - applySonosUriFix = isSonos, ) } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 9c15adc4..69bbe329 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -124,35 +124,7 @@ class AVTransportClientTest { } @Test - fun `setAVTransportURIWithMetadata transforms https to x-rincon-mp3radio when Sonos flag set`() = - runTest { - val sonosClient = AVTransportClient( - SoapClient(OkHttpClient()), - server.url("/MediaRenderer/AVTransport/Control"), - applySonosUriFix = true, - ) - server.enqueue(emptyResponse("SetAVTransportURI")) - sonosClient.setAVTransportURIWithMetadata( - uri = "https://example.com/track.mp3", - mime = "audio/mpeg", - title = "Song", - ) - val body = server.takeRequest().body.readUtf8() - assertTrue( - body.contains( - "x-rincon-mp3radio://example.com/track.mp3", - ), - ) { "missing transformed CurrentURI: $body" } - assertTrue(body.contains("x-rincon-mp3radio://example.com/track.mp3")) { - "transformed URI not present in body: $body" - } - assertFalse(body.contains("https://example.com/track.mp3")) { - "untransformed URI should not appear: $body" - } - } - - @Test - fun `setAVTransportURIWithMetadata leaves https untransformed by default`() = runTest { + fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest { server.enqueue(emptyResponse("SetAVTransportURI")) client.setAVTransportURIWithMetadata( uri = "https://example.com/track.mp3", @@ -161,10 +133,10 @@ class AVTransportClientTest { ) val body = server.takeRequest().body.readUtf8() assertTrue(body.contains("https://example.com/track.mp3")) { - "CurrentURI should be untransformed: $body" + "CurrentURI should be sent as plain https: $body" } assertFalse(body.contains("x-rincon-mp3radio")) { - "Sonos scheme should not appear without flag: $body" + "x-rincon-mp3radio scheme should not appear: $body" } } From ece37e9a925ec4dbb322f2da06c1f1632e337193 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 21:42:01 -0400 Subject: [PATCH 33/69] fix(android): remove pollLoop natural-advance -- races with selectUpnp and skip Polling alone cannot distinguish Sonos auto-advancing via SetNextAVTransportURI from URI changes we made ourselves via syncCurrentItemToRemote. This produced two races: (1) activation race -- first poll returns stale URI from prior session, second returns new URI, false-positive fires and double-advances the cursor; (2) user-skip race -- skip's syncCurrentItemToRemote changes the URI, next poll sees the change and fires again. Remove the detection block and previousTrackUri capture from pollOnce entirely. pollLoop is now a pure state-tracker (position + transport state) plus the one-shot initial pre-queue gate. GENA event subscriptions to AVTransport LastChange are the correct fix; deferred to its own slice (see parity-map). Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 56 ++++++------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 34cec946..bb56e9e6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -53,9 +53,9 @@ class MinstrelForwardingPlayer( holder.active.collect { active -> onActiveChanged(active) } } // No Player.Listener -- the seekToNextMediaItem/seekToPreviousMediaItem - // overrides and pollLoop natural-advance branch each call preQueueNext - // explicitly. A listener-based path raced with the override's launched - // syncCurrentItemToRemote, producing non-deterministic SOAP order. + // overrides call preQueueNext explicitly. A listener-based path raced + // with the override's launched syncCurrentItemToRemote, producing + // non-deterministic SOAP order. } private fun isRemote(): Boolean = holder.active.value != null @@ -169,11 +169,9 @@ class MinstrelForwardingPlayer( /** * Speculative pre-queue of the next track so Sonos can advance gap-free. - * Failures (token mint OR SetNextAVTransportURI rejection) silently fall - * back to the poll-driven advance path -- the pollLoop detects STOPPED + - * 0:00 RelTime and calls syncCurrentItemToRemote on whatever the new - * currentMediaItem is. The only consequence of failure here is a sub-second - * audible gap at track boundary, not a broken route. + * Failures (token mint OR SetNextAVTransportURI rejection) are logged and + * swallowed. The only consequence is a sub-second audible gap at the track + * boundary, not a broken route. */ private suspend fun preQueueNext(active: ActiveUpnp) { val nextMediaId = handlerEvaluate { nextItemMediaId() } ?: return @@ -223,6 +221,15 @@ class MinstrelForwardingPlayer( } private suspend fun pollLoop(active: ActiveUpnp) { + // pollLoop is a state-tracker for position + transport state. We do NOT + // attempt to mirror Sonos's auto-advance into the local queue cursor -- + // that would require GENA event subscriptions to AVTransport's LastChange + // event (see the parity-map). Without that, polling alone cannot + // distinguish "Sonos finished track N and auto-played track N+1 via + // SetNextAVTransportURI" from "we just changed Sonos's URI ourselves + // via syncCurrentItemToRemote". The local cursor stays in sync via user + // transport actions (pause/seek/skip flow through the override which + // re-runs syncCurrentItemToRemote). var initialPreQueueDone = false while (scope.isActive && holder.active.value?.routeId == active.routeId) { val outcome = runCatching { pollOnce(active) } @@ -255,16 +262,11 @@ class MinstrelForwardingPlayer( } /** - * One poll tick: read position + transport state from Sonos, apply to - * [remoteState], and detect natural-advance (URI change driven by a - * previously queued SetNextAVTransportURI). Extracted from [pollLoop] - * to keep that method within detekt's LongMethod=60 limit. + * One poll tick: read position + transport state from Sonos and apply to + * [remoteState]. Extracted from [pollLoop] to keep that method within + * detekt's LongMethod=60 limit. */ private suspend fun pollOnce(active: ActiveUpnp) { - // Capture before applyPositionInfo so we can detect a URI - // change that Sonos made via the pre-queued SetNextAVTransportURI - // (natural auto-advance without any user skip action). - val previousTrackUri = remoteState.currentTrackUri val info = active.avTransport.getPositionInfo() remoteState.applyPositionInfo( positionMs = info.relTimeMs, @@ -278,28 +280,6 @@ class MinstrelForwardingPlayer( TransportState.STOPPED -> remoteState.applyTransportStopped() TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit } - // Detect remote-side natural advance: Sonos auto-advanced - // through SetNextAVTransportURI so the URI changed without - // any local user action. Advance the local queue cursor and - // call preQueueNext explicitly to set up the next-next URI on - // Sonos. Both guards must be non-blank: previousTrackUri="" on - // the very first poll and we must not treat that initial - // empty-to-URI transition as an advance. - if (info.trackUri.isNotBlank() && - previousTrackUri.isNotBlank() && - info.trackUri != previousTrackUri - ) { - Timber.w( - "UPnP natural-advance detected: %s -> %s", - previousTrackUri, - info.trackUri, - ) - // Run on the application looper. ExoPlayer is paused - // while UPnP is active (playWhenReady=false invariant) so - // the cursor advances without producing local audio. - handler.post { delegate.seekToNextMediaItem() } - preQueueNext(active) - } } /** Run [block] on the application looper and bring its result back. */ From 2a098a78fe75f781f3f2dfc0d301c4d4658f30a5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 21:55:03 -0400 Subject: [PATCH 34/69] feat(android): Sonos queue mode -- ClearQueue + AddURIToQueue + x-rincon-queue Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 154 +++++++----------- .../minstrel/player/PlayerFactory.kt | 2 - .../minstrel/player/RemotePlayerState.kt | 5 +- .../player/output/OutputPickerController.kt | 71 ++++---- .../player/output/upnp/AVTransportClient.kt | 87 ++++++++-- .../minstrel/player/RemotePlayerStateTest.kt | 7 +- .../output/upnp/AVTransportClientTest.kt | 63 +++++-- 7 files changed, 240 insertions(+), 149 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index bb56e9e6..a3a3883e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -8,7 +8,6 @@ import androidx.media3.common.Player import com.fabledsword.minstrel.player.output.ActiveUpnp import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.upnp.TransportState -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -35,12 +34,18 @@ import timber.log.Timber * Drop heuristic: 3 consecutive poll failures fire [onDrop]. The * factory wraps that callback into a SharedFlow consumed by the * NowPlaying surface as a snackbar. + * + * Queue mode: OutputPickerController loads the full queue into Sonos's + * native queue via ClearQueue + AddURIToQueue, then points the + * transport at x-rincon-queue:#0. Skip/prev/seekTo delegate to + * AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free + * advance natively. PollLoop syncs the local cursor by comparing the + * 1-based Track index from GetPositionInfo. */ class MinstrelForwardingPlayer( private val delegate: Player, private val holder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, - private val tokens: StreamTokenProvider, private val onDrop: (routeName: String) -> Unit, ) : ForwardingPlayer(delegate) { @@ -52,10 +57,6 @@ class MinstrelForwardingPlayer( scope.launch { holder.active.collect { active -> onActiveChanged(active) } } - // No Player.Listener -- the seekToNextMediaItem/seekToPreviousMediaItem - // overrides call preQueueNext explicitly. A listener-based path raced - // with the override's launched syncCurrentItemToRemote, producing - // non-deterministic SOAP order. } private fun isRemote(): Boolean = holder.active.value != null @@ -95,6 +96,7 @@ class MinstrelForwardingPlayer( positionMs = positionMs, durationMs = remoteState.durationMs, trackUri = remoteState.currentTrackUri, + trackNumber = remoteState.trackNumber, ) scope.launch { runCatching { active.avTransport.seek(positionMs) } @@ -103,21 +105,41 @@ class MinstrelForwardingPlayer( } } + /** + * Widget-driven track change (user taps a track in the queue widget). + * Seeks Sonos to the correct queue slot, then seeks within-track if + * [positionMs] is non-zero. + */ + override fun seekTo(mediaItemIndex: Int, positionMs: Long) { + val active = holder.active.value + if (active == null) { + super.seekTo(mediaItemIndex, positionMs) + return + } + super.seekTo(mediaItemIndex, positionMs) + scope.launch { + runCatching { + active.avTransport.seekToTrack(mediaItemIndex + 1) + if (positionMs > 0L) { + active.avTransport.seek(positionMs) + } + }.onFailure { handleSoapFailure(active, it) } + } + } + override fun seekToNextMediaItem() { val active = holder.active.value if (active == null) { super.seekToNextMediaItem() return } - // Advance the local cursor first so syncCurrentItemToRemote can read - // the new currentMediaItem on the application looper. ExoPlayer stays - // paused while UPnP is active (playWhenReady=false invariant), so this - // does not produce local audio. After the sync lands, preQueueNext - // primes the next-next track on Sonos -- sequential, not raced. + // Super first for immediate local cursor advance (UI feedback); + // then delegate to Sonos Next. PollLoop reconciles cursor via + // Track index if they diverge. super.seekToNextMediaItem() scope.launch { - syncCurrentItemToRemote(active) - preQueueNext(active) + runCatching { active.avTransport.next() } + .onFailure { handleSoapFailure(active, it) } } } @@ -127,11 +149,12 @@ class MinstrelForwardingPlayer( super.seekToPreviousMediaItem() return } - // Same as seekToNextMediaItem: sync then pre-queue in one coroutine. + // Super first for immediate local cursor advance (UI feedback); + // then delegate to Sonos Previous. PollLoop reconciles. super.seekToPreviousMediaItem() scope.launch { - syncCurrentItemToRemote(active) - preQueueNext(active) + runCatching { active.avTransport.previous() } + .onFailure { handleSoapFailure(active, it) } } } @@ -153,46 +176,6 @@ class MinstrelForwardingPlayer( super.release() } - private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) { - val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return - runCatching { - val token = tokens.mint(mediaId) - active.avTransport.setAVTransportURIWithMetadata( - uri = token.url, - mime = token.mime, - title = token.title, - ) - active.avTransport.play() - remoteState.applyTransportPlaying() - }.onFailure { handleSoapFailure(active, it) } - } - - /** - * Speculative pre-queue of the next track so Sonos can advance gap-free. - * Failures (token mint OR SetNextAVTransportURI rejection) are logged and - * swallowed. The only consequence is a sub-second audible gap at the track - * boundary, not a broken route. - */ - private suspend fun preQueueNext(active: ActiveUpnp) { - val nextMediaId = handlerEvaluate { nextItemMediaId() } ?: return - val token = runCatching { tokens.mint(nextMediaId) }.getOrNull() ?: return - runCatching { - active.avTransport.setNextAVTransportURI( - uri = token.url, - mime = token.mime, - title = token.title, - ) - }.onFailure { - Timber.w(it, "SetNextAVTransportURI failed (poll-driven advance will fall back)") - } - } - - private fun nextItemMediaId(): String? { - val nextIdx = delegate.nextMediaItemIndex - if (nextIdx == androidx.media3.common.C.INDEX_UNSET) return null - return delegate.getMediaItemAt(nextIdx).mediaId - } - private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) { pollJob?.cancel() Timber.w(t, "UPnP transport call failed on %s", active.routeName) @@ -212,46 +195,16 @@ class MinstrelForwardingPlayer( // fires, but delegate.pause() bypasses the override entirely). handler.post { delegate.pause() } pollJob = scope.launch { pollLoop(active) } - // Do NOT pre-queue here: selectUpnp's SetAV+Play SOAP may not have - // landed yet. Initial pre-queue fires from pollLoop once the first - // successful poll confirms Sonos accepted the track (trackUri lands). } else { remoteState.reset() } } private suspend fun pollLoop(active: ActiveUpnp) { - // pollLoop is a state-tracker for position + transport state. We do NOT - // attempt to mirror Sonos's auto-advance into the local queue cursor -- - // that would require GENA event subscriptions to AVTransport's LastChange - // event (see the parity-map). Without that, polling alone cannot - // distinguish "Sonos finished track N and auto-played track N+1 via - // SetNextAVTransportURI" from "we just changed Sonos's URI ourselves - // via syncCurrentItemToRemote". The local cursor stays in sync via user - // transport actions (pause/seek/skip flow through the override which - // re-runs syncCurrentItemToRemote). - var initialPreQueueDone = false while (scope.isActive && holder.active.value?.routeId == active.routeId) { val outcome = runCatching { pollOnce(active) } if (outcome.isSuccess) { remoteState.recordPollSuccess() - // Initial pre-queue fires once Sonos confirms it accepted the - // SetAV+Play from selectUpnp (trackUri lands in the first - // successful poll). Firing from onActiveChanged would race - // with selectUpnp's SOAP calls. - if (!initialPreQueueDone) { - Timber.w( - "UPnP poll OK: route=%s trackUri=%s posMs=%d", - active.routeName, - remoteState.currentTrackUri, - remoteState.positionMs, - ) - } - if (!initialPreQueueDone && remoteState.currentTrackUri.isNotBlank()) { - Timber.w("UPnP initial pre-queue for %s", active.routeName) - preQueueNext(active) - initialPreQueueDone = true - } } else if (remoteState.recordPollFailure()) { Timber.w("UPnP drop threshold tripped for %s", active.routeName) handler.post { onDrop(active.routeName) } @@ -263,8 +216,13 @@ class MinstrelForwardingPlayer( /** * One poll tick: read position + transport state from Sonos and apply to - * [remoteState]. Extracted from [pollLoop] to keep that method within - * detekt's LongMethod=60 limit. + * [remoteState]. Cursor sync: Sonos's 1-based Track index is the truth — + * if it disagrees with the local cursor, post an advance on the player + * thread so they converge. This handles Sonos hardware-button advances + * and natural auto-advance without GENA eventing. + * + * Extracted from [pollLoop] to keep that method within detekt's + * LongMethod=60 limit. */ private suspend fun pollOnce(active: ActiveUpnp) { val info = active.avTransport.getPositionInfo() @@ -272,6 +230,7 @@ class MinstrelForwardingPlayer( positionMs = info.relTimeMs, durationMs = info.trackDurationMs, trackUri = info.trackUri, + trackNumber = info.track, ) val transport = active.avTransport.getTransportInfo() when (transport.state) { @@ -280,15 +239,18 @@ class MinstrelForwardingPlayer( TransportState.STOPPED -> remoteState.applyTransportStopped() TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit } - } - - /** Run [block] on the application looper and bring its result back. */ - private suspend fun handlerEvaluate(block: () -> T): T? { - val deferred = CompletableDeferred() - handler.post { - deferred.complete(runCatching(block).getOrNull()) + // Cursor sync: post a check to the player thread. Track is 1-based; + // sonosIndex is 0-based. If Sonos advanced (hardware button, natural + // end-of-track) and our local cursor is behind, walk it forward. + val sonosIndex = info.track - 1 + if (sonosIndex >= 0) { + handler.post { + val localIndex = delegate.currentMediaItemIndex + if (sonosIndex != localIndex && sonosIndex < delegate.mediaItemCount) { + delegate.seekTo(sonosIndex, 0L) + } + } } - return deferred.await() } private companion object { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index e7222217..dd28ca5e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -51,7 +51,6 @@ class PlayerFactory @Inject constructor( private val okHttpClient: OkHttpClient, private val cacheConfig: CacheConfig, private val activeUpnpHolder: ActiveUpnpHolder, - private val streamTokens: StreamTokenProvider, private val remoteState: RemotePlayerState, ) { private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } @@ -78,7 +77,6 @@ class PlayerFactory @Inject constructor( delegate = exo, holder = activeUpnpHolder, remoteState = remoteState, - tokens = streamTokens, onDrop = { name -> emitDrop(name) }, ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 1bae6460..40afc914 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -25,13 +25,15 @@ class RemotePlayerState @Inject constructor() { @Volatile var isPlaying: Boolean = false; private set @Volatile var currentTrackUri: String = ""; private set @Volatile var lastError: Throwable? = null; private set + @Volatile var trackNumber: Int = 0; private set @Volatile private var consecutivePollFailures: Int = 0 - fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String) { + fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) { this.positionMs = positionMs this.durationMs = durationMs this.currentTrackUri = trackUri + this.trackNumber = trackNumber } fun applyTransportPlaying() { isPlaying = true } @@ -61,6 +63,7 @@ class RemotePlayerState @Inject constructor() { currentTrackUri = "" lastError = null consecutivePollFailures = 0 + trackNumber = 0 } private companion object { 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 51ce71fe..dcef6235 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 @@ -6,10 +6,12 @@ import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerFactory import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.StreamTokenProvider +import com.fabledsword.minstrel.player.output.upnp.AVTransportClient import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient import com.fabledsword.minstrel.player.output.upnp.SoapClient import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController @@ -229,18 +231,19 @@ class OutputPickerController @Inject constructor( } /** - * Drive the UPnP renderer: mint a token for the currently playing - * track, set the renderer's URI, play, then pause local playback so - * audio yields to the speaker. Wrapped in `runCatching` at each - * step — token failure, transport-lookup failure, and SOAP failure - * each abandon the selection cleanly rather than crashing. Failures - * log at warn level via Timber so on-device verification can find - * the cause in logcat (OkHttp's logger doesn't cover our own - * deserialize / SOAP-parse code paths). + * Drive the UPnP renderer using Sonos native queue mode: + * clear the device's queue, load every track from our local queue + * via AddURIToQueue, point the transport at the queue URI, seek to + * the current index, and play. Wrapped in `runCatching` — SOAP + * failure abandons the selection cleanly. Local pause is handled by + * MinstrelForwardingPlayer.onActiveChanged via holder.set(), so we + * do not call playerController.pause() here (it would race with the + * SOAP override once holder.active is non-null). */ private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock { - val trackId = playerController.uiState.value.currentTrack?.id - if (trackId == null) { + val uiState = playerController.uiState.value + val currentTrack = uiState.currentTrack + if (currentTrack == null) { Timber.w("UPnP select skipped: no currentTrack (start playback first)") return@withLock } @@ -259,13 +262,6 @@ class OutputPickerController @Inject constructor( return@withLock } val rendering = renderingClientFor(effectiveRoute.id) - // Local pause is handled by MinstrelForwardingPlayer.onActiveChanged - // via handler.post { delegate.pause() } -- called immediately after - // holder.set() fires. That path bypasses the SOAP-routing override, so - // there is no race between pausing ExoPlayer and starting the remote - // renderer. Do NOT call playerController.pause() here: it dispatches - // through Handler.post on the application looper, meaning it runs after - // holder.active is non-null and the override would route it to SOAP. selectedUpnpRouteIdInternal.value = effectiveRoute.id activeUpnpHolder.set( ActiveUpnp( @@ -276,17 +272,7 @@ class OutputPickerController @Inject constructor( ), ) runCatching { - Timber.w("UPnP select: mint token for track=$trackId, route=${effectiveRoute.name}") - val token = streamTokens.mint(trackId) - Timber.w("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") - transport.setAVTransportURIWithMetadata( - uri = token.url, - mime = token.mime, - title = token.title, - ) - Timber.w("UPnP select: Play") - transport.play() - Timber.w("UPnP select: done") + loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex) }.onFailure { e -> Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}") activeUpnpHolder.set(null) @@ -294,6 +280,35 @@ class OutputPickerController @Inject constructor( } } + private suspend fun loadQueueOnSonos( + transport: AVTransportClient, + route: OutputRoute, + queue: List, + currentIndex: Int, + ) { + 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 token = streamTokens.mint(ref.id) + transport.addURIToQueue( + uri = token.url, + mime = token.mime, + title = token.title, + enqueuedURIPosition = idx + 1, + ) + } + val coordinatorUdn = route.id.bareUdn() + val queueUri = "x-rincon-queue:$coordinatorUdn#0" + Timber.w("UPnP select: SetAVTransportURI %s", queueUri) + transport.setAVTransportURI(queueUri, "") + Timber.w("UPnP select: Seek to track %d", currentIndex + 1) + transport.seekToTrack(currentIndex + 1) + Timber.w("UPnP select: Play") + transport.play() + Timber.w("UPnP select: done") + } + private fun renderingClientFor(routeId: String): RenderingControlClient? { val rcUrl = upnpDiscovery.routes.value .firstOrNull { it.id == routeId } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index b7dfa85d..8033323f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -4,8 +4,9 @@ import okhttp3.HttpUrl /** * High-level wrapper for the UPnP AVTransport service. - * Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / - * Stop / Seek / GetPositionInfo / GetTransportInfo. + * Covers SetAVTransportURI / Play / Pause / Stop / Seek / + * GetPositionInfo / GetTransportInfo / queue management + * (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack). */ // One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping. @Suppress("TooManyFunctions") @@ -48,20 +49,84 @@ class AVTransportClient( } /** - * Queues the next track on the renderer so it can pre-buffer before - * the current track finishes. Uses the same DIDL-Lite shape as - * [setAVTransportURIWithMetadata]. + * Remove all tracks from the renderer's queue. UPnP action name is + * `RemoveAllTracksFromQueue`. Used at activation time to clear out any + * leftover queue from prior sessions before loading our local queue. */ - suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) { - val safeTitle = title.ifBlank { "Minstrel" } + suspend fun removeAllTracksFromQueue() { soap.call( controlUrl = controlUrl, serviceType = SERVICE_TYPE, - action = "SetNextAVTransportURI", + action = "RemoveAllTracksFromQueue", + args = mapOf("InstanceID" to "0"), + ) + } + + /** + * Append a track to the renderer's queue. Sonos returns the assigned + * track number + new total queue length in the response, but we don't + * read those (we know our intended position). DIDL-Lite metadata is + * required; reuses the same shape as [setAVTransportURIWithMetadata]. + * + * [enqueuedURIPosition] is 1-based; 0 means "append to end" per UPnP. + * Our caller passes 1, 2, 3, ... to ensure stable order. + */ + suspend fun addURIToQueue( + uri: String, + mime: String, + title: String, + enqueuedURIPosition: Int = 0, + ) { + val safeTitle = title.ifBlank { "Minstrel" } + val didl = buildDidlLite(uri, mime, safeTitle) + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "AddURIToQueue", args = mapOf( "InstanceID" to "0", - "NextURI" to uri, - "NextURIMetaData" to buildDidlLite(uri, mime, safeTitle), + "EnqueuedURI" to uri, + "EnqueuedURIMetaData" to didl, + "DesiredFirstTrackNumberEnqueued" to enqueuedURIPosition.toString(), + "EnqueueAsNext" to "0", + ), + ) + } + + /** Skip to the next track in the renderer's queue. */ + suspend fun next() { + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "Next", + args = mapOf("InstanceID" to "0"), + ) + } + + /** Skip to the previous track in the renderer's queue. */ + suspend fun previous() { + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "Previous", + args = mapOf("InstanceID" to "0"), + ) + } + + /** + * Seek to a specific track in the queue. UPnP Seek unit "TRACK_NR"; + * target is the 1-based track index. Separate from the existing + * [seek] which uses unit REL_TIME for position-within-track. + */ + suspend fun seekToTrack(trackNumber: Int) { + soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "Seek", + args = mapOf( + "InstanceID" to "0", + "Unit" to "TRACK_NR", + "Target" to trackNumber.toString(), ), ) } @@ -114,6 +179,7 @@ class AVTransportClient( args = mapOf("InstanceID" to "0"), ) return PositionInfo( + track = result["Track"]?.toIntOrNull() ?: 0, trackUri = result["TrackURI"].orEmpty(), relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()), trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()), @@ -182,6 +248,7 @@ class AVTransportClient( } data class PositionInfo( + val track: Int, val trackUri: String, val relTimeMs: Long, val trackDurationMs: Long, diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt index 1ff51e9c..7a6ac898 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt @@ -16,12 +16,15 @@ class RemotePlayerStateTest { } @Test - fun `applyPositionInfo updates position and duration`() { + fun `applyPositionInfo updates position, duration, and trackNumber`() { val state = RemotePlayerState() - state.applyPositionInfo(positionMs = 65_000L, durationMs = 210_000L, trackUri = "x") + state.applyPositionInfo( + positionMs = 65_000L, durationMs = 210_000L, trackUri = "x", trackNumber = 3, + ) assertEquals(65_000L, state.positionMs) assertEquals(210_000L, state.durationMs) assertEquals("x", state.currentTrackUri) + assertEquals(3, state.trackNumber) } @Test diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 69bbe329..c6c2ad96 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -38,7 +38,7 @@ class AVTransportClientTest { } @Test - fun `getPositionInfo parses RelTime and TrackDuration`() = runTest { + fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest { server.enqueue( MockResponse().setBody( """ @@ -46,7 +46,7 @@ class AVTransportClientTest { - 1 + 3 0:03:30 http://x/y.mp3 0:01:05 @@ -56,6 +56,7 @@ class AVTransportClientTest { ), ) val info = client.getPositionInfo() + assertEquals(3, info.track) assertEquals(65_000L, info.relTimeMs) assertEquals(210_000L, info.trackDurationMs) assertEquals("http://x/y.mp3", info.trackUri) @@ -104,25 +105,67 @@ class AVTransportClientTest { } @Test - fun `setNextAVTransportURI sends DIDL-Lite with mime and title`() = runTest { - server.enqueue(emptyResponse("SetNextAVTransportURI")) - client.setNextAVTransportURI( + fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest { + server.enqueue(emptyResponse("RemoveAllTracksFromQueue")) + client.removeAllTracksFromQueue() + val request = server.takeRequest() + assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("RemoveAllTracksFromQueue")) { + "SOAPACTION header missing action: ${request.getHeader("SOAPACTION")}" + } + val body = request.body.readUtf8() + assertTrue(body.contains("RemoveAllTracksFromQueue")) { "body: $body" } + } + + @Test + fun `addURIToQueue sends EnqueuedURI and DIDL-Lite metadata`() = runTest { + server.enqueue(emptyResponse("AddURIToQueue")) + client.addURIToQueue( uri = "http://x/y.mp3", mime = "audio/mpeg", title = "Song", + enqueuedURIPosition = 2, ) val body = server.takeRequest().body.readUtf8() - assertTrue(body.contains("http://x/y.mp3")) { - "body missing NextURI: $body" + assertTrue(body.contains("http://x/y.mp3")) { + "body missing EnqueuedURI: $body" } + val positionTag = "2" + assertTrue(body.contains(positionTag)) { "body missing position: $body" } assertTrue(body.contains("<dc:title>Song</dc:title>")) { - "title missing: $body" + "title missing in DIDL: $body" } - assertTrue(body.contains("audio/mpeg")) { - "body missing mime type: $body" + assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" } + } + + @Test + fun `next sends Next SOAP action`() = runTest { + server.enqueue(emptyResponse("Next")) + client.next() + val request = server.takeRequest() + assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("\"Next\"")) { + "SOAPACTION: ${request.getHeader("SOAPACTION")}" } } + @Test + fun `previous sends Previous SOAP action`() = runTest { + server.enqueue(emptyResponse("Previous")) + client.previous() + val request = server.takeRequest() + assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("\"Previous\"")) { + "SOAPACTION: ${request.getHeader("SOAPACTION")}" + } + } + + @Test + fun `seekToTrack sends TRACK_NR unit with 1-based target`() = runTest { + server.enqueue(emptyResponse("Seek")) + client.seekToTrack(trackNumber = 4) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("TRACK_NR")) { "body: $body" } + assertTrue(body.contains("4")) { "body: $body" } + } + @Test fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest { server.enqueue(emptyResponse("SetAVTransportURI")) From e20d7b1438f05e2f1dc687e6095fa8b3271e0b82 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:06:54 -0400 Subject: [PATCH 35/69] fix(android): correct SOAPACTION assertions in next/previous tests --- .../minstrel/player/output/upnp/AVTransportClientTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index c6c2ad96..a76ba348 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -142,7 +142,7 @@ class AVTransportClientTest { server.enqueue(emptyResponse("Next")) client.next() val request = server.takeRequest() - assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("\"Next\"")) { + assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Next\"")) { "SOAPACTION: ${request.getHeader("SOAPACTION")}" } } @@ -152,7 +152,7 @@ class AVTransportClientTest { server.enqueue(emptyResponse("Previous")) client.previous() val request = server.takeRequest() - assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("\"Previous\"")) { + assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Previous\"")) { "SOAPACTION: ${request.getHeader("SOAPACTION")}" } } From e2866795eff067a36bd698917a61da3f1b439fd6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:18:11 -0400 Subject: [PATCH 36/69] fix(android): skip zero-duration auto-advance during UPnP playback --- .../com/fabledsword/minstrel/player/PlayerController.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 908b7e3e..15e3e7fa 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 @@ -61,6 +61,7 @@ class PlayerController @Inject constructor( @ApplicationScope private val scope: CoroutineScope, private val radio: RadioController, private val playerFactory: PlayerFactory, + private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, ) { /** @@ -321,6 +322,11 @@ class PlayerController @Inject constructor( * and advance past the dead track. Otherwise no-op. */ private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) { + // Skip during UPnP playback. ExoPlayer is intentionally paused and never + // buffers tracks, so duration is permanently 0 / TIME_UNSET -- without + // this guard we rapid-advance through the entire local queue (and spam + // /api/playback-errors) every time the user activates a UPnP route. + if (activeUpnpHolder.active.value != null) return val current = queueRefs.getOrNull(idx) ?: return val duration = controller.duration val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET From e011b04e0471c86573ceda47a0e404477622fba8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:24:34 -0400 Subject: [PATCH 37/69] fix(android): remove pollLoop cursor sync -- races with queue load and SOAP --- .../player/MinstrelForwardingPlayer.kt | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index a3a3883e..c5289f92 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -216,13 +216,15 @@ class MinstrelForwardingPlayer( /** * One poll tick: read position + transport state from Sonos and apply to - * [remoteState]. Cursor sync: Sonos's 1-based Track index is the truth — - * if it disagrees with the local cursor, post an advance on the player - * thread so they converge. This handles Sonos hardware-button advances - * and natural auto-advance without GENA eventing. - * - * Extracted from [pollLoop] to keep that method within detekt's - * LongMethod=60 limit. + * [remoteState]. The local cursor is NOT synced to Sonos's Track index -- + * during the 17-second queue load Sonos reports Track=1 (the first added + * track) which would silently walk the local cursor to 0, then SetAV+Seek + * lands and Sonos jumps to the user's actual track, racing with the + * sync. Local + Sonos cursors stay in sync because every override + * (seekToNext/Prev/seekTo) advances both sides by the same delta. + * Hardware-button or Sonos-app driven advances ARE missed until the + * user takes a transport action -- accepted tradeoff pending GENA + * event subscriptions. */ private suspend fun pollOnce(active: ActiveUpnp) { val info = active.avTransport.getPositionInfo() @@ -239,18 +241,6 @@ class MinstrelForwardingPlayer( TransportState.STOPPED -> remoteState.applyTransportStopped() TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit } - // Cursor sync: post a check to the player thread. Track is 1-based; - // sonosIndex is 0-based. If Sonos advanced (hardware button, natural - // end-of-track) and our local cursor is behind, walk it forward. - val sonosIndex = info.track - 1 - if (sonosIndex >= 0) { - handler.post { - val localIndex = delegate.currentMediaItemIndex - if (sonosIndex != localIndex && sonosIndex < delegate.mediaItemCount) { - delegate.seekTo(sonosIndex, 0L) - } - } - } } private companion object { From e6c3c959faa1215c92c47da58d9218e43194fb75 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:25:53 -0400 Subject: [PATCH 38/69] fix(android): handleZeroDurationIfNeeded ReturnCount within cap --- .../java/com/fabledsword/minstrel/player/PlayerController.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 15e3e7fa..0b3aaa98 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 @@ -326,8 +326,8 @@ class PlayerController @Inject constructor( // buffers tracks, so duration is permanently 0 / TIME_UNSET -- without // this guard we rapid-advance through the entire local queue (and spam // /api/playback-errors) every time the user activates a UPnP route. - if (activeUpnpHolder.active.value != null) return - val current = queueRefs.getOrNull(idx) ?: return + val current = queueRefs.getOrNull(idx) + if (current == null || activeUpnpHolder.active.value != null) return val duration = controller.duration val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET if (!isZeroDuration) return From 612953615333808dcaf64a464bd7c4a0fa40ae75 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:32:33 -0400 Subject: [PATCH 39/69] diag(android): log ForwardingPlayer transport overrides on entry --- .../minstrel/player/MinstrelForwardingPlayer.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index c5289f92..1bc746d4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -63,6 +63,7 @@ class MinstrelForwardingPlayer( override fun play() { val active = holder.active.value + Timber.w("ForwardingPlayer.play() active=%s", active?.routeName) if (active == null) { super.play() } else { @@ -76,6 +77,7 @@ class MinstrelForwardingPlayer( override fun pause() { val active = holder.active.value + Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName) if (active == null) { super.pause() } else { @@ -89,6 +91,7 @@ class MinstrelForwardingPlayer( override fun seekTo(positionMs: Long) { val active = holder.active.value + Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName) if (active == null) { super.seekTo(positionMs) } else { @@ -112,6 +115,10 @@ class MinstrelForwardingPlayer( */ override fun seekTo(mediaItemIndex: Int, positionMs: Long) { val active = holder.active.value + Timber.w( + "ForwardingPlayer.seekTo(idx=%d, %dms) active=%s", + mediaItemIndex, positionMs, active?.routeName, + ) if (active == null) { super.seekTo(mediaItemIndex, positionMs) return @@ -129,6 +136,7 @@ class MinstrelForwardingPlayer( override fun seekToNextMediaItem() { val active = holder.active.value + Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName) if (active == null) { super.seekToNextMediaItem() return @@ -145,6 +153,7 @@ class MinstrelForwardingPlayer( override fun seekToPreviousMediaItem() { val active = holder.active.value + Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName) if (active == null) { super.seekToPreviousMediaItem() return From 9c0013f4b660725c3f97def3c22f181f6865df4e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:46:57 -0400 Subject: [PATCH 40/69] fix(android): defer holder.active until SOAP wired -- drop transport during load Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 33 ++++++++++++++++ .../minstrel/player/PlayerController.kt | 13 ++++--- .../player/output/ActiveUpnpHolder.kt | 14 +++++++ .../player/output/OutputPickerController.kt | 39 +++++++++++++------ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 1bc746d4..ef7efba0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -61,7 +61,20 @@ class MinstrelForwardingPlayer( private fun isRemote(): Boolean = holder.active.value != null + /** + * Returns true when selectUpnp has marked a UPnP route as the intended + * target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this + * window we drop transport commands silently -- they would hit Sonos's + * stale state from a prior session and trigger restarts. + */ + private fun isLoadingUpnp(): Boolean = + holder.target.value != null && holder.active.value == null + override fun play() { + if (isLoadingUpnp()) { + Timber.w("ForwardingPlayer.play() dropped -- UPnP loading") + return + } val active = holder.active.value Timber.w("ForwardingPlayer.play() active=%s", active?.routeName) if (active == null) { @@ -76,6 +89,10 @@ class MinstrelForwardingPlayer( } override fun pause() { + if (isLoadingUpnp()) { + Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading") + return + } val active = holder.active.value Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName) if (active == null) { @@ -90,6 +107,10 @@ class MinstrelForwardingPlayer( } override fun seekTo(positionMs: Long) { + if (isLoadingUpnp()) { + Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading") + return + } val active = holder.active.value Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName) if (active == null) { @@ -114,6 +135,10 @@ class MinstrelForwardingPlayer( * [positionMs] is non-zero. */ override fun seekTo(mediaItemIndex: Int, positionMs: Long) { + if (isLoadingUpnp()) { + Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading") + return + } val active = holder.active.value Timber.w( "ForwardingPlayer.seekTo(idx=%d, %dms) active=%s", @@ -135,6 +160,10 @@ class MinstrelForwardingPlayer( } override fun seekToNextMediaItem() { + if (isLoadingUpnp()) { + Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading") + return + } val active = holder.active.value Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName) if (active == null) { @@ -152,6 +181,10 @@ class MinstrelForwardingPlayer( } override fun seekToPreviousMediaItem() { + if (isLoadingUpnp()) { + Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading") + return + } val active = holder.active.value Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName) if (active == null) { 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 0b3aaa98..ff44c4bd 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 @@ -322,12 +322,15 @@ class PlayerController @Inject constructor( * and advance past the dead track. Otherwise no-op. */ private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) { - // Skip during UPnP playback. ExoPlayer is intentionally paused and never - // buffers tracks, so duration is permanently 0 / TIME_UNSET -- without - // this guard we rapid-advance through the entire local queue (and spam - // /api/playback-errors) every time the user activates a UPnP route. + // Skip during UPnP playback (active) AND during the activation load + // window (target set, active not yet wired). ExoPlayer is intentionally + // paused throughout both windows so its STATE_READY duration is always + // 0 / TIME_UNSET -- without this guard we rapid-advance through the + // entire local queue (and spam /api/playback-errors). val current = queueRefs.getOrNull(idx) - if (current == null || activeUpnpHolder.active.value != null) return + val upnpEngaged = activeUpnpHolder.active.value != null || + activeUpnpHolder.target.value != null + if (current == null || upnpEngaged) return val duration = controller.duration val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET if (!isZeroDuration) return diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt index c92201c6..601925ac 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt @@ -23,7 +23,21 @@ data class ActiveUpnp( @Singleton class ActiveUpnpHolder @Inject constructor() { + private val internal = MutableStateFlow(null) val active: StateFlow = internal.asStateFlow() + + /** + * Pending route id during selectUpnp's queue-load window. Set when + * loadQueueOnSonos starts; cleared on completion (success or failure). + * ForwardingPlayer overrides treat (target != null && active == null) + * as "UPnP intended but SOAP not yet wired" -- drop transport commands + * silently rather than send them to a half-loaded queue. + */ + private val targetInternal = MutableStateFlow(null) + val target: StateFlow = targetInternal.asStateFlow() + fun set(active: ActiveUpnp?) { internal.value = active } + + fun setTarget(routeId: String?) { targetInternal.value = routeId } } 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 dcef6235..971b96af 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 @@ -166,6 +166,7 @@ class OutputPickerController @Inject constructor( val capturedPositionMs = remoteState.positionMs val wasPlayingRemote = remoteState.isPlaying activeUpnpHolder.set(null) + activeUpnpHolder.setTarget(null) selectedUpnpRouteIdInternal.value = null playerController.seekTo(capturedPositionMs) if (wasPlayingRemote) playerController.play() @@ -221,6 +222,7 @@ class OutputPickerController @Inject constructor( runCatching { active?.avTransport?.stop() } .onFailure { Timber.w(it, "UPnP Stop failed during disconnect") } activeUpnpHolder.set(null) + activeUpnpHolder.setTarget(null) selectedUpnpRouteIdInternal.value = null playerController.seekTo(capturedPositionMs) if (wasPlayingRemote) playerController.play() @@ -235,10 +237,14 @@ class OutputPickerController @Inject constructor( * clear the device's queue, load every track from our local queue * via AddURIToQueue, point the transport at the queue URI, seek to * the current index, and play. Wrapped in `runCatching` — SOAP - * failure abandons the selection cleanly. Local pause is handled by - * MinstrelForwardingPlayer.onActiveChanged via holder.set(), so we - * do not call playerController.pause() here (it would race with the - * SOAP override once holder.active is non-null). + * failure abandons the selection cleanly. + * + * Order of operations is deliberate: + * 1. Pause local so the user doesn't keep hearing local audio. + * 2. Set target early so ForwardingPlayer drops transport taps + * while the 17-second queue load is in progress. + * 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands + * are never routed to a half-loaded Sonos queue. */ private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock { val uiState = playerController.uiState.value @@ -262,20 +268,29 @@ class OutputPickerController @Inject constructor( return@withLock } val rendering = renderingClientFor(effectiveRoute.id) + // Pause local before flipping UI state -- user shouldn't keep hearing + // local audio while we queue up Sonos. + playerController.pause() selectedUpnpRouteIdInternal.value = effectiveRoute.id - activeUpnpHolder.set( - ActiveUpnp( - routeId = effectiveRoute.id, - routeName = effectiveRoute.name, - avTransport = transport, - rendering = rendering, - ), - ) + // Mark UPnP loading. ForwardingPlayer overrides drop transport commands + // silently while target is set but active is null -- the user's premature + // taps don't hit Sonos's stale state from a prior session. + activeUpnpHolder.setTarget(effectiveRoute.id) runCatching { loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex) + // Wire active LAST -- SOAP path is now safe to use. + activeUpnpHolder.set( + ActiveUpnp( + routeId = effectiveRoute.id, + routeName = effectiveRoute.name, + avTransport = transport, + rendering = rendering, + ), + ) }.onFailure { e -> Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}") activeUpnpHolder.set(null) + activeUpnpHolder.setTarget(null) selectedUpnpRouteIdInternal.value = null } } From 87ad7f4dc2e365db21e95f25cde57c09c0e9efd8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 22:56:06 -0400 Subject: [PATCH 41/69] 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( From 33285b53c6acc6ddad816f33e7c688a3c6bb4eed Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:08:32 -0400 Subject: [PATCH 42/69] fix(android): refresh uiState.isPlaying from remoteState during UPnP --- .../minstrel/player/PlayerController.kt | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 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 180e85b3..3db7a6e5 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 @@ -63,6 +63,7 @@ class PlayerController @Inject constructor( private val radio: RadioController, private val playerFactory: PlayerFactory, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, + private val remoteState: RemotePlayerState, ) { /** @@ -471,10 +472,31 @@ class PlayerController @Inject constructor( scope.launch(Dispatchers.Main.immediate) { while (isActive) { delay(POSITION_POLL_INTERVAL_MS) - if (!controller.isPlaying) continue - uiStateInternal.value = uiStateInternal.value.copy( - positionMs = controller.currentPosition.coerceAtLeast(0), - bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0), + // When UPnP is active, MediaController's cached state is stale -- + // remoteState.applyTransportPlaying() doesn't fire a Player.Listener + // event, so onEvents never refreshes isPlaying / positionMs from + // the wrapped player. Read directly from remoteState here so the + // play/pause toggle and scrubber reflect Sonos's actual state. + val upnpActive = activeUpnpHolder.active.value != null + val effectiveIsPlaying = + if (upnpActive) remoteState.isPlaying else controller.isPlaying + val effectivePosition = + if (upnpActive) remoteState.positionMs else controller.currentPosition + val effectiveDuration = + if (upnpActive) remoteState.durationMs else controller.duration + val current = uiStateInternal.value + val newPos = effectivePosition.coerceAtLeast(0) + val newDur = effectiveDuration.coerceAtLeast(0) + val newBuf = controller.bufferedPosition.coerceAtLeast(0) + val changed = current.isPlaying != effectiveIsPlaying || + current.positionMs != newPos || + current.durationMs != newDur + if (!changed) continue + uiStateInternal.value = current.copy( + isPlaying = effectiveIsPlaying, + positionMs = newPos, + durationMs = newDur, + bufferedPositionMs = newBuf, ) } } From 6a7958c9212cf20ebf3720b9637a542796ca7862 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:19:29 -0400 Subject: [PATCH 43/69] fix(android): debounce non-PLAYING poll to suppress UPnP track-change flicker --- .../player/MinstrelForwardingPlayer.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index ef7efba0..db857765 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -53,6 +53,12 @@ class MinstrelForwardingPlayer( private val handler = Handler(delegate.applicationLooper) private var pollJob: Job? = null + // Tracks consecutive non-PLAYING poll observations so a single transient + // PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not + // flip the play/pause button. Manual pause still feels instant because it + // bypasses the poll entirely via applyTransportPaused(). + @Volatile private var nonPlayingPollStreak = 0 + init { scope.launch { holder.active.collect { active -> onActiveChanged(active) } @@ -227,6 +233,7 @@ class MinstrelForwardingPlayer( private fun onActiveChanged(active: ActiveUpnp?) { pollJob?.cancel() + nonPlayingPollStreak = 0 if (active != null) { Timber.w("UPnP active: %s -- pollLoop starting", active.routeName) // Pause the wrapped ExoPlayer so we are not playing local audio @@ -278,14 +285,28 @@ class MinstrelForwardingPlayer( ) val transport = active.avTransport.getTransportInfo() when (transport.state) { - TransportState.PLAYING -> remoteState.applyTransportPlaying() - TransportState.PAUSED -> remoteState.applyTransportPaused() - TransportState.STOPPED -> remoteState.applyTransportStopped() + TransportState.PLAYING -> { + nonPlayingPollStreak = 0 + remoteState.applyTransportPlaying() + } + TransportState.PAUSED -> { + nonPlayingPollStreak += 1 + if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) { + remoteState.applyTransportPaused() + } + } + TransportState.STOPPED -> { + nonPlayingPollStreak += 1 + if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) { + remoteState.applyTransportStopped() + } + } TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit } } private companion object { const val POLL_INTERVAL_MS = 1_000L + const val NON_PLAYING_CONFIRM = 2 } } From b1a66f18bdb2a8170942255407b0e0d8240fbc05 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:29:42 -0400 Subject: [PATCH 44/69] feat(android): shuffle on system playlist tile play -- Home + Playlists list --- .../minstrel/home/ui/HomeScreen.kt | 44 +------------ .../playlists/data/PlaylistPlayback.kt | 65 ++++++++++++++++++ .../playlists/ui/PlaylistsListScreen.kt | 66 ++++++++++++++++++- 3 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index cf5a3649..eb913061 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -69,7 +69,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.Home import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.playlists.data.PlaylistsRepository -import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs +import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard @@ -95,11 +95,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout import javax.inject.Inject private const val SHARE_STOP_TIMEOUT_MS = 5_000L -private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140 // Recently Added is laid out in a multi-row LazyHorizontalGrid that // scrolls as one panel (same pattern as Most Played). Two rows trades @@ -259,45 +257,9 @@ class HomeViewModel @Inject constructor( */ suspend fun playPlaylist(playlist: PlaylistRef) { viewModelScope.launch { - val detail = try { - withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) { - if (playlist.refreshable && playlist.systemVariant != null) { - playlistsRepository.systemShuffle(playlist.systemVariant) - } else { - playlistsRepository.refreshDetail(playlist.id) - } - } - } catch ( - @Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException, - ) { - poolMessages.trySend("Couldn't load playlist - check your connection") - return@launch - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - poolMessages.trySend( - "Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}", - ) - return@launch + playPlaylistShuffled(playlist, playlistsRepository, player) { + poolMessages.trySend(it) } - // Shared with PlaylistDetailViewModel.play - filters out - // unplayable rows (missing trackId or empty streamUrl) so the - // queue can't end up with tracks Media3 silently rejects. - val tracks = detail.tracks.toPlayableTrackRefs() - if (tracks.isEmpty()) { - poolMessages.trySend("Mix isn't ready yet - try again in a moment") - return@launch - } - // Drift #564: send the BARE systemVariant string, not - // "playlist:" — the server's rotation matcher - // (internal/playevents/writer.go systemPlaylistSources) - // keys on the bare variant. Web sends the bare form too - // (web/src/lib/components/PlaylistCard.svelte:83), so this - // brings Android into alignment. Wrong prefix here meant - // system-mix plays from Android Home never advanced the - // rotation. - val source = if (playlist.refreshable) playlist.systemVariant else null - player.setQueue(tracks, initialIndex = 0, source = source) }.join() } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt new file mode 100644 index 00000000..4f920d5e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt @@ -0,0 +1,65 @@ +package com.fabledsword.minstrel.playlists.data + +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.shared.ErrorCopy +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout + +private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L + +/** + * Fetch a playlist and hand it to the player as a shuffled queue. + * + * Behavior matches the tile play-button contract used on Home and the + * Playlists list: refreshable system playlists go through systemShuffle + * (server-side rotation-aware order; tagging with the variant advances + * rotation), everything else uses refreshDetail. System playlists are + * then client-side shuffled so the tile feels random rather than + * "start at the rotation head". User playlists keep their authored + * order. + * + * Errors and empty mixes surface through [onMessage] for the caller to + * present as a snackbar / toast / etc. Returns when the player has + * accepted the queue (or an error path bailed). + */ +suspend fun playPlaylistShuffled( + playlist: PlaylistRef, + repository: PlaylistsRepository, + player: PlayerController, + onMessage: (String) -> Unit, +) { + val detail = try { + withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) { + if (playlist.refreshable && playlist.systemVariant != null) { + repository.systemShuffle(playlist.systemVariant) + } else { + repository.refreshDetail(playlist.id) + } + } + } catch ( + @Suppress("SwallowedException") _: TimeoutCancellationException, + ) { + onMessage("Couldn't load playlist - check your connection") + return + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}") + return + } + val tracks = detail.tracks.toPlayableTrackRefs() + if (tracks.isEmpty()) { + onMessage("Mix isn't ready yet - try again in a moment") + return + } + // Drift #564: bare systemVariant string (not "playlist:") -- + // server's rotation matcher keys on the bare variant. + val source = if (playlist.refreshable) playlist.systemVariant else null + // System playlist tile play button == "pick a random song + shuffle + // the rest" UX. Server's rotation-aware order still drives rotation + // bookkeeping via `source`; the client shuffle just removes the + // deterministic "start at rotation head" feel. + val ordered = if (playlist.isSystem) tracks.shuffled() else tracks + player.setQueue(ordered, initialIndex = 0, source = source) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index ee730e76..b5302718 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -23,11 +27,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController +import com.fabledsword.minstrel.connectivity.ConnectivityObserver import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.Playlists +import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.playlists.widgets.PlaylistCard @@ -37,12 +44,19 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + // ─── State ─────────────────────────────────────────────────────────── // ─── ViewModel ─────────────────────────────────────────────────────── @@ -50,9 +64,25 @@ import javax.inject.Inject @HiltViewModel class PlaylistsListViewModel @Inject constructor( private val repository: PlaylistsRepository, + private val player: PlayerController, private val eventsStream: EventsStream, + connectivity: ConnectivityObserver, ) : ViewModel() { + private val poolMessages = Channel(Channel.BUFFERED) + + /** Transient snackbar messages from playlist tile play taps. */ + val transientMessages: Flow = poolMessages.receiveAsFlow() + + /** True when the device has no usable internet -- gates refreshable system tiles. */ + val offline: StateFlow = connectivity.online + .map { !it } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) + init { refresh() // Live updates: a playlist created/updated/deleted from another @@ -69,6 +99,15 @@ class PlaylistsListViewModel @Inject constructor( runCatching { repository.refreshList() } } + /** Tile play button: shuffle the playlist's tracks and start at index 0. */ + suspend fun playPlaylist(playlist: PlaylistRef) { + viewModelScope.launch { + playPlaylistShuffled(playlist, repository, player) { + poolMessages.trySend(it) + } + }.join() + } + val uiState: StateFlow>> = repository.observeAll() .map { list -> @@ -88,6 +127,10 @@ fun PlaylistsListScreen( navController: NavHostController, viewModel: PlaylistsListViewModel = hiltViewModel(), ) { + val snackbar = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.transientMessages.collect { snackbar.showSnackbar(it) } + } Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -97,8 +140,10 @@ fun PlaylistsListScreen( currentRouteName = Playlists::class.qualifiedName, ) }, + snackbarHost = { SnackbarHost(snackbar) }, ) { inner -> val state by viewModel.uiState.collectAsStateWithLifecycle() + val offline by viewModel.offline.collectAsStateWithLifecycle() PullToRefreshScaffold( onRefresh = { viewModel.refresh().join() }, modifier = Modifier.fillMaxSize().padding(inner), @@ -117,7 +162,9 @@ fun PlaylistsListScreen( ) is UiState.Success -> PlaylistsGrid( playlists = s.data, + offline = offline, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, + onPlay = viewModel::playPlaylist, ) } } @@ -127,7 +174,9 @@ fun PlaylistsListScreen( @Composable private fun PlaylistsGrid( playlists: List, + offline: Boolean, onPlaylistClick: (String) -> Unit, + onPlay: suspend (PlaylistRef) -> Unit, ) { val systemPlaylists = playlists.filter { it.isSystem } val userPlaylists = playlists.filter { !it.isSystem } @@ -142,7 +191,15 @@ private fun PlaylistsGrid( SectionHeader("System playlists") } items(items = systemPlaylists, key = { it.id }) { playlist -> - PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) }) + PlaylistCard( + playlist = playlist, + onClick = { onPlaylistClick(playlist.id) }, + onPlay = { onPlay(playlist) }, + // Refreshable system tiles need server endpoints (systemShuffle); + // disable when offline. Empty mixes show a snackbar after tap. + playEnabled = playlist.trackCount > 0 && + !(offline && playlist.refreshable), + ) } } if (userPlaylists.isNotEmpty()) { @@ -150,7 +207,12 @@ private fun PlaylistsGrid( SectionHeader("Your playlists") } items(items = userPlaylists, key = { it.id }) { playlist -> - PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) }) + PlaylistCard( + playlist = playlist, + onClick = { onPlaylistClick(playlist.id) }, + onPlay = { onPlay(playlist) }, + playEnabled = playlist.trackCount > 0, + ) } } } From 8f89279fa47bc07e9350466ba812942af6f24e62 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:36:43 -0400 Subject: [PATCH 45/69] fix(android): UPnP survives screen-off + cursor catches up to Sonos --- .../player/MinstrelForwardingPlayer.kt | 40 ++++++++++++++----- .../minstrel/player/RemotePlayerState.kt | 7 +++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index db857765..cc2e74ae 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -264,16 +264,19 @@ class MinstrelForwardingPlayer( } /** - * One poll tick: read position + transport state from Sonos and apply to - * [remoteState]. The local cursor is NOT synced to Sonos's Track index -- - * during the 17-second queue load Sonos reports Track=1 (the first added - * track) which would silently walk the local cursor to 0, then SetAV+Seek - * lands and Sonos jumps to the user's actual track, racing with the - * sync. Local + Sonos cursors stay in sync because every override - * (seekToNext/Prev/seekTo) advances both sides by the same delta. - * Hardware-button or Sonos-app driven advances ARE missed until the - * user takes a transport action -- accepted tradeoff pending GENA - * event subscriptions. + * One poll tick: read position + transport state from Sonos, apply to + * [remoteState], and forward-sync the local cursor to Sonos's Track + * index when not in queue load. + * + * Cursor sync is gated on `holder.target == null` (= not loading) + * because during load Sonos reports Track=1 while we're still + * appending, and syncing would race the SetAV+Seek that lands + * after. Outside load, forward sync catches Sonos auto-advances + * (queue end-of-track), Sonos-app driven Next presses, and any + * drift after a brief poll-failure burst that didn't trip the + * drop threshold. Forward-only because a Next override we just + * issued can race with a poll still reporting the prior Track -- + * the next poll catches up safely. */ private suspend fun pollOnce(active: ActiveUpnp) { val info = active.avTransport.getPositionInfo() @@ -283,6 +286,7 @@ class MinstrelForwardingPlayer( trackUri = info.trackUri, trackNumber = info.track, ) + maybeSyncLocalCursor(info.track) val transport = active.avTransport.getTransportInfo() when (transport.state) { TransportState.PLAYING -> { @@ -305,6 +309,22 @@ class MinstrelForwardingPlayer( } } + private fun maybeSyncLocalCursor(sonosTrack: Int) { + if (holder.target.value != null) return + if (sonosTrack <= 0) return + val sonosIdx = sonosTrack - 1 + handler.post { + val localIdx = delegate.currentMediaItemIndex + if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) { + Timber.w( + "UPnP cursor catch-up: local=%d -> sonos=%d", + localIdx, sonosIdx, + ) + delegate.seekTo(sonosIdx, 0L) + } + } + } + private companion object { const val POLL_INTERVAL_MS = 1_000L const val NON_PLAYING_CONFIRM = 2 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 40afc914..fdbd5fcc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -67,6 +67,11 @@ class RemotePlayerState @Inject constructor() { } private companion object { - const val DROP_THRESHOLD = 3 + // ~30 seconds of consecutive poll failures before declaring the route + // dropped. Bumped from 3 because screen-off WiFi sleep / brief Doze + // can stall socket I/O for several seconds without the renderer + // actually being unreachable -- a 3-failure drop kicked us back to + // local audio every time the phone went into a pocket. + const val DROP_THRESHOLD = 30 } } From 3576e241c0f105bded995c73fd93177037aa89ac Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:37:30 -0400 Subject: [PATCH 46/69] fix(android): split playPlaylistShuffled to satisfy detekt ReturnCount --- .../playlists/data/PlaylistPlayback.kt | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt index 4f920d5e..5a54aebf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt @@ -29,25 +29,7 @@ suspend fun playPlaylistShuffled( player: PlayerController, onMessage: (String) -> Unit, ) { - val detail = try { - withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) { - if (playlist.refreshable && playlist.systemVariant != null) { - repository.systemShuffle(playlist.systemVariant) - } else { - repository.refreshDetail(playlist.id) - } - } - } catch ( - @Suppress("SwallowedException") _: TimeoutCancellationException, - ) { - onMessage("Couldn't load playlist - check your connection") - return - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}") - return - } + val detail = fetchPlaylistDetail(playlist, repository, onMessage) ?: return val tracks = detail.tracks.toPlayableTrackRefs() if (tracks.isEmpty()) { onMessage("Mix isn't ready yet - try again in a moment") @@ -63,3 +45,27 @@ suspend fun playPlaylistShuffled( val ordered = if (playlist.isSystem) tracks.shuffled() else tracks player.setQueue(ordered, initialIndex = 0, source = source) } + +private suspend fun fetchPlaylistDetail( + playlist: PlaylistRef, + repository: PlaylistsRepository, + onMessage: (String) -> Unit, +): PlaylistDetailRef? = try { + withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) { + if (playlist.refreshable && playlist.systemVariant != null) { + repository.systemShuffle(playlist.systemVariant) + } else { + repository.refreshDetail(playlist.id) + } + } +} catch ( + @Suppress("SwallowedException") _: TimeoutCancellationException, +) { + onMessage("Couldn't load playlist - check your connection") + null +} catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, +) { + onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}") + null +} From eae5dcad23bdd084e8e93c3fe16b2d88d7119f4b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:42:18 -0400 Subject: [PATCH 47/69] fix(android): correct ErrorCopy import path in PlaylistPlayback --- .../com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt index 5a54aebf..aca9f650 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistPlayback.kt @@ -2,7 +2,7 @@ package com.fabledsword.minstrel.playlists.data import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.player.PlayerController -import com.fabledsword.minstrel.shared.ErrorCopy +import com.fabledsword.minstrel.api.ErrorCopy import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout From e62fac3a0eab564d56b65aed3d0c9c113d173ce8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:48:52 -0400 Subject: [PATCH 48/69] fix(android): onEvents reads UPnP state so resume keeps duration --- .../minstrel/player/PlayerController.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 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 3db7a6e5..b8602717 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 @@ -429,15 +429,30 @@ class PlayerController @Inject constructor( ?.getString(MINSTREL_SOURCE_KEY) val isUpnpLoading = activeUpnpHolder.target.value != null && activeUpnpHolder.active.value == null + // When UPnP is active, the wrapped ExoPlayer is paused with + // no real audio loaded -- player.duration / isPlaying / + // currentPosition all reflect that. Read from remoteState + // instead so an onEvents fire (e.g. activity resume) doesn't + // clobber the UI with zeros. + val upnpActive = activeUpnpHolder.active.value != null uiStateInternal.value = PlayerUiState( currentTrack = current, queue = queueRefs, queueIndex = idx, - isPlaying = player.isPlaying, - isBuffering = player.playbackState == Player.STATE_BUFFERING, - positionMs = player.currentPosition.coerceAtLeast(0), - durationMs = player.duration.coerceAtLeast(0), + isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying, + isBuffering = !upnpActive && + player.playbackState == Player.STATE_BUFFERING, + positionMs = if (upnpActive) { + remoteState.positionMs + } else { + player.currentPosition + }.coerceAtLeast(0), + durationMs = if (upnpActive) { + remoteState.durationMs + } else { + player.duration + }.coerceAtLeast(0), bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0), playbackError = player.playerError?.message, currentSource = source, From 47b0894ad60f2169898d0aebd29fae9d80a5e389 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:50:37 -0400 Subject: [PATCH 49/69] test(android): update RemotePlayerState threshold expectation to 30 --- .../minstrel/player/RemotePlayerStateTest.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt index 7a6ac898..bb0c0224 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt @@ -53,17 +53,23 @@ class RemotePlayerStateTest { @Test fun `recordPollFailure trips after threshold`() { val state = RemotePlayerState() - assertFalse(state.recordPollFailure()) - assertFalse(state.recordPollFailure()) + // Threshold is 30 -- tolerate ~30s of screen-off WiFi sleep before + // declaring the remote dropped. Pre-threshold calls all return false. + repeat(DROP_THRESHOLD - 1) { + assertFalse(state.recordPollFailure()) + } assertTrue(state.recordPollFailure()) } @Test fun `recordPollSuccess clears the failure counter`() { val state = RemotePlayerState() - state.recordPollFailure() - state.recordPollFailure() + repeat(DROP_THRESHOLD - 1) { state.recordPollFailure() } state.recordPollSuccess() assertFalse(state.recordPollFailure()) } + + private companion object { + const val DROP_THRESHOLD = 30 + } } From 85926f4ec04a2d39b4374b548e32fab3447cca9a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:51:54 -0400 Subject: [PATCH 50/69] fix(android): keep service alive when UPnP is playing -- override playWhenReady --- .../minstrel/player/MinstrelForwardingPlayer.kt | 8 ++++++++ .../fabledsword/minstrel/player/MinstrelPlayerService.kt | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index cc2e74ae..b4e7a327 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -218,6 +218,14 @@ class MinstrelForwardingPlayer( override fun getPlaybackState(): Int = if (isRemote()) Player.STATE_READY else super.getPlaybackState() + // Mirror remote state so any consumer that gates on playWhenReady -- + // notably MediaSessionService's foreground-keepalive checks and our own + // onTaskRemoved -- sees the remote renderer as the source of truth. + // Without this, swiping the app away with Sonos playing would stop the + // service, kill the poll loop, and leave Sonos orphaned. + override fun getPlayWhenReady(): Boolean = + if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady() + override fun release() { pollJob?.cancel() scope.cancel() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index 8c867142..7458a730 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -176,7 +176,11 @@ class MinstrelPlayerService : MediaSessionService() { override fun onTaskRemoved(rootIntent: Intent?) { val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent) - val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED + // player.isPlaying is overridden on MinstrelForwardingPlayer to return + // remoteState.isPlaying while UPnP is active, so a swipe-away with + // Sonos playing keeps the service (and its UPnP poll loop) alive. + val activelyPlaying = player.isPlaying || + (player.playWhenReady && player.playbackState != Player.STATE_ENDED) if (!activelyPlaying) { stopSelf() } From 9628ed17495ecd44c7f386d0c68650465383c11c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:00:45 -0400 Subject: [PATCH 51/69] fix(android): duration falls back to local while Sonos hasn't reported one --- .../minstrel/player/PlayerController.kt | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 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 b8602717..4275da1c 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 @@ -433,7 +433,11 @@ class PlayerController @Inject constructor( // no real audio loaded -- player.duration / isPlaying / // currentPosition all reflect that. Read from remoteState // instead so an onEvents fire (e.g. activity resume) doesn't - // clobber the UI with zeros. + // clobber the UI with zeros. Duration falls back to the + // wrapped player's value when Sonos hasn't reported one yet + // (pre-first-poll window, or Sonos still buffering) -- the + // wrapped ExoPlayer was prepared with the same MediaItem so + // it knows the real duration before any SOAP poll lands. val upnpActive = activeUpnpHolder.active.value != null uiStateInternal.value = PlayerUiState( @@ -448,11 +452,11 @@ class PlayerController @Inject constructor( } else { player.currentPosition }.coerceAtLeast(0), - durationMs = if (upnpActive) { - remoteState.durationMs - } else { - player.duration - }.coerceAtLeast(0), + durationMs = effectiveDuration( + upnpActive, + remoteState.durationMs, + player.duration, + ), bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0), playbackError = player.playerError?.message, currentSource = source, @@ -497,11 +501,13 @@ class PlayerController @Inject constructor( if (upnpActive) remoteState.isPlaying else controller.isPlaying val effectivePosition = if (upnpActive) remoteState.positionMs else controller.currentPosition - val effectiveDuration = - if (upnpActive) remoteState.durationMs else controller.duration val current = uiStateInternal.value val newPos = effectivePosition.coerceAtLeast(0) - val newDur = effectiveDuration.coerceAtLeast(0) + val newDur = effectiveDuration( + upnpActive, + remoteState.durationMs, + controller.duration, + ) val newBuf = controller.bufferedPosition.coerceAtLeast(0) val changed = current.isPlaying != effectiveIsPlaying || current.positionMs != newPos || @@ -574,6 +580,18 @@ class PlayerController @Inject constructor( private fun sourceExtras(source: String): Bundle = Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) } + /** + * Duration to surface to the UI. When UPnP is active the remote's + * reported duration wins -- but if Sonos hasn't reported one yet + * (pre-first-poll window after activation, or transient 0 during a + * track-change buffer), fall back to the wrapped ExoPlayer's value + * for that MediaItem so the scrubber doesn't blink to zero. + */ + private fun effectiveDuration(upnpActive: Boolean, remoteMs: Long, localMs: Long): Long { + val pick = if (upnpActive && remoteMs > 0) remoteMs else localMs + return pick.coerceAtLeast(0) + } + companion object { const val MINSTREL_SOURCE_KEY: String = "minstrel_source" } From 88b161193d7e01e396efa14a957986f4266861c4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:03:48 -0400 Subject: [PATCH 52/69] fix(android): TrackRef.durationSec is final fallback so duration is never 0 --- .../minstrel/player/PlayerController.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 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 4275da1c..ffa3d021 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 @@ -581,19 +581,27 @@ class PlayerController @Inject constructor( Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) } /** - * Duration to surface to the UI. When UPnP is active the remote's - * reported duration wins -- but if Sonos hasn't reported one yet - * (pre-first-poll window after activation, or transient 0 during a - * track-change buffer), fall back to the wrapped ExoPlayer's value - * for that MediaItem so the scrubber doesn't blink to zero. + * Duration to surface to the UI. Priority: Sonos's reported duration + * (only when UPnP active and non-zero) -> wrapped ExoPlayer's value + * (only valid once it's probed the stream) -> TrackRef.durationSec + * (always known from the server response). The third tier is what + * keeps the scrubber populated when the user taps Sonos before the + * wrapped player has had time to probe its own duration -- without + * it, both top tiers report 0/TIME_UNSET and the field reads empty + * until the first SOAP poll lands. */ + @Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns private fun effectiveDuration(upnpActive: Boolean, remoteMs: Long, localMs: Long): Long { - val pick = if (upnpActive && remoteMs > 0) remoteMs else localMs - return pick.coerceAtLeast(0) + if (upnpActive && remoteMs > 0) return remoteMs + if (localMs > 0) return localMs + val idx = mediaController?.currentMediaItemIndex ?: 0 + val ref = queueRefs.getOrNull(idx) ?: return 0 + return ref.durationSec.toLong() * MS_PER_SECOND } companion object { const val MINSTREL_SOURCE_KEY: String = "minstrel_source" + private const val MS_PER_SECOND = 1_000L } } From 2425a305eb6cff164754e0948ec15a6c536cd8d2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:16:57 -0400 Subject: [PATCH 53/69] fix(android): interpolate UPnP position between polls + suppress post-seek race --- .../player/MinstrelForwardingPlayer.kt | 18 ++++++++- .../minstrel/player/PlayerController.kt | 39 +++++++++++++++---- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index b4e7a327..c7ed1a98 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -3,6 +3,7 @@ package com.fabledsword.minstrel.player import android.os.Handler +import android.os.SystemClock import androidx.media3.common.ForwardingPlayer import androidx.media3.common.Player import com.fabledsword.minstrel.player.output.ActiveUpnp @@ -59,6 +60,13 @@ class MinstrelForwardingPlayer( // bypasses the poll entirely via applyTransportPaused(). @Volatile private var nonPlayingPollStreak = 0 + // Wall-clock of the most-recent within-track seek we issued to Sonos. + // pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS + // -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that + // window reports the *old* position. Without the lockout the scrubber + // visibly jumps backwards immediately after a drag, then forwards again. + @Volatile private var lastSeekIssuedAtMs: Long = 0L + init { scope.launch { holder.active.collect { active -> onActiveChanged(active) } @@ -122,6 +130,7 @@ class MinstrelForwardingPlayer( if (active == null) { super.seekTo(positionMs) } else { + lastSeekIssuedAtMs = SystemClock.elapsedRealtime() remoteState.applyPositionInfo( positionMs = positionMs, durationMs = remoteState.durationMs, @@ -288,8 +297,14 @@ class MinstrelForwardingPlayer( */ private suspend fun pollOnce(active: ActiveUpnp) { val info = active.avTransport.getPositionInfo() + val now = SystemClock.elapsedRealtime() + val inSeekAckWindow = lastSeekIssuedAtMs > 0L && + (now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS + // Inside the seek-ack window, keep the optimistic position we wrote in + // seekTo -- the poll's reported position is stale until Sonos finishes + // processing the Seek SOAP. Other fields still refresh from the poll. remoteState.applyPositionInfo( - positionMs = info.relTimeMs, + positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs, durationMs = info.trackDurationMs, trackUri = info.trackUri, trackNumber = info.track, @@ -336,5 +351,6 @@ class MinstrelForwardingPlayer( private companion object { const val POLL_INTERVAL_MS = 1_000L const val NON_PLAYING_CONFIRM = 2 + const val SEEK_ACK_WINDOW_MS = 2_000L } } 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 ffa3d021..e0760987 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 @@ -5,6 +5,7 @@ import android.content.Context import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.SystemClock import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player @@ -491,16 +492,14 @@ class PlayerController @Inject constructor( scope.launch(Dispatchers.Main.immediate) { while (isActive) { delay(POSITION_POLL_INTERVAL_MS) - // When UPnP is active, MediaController's cached state is stale -- - // remoteState.applyTransportPlaying() doesn't fire a Player.Listener - // event, so onEvents never refreshes isPlaying / positionMs from - // the wrapped player. Read directly from remoteState here so the - // play/pause toggle and scrubber reflect Sonos's actual state. val upnpActive = activeUpnpHolder.active.value != null val effectiveIsPlaying = if (upnpActive) remoteState.isPlaying else controller.isPlaying - val effectivePosition = - if (upnpActive) remoteState.positionMs else controller.currentPosition + val effectivePosition = if (upnpActive) { + interpolatedRemotePosition(effectiveIsPlaying) + } else { + controller.currentPosition + } val current = uiStateInternal.value val newPos = effectivePosition.coerceAtLeast(0) val newDur = effectiveDuration( @@ -523,6 +522,32 @@ class PlayerController @Inject constructor( } } + // ── Remote position interpolation state ────────────────────────────── + // remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz + // SOAP poll (and only when the round-trip completes -- screen-off WiFi + // sleep can stall it for many seconds). To keep the scrubber moving + // smoothly we anchor each fresh reading + an elapsed-realtime stamp; + // between updates we display anchor + elapsed when Sonos is playing. + // A real correction lands as soon as the next poll arrives. + @Volatile private var lastSeenRemotePositionMs: Long = -1L + @Volatile private var positionAnchorMs: Long = 0L + @Volatile private var positionAnchorAtRealtimeMs: Long = 0L + + private fun interpolatedRemotePosition(isPlaying: Boolean): Long { + val raw = remoteState.positionMs + val now = SystemClock.elapsedRealtime() + if (raw != lastSeenRemotePositionMs) { + lastSeenRemotePositionMs = raw + positionAnchorMs = raw + positionAnchorAtRealtimeMs = now + } + return if (isPlaying) { + positionAnchorMs + (now - positionAnchorAtRealtimeMs) + } else { + positionAnchorMs + } + } + /** * Bridges Media3's `ListenableFuture.buildAsync()` * to a suspend function without pulling in `kotlinx-coroutines-guava` From c245b1ef0b9fc1790c54552068322d8461b7cb02 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:37:34 -0400 Subject: [PATCH 54/69] fix(android): hold UI patch until cursor sync lands; reanchor on track flip --- .../minstrel/player/PlayerController.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 e0760987..3f7ae5a8 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 @@ -411,6 +411,11 @@ class PlayerController @Inject constructor( // Reset the per-item evaluation guard so the new // item's STATE_READY transition gets a fresh check. lastEvaluatedItemIndex = -1 + // The track flipped -- re-anchor the position interpolator + // so the next polling tick treats the new track's + // remoteState.positionMs as fresh rather than carrying the + // old anchor + elapsed forward. + lastSeenRemotePositionMs = -1L } override fun onPlaybackStateChanged(playbackState: Int) { @@ -493,6 +498,14 @@ class PlayerController @Inject constructor( while (isActive) { delay(POSITION_POLL_INTERVAL_MS) val upnpActive = activeUpnpHolder.active.value != null + // Track-alignment gate: if Sonos's reported Track is past the + // wrapped player's currentMediaItemIndex, a forward cursor sync + // is in flight (pollOnce wrote new remoteState, handler.post'd + // delegate.seekTo; the seek hasn't run yet). Skipping the tick + // here lets onMediaItemTransition rebuild the full uiState + // atomically rather than patching position+duration while + // currentTrack/queueIndex still point at the old track. + if (upnpActive && isForwardCursorSyncPending(controller)) continue val effectiveIsPlaying = if (upnpActive) remoteState.isPlaying else controller.isPlaying val effectivePosition = if (upnpActive) { @@ -533,6 +546,12 @@ class PlayerController @Inject constructor( @Volatile private var positionAnchorMs: Long = 0L @Volatile private var positionAnchorAtRealtimeMs: Long = 0L + private fun isForwardCursorSyncPending(controller: MediaController): Boolean { + val sonosTrack = remoteState.trackNumber + if (sonosTrack <= 0) return false + return (sonosTrack - 1) > controller.currentMediaItemIndex + } + private fun interpolatedRemotePosition(isPlaying: Boolean): Long { val raw = remoteState.positionMs val now = SystemClock.elapsedRealtime() From 41230b5afbdec28bb9e9d6fc1399d2412ae227ce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:42:35 -0400 Subject: [PATCH 55/69] fix(android): single jump per polling loop for detekt LoopWithTooManyJumpStatements --- .../minstrel/player/PlayerController.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 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 3f7ae5a8..74cc2dc2 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 @@ -524,13 +524,14 @@ class PlayerController @Inject constructor( val changed = current.isPlaying != effectiveIsPlaying || current.positionMs != newPos || current.durationMs != newDur - if (!changed) continue - uiStateInternal.value = current.copy( - isPlaying = effectiveIsPlaying, - positionMs = newPos, - durationMs = newDur, - bufferedPositionMs = newBuf, - ) + if (changed) { + uiStateInternal.value = current.copy( + isPlaying = effectiveIsPlaying, + positionMs = newPos, + durationMs = newDur, + bufferedPositionMs = newBuf, + ) + } } } } From 389c896d65eb65d5e03c66afd30ef2927405c0c5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:55:10 -0400 Subject: [PATCH 56/69] fix(android): force poll on ON_RESUME + cap interpolation drift at 5s --- .../player/MinstrelForwardingPlayer.kt | 38 ++++++++++++++++++- .../minstrel/player/PlayerController.kt | 13 ++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index c7ed1a98..60525efd 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -4,6 +4,9 @@ package com.fabledsword.minstrel.player import android.os.Handler import android.os.SystemClock +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import androidx.media3.common.ForwardingPlayer import androidx.media3.common.Player import com.fabledsword.minstrel.player.output.ActiveUpnp @@ -14,9 +17,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select import timber.log.Timber /** @@ -67,10 +73,29 @@ class MinstrelForwardingPlayer( // visibly jumps backwards immediately after a drag, then forwards again. @Volatile private var lastSeekIssuedAtMs: Long = 0L + // Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit; + // pollLoop's select{} races the delay against this channel so the next + // pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS. + // Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI + // catches up to Sonos within RTT rather than the full poll cadence. + // CONFLATED so repeated trySend's between polls don't queue up. + private val pollTrigger = Channel(Channel.CONFLATED) + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + pollTrigger.trySend(Unit) + } + } + init { scope.launch { holder.active.collect { active -> onActiveChanged(active) } } + // Process lifecycle is observed on the main thread; ProcessLifecycleOwner's + // addObserver requires it. The observer just trySend's to the channel. + handler.post { + ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) + } } private fun isRemote(): Boolean = holder.active.value != null @@ -238,6 +263,9 @@ class MinstrelForwardingPlayer( override fun release() { pollJob?.cancel() scope.cancel() + handler.post { + ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) + } super.release() } @@ -266,6 +294,7 @@ class MinstrelForwardingPlayer( } } + @OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive private suspend fun pollLoop(active: ActiveUpnp) { while (scope.isActive && holder.active.value?.routeId == active.routeId) { val outcome = runCatching { pollOnce(active) } @@ -276,7 +305,12 @@ class MinstrelForwardingPlayer( handler.post { onDrop(active.routeName) } return } - delay(POLL_INTERVAL_MS) + // Race the normal cadence against any external wake (activity + // resume). Whichever wins continues to the next pollOnce. + select { + onTimeout(POLL_INTERVAL_MS) {} + pollTrigger.onReceive {} + } } } 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 74cc2dc2..f9ee7ed1 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 @@ -561,11 +561,13 @@ class PlayerController @Inject constructor( positionAnchorMs = raw positionAnchorAtRealtimeMs = now } - return if (isPlaying) { - positionAnchorMs + (now - positionAnchorAtRealtimeMs) - } else { - positionAnchorMs - } + if (!isPlaying) return positionAnchorMs + // Cap how far past the last anchor we extrapolate. After + // MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the + // displayed position at anchor + cap rather than projecting wildly. + // The next successful poll re-anchors and motion resumes. + val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS) + return positionAnchorMs + delta } /** @@ -647,6 +649,7 @@ class PlayerController @Inject constructor( companion object { const val MINSTREL_SOURCE_KEY: String = "minstrel_source" private const val MS_PER_SECOND = 1_000L + private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L } } From c5b326c620282309f2f559052bbe1fdc66f9666c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:56:48 -0400 Subject: [PATCH 57/69] fix(android): UPnP extend captures SOAP fault, aborts after 3 failures --- .../player/output/OutputPickerController.kt | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) 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 382bd1c4..69119c51 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 @@ -14,6 +14,7 @@ import com.fabledsword.minstrel.player.StreamTokenProvider import com.fabledsword.minstrel.player.output.upnp.AVTransportClient import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient import com.fabledsword.minstrel.player.output.upnp.SoapClient +import com.fabledsword.minstrel.player.output.upnp.SoapFaultException import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import com.fabledsword.minstrel.player.output.upnp.bareUdn import dagger.hilt.android.qualifiers.ApplicationContext @@ -350,12 +351,14 @@ class OutputPickerController @Inject constructor( "UPnP extend: appending %d tracks starting at position %d", tracks.size, startPosition + 1, ) - tracks.forEachIndexed { i, ref -> + var consecutiveFailures = 0 + var succeeded = 0 + for ((i, ref) in tracks.withIndex()) { if (activeUpnpHolder.active.value?.routeId != route.id) { Timber.w("UPnP extend: cancelled at offset %d (route changed)", i) - return + break } - runCatching { + val outcome = runCatching { val token = streamTokens.mint(ref.id) transport.addURIToQueue( uri = token.url, @@ -363,9 +366,27 @@ class OutputPickerController @Inject constructor( title = token.title, enqueuedURIPosition = startPosition + i + 1, ) - }.onFailure { Timber.w(it, "UPnP extend: append failed at offset %d", i) } + } + if (outcome.isSuccess) { + consecutiveFailures = 0 + succeeded += 1 + } else { + consecutiveFailures += 1 + val e = outcome.exceptionOrNull() + val detail = (e as? SoapFaultException)?.let { + "code=${it.code} desc=${it.description}" + } ?: e?.message + Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail) + if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) { + Timber.w( + "UPnP extend: aborting after %d consecutive failures", + consecutiveFailures, + ) + break + } + } } - Timber.w("UPnP extend: done (%d tracks appended)", tracks.size) + Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size) } private fun renderingClientFor(routeId: String): RenderingControlClient? { @@ -396,4 +417,8 @@ class OutputPickerController @Inject constructor( val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn } return builtIn + rest.sortedBy { it.name.lowercase() } } + + private companion object { + const val EXTEND_ABORT_AFTER_FAILURES = 3 + } } From 3085d6f40936d3a5b60b728c5f3d3c8d52c94166 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:59:31 -0400 Subject: [PATCH 58/69] fix(android): DIDL-Lite restricted=true / id=-1 so Sonos accepts metadata --- .../minstrel/player/output/upnp/AVTransportClient.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index 8033323f..61e2a2ad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -204,13 +204,18 @@ class AVTransportClient( } private fun buildDidlLite(uri: String, mime: String, title: String): String { + // Sonos firmware accepts boolean-style restricted ("true"/"false") only + // and rejects "1"/"0" silently; if it can't parse our DIDL it regenerates + // its own with generic class object.item, no title, and the URL query + // string as the displayed name (logcat 2026-06-04 confirmed). Match the + // SoCo convention which the firmware definitely accepts. val safeTitle = title.ifBlank { "Minstrel" } return buildString { append("") - append("") + append("") append("").append(xmlEscape(safeTitle)).append("") append("object.item.audioItem.musicTrack") append(" Date: Thu, 4 Jun 2026 01:07:57 -0400 Subject: [PATCH 59/69] fix(android): single break in extend loop for detekt LoopWithTooManyJumpStatements --- .../player/output/OutputPickerController.kt | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) 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 69119c51..48fdc0de 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 @@ -353,36 +353,39 @@ class OutputPickerController @Inject constructor( ) var consecutiveFailures = 0 var succeeded = 0 + var aborted = false for ((i, ref) in tracks.withIndex()) { + if (aborted) break if (activeUpnpHolder.active.value?.routeId != route.id) { Timber.w("UPnP extend: cancelled at offset %d (route changed)", i) - break - } - val outcome = runCatching { - val token = streamTokens.mint(ref.id) - transport.addURIToQueue( - uri = token.url, - mime = token.mime, - title = token.title, - enqueuedURIPosition = startPosition + i + 1, - ) - } - if (outcome.isSuccess) { - consecutiveFailures = 0 - succeeded += 1 + aborted = true } else { - consecutiveFailures += 1 - val e = outcome.exceptionOrNull() - val detail = (e as? SoapFaultException)?.let { - "code=${it.code} desc=${it.description}" - } ?: e?.message - Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail) - if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) { - Timber.w( - "UPnP extend: aborting after %d consecutive failures", - consecutiveFailures, + val outcome = runCatching { + val token = streamTokens.mint(ref.id) + transport.addURIToQueue( + uri = token.url, + mime = token.mime, + title = token.title, + enqueuedURIPosition = startPosition + i + 1, ) - break + } + if (outcome.isSuccess) { + consecutiveFailures = 0 + succeeded += 1 + } else { + consecutiveFailures += 1 + val e = outcome.exceptionOrNull() + val detail = (e as? SoapFaultException)?.let { + "code=${it.code} desc=${it.description}" + } ?: e?.message + Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail) + if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) { + Timber.w( + "UPnP extend: aborting after %d consecutive failures", + consecutiveFailures, + ) + aborted = true + } } } } From 5db90844cbc3ec55e0f878a845a76776f1a12b51 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 06:34:00 -0400 Subject: [PATCH 60/69] fix(android): DIDL-Lite includes Rincon namespace + cdudn desc for Sonos --- .../player/output/upnp/AVTransportClient.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index 61e2a2ad..11dbbe58 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -204,22 +204,29 @@ class AVTransportClient( } private fun buildDidlLite(uri: String, mime: String, title: String): String { - // Sonos firmware accepts boolean-style restricted ("true"/"false") only - // and rejects "1"/"0" silently; if it can't parse our DIDL it regenerates - // its own with generic class object.item, no title, and the URL query - // string as the displayed name (logcat 2026-06-04 confirmed). Match the - // SoCo convention which the firmware definitely accepts. + // Sonos requires (a) the rinconnetworks namespace declared on + // even if we don't use Rincon elements directly, and + // (b) a element identifying the URI as an + // external (non-Sonos-library) source. Without those, Sonos + // discards our metadata content and regenerates its own with + // class=object.item and the URL query string as the title + // (logcat 2026-06-04 confirmed). Match SoCo's pattern. val safeTitle = title.ifBlank { "Minstrel" } return buildString { append("") + append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ") + append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">") append("") append("").append(xmlEscape(safeTitle)).append("") append("object.item.audioItem.musicTrack") append("").append(xmlEscape(uri)).append("") + append("") + append("RINCON_AssociatedZPUDN") + append("") append("") append("") } From 36054506c2b9c6b55773c8b60025755b3c92b661 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 06:46:38 -0400 Subject: [PATCH 61/69] fix(android): polling tick owns track-change updates when delegate.seekTo silent --- .../minstrel/player/PlayerController.kt | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 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 f9ee7ed1..87318f66 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 @@ -497,45 +497,64 @@ class PlayerController @Inject constructor( scope.launch(Dispatchers.Main.immediate) { while (isActive) { delay(POSITION_POLL_INTERVAL_MS) - val upnpActive = activeUpnpHolder.active.value != null - // Track-alignment gate: if Sonos's reported Track is past the - // wrapped player's currentMediaItemIndex, a forward cursor sync - // is in flight (pollOnce wrote new remoteState, handler.post'd - // delegate.seekTo; the seek hasn't run yet). Skipping the tick - // here lets onMediaItemTransition rebuild the full uiState - // atomically rather than patching position+duration while - // currentTrack/queueIndex still point at the old track. - if (upnpActive && isForwardCursorSyncPending(controller)) continue - val effectiveIsPlaying = - if (upnpActive) remoteState.isPlaying else controller.isPlaying - val effectivePosition = if (upnpActive) { - interpolatedRemotePosition(effectiveIsPlaying) - } else { - controller.currentPosition - } - val current = uiStateInternal.value - val newPos = effectivePosition.coerceAtLeast(0) - val newDur = effectiveDuration( - upnpActive, - remoteState.durationMs, - controller.duration, - ) - val newBuf = controller.bufferedPosition.coerceAtLeast(0) - val changed = current.isPlaying != effectiveIsPlaying || - current.positionMs != newPos || - current.durationMs != newDur - if (changed) { - uiStateInternal.value = current.copy( - isPlaying = effectiveIsPlaying, - positionMs = newPos, - durationMs = newDur, - bufferedPositionMs = newBuf, - ) - } + tickPositionPoll(controller) } } } + /** + * One position-polling tick. Owns track-change detection too: when UPnP + * is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from + * `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving + * uiState.queueIndex stuck on the old track even after Sonos has + * advanced. So the tick reads Sonos's reported Track as the source of + * truth, rebuilds the index/title fields itself, and force-syncs the + * wrapped player as defense in depth. + */ + private fun tickPositionPoll(controller: MediaController) { + val upnpActive = activeUpnpHolder.active.value != null + val effectiveIsPlaying = + if (upnpActive) remoteState.isPlaying else controller.isPlaying + val effectivePosition = if (upnpActive) { + interpolatedRemotePosition(effectiveIsPlaying) + } else { + controller.currentPosition + } + val desiredIdx = desiredQueueIndex(controller, upnpActive) + val current = uiStateInternal.value + val newPos = effectivePosition.coerceAtLeast(0) + val newDur = effectiveDuration(upnpActive, remoteState.durationMs, controller.duration) + val newBuf = controller.bufferedPosition.coerceAtLeast(0) + val trackChanged = desiredIdx != current.queueIndex && desiredIdx in queueRefs.indices + val somethingChanged = trackChanged || + current.isPlaying != effectiveIsPlaying || + current.positionMs != newPos || + current.durationMs != newDur + if (!somethingChanged) return + val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack + val newIdx = if (trackChanged) desiredIdx else current.queueIndex + uiStateInternal.value = current.copy( + currentTrack = newTrack, + queueIndex = newIdx, + isPlaying = effectiveIsPlaying, + positionMs = newPos, + durationMs = newDur, + bufferedPositionMs = newBuf, + ) + if (trackChanged && controller.currentMediaItemIndex != desiredIdx) { + // Defense in depth: keep wrapped player aligned even when + // maybeSyncLocalCursor's seekTo didn't fire a transition event. + controller.seekTo(desiredIdx, 0L) + } + } + + private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int = + if (upnpActive) { + (remoteState.trackNumber - 1).coerceAtLeast(0) + } else { + controller.currentMediaItemIndex + } + // ── Remote position interpolation state ────────────────────────────── // remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz // SOAP poll (and only when the round-trip completes -- screen-off WiFi @@ -547,12 +566,6 @@ class PlayerController @Inject constructor( @Volatile private var positionAnchorMs: Long = 0L @Volatile private var positionAnchorAtRealtimeMs: Long = 0L - private fun isForwardCursorSyncPending(controller: MediaController): Boolean { - val sonosTrack = remoteState.trackNumber - if (sonosTrack <= 0) return false - return (sonosTrack - 1) > controller.currentMediaItemIndex - } - private fun interpolatedRemotePosition(isPlaying: Boolean): Long { val raw = remoteState.positionMs val now = SystemClock.elapsedRealtime() From cacb280832c9bc610e24df4469cad129fbc59377 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 06:50:35 -0400 Subject: [PATCH 62/69] fix(android): polling tick track update is forward-only so user Next isn't undone --- .../com/fabledsword/minstrel/player/PlayerController.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 87318f66..9a12fa26 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 @@ -525,7 +525,11 @@ class PlayerController @Inject constructor( val newPos = effectivePosition.coerceAtLeast(0) val newDur = effectiveDuration(upnpActive, remoteState.durationMs, controller.duration) val newBuf = controller.bufferedPosition.coerceAtLeast(0) - val trackChanged = desiredIdx != current.queueIndex && desiredIdx in queueRefs.indices + // Forward-only. If desiredIdx < queueIndex, the user just pressed Next + // and Sonos's poll hasn't caught up yet -- walking backward would undo + // their press (logcat 2026-06-04 showed exactly that jump-back-then- + // forward cycle). Mirrors maybeSyncLocalCursor's forward-only policy. + val trackChanged = desiredIdx > current.queueIndex && desiredIdx in queueRefs.indices val somethingChanged = trackChanged || current.isPlaying != effectiveIsPlaying || current.positionMs != newPos || From 8e578d2068eb39ed77c08c36d38d27672f3a20c3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 06:55:39 -0400 Subject: [PATCH 63/69] fix(android): 2s transport-ack lockout so Prev/Next isn't undone by stale poll --- .../player/MinstrelForwardingPlayer.kt | 3 +++ .../minstrel/player/PlayerController.kt | 19 ++++++++++++++----- .../minstrel/player/RemotePlayerState.kt | 13 +++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 60525efd..6b221739 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -189,6 +189,7 @@ class MinstrelForwardingPlayer( return } super.seekTo(mediaItemIndex, positionMs) + remoteState.markUserTransport(SystemClock.elapsedRealtime()) scope.launch { runCatching { active.avTransport.seekToTrack(mediaItemIndex + 1) @@ -214,6 +215,7 @@ class MinstrelForwardingPlayer( // then delegate to Sonos Next. PollLoop reconciles cursor via // Track index if they diverge. super.seekToNextMediaItem() + remoteState.markUserTransport(SystemClock.elapsedRealtime()) scope.launch { runCatching { active.avTransport.next() } .onFailure { handleSoapFailure(active, it) } @@ -234,6 +236,7 @@ class MinstrelForwardingPlayer( // Super first for immediate local cursor advance (UI feedback); // then delegate to Sonos Previous. PollLoop reconciles. super.seekToPreviousMediaItem() + remoteState.markUserTransport(SystemClock.elapsedRealtime()) scope.launch { runCatching { active.avTransport.previous() } .onFailure { handleSoapFailure(active, it) } 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 9a12fa26..4adc375a 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 @@ -525,11 +525,19 @@ class PlayerController @Inject constructor( val newPos = effectivePosition.coerceAtLeast(0) val newDur = effectiveDuration(upnpActive, remoteState.durationMs, controller.duration) val newBuf = controller.bufferedPosition.coerceAtLeast(0) - // Forward-only. If desiredIdx < queueIndex, the user just pressed Next - // and Sonos's poll hasn't caught up yet -- walking backward would undo - // their press (logcat 2026-06-04 showed exactly that jump-back-then- - // forward cycle). Mirrors maybeSyncLocalCursor's forward-only policy. - val trackChanged = desiredIdx > current.queueIndex && desiredIdx in queueRefs.indices + // Two guards against undoing the user's transport action while + // remoteState is mid-refresh from the next SOAP poll: + // 1. Forward-only: a backward move from desiredIdx < queueIndex + // would undo a Next press (logcat 2026-06-04). + // 2. Transport-ack lockout: even forward moves are ignored within + // TRANSPORT_ACK_WINDOW_MS of a user transport action, so a Prev + // press isn't undone by a stale remoteState reading the older + // pre-press track. + val sinceTransport = SystemClock.elapsedRealtime() - remoteState.lastUserTransportAtMs + val inTransportAckWindow = sinceTransport < TRANSPORT_ACK_WINDOW_MS + val trackChanged = !inTransportAckWindow && + desiredIdx > current.queueIndex && + desiredIdx in queueRefs.indices val somethingChanged = trackChanged || current.isPlaying != effectiveIsPlaying || current.positionMs != newPos || @@ -667,6 +675,7 @@ class PlayerController @Inject constructor( const val MINSTREL_SOURCE_KEY: String = "minstrel_source" private const val MS_PER_SECOND = 1_000L private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L + private const val TRANSPORT_ACK_WINDOW_MS = 2_000L } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index fdbd5fcc..1e44947c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -27,6 +27,18 @@ class RemotePlayerState @Inject constructor() { @Volatile var lastError: Throwable? = null; private set @Volatile var trackNumber: Int = 0; private set + // SystemClock.elapsedRealtime() at the last user-initiated transport + // (next/prev/seekToIndex). PlayerController.tickPositionPoll uses this to + // suppress track-change adjustments during the window where remoteState + // hasn't yet refreshed from the next SOAP poll -- without it the polling + // tick would walk the cursor back to Sonos's pre-press track and undo + // user input. Set from MinstrelForwardingPlayer's transport overrides. + @Volatile var lastUserTransportAtMs: Long = 0L; private set + + fun markUserTransport(nowMs: Long) { + lastUserTransportAtMs = nowMs + } + @Volatile private var consecutivePollFailures: Int = 0 fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) { @@ -64,6 +76,7 @@ class RemotePlayerState @Inject constructor() { lastError = null consecutivePollFailures = 0 trackNumber = 0 + lastUserTransportAtMs = 0L } private companion object { From ee8a1fdc93aa92260be3e59c9137644fce94c571 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 07:00:22 -0400 Subject: [PATCH 64/69] fix(android): event-driven pending-transport clear (Sonos ack OR 5s safety) --- .../player/MinstrelForwardingPlayer.kt | 17 +++++++++-- .../minstrel/player/PlayerController.kt | 30 +++++++++++-------- .../minstrel/player/RemotePlayerState.kt | 28 ++++++++++------- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 6b221739..f1f610c1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -189,7 +189,9 @@ class MinstrelForwardingPlayer( return } super.seekTo(mediaItemIndex, positionMs) - remoteState.markUserTransport(SystemClock.elapsedRealtime()) + remoteState.beginPendingTransport( + SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS, + ) scope.launch { runCatching { active.avTransport.seekToTrack(mediaItemIndex + 1) @@ -215,7 +217,9 @@ class MinstrelForwardingPlayer( // then delegate to Sonos Next. PollLoop reconciles cursor via // Track index if they diverge. super.seekToNextMediaItem() - remoteState.markUserTransport(SystemClock.elapsedRealtime()) + remoteState.beginPendingTransport( + SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS, + ) scope.launch { runCatching { active.avTransport.next() } .onFailure { handleSoapFailure(active, it) } @@ -236,7 +240,9 @@ class MinstrelForwardingPlayer( // Super first for immediate local cursor advance (UI feedback); // then delegate to Sonos Previous. PollLoop reconciles. super.seekToPreviousMediaItem() - remoteState.markUserTransport(SystemClock.elapsedRealtime()) + remoteState.beginPendingTransport( + SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS, + ) scope.launch { runCatching { active.avTransport.previous() } .onFailure { handleSoapFailure(active, it) } @@ -389,5 +395,10 @@ class MinstrelForwardingPlayer( const val POLL_INTERVAL_MS = 1_000L const val NON_PLAYING_CONFIRM = 2 const val SEEK_ACK_WINDOW_MS = 2_000L + // Safety upper bound on how long the polling tick will wait for + // Sonos to ack a user transport. The common case clears event-driven + // when Sonos's reported Track matches the wrapped player; this only + // kicks in if SOAP fails or Sonos drops the ack entirely. + const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L } } 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 4adc375a..fd0251aa 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 @@ -513,6 +513,20 @@ class PlayerController @Inject constructor( */ private fun tickPositionPoll(controller: MediaController) { val upnpActive = activeUpnpHolder.active.value != null + // Pending-transport handling. Event-driven primary: as soon as Sonos's + // reported Track matches the wrapped player's currentMediaItemIndex, + // the user's transport press has been ack'd and pending clears. + // Safety fallback: if the deadline expires without an ack, clear + // anyway so we don't ignore Sonos's actual state forever. + if (upnpActive && remoteState.pendingTransportDeadlineMs > 0L) { + val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0) + val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs + if (sonosIdx == controller.currentMediaItemIndex || timedOut) { + remoteState.clearPendingTransport() + } + } + val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L + val effectiveIsPlaying = if (upnpActive) remoteState.isPlaying else controller.isPlaying val effectivePosition = if (upnpActive) { @@ -525,17 +539,10 @@ class PlayerController @Inject constructor( val newPos = effectivePosition.coerceAtLeast(0) val newDur = effectiveDuration(upnpActive, remoteState.durationMs, controller.duration) val newBuf = controller.bufferedPosition.coerceAtLeast(0) - // Two guards against undoing the user's transport action while - // remoteState is mid-refresh from the next SOAP poll: - // 1. Forward-only: a backward move from desiredIdx < queueIndex - // would undo a Next press (logcat 2026-06-04). - // 2. Transport-ack lockout: even forward moves are ignored within - // TRANSPORT_ACK_WINDOW_MS of a user transport action, so a Prev - // press isn't undone by a stale remoteState reading the older - // pre-press track. - val sinceTransport = SystemClock.elapsedRealtime() - remoteState.lastUserTransportAtMs - val inTransportAckWindow = sinceTransport < TRANSPORT_ACK_WINDOW_MS - val trackChanged = !inTransportAckWindow && + // Track adjustments are forward-only AND suppressed while a user + // transport press is pending Sonos confirmation. Together those keep + // either direction of user input from being undone by a stale poll. + val trackChanged = !pendingTransport && desiredIdx > current.queueIndex && desiredIdx in queueRefs.indices val somethingChanged = trackChanged || @@ -675,7 +682,6 @@ class PlayerController @Inject constructor( const val MINSTREL_SOURCE_KEY: String = "minstrel_source" private const val MS_PER_SECOND = 1_000L private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L - private const val TRANSPORT_ACK_WINDOW_MS = 2_000L } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 1e44947c..0ffde318 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -27,16 +27,24 @@ class RemotePlayerState @Inject constructor() { @Volatile var lastError: Throwable? = null; private set @Volatile var trackNumber: Int = 0; private set - // SystemClock.elapsedRealtime() at the last user-initiated transport - // (next/prev/seekToIndex). PlayerController.tickPositionPoll uses this to - // suppress track-change adjustments during the window where remoteState - // hasn't yet refreshed from the next SOAP poll -- without it the polling - // tick would walk the cursor back to Sonos's pre-press track and undo - // user input. Set from MinstrelForwardingPlayer's transport overrides. - @Volatile var lastUserTransportAtMs: Long = 0L; private set + // Pending-transport deadline (SystemClock.elapsedRealtime() at which we + // give up waiting). When > 0, a user transport action (next/prev/seekTo + // idx) is in flight: ForwardingPlayer has already moved the wrapped + // player's index, but Sonos's reported Track hasn't refreshed via a + // SOAP poll yet. PlayerController.tickPositionPoll skips track + // adjustments while pending is non-zero. Pending clears when: + // (a) [event-driven, primary] a poll lands and Sonos's reported Track + // matches the wrapped player's currentMediaItemIndex; or + // (b) [safety fallback] the deadline expires (covers SOAP-fail cases + // where Sonos never acks). + @Volatile var pendingTransportDeadlineMs: Long = 0L; private set - fun markUserTransport(nowMs: Long) { - lastUserTransportAtMs = nowMs + fun beginPendingTransport(deadlineMs: Long) { + pendingTransportDeadlineMs = deadlineMs + } + + fun clearPendingTransport() { + pendingTransportDeadlineMs = 0L } @Volatile private var consecutivePollFailures: Int = 0 @@ -76,7 +84,7 @@ class RemotePlayerState @Inject constructor() { lastError = null consecutivePollFailures = 0 trackNumber = 0 - lastUserTransportAtMs = 0L + pendingTransportDeadlineMs = 0L } private companion object { From 7486bc24443df01a058ea1ea0a541f3fa738ef1d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 07:04:01 -0400 Subject: [PATCH 65/69] fix(android): split tickPositionPoll into helpers for detekt complexity --- .../minstrel/player/PlayerController.kt | 45 +++++++++++++------ 1 file changed, 32 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 fd0251aa..fbb993de 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 @@ -513,20 +513,8 @@ class PlayerController @Inject constructor( */ private fun tickPositionPoll(controller: MediaController) { val upnpActive = activeUpnpHolder.active.value != null - // Pending-transport handling. Event-driven primary: as soon as Sonos's - // reported Track matches the wrapped player's currentMediaItemIndex, - // the user's transport press has been ack'd and pending clears. - // Safety fallback: if the deadline expires without an ack, clear - // anyway so we don't ignore Sonos's actual state forever. - if (upnpActive && remoteState.pendingTransportDeadlineMs > 0L) { - val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0) - val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs - if (sonosIdx == controller.currentMediaItemIndex || timedOut) { - remoteState.clearPendingTransport() - } - } + resolvePendingTransport(controller, upnpActive) val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L - val effectiveIsPlaying = if (upnpActive) remoteState.isPlaying else controller.isPlaying val effectivePosition = if (upnpActive) { @@ -545,6 +533,37 @@ class PlayerController @Inject constructor( val trackChanged = !pendingTransport && desiredIdx > current.queueIndex && desiredIdx in queueRefs.indices + publishTickIfChanged( + controller, current, trackChanged, desiredIdx, + effectiveIsPlaying, newPos, newDur, newBuf, + ) + } + + /** + * Event-driven primary path: clear pending the moment Sonos's reported + * Track matches the wrapped player's index. Safety fallback: clear on + * deadline so we don't ignore Sonos's actual state forever if SOAP fails. + */ + private fun resolvePendingTransport(controller: MediaController, upnpActive: Boolean) { + if (!upnpActive || remoteState.pendingTransportDeadlineMs <= 0L) return + val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0) + val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs + if (sonosIdx == controller.currentMediaItemIndex || timedOut) { + remoteState.clearPendingTransport() + } + } + + @Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity + private fun publishTickIfChanged( + controller: MediaController, + current: PlayerUiState, + trackChanged: Boolean, + desiredIdx: Int, + effectiveIsPlaying: Boolean, + newPos: Long, + newDur: Long, + newBuf: Long, + ) { val somethingChanged = trackChanged || current.isPlaying != effectiveIsPlaying || current.positionMs != newPos || From 4021938046e7b7992a198d3e5a4f4544b67757f5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 07:06:07 -0400 Subject: [PATCH 66/69] fix(android): polling tick no longer force-syncs controller -- avoids Sonos seek-to-0 --- .../minstrel/player/PlayerController.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 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 fbb993de..df1cae82 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 @@ -534,7 +534,7 @@ class PlayerController @Inject constructor( desiredIdx > current.queueIndex && desiredIdx in queueRefs.indices publishTickIfChanged( - controller, current, trackChanged, desiredIdx, + current, trackChanged, desiredIdx, effectiveIsPlaying, newPos, newDur, newBuf, ) } @@ -555,7 +555,6 @@ class PlayerController @Inject constructor( @Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity private fun publishTickIfChanged( - controller: MediaController, current: PlayerUiState, trackChanged: Boolean, desiredIdx: Int, @@ -579,11 +578,14 @@ class PlayerController @Inject constructor( durationMs = newDur, bufferedPositionMs = newBuf, ) - if (trackChanged && controller.currentMediaItemIndex != desiredIdx) { - // Defense in depth: keep wrapped player aligned even when - // maybeSyncLocalCursor's seekTo didn't fire a transition event. - controller.seekTo(desiredIdx, 0L) - } + // Intentionally do NOT call controller.seekTo here. That would route + // through MinstrelForwardingPlayer's seekTo override and re-issue + // AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the + // start of the same track it's already playing, restarting the song. + // The wrapped player's index is kept in sync by maybeSyncLocalCursor's + // delegate.seekTo (which bypasses the override). If it lags briefly, + // the next pollOnce catches up; the uiState above already reflects + // Sonos's truth for the user. } private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int = From aa23a72693be4f2565c4f3d26ae9987459d22a7c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 07:17:17 -0400 Subject: [PATCH 67/69] fix(android): effectiveDuration uses desiredIdx so duration tracks the displayed title --- .../minstrel/player/PlayerController.kt | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 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 df1cae82..a6af30d3 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 @@ -462,6 +462,8 @@ class PlayerController @Inject constructor( upnpActive, remoteState.durationMs, player.duration, + desiredIdx = idx, + controllerIdx = idx, ), bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0), playbackError = player.playerError?.message, @@ -525,7 +527,13 @@ class PlayerController @Inject constructor( val desiredIdx = desiredQueueIndex(controller, upnpActive) val current = uiStateInternal.value val newPos = effectivePosition.coerceAtLeast(0) - val newDur = effectiveDuration(upnpActive, remoteState.durationMs, controller.duration) + val newDur = effectiveDuration( + upnpActive, + remoteState.durationMs, + controller.duration, + desiredIdx = desiredIdx, + controllerIdx = controller.currentMediaItemIndex, + ) val newBuf = controller.bufferedPosition.coerceAtLeast(0) // Track adjustments are forward-only AND suppressed while a user // transport press is pending Sonos confirmation. Together those keep @@ -691,11 +699,22 @@ class PlayerController @Inject constructor( * until the first SOAP poll lands. */ @Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns - private fun effectiveDuration(upnpActive: Boolean, remoteMs: Long, localMs: Long): Long { + private fun effectiveDuration( + upnpActive: Boolean, + remoteMs: Long, + localMs: Long, + desiredIdx: Int, + controllerIdx: Int, + ): Long { if (upnpActive && remoteMs > 0) return remoteMs - if (localMs > 0) return localMs - val idx = mediaController?.currentMediaItemIndex ?: 0 - val ref = queueRefs.getOrNull(idx) ?: return 0 + // Tier 2 (wrapped player's probed duration) only valid when the + // wrapped player is on the same track we're trying to show. After a + // Sonos natural advance the polling tick updates desiredIdx from + // Sonos's truth while controllerIdx is briefly stale -- using the + // wrapped player's duration here would surface the old track's + // length under the new track's title. + if (localMs > 0 && controllerIdx == desiredIdx) return localMs + val ref = queueRefs.getOrNull(desiredIdx) ?: return 0 return ref.durationSec.toLong() * MS_PER_SECOND } From 27bd38e005c09efa3fde4d0653aaca817697df22 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 07:29:28 -0400 Subject: [PATCH 68/69] feat(server): stream URL gets file extension so Sonos can probe duration --- internal/api/api.go | 4 ++++ internal/api/cast_token.go | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/api/api.go b/internal/api/api.go index 4a644661..e95a26a1 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -65,6 +65,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev // design at // docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md. api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream) + // Extension-bearing alias so Sonos's URL probe can identify the + // audio format from the path. The {ext} param is consumed by chi + // and ignored by the handler (which keys off {id}). See task #610. + api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream) api.Group(func(authed chi.Router) { authed.Use(auth.RequireUser(pool)) diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index e638308f..5f3f3046 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -57,6 +57,33 @@ func mimeForFormat(format string) string { } } +// extForFormat maps the tracks.file_format column to a path-safe file +// extension. Sonos firmware gates duration probing on the URL path +// extension (Content-Type header alone is insufficient) -- without a +// recognizable extension, Sonos reports TrackDuration=0 and seeks +// trigger auto-advance because every position past 0 looks past-the- +// end. Defaults to "mp3" for unknown formats. See task #610. +func extForFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "mp3", "mpeg": + return "mp3" + case "flac": + return "flac" + case "aac": + return "aac" + case "m4a", "mp4": + return "m4a" + case "ogg", "vorbis": + return "ogg" + case "opus": + return "opus" + case "wav", "wave": + return "wav" + default: + return "mp3" + } +} + // handleCastStreamToken issues a short-lived HMAC stream token for the // given trackId. Authenticated via the standard session cookie / bearer. // @@ -114,8 +141,12 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) if h := r.Header.Get("X-Forwarded-Host"); h != "" { host = h } + // Include the file extension in the path so Sonos's URL probe sees a + // recognizable audio file. Without it, Sonos reports TrackDuration=0 + // and seeks past 0s land "after the end" -> early track-skip. url := scheme + "://" + host + "/api/tracks/" + req.TrackID + - "/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) + "/stream." + extForFormat(track.FileFormat) + + "?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) writeJSON(w, http.StatusOK, castTokenResponse{ Token: token, From 8cd2383a42143d72760fb324aece73a88c6ceff6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 07:44:33 -0400 Subject: [PATCH 69/69] test(server): seed track for cast-token tests + assert file-ext in URL --- internal/api/cast_token_test.go | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/internal/api/cast_token_test.go b/internal/api/cast_token_test.go index 0687b57d..526f50ed 100644 --- a/internal/api/cast_token_test.go +++ b/internal/api/cast_token_test.go @@ -10,15 +10,21 @@ import ( "time" ) -const testTrackUUID = "11111111-1111-1111-1111-111111111111" +// nonExistentTrackUUID is used by tests that exercise paths which don't +// require the track to actually exist (auth/UUID-shape rejection). +const nonExistentTrackUUID = "11111111-1111-1111-1111-111111111111" func TestCastStreamToken_HappyPath(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "hunter2", false) + artist := seedArtist(t, pool, "Artist") + album := seedAlbum(t, pool, artist.ID, "Album", 0) + track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000) + trackID := uuidToString(track.ID) h.streamSecret = []byte("cast-token-test-secret") body, err := json.Marshal(castTokenRequest{ - TrackID: testTrackUUID, + TrackID: trackID, ExpSeconds: 3600, }) if err != nil { @@ -44,10 +50,16 @@ func TestCastStreamToken_HappyPath(t *testing.T) { if !strings.Contains(resp.URL, "token="+resp.Token) { t.Fatalf("URL missing token query: %s", resp.URL) } - if !strings.Contains(resp.URL, "/api/tracks/"+testTrackUUID+"/stream") { + if !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/stream") { t.Fatalf("URL missing stream path: %s", resp.URL) } - if !VerifyStreamToken(h.streamSecret, testTrackUUID, resp.Exp, resp.Token) { + // Stream URL must carry a file extension so Sonos's URL probe can + // identify the audio format (see task #610). Track seeded above is + // .flac via seedTrack's default file_format. + if !strings.Contains(resp.URL, "/stream.flac?") { + t.Fatalf("URL missing file-extension segment: %s", resp.URL) + } + if !VerifyStreamToken(h.streamSecret, trackID, resp.Exp, resp.Token) { t.Fatal("returned token does not verify") } } @@ -77,7 +89,7 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) { h, _ := testHandlers(t) h.streamSecret = []byte("cast-token-test-secret") - body, err := json.Marshal(castTokenRequest{TrackID: testTrackUUID}) + body, err := json.Marshal(castTokenRequest{TrackID: nonExistentTrackUUID}) if err != nil { t.Fatalf("marshal: %v", err) } @@ -96,11 +108,15 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) { func TestCastStreamToken_ClampsExpSeconds(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "hunter2", false) + artist := seedArtist(t, pool, "Artist") + album := seedAlbum(t, pool, artist.ID, "Album", 0) + track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000) + trackID := uuidToString(track.ID) h.streamSecret = []byte("cast-token-test-secret") // Request 1 second (below min 60), expect clamp to 60s. body, err := json.Marshal(castTokenRequest{ - TrackID: testTrackUUID, + TrackID: trackID, ExpSeconds: 1, }) if err != nil {