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.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -35,12 +34,18 @@ import timber.log.Timber
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The * Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
* factory wraps that callback into a SharedFlow consumed by the * factory wraps that callback into a SharedFlow consumed by the
* NowPlaying surface as a snackbar. * 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( class MinstrelForwardingPlayer(
private val delegate: Player, private val delegate: Player,
private val holder: ActiveUpnpHolder, private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val tokens: StreamTokenProvider,
private val onDrop: (routeName: String) -> Unit, private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) { ) : ForwardingPlayer(delegate) {
@@ -52,10 +57,6 @@ class MinstrelForwardingPlayer(
scope.launch { scope.launch {
holder.active.collect { active -> onActiveChanged(active) } 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 private fun isRemote(): Boolean = holder.active.value != null
@@ -95,6 +96,7 @@ class MinstrelForwardingPlayer(
positionMs = positionMs, positionMs = positionMs,
durationMs = remoteState.durationMs, durationMs = remoteState.durationMs,
trackUri = remoteState.currentTrackUri, trackUri = remoteState.currentTrackUri,
trackNumber = remoteState.trackNumber,
) )
scope.launch { scope.launch {
runCatching { active.avTransport.seek(positionMs) } 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() { override fun seekToNextMediaItem() {
val active = holder.active.value val active = holder.active.value
if (active == null) { if (active == null) {
super.seekToNextMediaItem() super.seekToNextMediaItem()
return return
} }
// Advance the local cursor first so syncCurrentItemToRemote can read // Super first for immediate local cursor advance (UI feedback);
// the new currentMediaItem on the application looper. ExoPlayer stays // then delegate to Sonos Next. PollLoop reconciles cursor via
// paused while UPnP is active (playWhenReady=false invariant), so this // Track index if they diverge.
// does not produce local audio. After the sync lands, preQueueNext
// primes the next-next track on Sonos -- sequential, not raced.
super.seekToNextMediaItem() super.seekToNextMediaItem()
scope.launch { scope.launch {
syncCurrentItemToRemote(active) runCatching { active.avTransport.next() }
preQueueNext(active) .onFailure { handleSoapFailure(active, it) }
} }
} }
@@ -127,11 +149,12 @@ class MinstrelForwardingPlayer(
super.seekToPreviousMediaItem() super.seekToPreviousMediaItem()
return 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() super.seekToPreviousMediaItem()
scope.launch { scope.launch {
syncCurrentItemToRemote(active) runCatching { active.avTransport.previous() }
preQueueNext(active) .onFailure { handleSoapFailure(active, it) }
} }
} }
@@ -153,46 +176,6 @@ class MinstrelForwardingPlayer(
super.release() 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) { private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel() pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName) 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). // fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() } handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) } 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 { } else {
remoteState.reset() remoteState.reset()
} }
} }
private suspend fun pollLoop(active: ActiveUpnp) { 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) { while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching { pollOnce(active) } val outcome = runCatching { pollOnce(active) }
if (outcome.isSuccess) { if (outcome.isSuccess) {
remoteState.recordPollSuccess() 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()) { } else if (remoteState.recordPollFailure()) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName) Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(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 * One poll tick: read position + transport state from Sonos and apply to
* [remoteState]. Extracted from [pollLoop] to keep that method within * [remoteState]. Cursor sync: Sonos's 1-based Track index is the truth —
* detekt's LongMethod=60 limit. * 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) { private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo() val info = active.avTransport.getPositionInfo()
@@ -272,6 +230,7 @@ class MinstrelForwardingPlayer(
positionMs = info.relTimeMs, positionMs = info.relTimeMs,
durationMs = info.trackDurationMs, durationMs = info.trackDurationMs,
trackUri = info.trackUri, trackUri = info.trackUri,
trackNumber = info.track,
) )
val transport = active.avTransport.getTransportInfo() val transport = active.avTransport.getTransportInfo()
when (transport.state) { when (transport.state) {
@@ -280,15 +239,18 @@ class MinstrelForwardingPlayer(
TransportState.STOPPED -> remoteState.applyTransportStopped() TransportState.STOPPED -> remoteState.applyTransportStopped()
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
} }
} // Cursor sync: post a check to the player thread. Track is 1-based;
// sonosIndex is 0-based. If Sonos advanced (hardware button, natural
/** Run [block] on the application looper and bring its result back. */ // end-of-track) and our local cursor is behind, walk it forward.
private suspend fun <T> handlerEvaluate(block: () -> T): T? { val sonosIndex = info.track - 1
val deferred = CompletableDeferred<T?>() if (sonosIndex >= 0) {
handler.post { handler.post {
deferred.complete(runCatching(block).getOrNull()) val localIndex = delegate.currentMediaItemIndex
if (sonosIndex != localIndex && sonosIndex < delegate.mediaItemCount) {
delegate.seekTo(sonosIndex, 0L)
}
}
} }
return deferred.await()
} }
private companion object { private companion object {
@@ -51,7 +51,6 @@ class PlayerFactory @Inject constructor(
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig, private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val streamTokens: StreamTokenProvider,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
) { ) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -78,7 +77,6 @@ class PlayerFactory @Inject constructor(
delegate = exo, delegate = exo,
holder = activeUpnpHolder, holder = activeUpnpHolder,
remoteState = remoteState, remoteState = remoteState,
tokens = streamTokens,
onDrop = { name -> emitDrop(name) }, onDrop = { name -> emitDrop(name) },
) )
} }
@@ -25,13 +25,15 @@ class RemotePlayerState @Inject constructor() {
@Volatile var isPlaying: Boolean = false; private set @Volatile var isPlaying: Boolean = false; private set
@Volatile var currentTrackUri: String = ""; private set @Volatile var currentTrackUri: String = ""; private set
@Volatile var lastError: Throwable? = null; private set @Volatile var lastError: Throwable? = null; private set
@Volatile var trackNumber: Int = 0; private set
@Volatile private var consecutivePollFailures: Int = 0 @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.positionMs = positionMs
this.durationMs = durationMs this.durationMs = durationMs
this.currentTrackUri = trackUri this.currentTrackUri = trackUri
this.trackNumber = trackNumber
} }
fun applyTransportPlaying() { isPlaying = true } fun applyTransportPlaying() { isPlaying = true }
@@ -61,6 +63,7 @@ class RemotePlayerState @Inject constructor() {
currentTrackUri = "" currentTrackUri = ""
lastError = null lastError = null
consecutivePollFailures = 0 consecutivePollFailures = 0
trackNumber = 0
} }
private companion object { private companion object {
@@ -6,10 +6,12 @@ import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider 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.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController 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 * Drive the UPnP renderer using Sonos native queue mode:
* track, set the renderer's URI, play, then pause local playback so * clear the device's queue, load every track from our local queue
* audio yields to the speaker. Wrapped in `runCatching` at each * via AddURIToQueue, point the transport at the queue URI, seek to
* step — token failure, transport-lookup failure, and SOAP failure * the current index, and play. Wrapped in `runCatching` — SOAP
* each abandon the selection cleanly rather than crashing. Failures * failure abandons the selection cleanly. Local pause is handled by
* log at warn level via Timber so on-device verification can find * MinstrelForwardingPlayer.onActiveChanged via holder.set(), so we
* the cause in logcat (OkHttp's logger doesn't cover our own * do not call playerController.pause() here (it would race with the
* deserialize / SOAP-parse code paths). * SOAP override once holder.active is non-null).
*/ */
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock { private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
val trackId = playerController.uiState.value.currentTrack?.id val uiState = playerController.uiState.value
if (trackId == null) { val currentTrack = uiState.currentTrack
if (currentTrack == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)") Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return@withLock return@withLock
} }
@@ -259,13 +262,6 @@ class OutputPickerController @Inject constructor(
return@withLock return@withLock
} }
val rendering = renderingClientFor(effectiveRoute.id) 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 selectedUpnpRouteIdInternal.value = effectiveRoute.id
activeUpnpHolder.set( activeUpnpHolder.set(
ActiveUpnp( ActiveUpnp(
@@ -276,17 +272,7 @@ class OutputPickerController @Inject constructor(
), ),
) )
runCatching { runCatching {
Timber.w("UPnP select: mint token for track=$trackId, route=${effectiveRoute.name}") loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
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")
}.onFailure { e -> }.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}") Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
activeUpnpHolder.set(null) 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? { private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value val rcUrl = upnpDiscovery.routes.value
.firstOrNull { it.id == routeId } .firstOrNull { it.id == routeId }
@@ -4,8 +4,9 @@ import okhttp3.HttpUrl
/** /**
* High-level wrapper for the UPnP AVTransport service. * High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / * Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* Stop / Seek / GetPositionInfo / GetTransportInfo. * GetPositionInfo / GetTransportInfo / queue management
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
*/ */
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping. // One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
@@ -48,20 +49,84 @@ class AVTransportClient(
} }
/** /**
* Queues the next track on the renderer so it can pre-buffer before * Remove all tracks from the renderer's queue. UPnP action name is
* the current track finishes. Uses the same DIDL-Lite shape as * `RemoveAllTracksFromQueue`. Used at activation time to clear out any
* [setAVTransportURIWithMetadata]. * leftover queue from prior sessions before loading our local queue.
*/ */
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) { suspend fun removeAllTracksFromQueue() {
val safeTitle = title.ifBlank { "Minstrel" }
soap.call( soap.call(
controlUrl = controlUrl, controlUrl = controlUrl,
serviceType = SERVICE_TYPE, 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( args = mapOf(
"InstanceID" to "0", "InstanceID" to "0",
"NextURI" to uri, "EnqueuedURI" to uri,
"NextURIMetaData" to buildDidlLite(uri, mime, safeTitle), "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"), args = mapOf("InstanceID" to "0"),
) )
return PositionInfo( return PositionInfo(
track = result["Track"]?.toIntOrNull() ?: 0,
trackUri = result["TrackURI"].orEmpty(), trackUri = result["TrackURI"].orEmpty(),
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()), relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()), trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
@@ -182,6 +248,7 @@ class AVTransportClient(
} }
data class PositionInfo( data class PositionInfo(
val track: Int,
val trackUri: String, val trackUri: String,
val relTimeMs: Long, val relTimeMs: Long,
val trackDurationMs: Long, val trackDurationMs: Long,
@@ -16,12 +16,15 @@ class RemotePlayerStateTest {
} }
@Test @Test
fun `applyPositionInfo updates position and duration`() { fun `applyPositionInfo updates position, duration, and trackNumber`() {
val state = RemotePlayerState() 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(65_000L, state.positionMs)
assertEquals(210_000L, state.durationMs) assertEquals(210_000L, state.durationMs)
assertEquals("x", state.currentTrackUri) assertEquals("x", state.currentTrackUri)
assertEquals(3, state.trackNumber)
} }
@Test @Test
@@ -38,7 +38,7 @@ class AVTransportClientTest {
} }
@Test @Test
fun `getPositionInfo parses RelTime and TrackDuration`() = runTest { fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest {
server.enqueue( server.enqueue(
MockResponse().setBody( MockResponse().setBody(
"""<?xml version="1.0"?> """<?xml version="1.0"?>
@@ -46,7 +46,7 @@ class AVTransportClientTest {
<s:Body> <s:Body>
<u:GetPositionInfoResponse <u:GetPositionInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"> xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<Track>1</Track> <Track>3</Track>
<TrackDuration>0:03:30</TrackDuration> <TrackDuration>0:03:30</TrackDuration>
<TrackURI>http://x/y.mp3</TrackURI> <TrackURI>http://x/y.mp3</TrackURI>
<RelTime>0:01:05</RelTime> <RelTime>0:01:05</RelTime>
@@ -56,6 +56,7 @@ class AVTransportClientTest {
), ),
) )
val info = client.getPositionInfo() val info = client.getPositionInfo()
assertEquals(3, info.track)
assertEquals(65_000L, info.relTimeMs) assertEquals(65_000L, info.relTimeMs)
assertEquals(210_000L, info.trackDurationMs) assertEquals(210_000L, info.trackDurationMs)
assertEquals("http://x/y.mp3", info.trackUri) assertEquals("http://x/y.mp3", info.trackUri)
@@ -104,25 +105,67 @@ class AVTransportClientTest {
} }
@Test @Test
fun `setNextAVTransportURI sends DIDL-Lite with mime and title`() = runTest { fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest {
server.enqueue(emptyResponse("SetNextAVTransportURI")) server.enqueue(emptyResponse("RemoveAllTracksFromQueue"))
client.setNextAVTransportURI( 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", uri = "http://x/y.mp3",
mime = "audio/mpeg", mime = "audio/mpeg",
title = "Song", title = "Song",
enqueuedURIPosition = 2,
) )
val body = server.takeRequest().body.readUtf8() val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<NextURI>http://x/y.mp3</NextURI>")) { assertTrue(body.contains("<EnqueuedURI>http://x/y.mp3</EnqueuedURI>")) {
"body missing NextURI: $body" "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;")) { 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")) { assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" }
"body missing mime type: $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 @Test
fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest { fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI")) server.enqueue(emptyResponse("SetAVTransportURI"))