feat(android): MinstrelForwardingPlayer wraps ExoPlayer with UPnP branching
android / Build + lint + test (push) Failing after 1m25s

Task 7 of UPnP transport-parity slice. Introduces the central
ForwardingPlayer that branches between local ExoPlayer and the active
UPnP renderer:

- MinstrelForwardingPlayer wraps the delegate Player; play/pause/seek
  and the next/previous transport calls translate to AVTransport SOAP
  when an ActiveUpnp is set, otherwise forward to super. Position +
  isPlaying + duration + playbackState reads pull from
  RemotePlayerState while remote.
- 1Hz poll loop drives GetPositionInfo + GetTransportInfo, feeding
  RemotePlayerState; the rolling-3 failure heuristic fires onDrop on
  the looper for the factory to surface as a snackbar.
- StreamTokenProvider extracts the CastApi.create() Retrofit wiring
  into a Hilt singleton so the service-side player and the
  controller-side picker share one CastApi instance.
- OutputPickerController constructor swaps Retrofit for
  StreamTokenProvider + ActiveUpnpHolder (the holder is wired now for
  Task 8). selectUpnp now mints via streamTokens.mint(trackId).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:12:11 -04:00
parent 3aee2276bc
commit ab6c3a1354
3 changed files with 232 additions and 8 deletions
@@ -0,0 +1,204 @@
@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API.
package com.fabledsword.minstrel.player
import android.os.Handler
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.TransportState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* Integration point for UPnP transport parity. Wraps the local
* ExoPlayer; every transport method either forwards (local route
* active -- the default) or translates into AVTransport SOAP +
* [RemotePlayerState] updates (UPnP route active).
*
* Created inside [MinstrelPlayerService]; runs on the service's main
* looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is
* active, the MediaSession's reads of [Player.isPlaying] and
* [Player.getCurrentPosition] pull from [RemotePlayerState]; the
* wrapped ExoPlayer stays paused at the position it had when the
* route was selected.
*
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
* factory wraps that callback into a SharedFlow consumed by the
* NowPlaying surface as a snackbar.
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val tokens: StreamTokenProvider,
private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null
init {
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
}
}
private fun isRemote(): Boolean = holder.active.value != null
override fun play() {
val active = holder.active.value
if (active == null) {
super.play()
} else {
scope.launch {
runCatching { active.avTransport.play() }
.onSuccess { remoteState.applyTransportPlaying() }
.onFailure { handleSoapFailure(active, it) }
}
}
}
override fun pause() {
val active = holder.active.value
if (active == null) {
super.pause()
} else {
scope.launch {
runCatching { active.avTransport.pause() }
.onSuccess { remoteState.applyTransportPaused() }
.onFailure { handleSoapFailure(active, it) }
}
}
}
override fun seekTo(positionMs: Long) {
val active = holder.active.value
if (active == null) {
super.seekTo(positionMs)
} else {
remoteState.applyPositionInfo(
positionMs = positionMs,
durationMs = remoteState.durationMs,
trackUri = remoteState.currentTrackUri,
)
scope.launch {
runCatching { active.avTransport.seek(positionMs) }
.onFailure { handleSoapFailure(active, it) }
}
}
}
override fun seekToNextMediaItem() {
val active = holder.active.value
super.seekToNextMediaItem()
if (active != null) {
scope.launch { syncCurrentItemToRemote(active) }
}
}
override fun seekToPreviousMediaItem() {
val active = holder.active.value
super.seekToPreviousMediaItem()
if (active != null) {
scope.launch { syncCurrentItemToRemote(active) }
}
}
override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
override fun getDuration(): Long =
if (isRemote()) remoteState.durationMs else super.getDuration()
override fun isPlaying(): Boolean =
if (isRemote()) remoteState.isPlaying else super.isPlaying()
override fun getPlaybackState(): Int =
if (isRemote()) Player.STATE_READY else super.getPlaybackState()
override fun release() {
pollJob?.cancel()
scope.cancel()
super.release()
}
private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) {
val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return
val token = runCatching { tokens.mint(mediaId) }.getOrNull() ?: return
runCatching {
active.avTransport.setAVTransportURIWithMetadata(
uri = token.url,
mime = token.mime,
title = token.title,
)
active.avTransport.play()
remoteState.applyTransportPlaying()
}.onFailure { handleSoapFailure(active, it) }
}
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
remoteState.applyError(t)
handler.post { onDrop(active.routeName) }
}
private fun onActiveChanged(active: ActiveUpnp?) {
pollJob?.cancel()
if (active != null) {
pollJob = scope.launch { pollLoop(active) }
} else {
remoteState.reset()
}
}
private suspend fun pollLoop(active: ActiveUpnp) {
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching {
val info = active.avTransport.getPositionInfo()
remoteState.applyPositionInfo(
positionMs = info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> remoteState.applyTransportPlaying()
TransportState.PAUSED -> remoteState.applyTransportPaused()
TransportState.STOPPED -> remoteState.applyTransportStopped()
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
}
if (outcome.isSuccess) {
remoteState.recordPollSuccess()
} else if (remoteState.recordPollFailure()) {
handler.post { onDrop(active.routeName) }
return
}
delay(POLL_INTERVAL_MS)
}
}
/** Run [block] on the application looper and bring its result back. */
private suspend fun <T> handlerEvaluate(block: () -> T): T? {
val deferred = CompletableDeferred<T?>()
handler.post {
deferred.complete(runCatching(block).getOrNull())
}
return deferred.await()
}
private companion object {
const val POLL_INTERVAL_MS = 1_000L
}
}
@@ -0,0 +1,24 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.api.endpoints.StreamTokenResponse
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mints stream tokens for a given track id. Pulled into its own Hilt
* singleton so [MinstrelForwardingPlayer] (service-side) and
* [com.fabledsword.minstrel.player.output.OutputPickerController]
* (controller-side) don't each construct their own [CastApi] from
* Retrofit. The shared Retrofit instance is unchanged.
*/
@Singleton
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
private val api: CastApi = retrofit.create()
suspend fun mint(trackId: String): StreamTokenResponse =
api.streamToken(StreamTokenRequest(trackId = trackId))
}
@@ -4,10 +4,9 @@ import android.content.Context
import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -17,8 +16,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -65,10 +62,9 @@ class OutputPickerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController, private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController, private val playerController: PlayerController,
retrofit: Retrofit, private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
) { ) {
private val castApi: CastApi = retrofit.create()
private val mediaRouter = MediaRouter.getInstance(context) private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder() private val selector = MediaRouteSelector.Builder()
@@ -193,7 +189,7 @@ class OutputPickerController @Inject constructor(
} }
runCatching { runCatching {
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}") Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId)) val token = streamTokens.mint(trackId)
Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}")
transport.setAVTransportURIWithMetadata( transport.setAVTransportURIWithMetadata(
uri = token.url, uri = token.url,