feat(android): UPnP picker integration (UPnP slice 6/6)
android / Build + lint + test (push) Successful in 3m52s
android / Build + lint + test (push) Successful in 3m52s
UpnpDiscoveryController - Hilt singleton that owns the SSDP listener, follows each discovered LOCATION URL to fetch + parse the device description, projects MediaRenderers into a StateFlow<List<UpnpRoute>>. OutputPickerController now combines system routes with the UPnP Flow into a unified RouteSnapshot. select() branches by protocol: SYSTEM goes through MediaRouter as before; UPNP requests a signed stream token via POST /api/cast/stream-token then calls AVTransport.SetAVTransportURI + Play against the discovered device. Local playback pauses on UPnP selection. OutputPickerSheet gains a MulticastHintRow shown when no UPnP devices appear after a 3s grace period - the 'your router may be blocking multicast' footer hint per the spec. Closes the UPnP slice spec'd in docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md. On-device verification pending: pair a Sonos / UPnP speaker, confirm discovery + selection + playback + the multicast-blocked hint.
This commit is contained in:
+100
-22
@@ -4,18 +4,30 @@ 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.player.PlayerController
|
||||||
|
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.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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
|
* route audio is being delivered to. [available] is every route the
|
||||||
* MediaRouter currently knows about, sorted current-first then by
|
* picker knows about — MediaRouter system routes merged with
|
||||||
* [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
|
* UPnP/DLNA renderers discovered on the LAN — sorted current-first
|
||||||
|
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
|
||||||
*/
|
*/
|
||||||
data class RouteSnapshot(
|
data class RouteSnapshot(
|
||||||
val current: OutputRoute,
|
val current: OutputRoute,
|
||||||
@@ -23,29 +35,59 @@ data class RouteSnapshot(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hilt-singleton facade over [MediaRouter]. Owns the callback
|
* Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
|
||||||
* lifecycle — default passive behavior at process start (route list
|
* Owns the callback lifecycle — default passive behavior at process
|
||||||
* updates without forcing Bluetooth scans), upgrades to active
|
* start (route list updates without forcing Bluetooth scans), upgrades
|
||||||
* discovery while the picker sheet is open so newly-paired devices
|
* to active discovery while the picker sheet is open so newly-paired
|
||||||
* appear promptly. The [routesState] StateFlow projects the current
|
* devices appear promptly. The [routesState] StateFlow projects the
|
||||||
* route + sorted available list as a [RouteSnapshot]; the picker
|
* current route + sorted available list as a [RouteSnapshot]; the
|
||||||
* ViewModel collects it.
|
* picker ViewModel collects it.
|
||||||
|
*
|
||||||
|
* Selection branches on [OutputRoute.Protocol]:
|
||||||
|
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||||
|
* wired, Bluetooth)
|
||||||
|
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
||||||
|
* [CastApi.streamToken], drive the discovered renderer with
|
||||||
|
* AVTransport.SetAVTransportURI + Play, pause local playback so
|
||||||
|
* audio yields to the network speaker
|
||||||
|
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
||||||
|
* reserved for follow-up slices; ignored for now.
|
||||||
*
|
*
|
||||||
* Mirrors the OutputPickerController role described in
|
* Mirrors the OutputPickerController role described in
|
||||||
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md.
|
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
|
||||||
|
* and the UPnP extensions in
|
||||||
|
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class OutputPickerController @Inject constructor(
|
class OutputPickerController @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
private val upnpDiscovery: UpnpDiscoveryController,
|
||||||
|
private val playerController: PlayerController,
|
||||||
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
|
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()
|
||||||
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
|
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val routesStateInternal = MutableStateFlow(snapshotFromRouter())
|
/**
|
||||||
val routesState: StateFlow<RouteSnapshot> = routesStateInternal.asStateFlow()
|
* Internal projection of the live MediaRouter snapshot. Refreshed
|
||||||
|
* on every Callback event; combined downstream with UPnP routes
|
||||||
|
* into the public [routesState].
|
||||||
|
*/
|
||||||
|
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
||||||
|
|
||||||
|
val routesState: StateFlow<RouteSnapshot> = combine(
|
||||||
|
systemRoutesInternal,
|
||||||
|
upnpDiscovery.routes,
|
||||||
|
) { sys, upnp ->
|
||||||
|
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
|
||||||
|
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
|
||||||
|
|
||||||
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) =
|
||||||
@@ -82,35 +124,71 @@ class OutputPickerController @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Upgrade callback registration to active discovery — call when
|
* Upgrade callback registration to active discovery — call when
|
||||||
* the picker sheet opens so newly-paired Bluetooth devices
|
* the picker sheet opens so newly-paired Bluetooth devices
|
||||||
* surface within a few seconds. Idempotent: re-registering with a
|
* surface within a few seconds, and fire an SSDP M-SEARCH burst
|
||||||
|
* for UPnP renderers. Idempotent: re-registering with a
|
||||||
* new flag set replaces the prior registration in MediaRouter.
|
* new flag set replaces the prior registration in MediaRouter.
|
||||||
*/
|
*/
|
||||||
fun upgradeDiscovery() {
|
fun upgradeDiscovery() {
|
||||||
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
|
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
|
||||||
|
upnpDiscovery.upgradeDiscovery()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downgrade callback registration back to default passive behavior
|
* Downgrade callback registration back to default passive behavior
|
||||||
* — call when the picker sheet closes so we don't keep Bluetooth
|
* — call when the picker sheet closes so we don't keep Bluetooth
|
||||||
* scanning on for battery cost. Two-arg overload = no flag =
|
* scanning on for battery cost. Two-arg overload = no flag =
|
||||||
* passive. Same idempotent re-registration semantics.
|
* passive. Same idempotent re-registration semantics. The UPnP
|
||||||
|
* side has no symmetric downgrade (passive SSDP NOTIFY listen is
|
||||||
|
* always on); the call is preserved for API parity.
|
||||||
*/
|
*/
|
||||||
fun downgradeDiscovery() {
|
fun downgradeDiscovery() {
|
||||||
mediaRouter.addCallback(selector, callback)
|
mediaRouter.addCallback(selector, callback)
|
||||||
|
upnpDiscovery.downgradeDiscovery()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select [route]. Audio routing happens at the system level; the
|
* Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
|
||||||
* MediaRouter callback's onRouteSelected fires, [refresh] re-emits,
|
* routes mint a signed stream token + drive AVTransport on the
|
||||||
* the chip flips to the new device.
|
* discovered renderer + pause local playback. Other protocols
|
||||||
|
* (CAST / SONOS) are reserved for follow-up slices and are
|
||||||
|
* silently ignored — the picker shouldn't show them yet.
|
||||||
*/
|
*/
|
||||||
fun select(route: OutputRoute) {
|
fun select(route: OutputRoute) {
|
||||||
|
when (route.protocol) {
|
||||||
|
OutputRoute.Protocol.SYSTEM -> selectSystem(route)
|
||||||
|
OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
|
||||||
|
OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectSystem(route: OutputRoute) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the UPnP renderer: mint a token for the currently playing
|
||||||
|
* track, set the renderer's URI, play, then pause local playback so
|
||||||
|
* audio yields to the speaker. Wrapped in `runCatching` at each
|
||||||
|
* step — token failure, transport-lookup failure, and SOAP failure
|
||||||
|
* each abandon the selection cleanly rather than crashing. Errors
|
||||||
|
* surface to the operator via OkHttp logging for now; spec'd UI
|
||||||
|
* error surfacing lands in a follow-up if hardware reveals it's
|
||||||
|
* needed.
|
||||||
|
*/
|
||||||
|
private suspend fun selectUpnp(route: OutputRoute) {
|
||||||
|
val trackId = playerController.uiState.value.currentTrack?.id ?: return
|
||||||
|
val transport = upnpDiscovery.transportFor(route.id) ?: return
|
||||||
|
runCatching {
|
||||||
|
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
|
||||||
|
transport.setAVTransportURI(token.url)
|
||||||
|
transport.play()
|
||||||
|
playerController.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refresh() {
|
private fun refresh() {
|
||||||
routesStateInternal.value = snapshotFromRouter()
|
systemRoutesInternal.value = snapshotFromRouter()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun snapshotFromRouter(): RouteSnapshot {
|
private fun snapshotFromRouter(): RouteSnapshot {
|
||||||
@@ -123,8 +201,8 @@ class OutputPickerController @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
|
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
|
||||||
* Other. Keeps the active output at the top + likely-wanted
|
* Other (UPnP renderers fall in Other). Keeps the active output at
|
||||||
* alternatives next + fallback last.
|
* the top + likely-wanted alternatives next + fallback last.
|
||||||
*/
|
*/
|
||||||
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
|
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
|
||||||
val rank: (OutputRoute) -> Int = { route ->
|
val rank: (OutputRoute) -> Int = { route ->
|
||||||
|
|||||||
+40
-4
@@ -22,6 +22,7 @@ import com.composables.icons.lucide.Circle
|
|||||||
import com.composables.icons.lucide.CircleCheck
|
import com.composables.icons.lucide.CircleCheck
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Settings
|
import com.composables.icons.lucide.Settings
|
||||||
|
import com.composables.icons.lucide.WifiOff
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Material 3 ModalBottomSheet listing the available output routes.
|
* Material 3 ModalBottomSheet listing the available output routes.
|
||||||
@@ -29,10 +30,18 @@ import com.composables.icons.lucide.Settings
|
|||||||
* CircleCheck; others show an empty Circle. Long route names
|
* CircleCheck; others show an empty Circle. Long route names
|
||||||
* truncate cleanly via maxLines = 2 + Ellipsis.
|
* truncate cleanly via maxLines = 2 + Ellipsis.
|
||||||
*
|
*
|
||||||
* Permission denial footer hint is intentionally NOT inlined here —
|
* Two footer hints, each driven by a flag the host screen owns:
|
||||||
* NowPlayingScreen owns the BLUETOOTH_CONNECT request flow and
|
* - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT
|
||||||
* passes a [permissionDenied] flag to control whether the hint
|
* request flow and sets this true on denial so the user sees the
|
||||||
* renders. Keeps this composable focused on rendering.
|
* "pair in Settings" affordance.
|
||||||
|
* - [noUpnpDiscovered] — set true when the picker has been open for
|
||||||
|
* a few seconds and no UPnP renderers have arrived; surfaces the
|
||||||
|
* "router may be blocking multicast" hint. Defaults to false so
|
||||||
|
* existing call sites that haven't wired the discovery-timing
|
||||||
|
* logic continue to render without the hint.
|
||||||
|
*
|
||||||
|
* Keeping the hint flags external preserves this composable's
|
||||||
|
* focus-on-rendering shape.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -41,6 +50,7 @@ fun OutputPickerSheet(
|
|||||||
permissionDenied: Boolean,
|
permissionDenied: Boolean,
|
||||||
onRouteSelected: (OutputRoute) -> Unit,
|
onRouteSelected: (OutputRoute) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
noUpnpDiscovered: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState()
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
@@ -69,6 +79,9 @@ fun OutputPickerSheet(
|
|||||||
if (permissionDenied) {
|
if (permissionDenied) {
|
||||||
PermissionHintRow()
|
PermissionHintRow()
|
||||||
}
|
}
|
||||||
|
if (noUpnpDiscovered) {
|
||||||
|
MulticastHintRow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +165,29 @@ private fun PermissionHintRow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MulticastHintRow() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.WifiOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(HINT_ICON_DP.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your router may be blocking multicast discovery.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||||
OutputRoute.Kind.Wired -> "Wired"
|
OutputRoute.Kind.Wired -> "Wired"
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output.upnp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
|
||||||
|
* device description XML, and projects discovered MediaRenderers into a
|
||||||
|
* `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
|
||||||
|
* system routes.
|
||||||
|
*
|
||||||
|
* Passive listen runs from process start (via `init`). Active discovery
|
||||||
|
* (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
|
||||||
|
* picker sheet opens; SSDP has no symmetric "downgrade" — the passive
|
||||||
|
* NOTIFY listener stays on for the process lifetime, so
|
||||||
|
* [downgradeDiscovery] is a no-op preserved for API parity with the
|
||||||
|
* MediaRouter-side controller.
|
||||||
|
*
|
||||||
|
* Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
|
||||||
|
* the same speaker replaces the prior entry rather than appending a
|
||||||
|
* duplicate row to the picker.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class UpnpDiscoveryController @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
@ApplicationScope private val appScope: CoroutineScope,
|
||||||
|
private val okHttp: OkHttpClient,
|
||||||
|
) {
|
||||||
|
private val ssdp = SsdpDiscovery(context)
|
||||||
|
|
||||||
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
|
private var fetchJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
ssdp.start(appScope)
|
||||||
|
fetchJob = appScope.launch(Dispatchers.IO) {
|
||||||
|
ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire an M-SEARCH burst so newly-paired speakers appear within a
|
||||||
|
* few seconds rather than waiting for the next NOTIFY beacon
|
||||||
|
* (typical SSDP cadence: every ~30min — far too slow for a UI
|
||||||
|
* that just opened).
|
||||||
|
*/
|
||||||
|
fun upgradeDiscovery() {
|
||||||
|
appScope.launch { ssdp.requestActiveScan() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op for the SSDP socket — passive NOTIFY listen is always on
|
||||||
|
* once [init] has run. Kept symmetric with the MediaRouter side's
|
||||||
|
* `downgradeDiscovery` so the picker controller fans out to both
|
||||||
|
* without conditional logic.
|
||||||
|
*/
|
||||||
|
fun downgradeDiscovery() {
|
||||||
|
// intentionally empty: see kdoc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an [AVTransportClient] bound to the previously-discovered
|
||||||
|
* route's control URL. Returns null when the routeId isn't in the
|
||||||
|
* current snapshot — the speaker disappeared between picker open
|
||||||
|
* and tap, or the caller passed a non-UPnP routeId. Callers handle
|
||||||
|
* the null by abandoning the selection (no crash, no fallback).
|
||||||
|
*/
|
||||||
|
fun transportFor(routeId: String): AVTransportClient? {
|
||||||
|
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||||
|
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleDiscovery(locationUrl: String) {
|
||||||
|
val route = fetchRoute(locationUrl) ?: return
|
||||||
|
routesInternal.value =
|
||||||
|
routesInternal.value.filterNot { it.id == route.id } + route
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch + parse the device description at [locationUrl] into a
|
||||||
|
* [UpnpRoute]. Returns null on URL parse failure, transport
|
||||||
|
* failure, empty body, or non-renderer device. Single-return form
|
||||||
|
* (chained `let`s + an early null guard) so detekt's default
|
||||||
|
* ReturnCount cap is respected.
|
||||||
|
*/
|
||||||
|
private fun fetchRoute(locationUrl: String): UpnpRoute? {
|
||||||
|
val url = locationUrl.toHttpUrlOrNull() ?: return null
|
||||||
|
return fetchBody(url)
|
||||||
|
?.let { DeviceDescription.parse(it, url) }
|
||||||
|
?.let { desc ->
|
||||||
|
UpnpRoute(
|
||||||
|
id = desc.udn,
|
||||||
|
name = desc.friendlyName.ifBlank { "Network speaker" },
|
||||||
|
manufacturer = desc.manufacturer,
|
||||||
|
modelName = desc.modelName,
|
||||||
|
avTransportControlUrl = desc.avTransportControlUrl,
|
||||||
|
renderingControlUrl = desc.renderingControlUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchBody(url: HttpUrl): String? {
|
||||||
|
val body = runCatching {
|
||||||
|
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
|
||||||
|
it.body?.string()
|
||||||
|
}
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
return body.ifEmpty { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user