From d62a3b8134c05a0e77b942161713780a1eadcd79 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:42:04 -0400 Subject: [PATCH] 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(), + ) +}