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