From b29875fd308d6fceec37a973b9ff36d76aea18b5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:24:08 -0400 Subject: [PATCH] feat(android): UPnP selection state, disconnect, BuiltIn-pinned alphabetical sort 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. --- .../minstrel/player/RemotePlayerState.kt | 6 +- .../player/output/OutputPickerController.kt | 108 ++++++++++++++---- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 9f43f444..1bae6460 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -1,5 +1,8 @@ package com.fabledsword.minstrel.player +import javax.inject.Inject +import javax.inject.Singleton + /** * Synthesized state for the UPnP route -- what the ForwardingPlayer * 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 * 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 durationMs: Long = 0L; private set 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 e064db0b..130b0bbf 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 @@ -6,8 +6,12 @@ import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.player.RemotePlayerState 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.sonos.SonosZoneGroup import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -23,9 +28,9 @@ import javax.inject.Singleton /** * Snapshot of the audio output route state. [current] is the live * route audio is being delivered to. [available] is every route the - * picker knows about — MediaRouter system routes merged with - * UPnP/DLNA renderers discovered on the LAN — sorted current-first - * then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other). + * picker knows about -- MediaRouter system routes merged with + * UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn + * "Phone speaker" pinned first, everything else alphabetical. */ data class RouteSnapshot( val current: OutputRoute, @@ -64,6 +69,8 @@ class OutputPickerController @Inject constructor( private val playerController: PlayerController, private val streamTokens: StreamTokenProvider, private val activeUpnpHolder: ActiveUpnpHolder, + private val remoteState: RemotePlayerState, + private val okHttp: OkHttpClient, ) { private val mediaRouter = MediaRouter.getInstance(context) @@ -78,14 +85,34 @@ class OutputPickerController @Inject constructor( */ private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter()) + private val selectedUpnpRouteIdInternal = MutableStateFlow(null) + private var lastKnownRemotePositionMs: Long = 0L + val routesState: StateFlow = combine( systemRoutesInternal, upnpDiscovery.routes, - ) { sys, upnp -> - val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) } - RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged)) + upnpDiscovery.sonosTopology, + selectedUpnpRouteIdInternal, + ) { 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) + private fun nonCoordinatorMemberUdns(topology: List): Set = + topology.flatMap { g -> + g.members.map { it.udn.removePrefix("uuid:") } + .filter { it != g.coordinatorUdn.removePrefix("uuid:") } + }.toSet() + private val callback = object : MediaRouter.Callback() { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() @@ -159,6 +186,20 @@ class OutputPickerController @Inject constructor( } 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 mediaRouter.selectRoute(target) } @@ -179,16 +220,32 @@ class OutputPickerController @Inject constructor( Timber.w("UPnP select skipped: no currentTrack (start playback first)") 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) { 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)", ) return } + val rendering = renderingClientFor(effectiveRoute.id) + selectedUpnpRouteIdInternal.value = effectiveRoute.id + activeUpnpHolder.set( + ActiveUpnp( + routeId = effectiveRoute.id, + routeName = effectiveRoute.name, + avTransport = transport, + rendering = rendering, + ), + ) 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) Timber.i("UPnP select: SetAVTransportURI to ${token.url} mime=${token.mime}") transport.setAVTransportURIWithMetadata( @@ -201,10 +258,19 @@ class OutputPickerController @Inject constructor( playerController.pause() Timber.i("UPnP select: done") }.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() { systemRoutesInternal.value = snapshotFromRouter() } @@ -214,24 +280,16 @@ class OutputPickerController @Inject constructor( .filter { it.matchesSelector(selector) } .map { OutputRoute.fromRouteInfo(it) } 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 - * Other (UPnP renderers fall in Other). Keeps the active output at - * the top + likely-wanted alternatives next + fallback last. + * BuiltIn "Phone speaker" pinned first; everything else + * alphabetical. Selection state is conveyed by the radio button + * indicator in the picker row, not by sort order. */ - private fun sortRoutes(current: OutputRoute, all: List): List { - val rank: (OutputRoute) -> Int = { route -> - when { - 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) + private fun sortRoutes(all: List): List { + val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn } + return builtIn + rest.sortedBy { it.name.lowercase() } } }