feat(android): Sonos queue mode -- ClearQueue + AddURIToQueue + x-rincon-queue
android / Build + lint + test (push) Failing after 9m9s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:55:03 -04:00
parent ece37e9a92
commit 2a098a78fe
7 changed files with 240 additions and 149 deletions
@@ -8,7 +8,6 @@ import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.TransportState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -35,12 +34,18 @@ import timber.log.Timber
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
* factory wraps that callback into a SharedFlow consumed by the
* NowPlaying surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop syncs the local cursor by comparing the
* 1-based Track index from GetPositionInfo.
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val tokens: StreamTokenProvider,
private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) {
@@ -52,10 +57,6 @@ class MinstrelForwardingPlayer(
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
}
// No Player.Listener -- the seekToNextMediaItem/seekToPreviousMediaItem
// overrides call preQueueNext explicitly. A listener-based path raced
// with the override's launched syncCurrentItemToRemote, producing
// non-deterministic SOAP order.
}
private fun isRemote(): Boolean = holder.active.value != null
@@ -95,6 +96,7 @@ class MinstrelForwardingPlayer(
positionMs = positionMs,
durationMs = remoteState.durationMs,
trackUri = remoteState.currentTrackUri,
trackNumber = remoteState.trackNumber,
)
scope.launch {
runCatching { active.avTransport.seek(positionMs) }
@@ -103,21 +105,41 @@ class MinstrelForwardingPlayer(
}
}
/**
* Widget-driven track change (user taps a track in the queue widget).
* Seeks Sonos to the correct queue slot, then seeks within-track if
* [positionMs] is non-zero.
*/
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
val active = holder.active.value
if (active == null) {
super.seekTo(mediaItemIndex, positionMs)
return
}
super.seekTo(mediaItemIndex, positionMs)
scope.launch {
runCatching {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}.onFailure { handleSoapFailure(active, it) }
}
}
override fun seekToNextMediaItem() {
val active = holder.active.value
if (active == null) {
super.seekToNextMediaItem()
return
}
// Advance the local cursor first so syncCurrentItemToRemote can read
// the new currentMediaItem on the application looper. ExoPlayer stays
// paused while UPnP is active (playWhenReady=false invariant), so this
// does not produce local audio. After the sync lands, preQueueNext
// primes the next-next track on Sonos -- sequential, not raced.
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Next. PollLoop reconciles cursor via
// Track index if they diverge.
super.seekToNextMediaItem()
scope.launch {
syncCurrentItemToRemote(active)
preQueueNext(active)
runCatching { active.avTransport.next() }
.onFailure { handleSoapFailure(active, it) }
}
}
@@ -127,11 +149,12 @@ class MinstrelForwardingPlayer(
super.seekToPreviousMediaItem()
return
}
// Same as seekToNextMediaItem: sync then pre-queue in one coroutine.
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Previous. PollLoop reconciles.
super.seekToPreviousMediaItem()
scope.launch {
syncCurrentItemToRemote(active)
preQueueNext(active)
runCatching { active.avTransport.previous() }
.onFailure { handleSoapFailure(active, it) }
}
}
@@ -153,46 +176,6 @@ class MinstrelForwardingPlayer(
super.release()
}
private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) {
val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return
runCatching {
val token = tokens.mint(mediaId)
active.avTransport.setAVTransportURIWithMetadata(
uri = token.url,
mime = token.mime,
title = token.title,
)
active.avTransport.play()
remoteState.applyTransportPlaying()
}.onFailure { handleSoapFailure(active, it) }
}
/**
* Speculative pre-queue of the next track so Sonos can advance gap-free.
* Failures (token mint OR SetNextAVTransportURI rejection) are logged and
* swallowed. The only consequence is a sub-second audible gap at the track
* boundary, not a broken route.
*/
private suspend fun preQueueNext(active: ActiveUpnp) {
val nextMediaId = handlerEvaluate { nextItemMediaId() } ?: return
val token = runCatching { tokens.mint(nextMediaId) }.getOrNull() ?: return
runCatching {
active.avTransport.setNextAVTransportURI(
uri = token.url,
mime = token.mime,
title = token.title,
)
}.onFailure {
Timber.w(it, "SetNextAVTransportURI failed (poll-driven advance will fall back)")
}
}
private fun nextItemMediaId(): String? {
val nextIdx = delegate.nextMediaItemIndex
if (nextIdx == androidx.media3.common.C.INDEX_UNSET) return null
return delegate.getMediaItemAt(nextIdx).mediaId
}
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
@@ -212,46 +195,16 @@ class MinstrelForwardingPlayer(
// fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) }
// Do NOT pre-queue here: selectUpnp's SetAV+Play SOAP may not have
// landed yet. Initial pre-queue fires from pollLoop once the first
// successful poll confirms Sonos accepted the track (trackUri lands).
} else {
remoteState.reset()
}
}
private suspend fun pollLoop(active: ActiveUpnp) {
// pollLoop is a state-tracker for position + transport state. We do NOT
// attempt to mirror Sonos's auto-advance into the local queue cursor --
// that would require GENA event subscriptions to AVTransport's LastChange
// event (see the parity-map). Without that, polling alone cannot
// distinguish "Sonos finished track N and auto-played track N+1 via
// SetNextAVTransportURI" from "we just changed Sonos's URI ourselves
// via syncCurrentItemToRemote". The local cursor stays in sync via user
// transport actions (pause/seek/skip flow through the override which
// re-runs syncCurrentItemToRemote).
var initialPreQueueDone = false
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching { pollOnce(active) }
if (outcome.isSuccess) {
remoteState.recordPollSuccess()
// Initial pre-queue fires once Sonos confirms it accepted the
// SetAV+Play from selectUpnp (trackUri lands in the first
// successful poll). Firing from onActiveChanged would race
// with selectUpnp's SOAP calls.
if (!initialPreQueueDone) {
Timber.w(
"UPnP poll OK: route=%s trackUri=%s posMs=%d",
active.routeName,
remoteState.currentTrackUri,
remoteState.positionMs,
)
}
if (!initialPreQueueDone && remoteState.currentTrackUri.isNotBlank()) {
Timber.w("UPnP initial pre-queue for %s", active.routeName)
preQueueNext(active)
initialPreQueueDone = true
}
} else if (remoteState.recordPollFailure()) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
@@ -263,8 +216,13 @@ class MinstrelForwardingPlayer(
/**
* One poll tick: read position + transport state from Sonos and apply to
* [remoteState]. Extracted from [pollLoop] to keep that method within
* detekt's LongMethod=60 limit.
* [remoteState]. Cursor sync: Sonos's 1-based Track index is the truth —
* if it disagrees with the local cursor, post an advance on the player
* thread so they converge. This handles Sonos hardware-button advances
* and natural auto-advance without GENA eventing.
*
* Extracted from [pollLoop] to keep that method within detekt's
* LongMethod=60 limit.
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
@@ -272,6 +230,7 @@ class MinstrelForwardingPlayer(
positionMs = info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
trackNumber = info.track,
)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
@@ -280,15 +239,18 @@ class MinstrelForwardingPlayer(
TransportState.STOPPED -> remoteState.applyTransportStopped()
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
}
/** Run [block] on the application looper and bring its result back. */
private suspend fun <T> handlerEvaluate(block: () -> T): T? {
val deferred = CompletableDeferred<T?>()
handler.post {
deferred.complete(runCatching(block).getOrNull())
// Cursor sync: post a check to the player thread. Track is 1-based;
// sonosIndex is 0-based. If Sonos advanced (hardware button, natural
// end-of-track) and our local cursor is behind, walk it forward.
val sonosIndex = info.track - 1
if (sonosIndex >= 0) {
handler.post {
val localIndex = delegate.currentMediaItemIndex
if (sonosIndex != localIndex && sonosIndex < delegate.mediaItemCount) {
delegate.seekTo(sonosIndex, 0L)
}
}
}
return deferred.await()
}
private companion object {
@@ -51,7 +51,6 @@ class PlayerFactory @Inject constructor(
private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
private val streamTokens: StreamTokenProvider,
private val remoteState: RemotePlayerState,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -78,7 +77,6 @@ class PlayerFactory @Inject constructor(
delegate = exo,
holder = activeUpnpHolder,
remoteState = remoteState,
tokens = streamTokens,
onDrop = { name -> emitDrop(name) },
)
}
@@ -25,13 +25,15 @@ class RemotePlayerState @Inject constructor() {
@Volatile var isPlaying: Boolean = false; private set
@Volatile var currentTrackUri: String = ""; private set
@Volatile var lastError: Throwable? = null; private set
@Volatile var trackNumber: Int = 0; private set
@Volatile private var consecutivePollFailures: Int = 0
fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String) {
fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) {
this.positionMs = positionMs
this.durationMs = durationMs
this.currentTrackUri = trackUri
this.trackNumber = trackNumber
}
fun applyTransportPlaying() { isPlaying = true }
@@ -61,6 +63,7 @@ class RemotePlayerState @Inject constructor() {
currentTrackUri = ""
lastError = null
consecutivePollFailures = 0
trackNumber = 0
}
private companion object {
@@ -6,10 +6,12 @@ import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
@@ -229,18 +231,19 @@ class OutputPickerController @Inject constructor(
}
/**
* Drive the UPnP renderer: mint a token for the currently playing
* track, set the renderer's URI, play, then pause local playback so
* audio yields to the speaker. Wrapped in `runCatching` at each
* step — token failure, transport-lookup failure, and SOAP failure
* each abandon the selection cleanly rather than crashing. Failures
* log at warn level via Timber so on-device verification can find
* the cause in logcat (OkHttp's logger doesn't cover our own
* deserialize / SOAP-parse code paths).
* Drive the UPnP renderer using Sonos native queue mode:
* clear the device's queue, load every track from our local queue
* via AddURIToQueue, point the transport at the queue URI, seek to
* the current index, and play. Wrapped in `runCatching` — SOAP
* failure abandons the selection cleanly. Local pause is handled by
* MinstrelForwardingPlayer.onActiveChanged via holder.set(), so we
* do not call playerController.pause() here (it would race with the
* SOAP override once holder.active is non-null).
*/
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
val trackId = playerController.uiState.value.currentTrack?.id
if (trackId == null) {
val uiState = playerController.uiState.value
val currentTrack = uiState.currentTrack
if (currentTrack == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return@withLock
}
@@ -259,13 +262,6 @@ class OutputPickerController @Inject constructor(
return@withLock
}
val rendering = renderingClientFor(effectiveRoute.id)
// Local pause is handled by MinstrelForwardingPlayer.onActiveChanged
// via handler.post { delegate.pause() } -- called immediately after
// holder.set() fires. That path bypasses the SOAP-routing override, so
// there is no race between pausing ExoPlayer and starting the remote
// renderer. Do NOT call playerController.pause() here: it dispatches
// through Handler.post on the application looper, meaning it runs after
// holder.active is non-null and the override would route it to SOAP.
selectedUpnpRouteIdInternal.value = effectiveRoute.id
activeUpnpHolder.set(
ActiveUpnp(
@@ -276,17 +272,7 @@ class OutputPickerController @Inject constructor(
),
)
runCatching {
Timber.w("UPnP select: mint token for track=$trackId, route=${effectiveRoute.name}")
val token = streamTokens.mint(trackId)
Timber.w("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}")
transport.setAVTransportURIWithMetadata(
uri = token.url,
mime = token.mime,
title = token.title,
)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: done")
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
activeUpnpHolder.set(null)
@@ -294,6 +280,35 @@ class OutputPickerController @Inject constructor(
}
}
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
Timber.w("UPnP select: add %d tracks to queue", queue.size)
queue.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: done")
}
private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value
.firstOrNull { it.id == routeId }
@@ -4,8 +4,9 @@ import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause /
* Stop / Seek / GetPositionInfo / GetTransportInfo.
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* GetPositionInfo / GetTransportInfo / queue management
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
*/
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions")
@@ -48,20 +49,84 @@ class AVTransportClient(
}
/**
* 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].
* Remove all tracks from the renderer's queue. UPnP action name is
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
* leftover queue from prior sessions before loading our local queue.
*/
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
suspend fun removeAllTracksFromQueue() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetNextAVTransportURI",
action = "RemoveAllTracksFromQueue",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Append a track to the renderer's queue. Sonos returns the assigned
* track number + new total queue length in the response, but we don't
* read those (we know our intended position). DIDL-Lite metadata is
* required; reuses the same shape as [setAVTransportURIWithMetadata].
*
* [enqueuedURIPosition] is 1-based; 0 means "append to end" per UPnP.
* Our caller passes 1, 2, 3, ... to ensure stable order.
*/
suspend fun addURIToQueue(
uri: String,
mime: String,
title: String,
enqueuedURIPosition: Int = 0,
) {
val safeTitle = title.ifBlank { "Minstrel" }
val didl = buildDidlLite(uri, mime, safeTitle)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "AddURIToQueue",
args = mapOf(
"InstanceID" to "0",
"NextURI" to uri,
"NextURIMetaData" to buildDidlLite(uri, mime, safeTitle),
"EnqueuedURI" to uri,
"EnqueuedURIMetaData" to didl,
"DesiredFirstTrackNumberEnqueued" to enqueuedURIPosition.toString(),
"EnqueueAsNext" to "0",
),
)
}
/** Skip to the next track in the renderer's queue. */
suspend fun next() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Next",
args = mapOf("InstanceID" to "0"),
)
}
/** Skip to the previous track in the renderer's queue. */
suspend fun previous() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Previous",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Seek to a specific track in the queue. UPnP Seek unit "TRACK_NR";
* target is the 1-based track index. Separate from the existing
* [seek] which uses unit REL_TIME for position-within-track.
*/
suspend fun seekToTrack(trackNumber: Int) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Seek",
args = mapOf(
"InstanceID" to "0",
"Unit" to "TRACK_NR",
"Target" to trackNumber.toString(),
),
)
}
@@ -114,6 +179,7 @@ class AVTransportClient(
args = mapOf("InstanceID" to "0"),
)
return PositionInfo(
track = result["Track"]?.toIntOrNull() ?: 0,
trackUri = result["TrackURI"].orEmpty(),
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
@@ -182,6 +248,7 @@ class AVTransportClient(
}
data class PositionInfo(
val track: Int,
val trackUri: String,
val relTimeMs: Long,
val trackDurationMs: Long,
@@ -16,12 +16,15 @@ class RemotePlayerStateTest {
}
@Test
fun `applyPositionInfo updates position and duration`() {
fun `applyPositionInfo updates position, duration, and trackNumber`() {
val state = RemotePlayerState()
state.applyPositionInfo(positionMs = 65_000L, durationMs = 210_000L, trackUri = "x")
state.applyPositionInfo(
positionMs = 65_000L, durationMs = 210_000L, trackUri = "x", trackNumber = 3,
)
assertEquals(65_000L, state.positionMs)
assertEquals(210_000L, state.durationMs)
assertEquals("x", state.currentTrackUri)
assertEquals(3, state.trackNumber)
}
@Test
@@ -38,7 +38,7 @@ class AVTransportClientTest {
}
@Test
fun `getPositionInfo parses RelTime and TrackDuration`() = runTest {
fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
@@ -46,7 +46,7 @@ class AVTransportClientTest {
<s:Body>
<u:GetPositionInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<Track>1</Track>
<Track>3</Track>
<TrackDuration>0:03:30</TrackDuration>
<TrackURI>http://x/y.mp3</TrackURI>
<RelTime>0:01:05</RelTime>
@@ -56,6 +56,7 @@ class AVTransportClientTest {
),
)
val info = client.getPositionInfo()
assertEquals(3, info.track)
assertEquals(65_000L, info.relTimeMs)
assertEquals(210_000L, info.trackDurationMs)
assertEquals("http://x/y.mp3", info.trackUri)
@@ -104,25 +105,67 @@ class AVTransportClientTest {
}
@Test
fun `setNextAVTransportURI sends DIDL-Lite with mime and title`() = runTest {
server.enqueue(emptyResponse("SetNextAVTransportURI"))
client.setNextAVTransportURI(
fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest {
server.enqueue(emptyResponse("RemoveAllTracksFromQueue"))
client.removeAllTracksFromQueue()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("RemoveAllTracksFromQueue")) {
"SOAPACTION header missing action: ${request.getHeader("SOAPACTION")}"
}
val body = request.body.readUtf8()
assertTrue(body.contains("RemoveAllTracksFromQueue")) { "body: $body" }
}
@Test
fun `addURIToQueue sends EnqueuedURI and DIDL-Lite metadata`() = runTest {
server.enqueue(emptyResponse("AddURIToQueue"))
client.addURIToQueue(
uri = "http://x/y.mp3",
mime = "audio/mpeg",
title = "Song",
enqueuedURIPosition = 2,
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<NextURI>http://x/y.mp3</NextURI>")) {
"body missing NextURI: $body"
assertTrue(body.contains("<EnqueuedURI>http://x/y.mp3</EnqueuedURI>")) {
"body missing EnqueuedURI: $body"
}
val positionTag = "<DesiredFirstTrackNumberEnqueued>2</DesiredFirstTrackNumberEnqueued>"
assertTrue(body.contains(positionTag)) { "body missing position: $body" }
assertTrue(body.contains("&lt;dc:title&gt;Song&lt;/dc:title&gt;")) {
"title missing: $body"
"title missing in DIDL: $body"
}
assertTrue(body.contains("audio/mpeg")) {
"body missing mime type: $body"
assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" }
}
@Test
fun `next sends Next SOAP action`() = runTest {
server.enqueue(emptyResponse("Next"))
client.next()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("\"Next\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `previous sends Previous SOAP action`() = runTest {
server.enqueue(emptyResponse("Previous"))
client.previous()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("\"Previous\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `seekToTrack sends TRACK_NR unit with 1-based target`() = runTest {
server.enqueue(emptyResponse("Seek"))
client.seekToTrack(trackNumber = 4)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<Unit>TRACK_NR</Unit>")) { "body: $body" }
assertTrue(body.contains("<Target>4</Target>")) { "body: $body" }
}
@Test
fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI"))