From b1e55adc14ca1bb650ec1641f22d96d020e78f25 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 26 Oct 2026 07:38:29 -0400 Subject: [PATCH] feat(android): any UPnP/DLNA renderer plays, not only a Sonos (#6451) 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 --- .../player/MinstrelForwardingPlayer.kt | 195 +++++++++---- .../minstrel/player/PlayerFactory.kt | 3 + .../player/output/ActiveUpnpHolder.kt | 17 ++ .../player/output/OutputPickerController.kt | 174 +++++++++--- .../player/output/SingleTrackLoader.kt | 265 ++++++++++++++++++ .../player/output/TrackEndDetector.kt | 63 +++++ .../player/output/upnp/AVTransportClient.kt | 69 ++++- .../output/upnp/UpnpDiscoveryController.kt | 3 +- .../minstrel/player/output/upnp/UpnpRoute.kt | 11 +- .../player/output/SingleTrackQueueTest.kt | 65 +++++ .../player/output/TrackEndDetectorTest.kt | 68 +++++ .../output/upnp/AVTransportClientTest.kt | 41 ++- 12 files changed, 863 insertions(+), 111 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/TrackEndDetector.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/SingleTrackQueueTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/TrackEndDetectorTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index c5af2e2f..6716588e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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:#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]. + * 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:#0. Skip/prev/seekTo delegate to + * AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free + * 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 { - active.avTransport.seekToTrack(mediaItemIndex + 1) - if (positionMs > 0L) { - active.avTransport.seek(positionMs) - } + 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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 6e275df3..680f9a46 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -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) }, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt index 601925ac..f09a30c8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/ActiveUpnpHolder.kt @@ -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() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 2709c6b4..65598476 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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, + 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): 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 { - 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. 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, + 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, + 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//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? { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt new file mode 100644 index 00000000..32eddab1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/SingleTrackLoader.kt @@ -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 = 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, 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, 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): 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, 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/" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/TrackEndDetector.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/TrackEndDetector.kt new file mode 100644 index 00000000..5efd09b3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/TrackEndDetector.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index 2020d5c3..aecb3a90 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -4,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( * ... * * - * 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("") + append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"") + if (sonos) append(" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\"") + append(">") append("") append("").append(xmlEscape(safeTitle)).append("") append("object.item.audioItem.musicTrack") append("").append(xmlEscape(uri)).append("") - append("") - append("RINCON_AssociatedZPUDN") - append("") + append(":").append(fourthField).append("\">").append(xmlEscape(uri)).append("") + if (sonos) { + append("") + append("RINCON_AssociatedZPUDN") + append("") + } append("") append("") } @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 8426c875..2ede909a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -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) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt index b1ffbbba..7bd1099f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt @@ -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) +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/SingleTrackQueueTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/SingleTrackQueueTest.kt new file mode 100644 index 00000000..c690bb43 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/SingleTrackQueueTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/TrackEndDetectorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/TrackEndDetectorTest.kt new file mode 100644 index 00000000..a14b73f1 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/TrackEndDetectorTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt index 48919b60..44fcd435 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClientTest.kt @@ -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("http://example.com/api/tracks/t2/stream.flac")) { + "body: $body" + } + assertTrue(body.contains("")) { "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( """