feat(android): MinstrelForwardingPlayer wraps ExoPlayer with UPnP branching
android / Build + lint + test (push) Failing after 1m25s
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:
+204
@@ -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
-8
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user