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(
+ """
+
+
+
+
+ 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(),
+ )
+}