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
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:
+137
-48
@@ -14,6 +14,9 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
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.SoapFaultException
|
||||
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
|
||||
* 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
|
||||
* Queue mode ([ActiveUpnp.queueMode]):
|
||||
* - NATIVE_QUEUE (Sonos): 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 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].
|
||||
* advance natively.
|
||||
* - SINGLE_TRACK (any other UPnP / DLNA renderer): the renderer holds one
|
||||
* 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(
|
||||
private val delegate: Player,
|
||||
@@ -72,6 +82,7 @@ class MinstrelForwardingPlayer(
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val singleTrack: SingleTrackLoader,
|
||||
private val events: RemoteEvents = RemoteEvents(),
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
@@ -319,18 +330,14 @@ class MinstrelForwardingPlayer(
|
||||
return
|
||||
}
|
||||
super.seekTo(mediaItemIndex, positionMs)
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
retryTransport {
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
|
||||
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) {
|
||||
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
|
||||
// Track index if they diverge.
|
||||
super.seekToNextMediaItem()
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.next() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
|
||||
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.next() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,11 +376,56 @@ class MinstrelForwardingPlayer(
|
||||
// Super first for immediate local cursor advance (UI feedback);
|
||||
// then delegate to Sonos Previous. PollLoop reconciles.
|
||||
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(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -611,6 +660,14 @@ class MinstrelForwardingPlayer(
|
||||
*/
|
||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||
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 inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
|
||||
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
|
||||
@@ -618,15 +675,52 @@ class MinstrelForwardingPlayer(
|
||||
// seekTo -- the poll's reported position is stale until Sonos finishes
|
||||
// processing the Seek SOAP. Other fields still refresh from the poll.
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
|
||||
durationMs = info.trackDurationMs,
|
||||
trackUri = info.trackUri,
|
||||
trackNumber = info.track,
|
||||
positionMs = when {
|
||||
advanced -> 0L
|
||||
inSeekAckWindow -> remoteState.positionMs
|
||||
else -> info.relTimeMs
|
||||
},
|
||||
durationMs = durationFor(info, single),
|
||||
trackUri = trackUri,
|
||||
trackNumber = rendererTrack,
|
||||
)
|
||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||
events.onRendererTrack(info.track)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
syncLocalCursorToRemote(sonosTrack = rendererTrack, trackUri = trackUri)
|
||||
if (single == null) events.onRendererTrack(info.track)
|
||||
if (advanced) {
|
||||
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 -> {
|
||||
nonPlayingPollStreak = 0
|
||||
remoteState.applyTransportPlaying()
|
||||
@@ -643,13 +737,18 @@ class MinstrelForwardingPlayer(
|
||||
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.
|
||||
*
|
||||
@@ -711,7 +810,7 @@ class MinstrelForwardingPlayer(
|
||||
)
|
||||
// Tell the user and the admin inbox. Silence here would be the
|
||||
// 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
|
||||
}
|
||||
@@ -763,6 +862,9 @@ class MinstrelForwardingPlayer(
|
||||
): RemoteStallWatchdog.QueueState {
|
||||
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
|
||||
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()
|
||||
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
|
||||
@@ -834,7 +936,7 @@ class MinstrelForwardingPlayer(
|
||||
* Must run on the application looper -- reads the delegate timeline.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||
@@ -860,20 +962,7 @@ class MinstrelForwardingPlayer(
|
||||
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 {
|
||||
// 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 NON_PLAYING_CONFIRM = 2
|
||||
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.ReplayGainStore
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.SingleTrackLoader
|
||||
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
@@ -62,6 +63,7 @@ class PlayerFactory @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val replayGains: ReplayGainStore,
|
||||
private val sonosQueue: SonosQueueLoader,
|
||||
private val singleTrack: SingleTrackLoader,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
@@ -121,6 +123,7 @@ class PlayerFactory @Inject constructor(
|
||||
remoteState = remoteState,
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
networkStatus = serverHealth,
|
||||
singleTrack = singleTrack,
|
||||
events = MinstrelForwardingPlayer.RemoteEvents(
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||
|
||||
@@ -13,14 +13,31 @@ import javax.inject.Singleton
|
||||
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
|
||||
* from [OutputPickerController] -- the picker writes; the forwarding
|
||||
* 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(
|
||||
val routeId: String,
|
||||
val routeName: String,
|
||||
val avTransport: AVTransportClient,
|
||||
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
|
||||
class ActiveUpnpHolder @Inject constructor() {
|
||||
|
||||
|
||||
+132
-40
@@ -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.TransportState
|
||||
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -58,10 +58,11 @@ data class RouteSnapshot(
|
||||
* Selection branches on [OutputRoute.Protocol]:
|
||||
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||
* wired, Bluetooth)
|
||||
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
||||
* [SonosQueueLoader], drive the discovered renderer with
|
||||
* AVTransport.SetAVTransportURI + Play, pause local playback so
|
||||
* audio yields to the network speaker
|
||||
* - [OutputRoute.Protocol.UPNP] — pause local playback so audio yields
|
||||
* to the network speaker, then hand the renderer the queue: a Sonos
|
||||
* takes all of it through [SonosQueueLoader]; any other UPnP / DLNA
|
||||
* renderer takes one track at a time through [SingleTrackLoader]
|
||||
* (see [QueueMode])
|
||||
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
||||
* not implemented; selecting one does nothing.
|
||||
*/
|
||||
@@ -73,6 +74,7 @@ class OutputPickerController @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val sonosQueue: SonosQueueLoader,
|
||||
private val singleTrack: SingleTrackLoader,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val okHttp: OkHttpClient,
|
||||
@@ -330,6 +332,12 @@ class OutputPickerController @Inject constructor(
|
||||
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
|
||||
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)
|
||||
Timber.w(
|
||||
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
|
||||
@@ -371,6 +379,10 @@ class OutputPickerController @Inject constructor(
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
return@withLock
|
||||
}
|
||||
if (!upnpRoute.isSonos) {
|
||||
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
|
||||
return@withLock
|
||||
}
|
||||
val handledIncrementally = runCatching {
|
||||
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
|
||||
}.getOrElse { e ->
|
||||
@@ -403,6 +415,7 @@ class OutputPickerController @Inject constructor(
|
||||
routeName = outputRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
queueMode = QueueMode.NATIVE_QUEUE,
|
||||
),
|
||||
)
|
||||
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
|
||||
@@ -496,11 +560,10 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Hand the renderer the local queue and play it. A Sonos gets its native
|
||||
* queue loaded ([SonosQueueLoader]); any other renderer is started on the
|
||||
* current track with the next one armed ([SingleTrackLoader]). Wrapped in
|
||||
* `runCatching` — SOAP failure abandons the selection cleanly.
|
||||
*
|
||||
* Order of operations is deliberate:
|
||||
* 1. Pause local so the user doesn't keep hearing local audio.
|
||||
@@ -531,6 +594,7 @@ class OutputPickerController @Inject constructor(
|
||||
return@withLock
|
||||
}
|
||||
val rendering = renderingClientFor(effectiveRoute.id)
|
||||
val queueMode = queueModeFor(effectiveRoute.id)
|
||||
// Pause local before flipping UI state -- user shouldn't keep hearing
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
@@ -539,7 +603,12 @@ class OutputPickerController @Inject constructor(
|
||||
// session we got disconnected from), attach to it in place instead of
|
||||
// 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.
|
||||
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||
val adopted = tryAdoptRunningSession(
|
||||
AdoptTarget(transport, rendering, effectiveRoute, queueMode),
|
||||
uiState.queue,
|
||||
uiState.queueIndex,
|
||||
)
|
||||
if (adopted) {
|
||||
return@withLock
|
||||
}
|
||||
// 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.
|
||||
activeUpnpHolder.setTarget(effectiveRoute.id)
|
||||
runCatching {
|
||||
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.
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
@@ -555,6 +629,7 @@ class OutputPickerController @Inject constructor(
|
||||
routeName = effectiveRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
queueMode = queueMode,
|
||||
),
|
||||
)
|
||||
// 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
|
||||
* adoptable continuation of our local queue: same track id at the same
|
||||
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||
* adoptable continuation of our local queue. Null means "not ours / not
|
||||
* playing" -> caller reloads.
|
||||
*/
|
||||
private data class AdoptPlan(
|
||||
val localIndex: Int,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val trackUri: String,
|
||||
val trackNumber: Int,
|
||||
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
|
||||
* phone got disconnected from but the speaker kept streaming) instead of
|
||||
* 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
|
||||
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||
* the caller does the full clear+reload) when the renderer isn't playing
|
||||
* our queue. The local player is already paused by the caller.
|
||||
* skips a beat, and skip/seek now drive it. Returns false (and the caller
|
||||
* does the full load) when the renderer isn't playing our queue. The
|
||||
* local player is already paused by the caller.
|
||||
*/
|
||||
private suspend fun tryAdoptRunningSession(
|
||||
transport: AVTransportClient,
|
||||
rendering: RenderingControlClient?,
|
||||
route: OutputRoute,
|
||||
target: AdoptTarget,
|
||||
localQueue: List<TrackRef>,
|
||||
localIndex: Int,
|
||||
): 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(
|
||||
"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
|
||||
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||
@@ -611,7 +697,7 @@ class OutputPickerController @Inject constructor(
|
||||
positionMs = plan.positionMs,
|
||||
durationMs = plan.durationMs,
|
||||
trackUri = plan.trackUri,
|
||||
trackNumber = plan.trackNumber,
|
||||
trackNumber = plan.localIndex + 1,
|
||||
)
|
||||
remoteState.setPlayIntent(plan.playing)
|
||||
activeUpnpHolder.set(
|
||||
@@ -620,36 +706,45 @@ class OutputPickerController @Inject constructor(
|
||||
routeName = route.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
queueMode = queueMode,
|
||||
),
|
||||
)
|
||||
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(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
target: AdoptTarget,
|
||||
localQueue: List<TrackRef>,
|
||||
localIndex: Int,
|
||||
): AdoptPlan? {
|
||||
val probe = runCatching {
|
||||
transport.getTransportInfo() to transport.getPositionInfo()
|
||||
target.transport.getTransportInfo() to target.transport.getPositionInfo()
|
||||
}.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
|
||||
}
|
||||
val (info, pos) = probe
|
||||
val idx0 = pos.track - 1
|
||||
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||
val playingId = streamUriTrackId(pos.trackUri)
|
||||
val midPlayback =
|
||||
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||
val aligned = midPlayback && playingId != null &&
|
||||
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||
return if (aligned) {
|
||||
val idx0 = when {
|
||||
playingId == null -> null
|
||||
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(
|
||||
localIndex = idx0,
|
||||
positionMs = pos.relTimeMs,
|
||||
durationMs = pos.trackDurationMs,
|
||||
trackUri = pos.trackUri,
|
||||
trackNumber = pos.track,
|
||||
playing = info.state == TransportState.PLAYING,
|
||||
)
|
||||
} else {
|
||||
@@ -657,12 +752,9 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||
private fun trackIdFromStreamUrl(url: String): String? {
|
||||
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||
val i = segments.indexOf("tracks")
|
||||
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||
}
|
||||
/** How the route is fed tracks; a route gone from discovery reads as not-Sonos. */
|
||||
private fun queueModeFor(routeId: String): QueueMode =
|
||||
QueueMode.forRoute(upnpDiscovery.routes.value.firstOrNull { it.id == routeId }?.isSonos == true)
|
||||
|
||||
|
||||
private fun renderingClientFor(routeId: String): RenderingControlClient? {
|
||||
|
||||
+265
@@ -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
|
||||
}
|
||||
}
|
||||
+50
-9
@@ -4,15 +4,21 @@ import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* High-level wrapper for the UPnP AVTransport service.
|
||||
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
|
||||
* GetPositionInfo / GetTransportInfo / queue management
|
||||
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
|
||||
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / Stop /
|
||||
* Seek / GetPositionInfo / GetTransportInfo, plus the Sonos queue
|
||||
* 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.
|
||||
@Suppress("TooManyFunctions")
|
||||
class AVTransportClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
private val sonos: Boolean,
|
||||
) {
|
||||
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
||||
soap.call(
|
||||
@@ -39,15 +45,34 @@ class AVTransportClient(
|
||||
* <res protocolInfo="http-get:*:<mime>:*">...</res>
|
||||
* </item>
|
||||
* </DIDL-Lite>
|
||||
* Generic UPnP renderers tolerate this shape too — there's no
|
||||
* downside to always sending it. Title falls back to "Minstrel"
|
||||
* when the caller doesn't supply one.
|
||||
* The Sonos-only parts are left out for other renderers; see
|
||||
* [buildDidlLite]. Title falls back to "Minstrel" when the caller
|
||||
* doesn't supply one.
|
||||
*/
|
||||
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
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
|
||||
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
|
||||
@@ -247,6 +272,7 @@ class AVTransportClient(
|
||||
"PAUSED_PLAYBACK" -> TransportState.PAUSED
|
||||
"STOPPED" -> TransportState.STOPPED
|
||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||
"NO_MEDIA_PRESENT" -> TransportState.NO_MEDIA
|
||||
else -> TransportState.UNKNOWN
|
||||
}
|
||||
// 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
|
||||
// class=object.item and the URL query string as the title
|
||||
// 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 fourthField = if (sonos) "*" else DLNA_SEEKABLE
|
||||
return buildString {
|
||||
append("<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: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("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
||||
append(":").append(fourthField).append("\">").append(xmlEscape(uri)).append("</res>")
|
||||
if (sonos) {
|
||||
append("<desc id=\"cdudn\" ")
|
||||
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("RINCON_AssociatedZPUDN")
|
||||
append("</desc>")
|
||||
}
|
||||
append("</item>")
|
||||
append("</DIDL-Lite>")
|
||||
}
|
||||
@@ -308,6 +343,7 @@ class AVTransportClient(
|
||||
|
||||
private companion object {
|
||||
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 SECONDS_PER_MINUTE = 60L
|
||||
const val SECONDS_PER_HOUR = 3600L
|
||||
@@ -330,7 +366,12 @@ data class PositionInfo(
|
||||
*/
|
||||
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
|
||||
|
||||
+2
-1
@@ -135,13 +135,14 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
sonos = route.isSonos,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleDiscovery(locationUrl: String) {
|
||||
val route = fetchRoute(locationUrl) ?: return
|
||||
upsertRoute(route)
|
||||
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
|
||||
if (route.isSonos) {
|
||||
refreshSonosTopology(route)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,13 @@ data class UpnpRoute(
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
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)
|
||||
}
|
||||
|
||||
+65
@@ -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))
|
||||
}
|
||||
}
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
+40
-1
@@ -21,7 +21,7 @@ class AVTransportClientTest {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
|
||||
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
|
||||
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl, sonos = true)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -229,6 +229,45 @@ class AVTransportClientTest {
|
||||
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(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
|
||||
Reference in New Issue
Block a user