feat(android): any UPnP/DLNA renderer plays, not only a Sonos (#6451)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m33s
release / android (push) Failing after 1m44s
release / go (push) Successful in 1m48s
release / Build signed APK (releases and dev) (push) Successful in 4m54s
release / integration (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Discovery already listed every MediaRenderer on the LAN, but playback only
spoke the Sonos native queue (AddURIToQueue, x-rincon-queue, Seek TRACK_NR,
Next/Previous), which a generic renderer faults on, so picking one fell
straight back to the phone.

A renderer that isn't a Sonos is now fed one track at a time by
SingleTrackLoader: SetAVTransportURI + Play for the current track, and
SetNextAVTransportURI for the one after it so the renderer moves on by
itself without a gap. A renderer without SetNext is advanced by the app
when the poll sees it stop (or report NO_MEDIA_PRESENT) at the end of a
track; a stop partway is still the stall watchdog's.

ActiveUpnp carries the queue mode, picked from the route at select. Skip,
previous and queue taps point a one-track renderer at the cursor's track,
the poll takes the queue position from the loader instead of the
renderer's Track=1, queue changes keep the playing track and re-arm the
next, and adopting a running session matches by track id. Sonos keeps its
native queue unchanged.

DIDL-Lite drops the Rincon metadata for non-Sonos renderers and declares
DLNA.ORG_OP=01 so renderers that need it will seek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 07:38:29 -04:00
co-authored by Claude Opus 5.5
parent 5dc6aa9c2f
commit b1e55adc14
12 changed files with 863 additions and 111 deletions
@@ -14,6 +14,9 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
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.QueueMode
import com.fabledsword.minstrel.player.output.SingleTrackLoader
import com.fabledsword.minstrel.player.output.streamUriTrackId
import com.fabledsword.minstrel.player.output.upnp.PositionInfo import com.fabledsword.minstrel.player.output.upnp.PositionInfo
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportInfo import com.fabledsword.minstrel.player.output.upnp.TransportInfo
@@ -57,14 +60,21 @@ import timber.log.Timber
* wraps that callback into a SharedFlow consumed by the NowPlaying * wraps that callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar. * surface as a snackbar.
* *
* Queue mode: OutputPickerController loads the full queue into Sonos's * Queue mode ([ActiveUpnp.queueMode]):
* native queue via ClearQueue + AddURIToQueue, then points the * - NATIVE_QUEUE (Sonos): OutputPickerController loads the full queue
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to * into Sonos's native queue via ClearQueue + AddURIToQueue, then points
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free * the transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* advance natively. PollLoop keeps the local cursor aligned to the * AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* track the renderer is actually playing, matched by track identity * advance natively.
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload * - SINGLE_TRACK (any other UPnP / DLNA renderer): the renderer holds one
* index drift -- see [syncLocalCursorToRemote]. * track. Skip/prev/seekTo point it at the track the local cursor moved
* to, and each poll lets [SingleTrackLoader] follow or drive the advance
* to the next track and supply the queue position the renderer cannot.
*
* PollLoop keeps the local cursor aligned to the track the renderer is
* actually playing, matched by track identity (the id in
* GetPositionInfo's TrackURI) so it survives queue-reload index drift --
* see [syncLocalCursorToRemote].
*/ */
class MinstrelForwardingPlayer( class MinstrelForwardingPlayer(
private val delegate: Player, private val delegate: Player,
@@ -72,6 +82,7 @@ class MinstrelForwardingPlayer(
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock, private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController, private val networkStatus: NetworkStatusController,
private val singleTrack: SingleTrackLoader,
private val events: RemoteEvents = RemoteEvents(), private val events: RemoteEvents = RemoteEvents(),
) : ForwardingPlayer(delegate) { ) : ForwardingPlayer(delegate) {
@@ -319,18 +330,14 @@ class MinstrelForwardingPlayer(
return return
} }
super.seekTo(mediaItemIndex, positionMs) super.seekTo(mediaItemIndex, positionMs)
remoteState.beginPendingTransport( when (active.queueMode) {
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS, QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
) QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) {
scope.launch { active.avTransport.seekToTrack(mediaItemIndex + 1)
runCatching { if (positionMs > 0L) {
retryTransport { active.avTransport.seek(positionMs)
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
} }
}.onFailure { handleTransportFailure(active, it) } }
} }
} }
@@ -349,12 +356,9 @@ class MinstrelForwardingPlayer(
// then delegate to Sonos Next. PollLoop reconciles cursor via // then delegate to Sonos Next. PollLoop reconciles cursor via
// Track index if they diverge. // Track index if they diverge.
super.seekToNextMediaItem() super.seekToNextMediaItem()
remoteState.beginPendingTransport( when (active.queueMode) {
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS, QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
) QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.next() }
scope.launch {
runCatching { retryTransport { active.avTransport.next() } }
.onFailure { handleTransportFailure(active, it) }
} }
} }
@@ -372,11 +376,56 @@ class MinstrelForwardingPlayer(
// Super first for immediate local cursor advance (UI feedback); // Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Previous. PollLoop reconciles. // then delegate to Sonos Previous. PollLoop reconciles.
super.seekToPreviousMediaItem() super.seekToPreviousMediaItem()
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.previous() }
}
}
/**
* Send a track change to a Sonos native queue. The local cursor has
* already moved; the pending window holds the poll's cursor sync off
* until Sonos reports the new track, so it can't undo the press.
*/
private fun sendNativeQueueCommand(active: ActiveUpnp, command: suspend () -> Unit) {
remoteState.beginPendingTransport( remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS, SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
) )
scope.launch { scope.launch {
runCatching { retryTransport { active.avTransport.previous() } } runCatching { retryTransport { command() } }
.onFailure { handleTransportFailure(active, it) }
}
}
/**
* Point a one-track renderer at [index] of the queue, the track the local
* cursor was just moved to. The same track as the renderer's is a seek
* within it, so next on the last track (the cursor cannot move) sends
* nothing rather than restarting the track. The seek after a new track is
* best-effort: a renderer still loading the URI may refuse it, and the
* track playing from the start beats it not playing.
*/
private fun playSingleTrackAt(active: ActiveUpnp, index: Int, positionMs: Long = 0L) {
if (index == singleTrack.currentIndex) {
if (positionMs > 0L) seekTo(positionMs)
} else {
startSingleTrack(active, index, positionMs)
}
}
private fun startSingleTrack(active: ActiveUpnp, index: Int, positionMs: Long) {
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { singleTrack.playIndex(active.avTransport, index) } }
.onSuccess {
if (positionMs > 0L) {
runCatching { active.avTransport.seek(positionMs) }
.onFailure { Timber.w(it, "UPnP seek after track change refused") }
}
pollTrigger.trySend(Unit)
}
.onFailure { handleTransportFailure(active, it) } .onFailure { handleTransportFailure(active, it) }
} }
} }
@@ -611,6 +660,14 @@ class MinstrelForwardingPlayer(
*/ */
private suspend fun pollOnce(active: ActiveUpnp) { private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo() val info = active.avTransport.getPositionInfo()
val transport = active.avTransport.getTransportInfo()
val single = pollSingleTrack(active, info, transport)
// The loader just started the next track, so this reading describes
// the one that ended. Report the new track from its start and leave
// the cursor to the new index; the next poll reads it for real.
val advanced = single?.advanced == true
val rendererTrack = single?.trackNumber ?: info.track
val trackUri = if (advanced) "" else info.trackUri
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val inSeekAckWindow = lastSeekIssuedAtMs > 0L && val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS (now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
@@ -618,15 +675,52 @@ class MinstrelForwardingPlayer(
// seekTo -- the poll's reported position is stale until Sonos finishes // seekTo -- the poll's reported position is stale until Sonos finishes
// processing the Seek SOAP. Other fields still refresh from the poll. // processing the Seek SOAP. Other fields still refresh from the poll.
remoteState.applyPositionInfo( remoteState.applyPositionInfo(
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs, positionMs = when {
durationMs = info.trackDurationMs, advanced -> 0L
trackUri = info.trackUri, inSeekAckWindow -> remoteState.positionMs
trackNumber = info.track, else -> info.relTimeMs
},
durationMs = durationFor(info, single),
trackUri = trackUri,
trackNumber = rendererTrack,
) )
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri) syncLocalCursorToRemote(sonosTrack = rendererTrack, trackUri = trackUri)
events.onRendererTrack(info.track) if (single == null) events.onRendererTrack(info.track)
val transport = active.avTransport.getTransportInfo() if (advanced) {
when (transport.state) { applyTransportState(TransportState.PLAYING)
} else {
applyTransportState(transport.state)
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
}
notifyRemoteStateChanged()
}
/**
* A one-track renderer reports Track=1 whatever it plays; the loader
* knows the queue position, and may start the next track from here.
* Null for a Sonos, whose native queue answers for itself.
*/
private suspend fun pollSingleTrack(
active: ActiveUpnp,
info: PositionInfo,
transport: TransportInfo,
): SingleTrackLoader.PollOutcome? =
if (active.queueMode == QueueMode.SINGLE_TRACK) {
singleTrack.onPoll(
transport = active.avTransport,
trackUri = info.trackUri,
state = transport.state,
positionMs = info.relTimeMs,
durationMs = info.trackDurationMs,
playIntent = remoteState.lastPlayIntent,
)
} else {
null
}
private fun applyTransportState(state: TransportState) {
when (state) {
TransportState.PLAYING -> { TransportState.PLAYING -> {
nonPlayingPollStreak = 0 nonPlayingPollStreak = 0
remoteState.applyTransportPlaying() remoteState.applyTransportPlaying()
@@ -643,13 +737,18 @@ class MinstrelForwardingPlayer(
remoteState.applyTransportStopped() remoteState.applyTransportStopped()
} }
} }
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit TransportState.TRANSITIONING, TransportState.NO_MEDIA, TransportState.UNKNOWN -> Unit
} }
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
notifyRemoteStateChanged()
} }
/**
* The track's duration as the renderer reports it. A one-track renderer
* that reports none (some leave TrackDuration at 0:00:00) falls back to
* the library's duration, so the scrubber has an end to run to.
*/
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
if (single != null && info.trackDurationMs <= 0L) singleTrack.knownDurationMs() else info.trackDurationMs
/** /**
* Ask the watchdog what to make of this poll, and act on its answer. * Ask the watchdog what to make of this poll, and act on its answer.
* *
@@ -711,7 +810,7 @@ class MinstrelForwardingPlayer(
) )
// Tell the user and the admin inbox. Silence here would be the // Tell the user and the admin inbox. Silence here would be the
// original bug: playback simply ends and nobody finds out. // original bug: playback simply ends and nobody finds out.
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } } streamUriTrackId(trackUri)?.let { handler.post { events.onStalled(it) } }
} }
RemoteStallWatchdog.Decision.None -> Unit RemoteStallWatchdog.Decision.None -> Unit
} }
@@ -763,6 +862,9 @@ class MinstrelForwardingPlayer(
): RemoteStallWatchdog.QueueState { ): RemoteStallWatchdog.QueueState {
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
// A one-track renderer has no queue length to ask for; the loader
// knows whether it just played out the queue's last track.
if (active.queueMode == QueueMode.SINGLE_TRACK) return singleTrack.queueState()
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) { if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
@@ -834,7 +936,7 @@ class MinstrelForwardingPlayer(
* Must run on the application looper -- reads the delegate timeline. * Must run on the application looper -- reads the delegate timeline.
*/ */
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? { private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
val byId = trackIdFromStreamUri(trackUri)?.let { id -> val byId = streamUriTrackId(trackUri)?.let { id ->
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1) nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
} }
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount } return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
@@ -860,20 +962,7 @@ class MinstrelForwardingPlayer(
return best return best
} }
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
private fun trackIdFromStreamUri(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private companion object { private companion object {
// Path segment that precedes the track-id in a stream URI
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
// back to a delegate MediaItem by identity.
const val TRACKS_PATH_MARKER = "/api/tracks/"
const val POLL_INTERVAL_MS = 1_000L const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2 const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -23,6 +23,7 @@ import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.player.gain.GainAudioProcessor import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.SingleTrackLoader
import com.fabledsword.minstrel.player.output.SonosQueueLoader import com.fabledsword.minstrel.player.output.SonosQueueLoader
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@@ -62,6 +63,7 @@ class PlayerFactory @Inject constructor(
private val authStore: AuthStore, private val authStore: AuthStore,
private val replayGains: ReplayGainStore, private val replayGains: ReplayGainStore,
private val sonosQueue: SonosQueueLoader, private val sonosQueue: SonosQueueLoader,
private val singleTrack: SingleTrackLoader,
) { ) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -121,6 +123,7 @@ class PlayerFactory @Inject constructor(
remoteState = remoteState, remoteState = remoteState,
castNetworkLock = CastNetworkLock(context), castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth, networkStatus = serverHealth,
singleTrack = singleTrack,
events = MinstrelForwardingPlayer.RemoteEvents( events = MinstrelForwardingPlayer.RemoteEvents(
onDrop = { name -> emitDrop(name) }, onDrop = { name -> emitDrop(name) },
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) }, onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
@@ -13,14 +13,31 @@ import javax.inject.Singleton
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer] * + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
* from [OutputPickerController] -- the picker writes; the forwarding * from [OutputPickerController] -- the picker writes; the forwarding
* player reads. Null = no UPnP active (local ExoPlayer path). * player reads. Null = no UPnP active (local ExoPlayer path).
*
* [queueMode] says how the renderer is fed tracks, which decides what a
* skip sends and where the poll reads the queue position from.
*/ */
data class ActiveUpnp( data class ActiveUpnp(
val routeId: String, val routeId: String,
val routeName: String, val routeName: String,
val avTransport: AVTransportClient, val avTransport: AVTransportClient,
val rendering: RenderingControlClient?, val rendering: RenderingControlClient?,
val queueMode: QueueMode,
) )
enum class QueueMode {
/** A Sonos holds the whole queue: [SonosQueueLoader]. */
NATIVE_QUEUE,
/** Any other renderer holds one track at a time: [SingleTrackLoader]. */
SINGLE_TRACK,
;
companion object {
fun forRoute(isSonos: Boolean): QueueMode = if (isSonos) NATIVE_QUEUE else SINGLE_TRACK
}
}
@Singleton @Singleton
class ActiveUpnpHolder @Inject constructor() { class ActiveUpnpHolder @Inject constructor() {
@@ -15,6 +15,7 @@ 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.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
import com.fabledsword.minstrel.player.output.upnp.bareUdn import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -58,10 +58,11 @@ data class RouteSnapshot(
* Selection branches on [OutputRoute.Protocol]: * Selection branches on [OutputRoute.Protocol]:
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in, * - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth) * wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via * - [OutputRoute.Protocol.UPNP] — pause local playback so audio yields
* [SonosQueueLoader], drive the discovered renderer with * to the network speaker, then hand the renderer the queue: a Sonos
* AVTransport.SetAVTransportURI + Play, pause local playback so * takes all of it through [SonosQueueLoader]; any other UPnP / DLNA
* audio yields to the network speaker * renderer takes one track at a time through [SingleTrackLoader]
* (see [QueueMode])
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] — * - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
* not implemented; selecting one does nothing. * not implemented; selecting one does nothing.
*/ */
@@ -73,6 +74,7 @@ class OutputPickerController @Inject constructor(
private val playerController: PlayerController, private val playerController: PlayerController,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val sonosQueue: SonosQueueLoader, private val sonosQueue: SonosQueueLoader,
private val singleTrack: SingleTrackLoader,
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
@@ -330,6 +332,12 @@ class OutputPickerController @Inject constructor(
Timber.w("Sonos queue repair: route or transport gone for %s", routeId) Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
return@withLock return@withLock
} }
if (!upnpRoute.isSonos) {
// Only a native queue can be truncated; a one-track renderer
// never asks for this.
Timber.w("Sonos queue repair skipped: %s holds no queue", upnpRoute.name)
return@withLock
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute) val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
Timber.w( Timber.w(
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)", "Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
@@ -371,6 +379,10 @@ class OutputPickerController @Inject constructor(
selectedUpnpRouteIdInternal.value = null selectedUpnpRouteIdInternal.value = null
return@withLock return@withLock
} }
if (!upnpRoute.isSonos) {
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
return@withLock
}
val handledIncrementally = runCatching { val handledIncrementally = runCatching {
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue) sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e -> }.getOrElse { e ->
@@ -403,6 +415,7 @@ class OutputPickerController @Inject constructor(
routeName = outputRoute.name, routeName = outputRoute.name,
avTransport = transport, avTransport = transport,
rendering = rendering, rendering = rendering,
queueMode = QueueMode.NATIVE_QUEUE,
), ),
) )
activeUpnpHolder.setTarget(null) activeUpnpHolder.setTarget(null)
@@ -413,6 +426,57 @@ class OutputPickerController @Inject constructor(
} }
} }
/**
* The one-track counterpart of the Sonos resync. A mutation that left the
* cast running (play next, radio append, a removal) keeps the current
* track playing and only re-arms what follows it. A queue replaced
* through setMediaItems -- the forwarding player has already cleared
* active, and dropped the play() that came with it -- starts the new
* queue at its current index, as the listener asked. So does a mutation
* that removed the playing track.
*/
private suspend fun resyncSingleTrack(
upnpRoute: UpnpRoute,
transport: AVTransportClient,
newQueue: List<TrackRef>,
newCurrentIndex: Int,
) {
val stillActive = activeUpnpHolder.active.value?.routeId == upnpRoute.id
if (stillActive && requeueKeepsTrack(transport, newQueue)) {
activeUpnpHolder.setTarget(null)
return
}
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(upnpRoute.id)
}
Timber.w("UPnP single-track resync: starting track %d on %s", newCurrentIndex + 1, upnpRoute.name)
runCatching {
singleTrack.load(transport, newQueue, newCurrentIndex)
activeUpnpHolder.set(
ActiveUpnp(
routeId = upnpRoute.id,
routeName = upnpRoute.name,
avTransport = transport,
rendering = renderingClientFor(upnpRoute.id),
queueMode = QueueMode.SINGLE_TRACK,
),
)
activeUpnpHolder.setTarget(null)
remoteState.setPlayIntent(true)
}.onFailure { e ->
Timber.w(e, "UPnP single-track resync failed -- dropping to local")
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
/** True when the playing track survived the change and was kept playing. */
private suspend fun requeueKeepsTrack(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
runCatching { singleTrack.requeue(transport, newQueue) }
.onFailure { Timber.w(it, "UPnP single-track requeue failed; restarting the queue") }
.getOrDefault(false)
/** /**
* Called when the active UPnP route drops unexpectedly (the poll loop's * Called when the active UPnP route drops unexpectedly (the poll loop's
@@ -496,11 +560,10 @@ class OutputPickerController @Inject constructor(
} }
/** /**
* Drive the UPnP renderer using Sonos native queue mode: * Hand the renderer the local queue and play it. A Sonos gets its native
* clear the device's queue, load every track from our local queue * queue loaded ([SonosQueueLoader]); any other renderer is started on the
* via AddURIToQueue, point the transport at the queue URI, seek to * current track with the next one armed ([SingleTrackLoader]). Wrapped in
* the current index, and play. Wrapped in `runCatching` — SOAP * `runCatching` — SOAP failure abandons the selection cleanly.
* failure abandons the selection cleanly.
* *
* Order of operations is deliberate: * Order of operations is deliberate:
* 1. Pause local so the user doesn't keep hearing local audio. * 1. Pause local so the user doesn't keep hearing local audio.
@@ -531,6 +594,7 @@ class OutputPickerController @Inject constructor(
return@withLock return@withLock
} }
val rendering = renderingClientFor(effectiveRoute.id) val rendering = renderingClientFor(effectiveRoute.id)
val queueMode = queueModeFor(effectiveRoute.id)
// Pause local before flipping UI state -- user shouldn't keep hearing // Pause local before flipping UI state -- user shouldn't keep hearing
// local audio while we queue up Sonos. // local audio while we queue up Sonos.
playerController.pause() playerController.pause()
@@ -539,7 +603,12 @@ class OutputPickerController @Inject constructor(
// session we got disconnected from), attach to it in place instead of // session we got disconnected from), attach to it in place instead of
// wiping + reloading -- no jarring restart, and skip/seek land on its // wiping + reloading -- no jarring restart, and skip/seek land on its
// live queue. Falls through to the clear+reload path when it isn't ours. // live queue. Falls through to the clear+reload path when it isn't ours.
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) { val adopted = tryAdoptRunningSession(
AdoptTarget(transport, rendering, effectiveRoute, queueMode),
uiState.queue,
uiState.queueIndex,
)
if (adopted) {
return@withLock return@withLock
} }
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands // Mark UPnP loading. ForwardingPlayer overrides drop transport commands
@@ -547,7 +616,12 @@ class OutputPickerController @Inject constructor(
// taps don't hit Sonos's stale state from a prior session. // taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id) activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching { runCatching {
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex) when (queueMode) {
QueueMode.NATIVE_QUEUE ->
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
QueueMode.SINGLE_TRACK ->
singleTrack.load(transport, uiState.queue, uiState.queueIndex)
}
// Wire active LAST -- SOAP path is now safe to use. // Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set( activeUpnpHolder.set(
ActiveUpnp( ActiveUpnp(
@@ -555,6 +629,7 @@ class OutputPickerController @Inject constructor(
routeName = effectiveRoute.name, routeName = effectiveRoute.name,
avTransport = transport, avTransport = transport,
rendering = rendering, rendering = rendering,
queueMode = queueMode,
), ),
) )
// Selecting a route loads the queue and Plays on the renderer, so // Selecting a route loads the queue and Plays on the renderer, so
@@ -570,38 +645,49 @@ class OutputPickerController @Inject constructor(
/** /**
* The renderer's current position + transport state, validated as an * The renderer's current position + transport state, validated as an
* adoptable continuation of our local queue: same track id at the same * adoptable continuation of our local queue. Null means "not ours / not
* 1-based index. Null means "not ours / not playing" -> caller reloads. * playing" -> caller reloads.
*/ */
private data class AdoptPlan( private data class AdoptPlan(
val localIndex: Int, val localIndex: Int,
val positionMs: Long, val positionMs: Long,
val durationMs: Long, val durationMs: Long,
val trackUri: String, val trackUri: String,
val trackNumber: Int,
val playing: Boolean, val playing: Boolean,
) )
/** The renderer a select is about to drive, and how. */
private data class AdoptTarget(
val transport: AVTransportClient,
val rendering: RenderingControlClient?,
val route: OutputRoute,
val queueMode: QueueMode,
)
/** /**
* Attach to a renderer that is *already* playing our queue (a session the * Attach to a renderer that is *already* playing our queue (a session the
* phone got disconnected from but the speaker kept streaming) instead of * phone got disconnected from but the speaker kept streaming) instead of
* clearing + reloading it. Probes the renderer's live state, and if it's * clearing + reloading it. Probes the renderer's live state, and if it's
* mid-playback on the same track id at the same queue index, syncs the * mid-playback on one of our tracks where our queue has it, syncs the
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never * local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
* skips a beat, and skip/seek now drive its live queue. Returns false (and * skips a beat, and skip/seek now drive it. Returns false (and the caller
* the caller does the full clear+reload) when the renderer isn't playing * does the full load) when the renderer isn't playing our queue. The
* our queue. The local player is already paused by the caller. * local player is already paused by the caller.
*/ */
private suspend fun tryAdoptRunningSession( private suspend fun tryAdoptRunningSession(
transport: AVTransportClient, target: AdoptTarget,
rendering: RenderingControlClient?,
route: OutputRoute,
localQueue: List<TrackRef>, localQueue: List<TrackRef>,
localIndex: Int,
): Boolean { ): Boolean {
val plan = probeAdoptable(transport, route, localQueue) ?: return false val (transport, rendering, route, queueMode) = target
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
if (queueMode == QueueMode.SINGLE_TRACK) {
// The renderer holds only this track; arm the one after it.
singleTrack.adopt(transport, localQueue, plan.localIndex, plan.trackUri)
}
Timber.w( Timber.w(
"UPnP adopt: attaching to live session on %s at track %d (%dms)", "UPnP adopt: attaching to live session on %s at track %d (%dms)",
route.name, plan.trackNumber, plan.positionMs, route.name, plan.localIndex + 1, plan.positionMs,
) )
// active is still null here, so this aligns the *local* cursor without // active is still null here, so this aligns the *local* cursor without
// emitting SOAP; the holder.set below then starts the poll loop, which // emitting SOAP; the holder.set below then starts the poll loop, which
@@ -611,7 +697,7 @@ class OutputPickerController @Inject constructor(
positionMs = plan.positionMs, positionMs = plan.positionMs,
durationMs = plan.durationMs, durationMs = plan.durationMs,
trackUri = plan.trackUri, trackUri = plan.trackUri,
trackNumber = plan.trackNumber, trackNumber = plan.localIndex + 1,
) )
remoteState.setPlayIntent(plan.playing) remoteState.setPlayIntent(plan.playing)
activeUpnpHolder.set( activeUpnpHolder.set(
@@ -620,36 +706,45 @@ class OutputPickerController @Inject constructor(
routeName = route.name, routeName = route.name,
avTransport = transport, avTransport = transport,
rendering = rendering, rendering = rendering,
queueMode = queueMode,
), ),
) )
return true return true
} }
/**
* A Sonos must be on our track at the same 1-based index of its native
* queue, since skips drive that queue. A one-track renderer has no index
* to agree on: it is ours if it is playing one of our tracks, taken
* nearest the local cursor.
*/
private suspend fun probeAdoptable( private suspend fun probeAdoptable(
transport: AVTransportClient, target: AdoptTarget,
route: OutputRoute,
localQueue: List<TrackRef>, localQueue: List<TrackRef>,
localIndex: Int,
): AdoptPlan? { ): AdoptPlan? {
val probe = runCatching { val probe = runCatching {
transport.getTransportInfo() to transport.getPositionInfo() target.transport.getTransportInfo() to target.transport.getPositionInfo()
}.getOrElse { }.getOrElse {
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name) Timber.w(it, "UPnP adopt: probe failed for %s; will reload", target.route.name)
return null return null
} }
val (info, pos) = probe val (info, pos) = probe
val idx0 = pos.track - 1 val playingId = streamUriTrackId(pos.trackUri)
val playingId = trackIdFromStreamUrl(pos.trackUri)
val midPlayback = val midPlayback =
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
val aligned = midPlayback && playingId != null && val idx0 = when {
idx0 in localQueue.indices && localQueue[idx0].id == playingId playingId == null -> null
return if (aligned) { target.queueMode == QueueMode.SINGLE_TRACK ->
nearestIndexOf(localQueue, playingId, localIndex)
else -> (pos.track - 1).takeIf { it in localQueue.indices && localQueue[it].id == playingId }
}
return if (midPlayback && idx0 != null) {
AdoptPlan( AdoptPlan(
localIndex = idx0, localIndex = idx0,
positionMs = pos.relTimeMs, positionMs = pos.relTimeMs,
durationMs = pos.trackDurationMs, durationMs = pos.trackDurationMs,
trackUri = pos.trackUri, trackUri = pos.trackUri,
trackNumber = pos.track,
playing = info.state == TransportState.PLAYING, playing = info.state == TransportState.PLAYING,
) )
} else { } else {
@@ -657,12 +752,9 @@ class OutputPickerController @Inject constructor(
} }
} }
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */ /** How the route is fed tracks; a route gone from discovery reads as not-Sonos. */
private fun trackIdFromStreamUrl(url: String): String? { private fun queueModeFor(routeId: String): QueueMode =
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null QueueMode.forRoute(upnpDiscovery.routes.value.firstOrNull { it.id == routeId }?.isSonos == true)
val i = segments.indexOf("tracks")
return if (i >= 0) segments.getOrNull(i + 1) else null
}
private fun renderingClientFor(routeId: String): RenderingControlClient? { private fun renderingClientFor(routeId: String): RenderingControlClient? {
@@ -0,0 +1,265 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.RemoteStallWatchdog
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import kotlin.math.abs
import javax.inject.Singleton
/**
* Drives a UPnP / DLNA renderer that has no queue of its own — anything that
* isn't a Sonos. [SonosQueueLoader] hands a Sonos the whole queue; the queue
* verbs it uses are Sonos extensions a generic renderer faults on. Here the
* app holds the queue and the renderer holds one track at a time:
*
* - SetAVTransportURI + Play starts a track.
* - SetNextAVTransportURI hands over the one after it, so a renderer that
* supports it moves on by itself, without a gap. The poll sees the
* renderer's URI change to that one and arms the next.
* - A renderer without SetNext (it faults) or one that accepts it and
* ignores it stops at the end of the track; [TrackEndDetector] tells that
* from a stop partway, and the app starts the next track itself.
*
* The renderer reports Track=1 for whatever it is playing, so the 1-based
* queue position everything else keys on comes from here ([PollOutcome]).
*
* One mutex covers every write, because the poll's auto-advance and the
* listener's skip both point the renderer somewhere and must not interleave.
*/
@Singleton
class SingleTrackLoader @Inject constructor(
private val streamTokens: StreamTokenProvider,
) {
private val mutex = Mutex()
@Volatile private var queue: List<TrackRef> = emptyList()
/** 0-based index of the track the renderer was last pointed at; -1 when none. */
@Volatile var currentIndex: Int = -1
private set
private var currentUri: String = ""
private var armedNextIndex: Int = -1
private var armedNextUri: String = ""
// Set when the renderer faults SetNextAVTransportURI: it does not have the
// action, and asking again per track would only collect the same fault.
// Cleared on every load, since a load may be a different renderer.
private var nextUnsupported = false
// Polls to wait before re-trying a SetNext that failed in transit. Each
// try mints a URL from the server, so a failure is not retried every
// second.
private var armRetryPolls = 0
// Whether the last poll found the current track played to its end. Read
// by [queueState] for the stall watchdog.
private var endedOnLastPoll = false
private val endDetector = TrackEndDetector()
/**
* What a poll learned. [trackNumber] is the 1-based queue position the
* renderer is playing (0 when nothing is loaded). [advanced] is true when
* this poll started the next track itself, so the reading it was given is
* already stale and the caller should not judge a stall from it.
*/
data class PollOutcome(val trackNumber: Int, val advanced: Boolean)
/** Take [queue] as the one to play, and start [index] on the renderer. */
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
mutex.withLock {
this.queue = queue
nextUnsupported = false
start(transport, index)
}
/**
* Start [index] of the queue already held — a skip, a previous, or a tap
* on a queue row. Returns false when [index] is outside the queue.
*/
suspend fun playIndex(transport: AVTransportClient, index: Int): Boolean =
mutex.withLock { start(transport, index) }
/**
* Attach to a renderer already playing [index] of [queue] at [uri] — a
* session the phone lost touch with and the renderer carried on with —
* without restarting it. Only arms the next track.
*/
suspend fun adopt(transport: AVTransportClient, queue: List<TrackRef>, index: Int, uri: String) =
mutex.withLock {
this.queue = queue
nextUnsupported = false
pointedAt(index, uri)
armNext(transport)
}
/**
* The local queue changed under a playing track (play next, radio
* append, a removal). Keep playing the current track and re-arm what
* follows it. Returns false when the current track is gone from
* [newQueue]; the caller then starts the new queue with [load].
*/
suspend fun requeue(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
mutex.withLock {
val playingId = queue.getOrNull(currentIndex)?.id
val idx = playingId?.let { nearestIndexOf(newQueue, it, currentIndex) }
queue = newQueue
if (idx == null) return@withLock false
currentIndex = idx
// What was armed was chosen from the old queue; SetNext replaces it.
armedNextIndex = -1
armedNextUri = ""
armNext(transport)
true
}
/**
* One poll's reading of the renderer. Follows a renderer that moved on by
* itself, re-tries arming the next track, and starts the next track when
* the current one has played to its end.
*/
suspend fun onPoll(
transport: AVTransportClient,
trackUri: String,
state: TransportState,
positionMs: Long,
durationMs: Long,
playIntent: Boolean,
): PollOutcome = mutex.withLock {
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
val moved = followRenderer(trackUri)
armNext(transport)
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false)
val known = queue[currentIndex].durationSec * MILLIS_PER_SECOND
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, known)
val next = currentIndex + 1
val advance = endedOnLastPoll && playIntent && next in queue.indices
if (advance) {
Timber.w("UPnP single-track: track %d ended; starting %d", currentIndex + 1, next + 1)
runCatching { start(transport, next) }
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
}
PollOutcome(currentIndex + 1, advanced = advance)
}
/**
* For the stall watchdog: a renderer stopped at the end of the queue's
* last track has simply finished. Anything else is left for the watchdog
* to judge, as it would for a renderer that says nothing.
*/
fun queueState(): RemoteStallWatchdog.QueueState =
if (endedOnLastPoll && currentIndex == queue.lastIndex) {
RemoteStallWatchdog.QueueState.COMPLETE
} else {
RemoteStallWatchdog.QueueState.UNKNOWN
}
/** The library's duration for the current track, for a renderer that reports none. */
fun knownDurationMs(): Long =
queue.getOrNull(currentIndex)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
if (index !in queue.indices) return false
val token = mint(index)
transport.setAVTransportURIWithMetadata(token.url, token.mime, token.title)
transport.play()
pointedAt(index, token.url)
armNext(transport)
return true
}
private fun pointedAt(index: Int, uri: String) {
currentIndex = index
currentUri = uri
armedNextIndex = -1
armedNextUri = ""
armRetryPolls = 0
endedOnLastPoll = false
endDetector.reset()
}
/**
* Update [currentIndex] when the renderer's URI says it is playing
* something other than the track we started. The armed next URI is the
* common case (it advanced by itself); otherwise the track id in the URI
* is looked up, for a renderer that was re-pointed some other way.
* Returns true when the index moved.
*/
private fun followRenderer(trackUri: String): Boolean {
val idx = when {
trackUri.isEmpty() || trackUri == currentUri -> null
trackUri == armedNextUri -> armedNextIndex
else -> streamUriTrackId(trackUri)?.let { nearestIndexOf(queue, it, currentIndex + 1) }
}
if (idx == null || idx == currentIndex) return false
Timber.w("UPnP single-track: renderer moved on to track %d", idx + 1)
pointedAt(idx, trackUri)
return true
}
private suspend fun armNext(transport: AVTransportClient) {
val next = currentIndex + 1
if (nextUnsupported || next !in queue.indices || next == armedNextIndex) return
if (armRetryPolls > 0) {
armRetryPolls -= 1
return
}
runCatching {
val token = mint(next)
transport.setNextAVTransportURI(token.url, token.mime, token.title)
token.url
}.onSuccess { url ->
armedNextIndex = next
armedNextUri = url
}.onFailure { e ->
if (e is SoapFaultException) {
// The renderer answered: it has no SetNext. The app advances it.
Timber.w("UPnP single-track: SetNext refused (%s); advancing from the app", e.code)
nextUnsupported = true
} else {
Timber.w(e, "UPnP single-track: SetNext failed; retrying later")
armRetryPolls = ARM_RETRY_POLLS
}
}
}
// Prerender both: the current track is about to be fetched, and the next
// one is fetched the moment the renderer moves on.
private suspend fun mint(index: Int) = streamTokens.mint(
queue[index].id,
asAlbum = SonosQueueLoader.playingAsAlbum(queue, index),
prerender = true,
)
private companion object {
const val MILLIS_PER_SECOND = 1_000L
const val ARM_RETRY_POLLS = 10
}
}
/**
* Index in [queue] of the track with [id], taking the occurrence nearest
* [preferNear] so a track queued twice does not send the cursor across the
* queue. Null when [id] is not queued.
*/
internal fun nearestIndexOf(queue: List<TrackRef>, id: String, preferNear: Int): Int? =
queue.indices.filter { queue[it].id == id }.minByOrNull { abs(it - preferNear) }
/** The track id in a `.../api/tracks/{id}/stream...` URL, or null for any other URL. */
internal fun streamUriTrackId(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private const val TRACKS_PATH_MARKER = "/api/tracks/"
@@ -0,0 +1,63 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.TransportState
/**
* Tells a renderer that finished its track from one that stopped partway.
*
* A renderer with no queue of its own reports the same STOPPED either way.
* Finishing means the app should start the next track; stopping partway is
* the stall watchdog's business (the stream died, or someone pressed stop on
* the device), and starting the next track then would skip music.
*
* The test is where the renderer was last seen playing: within [END_SLACK_MS]
* of the end counts as finished. The slack covers the 1 s poll cadence plus a
* renderer that stops a little short of the duration it reported. A track is
* only judged once it has been seen playing, so the brief STOPPED a renderer
* passes through while it loads a new URI never reads as an ending.
*
* Pure state, no SOAP: [SingleTrackLoader] feeds it each poll.
*/
internal class TrackEndDetector {
private var sawPlaying = false
private var lastPlayingPositionMs = 0L
private var durationMs = 0L
/**
* Feed one poll, learn whether the current track has ended.
* [reportedDurationMs] is the renderer's TrackDuration, which some
* renderers leave at zero; [knownDurationMs] is the library's duration
* for the same track, used when the renderer says nothing.
*/
fun onPoll(
state: TransportState,
positionMs: Long,
reportedDurationMs: Long,
knownDurationMs: Long,
): Boolean {
val duration = reportedDurationMs.takeIf { it > 0L } ?: knownDurationMs
if (duration > 0L) durationMs = duration
if (state == TransportState.PLAYING) {
sawPlaying = true
lastPlayingPositionMs = positionMs
return false
}
val stopped = state == TransportState.STOPPED || state == TransportState.NO_MEDIA
return stopped &&
sawPlaying &&
durationMs > 0L &&
lastPlayingPositionMs >= durationMs - END_SLACK_MS
}
/** A new track was handed to the renderer: judge it from scratch. */
fun reset() {
sawPlaying = false
lastPlayingPositionMs = 0L
durationMs = 0L
}
private companion object {
const val END_SLACK_MS = 5_000L
}
}
@@ -4,15 +4,21 @@ import okhttp3.HttpUrl
/** /**
* High-level wrapper for the UPnP AVTransport service. * High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / Play / Pause / Stop / Seek / * Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / Stop /
* GetPositionInfo / GetTransportInfo / queue management * Seek / GetPositionInfo / GetTransportInfo, plus the Sonos queue
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack). * extensions (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous,
* SeekToTrack).
*
* [sonos] says which kind of renderer is on the other end. It shapes the
* DIDL-Lite every track is described with, so it is fixed at construction
* rather than chosen per call.
*/ */
// 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")
class AVTransportClient( class AVTransportClient(
private val soap: SoapClient, private val soap: SoapClient,
private val controlUrl: HttpUrl, private val controlUrl: HttpUrl,
private val sonos: Boolean,
) { ) {
suspend fun setAVTransportURI(uri: String, metadata: String = "") { suspend fun setAVTransportURI(uri: String, metadata: String = "") {
soap.call( soap.call(
@@ -39,15 +45,34 @@ class AVTransportClient(
* <res protocolInfo="http-get:*:<mime>:*">...</res> * <res protocolInfo="http-get:*:<mime>:*">...</res>
* </item> * </item>
* </DIDL-Lite> * </DIDL-Lite>
* Generic UPnP renderers tolerate this shape too — there's no * The Sonos-only parts are left out for other renderers; see
* downside to always sending it. Title falls back to "Minstrel" * [buildDidlLite]. Title falls back to "Minstrel" when the caller
* when the caller doesn't supply one. * doesn't supply one.
*/ */
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) { suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" } val safeTitle = title.ifBlank { "Minstrel" }
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle)) setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
} }
/**
* Hand the renderer the track to play once the current one ends, so it
* can move on without a gap and without waiting for us. Optional in the
* UPnP spec: a renderer without it faults (401 Invalid Action, or 602
* Not Implemented), and the caller then advances the renderer itself.
*/
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetNextAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"NextURI" to uri,
"NextURIMetaData" to buildDidlLite(uri, mime, title),
),
)
}
/** /**
* Remove all tracks from the renderer's queue. UPnP action name is * Remove all tracks from the renderer's queue. UPnP action name is
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any * `RemoveAllTracksFromQueue`. Used at activation time to clear out any
@@ -247,6 +272,7 @@ class AVTransportClient(
"PAUSED_PLAYBACK" -> TransportState.PAUSED "PAUSED_PLAYBACK" -> TransportState.PAUSED
"STOPPED" -> TransportState.STOPPED "STOPPED" -> TransportState.STOPPED
"TRANSITIONING" -> TransportState.TRANSITIONING "TRANSITIONING" -> TransportState.TRANSITIONING
"NO_MEDIA_PRESENT" -> TransportState.NO_MEDIA
else -> TransportState.UNKNOWN else -> TransportState.UNKNOWN
} }
// CurrentTransportStatus is the renderer's own verdict on whether it is // CurrentTransportStatus is the renderer's own verdict on whether it is
@@ -267,22 +293,31 @@ class AVTransportClient(
// discards our metadata content and regenerates its own with // discards our metadata content and regenerates its own with
// class=object.item and the URL query string as the title // class=object.item and the URL query string as the title
// instead. Match SoCo's pattern. // instead. Match SoCo's pattern.
//
// Other renderers get plain DIDL with DLNA.ORG_OP=01 in the
// protocolInfo, which declares that the stream takes byte-range
// requests. Several DLNA renderers (TVs, AV receivers) refuse to
// seek without it; the server's streams all serve Range.
val safeTitle = title.ifBlank { "Minstrel" } val safeTitle = title.ifBlank { "Minstrel" }
val fourthField = if (sonos) "*" else DLNA_SEEKABLE
return buildString { return buildString {
append("<DIDL-Lite ") append("<DIDL-Lite ")
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ") append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ") append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ") append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"")
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">") if (sonos) append(" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\"")
append(">")
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">") append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>") append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>") append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime)) append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
append(":*\">").append(xmlEscape(uri)).append("</res>") append(":").append(fourthField).append("\">").append(xmlEscape(uri)).append("</res>")
append("<desc id=\"cdudn\" ") if (sonos) {
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">") append("<desc id=\"cdudn\" ")
append("RINCON_AssociatedZPUDN") append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("</desc>") append("RINCON_AssociatedZPUDN")
append("</desc>")
}
append("</item>") append("</item>")
append("</DIDL-Lite>") append("</DIDL-Lite>")
} }
@@ -308,6 +343,7 @@ class AVTransportClient(
private companion object { private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
const val DLNA_SEEKABLE = "DLNA.ORG_OP=01"
const val MILLIS_PER_SECOND = 1000L const val MILLIS_PER_SECOND = 1000L
const val SECONDS_PER_MINUTE = 60L const val SECONDS_PER_MINUTE = 60L
const val SECONDS_PER_HOUR = 3600L const val SECONDS_PER_HOUR = 3600L
@@ -330,7 +366,12 @@ data class PositionInfo(
*/ */
data class MediaInfo(val nrTracks: Int, val currentUri: String) data class MediaInfo(val nrTracks: Int, val currentUri: String)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN } /**
* [NO_MEDIA] is NO_MEDIA_PRESENT: nothing loaded. Several DLNA renderers land
* there instead of STOPPED when a track plays out, so a one-track renderer's
* ending reads it as a stop; it is not treated as a stall.
*/
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, NO_MEDIA, UNKNOWN }
/** /**
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites * [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
@@ -135,13 +135,14 @@ class UpnpDiscoveryController @Inject constructor(
return AVTransportClient( return AVTransportClient(
loggingSoapClient(controlHttp, route.name), loggingSoapClient(controlHttp, route.name),
route.avTransportControlUrl, route.avTransportControlUrl,
sonos = route.isSonos,
) )
} }
private suspend fun handleDiscovery(locationUrl: String) { private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return val route = fetchRoute(locationUrl) ?: return
upsertRoute(route) upsertRoute(route)
if (route.manufacturer.contains("Sonos", ignoreCase = true)) { if (route.isSonos) {
refreshSonosTopology(route) refreshSonosTopology(route)
} }
} }
@@ -25,4 +25,13 @@ data class UpnpRoute(
val avTransportControlUrl: HttpUrl, val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?, val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null, val zoneGroupTopologyControlUrl: HttpUrl? = null,
) ) {
/**
* Whether this renderer is a Sonos, which is driven through its native
* queue. Anything else gets one track at a time — the Sonos queue verbs
* are vendor extensions a generic renderer faults on.
*/
val isSonos: Boolean
get() = zoneGroupTopologyControlUrl != null ||
manufacturer.contains("Sonos", ignoreCase = true)
}
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The pieces that let a renderer without a queue be driven from ours: which
* renderers get which mode, and mapping the URI it reports back to a queue
* position.
*/
class SingleTrackQueueTest {
private fun t(id: String) = TrackRef(id = id, title = "", albumId = "", artistId = "")
private fun route(manufacturer: String, zgt: Boolean = false) = UpnpRoute(
id = "uuid:x",
name = "Room",
manufacturer = manufacturer,
modelName = "",
avTransportControlUrl = "http://192.168.0.2:1400/AVTransport/Control".toHttpUrl(),
renderingControlUrl = null,
zoneGroupTopologyControlUrl = if (zgt) "http://192.168.0.2:1400/ZGT/Control".toHttpUrl() else null,
)
@Test
fun `a Sonos is driven through its native queue`() {
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("Sonos, Inc.").isSonos))
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("", zgt = true).isSonos))
}
@Test
fun `any other renderer is driven one track at a time`() {
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("Yamaha Corporation").isSonos))
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("").isSonos))
}
@Test
fun `the track id is read from a stream URL, leveled or not`() {
assertEquals("abc", streamUriTrackId("http://h:8080/api/tracks/abc/stream.flac?t=tok"))
assertEquals("abc", streamUriTrackId("http://h/api/tracks/abc/leveled.flac?t=tok"))
}
@Test
fun `a URL that is not ours has no track id`() {
assertNull(streamUriTrackId("x-rincon-queue:RINCON_ABC#0"))
assertNull(streamUriTrackId(""))
assertNull(streamUriTrackId("http://h/api/tracks/"))
}
@Test
fun `a track queued twice resolves to the copy nearest the cursor`() {
val queue = listOf(t("a"), t("b"), t("a"), t("c"), t("a"))
assertEquals(2, nearestIndexOf(queue, "a", preferNear = 3))
assertEquals(0, nearestIndexOf(queue, "a", preferNear = 0))
}
@Test
fun `a track not in the queue has no index`() {
assertNull(nearestIndexOf(listOf(t("a")), "z", preferNear = 0))
}
}
@@ -0,0 +1,68 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.TransportState
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A one-track renderer says STOPPED both when a track plays out and when it
* stops partway; only the first should start the next track.
*/
class TrackEndDetectorTest {
private val d = TrackEndDetector()
private fun poll(state: TransportState, positionMs: Long, durationMs: Long = 200_000L, known: Long = 0L) =
d.onPoll(state, positionMs, durationMs, known)
@Test
fun `a stop just after the last playing reading near the end is an ending`() {
poll(TransportState.PLAYING, 198_000L)
assertTrue(poll(TransportState.STOPPED, 0L))
}
@Test
fun `a stop partway through is not an ending`() {
poll(TransportState.PLAYING, 90_000L)
assertFalse(poll(TransportState.STOPPED, 90_000L))
}
@Test
fun `the stop a renderer passes through while loading a track is not an ending`() {
// Short track, never seen playing yet: without the playing guard,
// position 0 would already be within the slack of the end.
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 3_000L))
}
@Test
fun `no media present after a track plays out is an ending`() {
poll(TransportState.PLAYING, 199_500L)
assertTrue(poll(TransportState.NO_MEDIA, 0L, durationMs = 0L))
}
@Test
fun `a pause near the end is not an ending`() {
poll(TransportState.PLAYING, 199_000L)
assertFalse(poll(TransportState.PAUSED, 199_000L))
}
@Test
fun `the library duration stands in for a renderer that reports none`() {
poll(TransportState.PLAYING, 178_000L, durationMs = 0L, known = 180_000L)
assertTrue(poll(TransportState.STOPPED, 0L, durationMs = 0L, known = 180_000L))
}
@Test
fun `with no duration from anywhere nothing is judged an ending`() {
poll(TransportState.PLAYING, 5_000L, durationMs = 0L)
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 0L))
}
@Test
fun `reset forgets the track that was playing`() {
poll(TransportState.PLAYING, 199_000L)
d.reset()
assertFalse(poll(TransportState.STOPPED, 0L))
}
}
@@ -21,7 +21,7 @@ class AVTransportClientTest {
server = MockWebServer() server = MockWebServer()
server.start() server.start()
val controlUrl = server.url("/MediaRenderer/AVTransport/Control") val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl) client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl, sonos = true)
} }
@AfterEach @AfterEach
@@ -229,6 +229,45 @@ class AVTransportClientTest {
assertEquals(0, client.getMediaInfo().nrTracks) assertEquals(0, client.getMediaInfo().nrTracks)
} }
@Test
fun `setNextAVTransportURI sends NextURI with its metadata`() = runTest {
server.enqueue(emptyResponse("SetNextAVTransportURI"))
client.setNextAVTransportURI(
uri = "http://example.com/api/tracks/t2/stream.flac",
mime = "audio/flac",
title = "Next",
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("SetNextAVTransportURI")) { "body: $body" }
assertTrue(body.contains("<NextURI>http://example.com/api/tracks/t2/stream.flac</NextURI>")) {
"body: $body"
}
assertTrue(body.contains("<NextURIMetaData>")) { "body: $body" }
}
@Test
fun `a Sonos gets the Rincon metadata it requires`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI"))
client.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("RINCON_AssociatedZPUDN")) { "body: $body" }
}
@Test
fun `a generic renderer gets plain DIDL that declares the stream seekable`() = runTest {
val generic = AVTransportClient(
SoapClient(OkHttpClient()),
server.url("/MediaRenderer/AVTransport/Control"),
sonos = false,
)
server.enqueue(emptyResponse("SetAVTransportURI"))
generic.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
val body = server.takeRequest().body.readUtf8()
assertFalse(body.contains("RINCON")) { "body: $body" }
assertFalse(body.contains("rinconnetworks")) { "body: $body" }
assertTrue(body.contains("audio/flac:DLNA.ORG_OP=01")) { "body: $body" }
}
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody( private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
"""<?xml version="1.0"?> """<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"> <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">