feat(android): AVTransport pause/seek/nextURI/positionInfo/transportInfo
android / Build + lint + test (push) Failing after 1m35s
android / Build + lint + test (push) Failing after 1m35s
- 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 <noreply@anthropic.com>
This commit is contained in:
+128
-26
@@ -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("<DIDL-Lite ")
|
||||
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
||||
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">")
|
||||
append("<item id=\"0\" parentID=\"-1\" restricted=\"1\">")
|
||||
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
||||
append("</item>")
|
||||
append("</DIDL-Lite>")
|
||||
}
|
||||
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("<DIDL-Lite ")
|
||||
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
||||
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">")
|
||||
append("<item id=\"0\" parentID=\"-1\" restricted=\"1\">")
|
||||
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
||||
append("</item>")
|
||||
append("</DIDL-Lite>")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user