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 new file mode 100644 index 00000000..1f88e18a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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 handlerEvaluate(block: () -> T): T? { + val deferred = CompletableDeferred() + handler.post { + deferred.complete(runCatching(block).getOrNull()) + } + return deferred.await() + } + + private companion object { + const val POLL_INTERVAL_MS = 1_000L + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt new file mode 100644 index 00000000..ffff44bf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/StreamTokenProvider.kt @@ -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)) +} 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 07c42a7d..76e64315 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 @@ -4,10 +4,9 @@ import android.content.Context import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaRouteSelector 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.player.PlayerController +import com.fabledsword.minstrel.player.StreamTokenProvider import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -17,8 +16,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import retrofit2.Retrofit -import retrofit2.create import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -65,10 +62,9 @@ class OutputPickerController @Inject constructor( @ApplicationScope private val scope: CoroutineScope, private val upnpDiscovery: UpnpDiscoveryController, 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 selector = MediaRouteSelector.Builder() @@ -193,7 +189,7 @@ class OutputPickerController @Inject constructor( } runCatching { 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}") transport.setAVTransportURIWithMetadata( uri = token.url,