feat(android): Sonos queue mode -- ClearQueue + AddURIToQueue + x-rincon-queue
android / Build + lint + test (push) Failing after 9m9s
android / Build + lint + test (push) Failing after 9m9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+58
-96
@@ -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 {
|
||||
|
||||
+43
-28
@@ -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 }
|
||||
|
||||
+77
-10
@@ -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
|
||||
|
||||
+53
-10
@@ -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("<dc:title>Song</dc:title>")) {
|
||||
"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"))
|
||||
|
||||
Reference in New Issue
Block a user