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 } + } +}