From 2a098a78fe75f781f3f2dfc0d301c4d4658f30a5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 21:55:03 -0400 Subject: [PATCH] feat(android): Sonos queue mode -- ClearQueue + AddURIToQueue + x-rincon-queue Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 154 +++++++----------- .../minstrel/player/PlayerFactory.kt | 2 - .../minstrel/player/RemotePlayerState.kt | 5 +- .../player/output/OutputPickerController.kt | 71 ++++---- .../player/output/upnp/AVTransportClient.kt | 87 ++++++++-- .../minstrel/player/RemotePlayerStateTest.kt | 7 +- .../output/upnp/AVTransportClientTest.kt | 63 +++++-- 7 files changed, 240 insertions(+), 149 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index bb56e9e6..a3a3883e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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:#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 handlerEvaluate(block: () -> T): T? { - val deferred = CompletableDeferred() - 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 { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index e7222217..dd28ca5e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -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) }, ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 1bae6460..40afc914 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -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 { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 51ce71fe..dcef6235 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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, + 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 } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index b7dfa85d..8033323f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -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, diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt index 1ff51e9c..7a6ac898 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt @@ -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 diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 69bbe329..c6c2ad96 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -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( """ @@ -46,7 +46,7 @@ class AVTransportClientTest { - 1 + 3 0:03:30 http://x/y.mp3 0:01:05 @@ -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("http://x/y.mp3")) { - "body missing NextURI: $body" + assertTrue(body.contains("http://x/y.mp3")) { + "body missing EnqueuedURI: $body" } + val positionTag = "2" + 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("TRACK_NR")) { "body: $body" } + assertTrue(body.contains("4")) { "body: $body" } + } + @Test fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest { server.enqueue(emptyResponse("SetAVTransportURI"))