feat(android): UPnP selection state, disconnect, BuiltIn-pinned alphabetical sort
android / Build + lint + test (push) Failing after 1m21s
android / Build + lint + test (push) Failing after 1m21s
OutputPickerController now owns selection state for the UPnP leg and runs the disconnect flow when the user picks a system route while a renderer is active. - Inject OkHttpClient + RemotePlayerState so we can build a RenderingControlClient at selection time and capture the last-known remote position on disconnect. - selectUpnp publishes ActiveUpnp(routeId, routeName, avTransport, rendering) to ActiveUpnpHolder, marks the route id in selectedUpnpRouteIdInternal, and honors Sonos topology by routing through coordinatorRouteFor before SOAP. - selectSystem now does the disconnect: AVTransport.Stop -> clear the holder -> seek local ExoPlayer to the remembered position -> resume if the remote was playing. - routesState combines 4 sources (system, UPnP, Sonos topology, upnp-selected id). Non-coordinator Sonos members are filtered out of the visible list. current resolves from the merged list when a UPnP route is selected; otherwise from the system snapshot. - sortRoutes drops the current-first rule -- BuiltIn "Phone speaker" pins to the top, everything else lowercase-alphabetical. Selection state moves to the radio-button indicator in the picker row. - RemotePlayerState gets @Singleton + @Inject constructor() so Hilt can provide the shared instance to both the picker and the forthcoming MinstrelForwardingPlayer.
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.player
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synthesized state for the UPnP route -- what the ForwardingPlayer
|
* Synthesized state for the UPnP route -- what the ForwardingPlayer
|
||||||
* exposes via Player.getCurrentPosition / isPlaying / etc. when the
|
* exposes via Player.getCurrentPosition / isPlaying / etc. when the
|
||||||
@@ -14,7 +17,8 @@ package com.fabledsword.minstrel.player
|
|||||||
* consecutive poll failures = remote considered dropped (returns true
|
* consecutive poll failures = remote considered dropped (returns true
|
||||||
* from recordPollFailure for the caller to surface). Success resets it.
|
* from recordPollFailure for the caller to surface). Success resets it.
|
||||||
*/
|
*/
|
||||||
class RemotePlayerState {
|
@Singleton
|
||||||
|
class RemotePlayerState @Inject constructor() {
|
||||||
|
|
||||||
@Volatile var positionMs: Long = 0L; private set
|
@Volatile var positionMs: Long = 0L; private set
|
||||||
@Volatile var durationMs: Long = 0L; private set
|
@Volatile var durationMs: Long = 0L; private set
|
||||||
|
|||||||
+83
-25
@@ -6,8 +6,12 @@ import androidx.mediarouter.media.MediaRouteSelector
|
|||||||
import androidx.mediarouter.media.MediaRouter
|
import androidx.mediarouter.media.MediaRouter
|
||||||
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.RemotePlayerState
|
||||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -16,6 +20,7 @@ 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 okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -23,9 +28,9 @@ import javax.inject.Singleton
|
|||||||
/**
|
/**
|
||||||
* Snapshot of the audio output route state. [current] is the live
|
* Snapshot of the audio output route state. [current] is the live
|
||||||
* route audio is being delivered to. [available] is every route the
|
* route audio is being delivered to. [available] is every route the
|
||||||
* picker knows about — MediaRouter system routes merged with
|
* picker knows about -- MediaRouter system routes merged with
|
||||||
* UPnP/DLNA renderers discovered on the LAN — sorted current-first
|
* UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
|
||||||
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
|
* "Phone speaker" pinned first, everything else alphabetical.
|
||||||
*/
|
*/
|
||||||
data class RouteSnapshot(
|
data class RouteSnapshot(
|
||||||
val current: OutputRoute,
|
val current: OutputRoute,
|
||||||
@@ -64,6 +69,8 @@ class OutputPickerController @Inject constructor(
|
|||||||
private val playerController: PlayerController,
|
private val playerController: PlayerController,
|
||||||
private val streamTokens: StreamTokenProvider,
|
private val streamTokens: StreamTokenProvider,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val okHttp: OkHttpClient,
|
||||||
) {
|
) {
|
||||||
private val mediaRouter = MediaRouter.getInstance(context)
|
private val mediaRouter = MediaRouter.getInstance(context)
|
||||||
|
|
||||||
@@ -78,14 +85,34 @@ class OutputPickerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
||||||
|
|
||||||
|
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
|
||||||
|
private var lastKnownRemotePositionMs: Long = 0L
|
||||||
|
|
||||||
val routesState: StateFlow<RouteSnapshot> = combine(
|
val routesState: StateFlow<RouteSnapshot> = combine(
|
||||||
systemRoutesInternal,
|
systemRoutesInternal,
|
||||||
upnpDiscovery.routes,
|
upnpDiscovery.routes,
|
||||||
) { sys, upnp ->
|
upnpDiscovery.sonosTopology,
|
||||||
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
|
selectedUpnpRouteIdInternal,
|
||||||
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
|
) { sys, upnp, topology, upnpSelected ->
|
||||||
|
val suppressed = nonCoordinatorMemberUdns(topology)
|
||||||
|
val visibleUpnp = upnp
|
||||||
|
.filter { it.id.removePrefix("uuid:") !in suppressed }
|
||||||
|
.map { OutputRoute.fromUpnpRoute(it) }
|
||||||
|
val merged = sys.available + visibleUpnp
|
||||||
|
val current = if (upnpSelected != null) {
|
||||||
|
merged.firstOrNull { it.id == upnpSelected } ?: sys.current
|
||||||
|
} else {
|
||||||
|
sys.current
|
||||||
|
}
|
||||||
|
RouteSnapshot(current = current, available = sortRoutes(merged))
|
||||||
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
|
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
|
||||||
|
|
||||||
|
private fun nonCoordinatorMemberUdns(topology: List<SonosZoneGroup>): Set<String> =
|
||||||
|
topology.flatMap { g ->
|
||||||
|
g.members.map { it.udn.removePrefix("uuid:") }
|
||||||
|
.filter { it != g.coordinatorUdn.removePrefix("uuid:") }
|
||||||
|
}.toSet()
|
||||||
|
|
||||||
private val callback = object : MediaRouter.Callback() {
|
private val callback = object : MediaRouter.Callback() {
|
||||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||||
refresh()
|
refresh()
|
||||||
@@ -159,6 +186,20 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun selectSystem(route: OutputRoute) {
|
private fun selectSystem(route: OutputRoute) {
|
||||||
|
val wasUpnp = selectedUpnpRouteIdInternal.value
|
||||||
|
if (wasUpnp != null) {
|
||||||
|
val active = activeUpnpHolder.active.value
|
||||||
|
lastKnownRemotePositionMs = remoteState.positionMs
|
||||||
|
val wasPlayingRemote = remoteState.isPlaying
|
||||||
|
scope.launch {
|
||||||
|
runCatching { active?.avTransport?.stop() }
|
||||||
|
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
|
||||||
|
activeUpnpHolder.set(null)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
playerController.seekTo(lastKnownRemotePositionMs)
|
||||||
|
if (wasPlayingRemote) playerController.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
|
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
|
||||||
mediaRouter.selectRoute(target)
|
mediaRouter.selectRoute(target)
|
||||||
}
|
}
|
||||||
@@ -179,16 +220,32 @@ class OutputPickerController @Inject constructor(
|
|||||||
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
|
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val transport = upnpDiscovery.transportFor(route.id)
|
// Honor Sonos topology: pick the coordinator's route when the user
|
||||||
|
// tapped a group row. Suppression in routesState already keeps the
|
||||||
|
// visible row at the coordinator's id, so this is identity in the
|
||||||
|
// common case -- defensive for follow-up flows.
|
||||||
|
val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id)
|
||||||
|
?.let { OutputRoute.fromUpnpRoute(it) } ?: route
|
||||||
|
val transport = upnpDiscovery.transportFor(effectiveRoute.id)
|
||||||
if (transport == null) {
|
if (transport == null) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP select skipped: no transport for route id=${route.id} " +
|
"UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
|
||||||
"(route disappeared or id mismatch with discovery list)",
|
"(route disappeared or id mismatch with discovery list)",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val rendering = renderingClientFor(effectiveRoute.id)
|
||||||
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
|
activeUpnpHolder.set(
|
||||||
|
ActiveUpnp(
|
||||||
|
routeId = effectiveRoute.id,
|
||||||
|
routeName = effectiveRoute.name,
|
||||||
|
avTransport = transport,
|
||||||
|
rendering = rendering,
|
||||||
|
),
|
||||||
|
)
|
||||||
runCatching {
|
runCatching {
|
||||||
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
|
Timber.i("UPnP select: mint token for track=$trackId, route=${effectiveRoute.name}")
|
||||||
val token = streamTokens.mint(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(
|
||||||
@@ -201,10 +258,19 @@ class OutputPickerController @Inject constructor(
|
|||||||
playerController.pause()
|
playerController.pause()
|
||||||
Timber.i("UPnP select: done")
|
Timber.i("UPnP select: done")
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.w(e, "UPnP select failed for route ${route.id}")
|
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||||
|
activeUpnpHolder.set(null)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun renderingClientFor(routeId: String): RenderingControlClient? {
|
||||||
|
val rcUrl = upnpDiscovery.routes.value
|
||||||
|
.firstOrNull { it.id == routeId }
|
||||||
|
?.renderingControlUrl ?: return null
|
||||||
|
return RenderingControlClient(SoapClient(okHttp), rcUrl)
|
||||||
|
}
|
||||||
|
|
||||||
private fun refresh() {
|
private fun refresh() {
|
||||||
systemRoutesInternal.value = snapshotFromRouter()
|
systemRoutesInternal.value = snapshotFromRouter()
|
||||||
}
|
}
|
||||||
@@ -214,24 +280,16 @@ class OutputPickerController @Inject constructor(
|
|||||||
.filter { it.matchesSelector(selector) }
|
.filter { it.matchesSelector(selector) }
|
||||||
.map { OutputRoute.fromRouteInfo(it) }
|
.map { OutputRoute.fromRouteInfo(it) }
|
||||||
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
|
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
|
||||||
return RouteSnapshot(current = current, available = sortRoutes(current, all))
|
return RouteSnapshot(current = current, available = sortRoutes(all))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
|
* BuiltIn "Phone speaker" pinned first; everything else
|
||||||
* Other (UPnP renderers fall in Other). Keeps the active output at
|
* alphabetical. Selection state is conveyed by the radio button
|
||||||
* the top + likely-wanted alternatives next + fallback last.
|
* indicator in the picker row, not by sort order.
|
||||||
*/
|
*/
|
||||||
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
|
private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
|
||||||
val rank: (OutputRoute) -> Int = { route ->
|
val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
|
||||||
when {
|
return builtIn + rest.sortedBy { it.name.lowercase() }
|
||||||
route.id == current.id -> 0
|
|
||||||
route.kind == OutputRoute.Kind.Bluetooth -> 1
|
|
||||||
route.kind == OutputRoute.Kind.Wired -> 2
|
|
||||||
route.kind == OutputRoute.Kind.BuiltIn -> 3
|
|
||||||
else -> 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return all.sortedBy(rank)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user