fix(android): OutputPicker -- local capture, selectUpnp mutex, shared dedup helper, suppress
android / Build + lint + test (push) Has been cancelled
android / Build + lint + test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+12
-16
@@ -1,3 +1,4 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
|
||||||
package com.fabledsword.minstrel.player.output
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -11,7 +12,6 @@ import com.fabledsword.minstrel.player.StreamTokenProvider
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
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
|
||||||
@@ -20,6 +20,8 @@ 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 kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -86,17 +88,17 @@ class OutputPickerController @Inject constructor(
|
|||||||
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
||||||
|
|
||||||
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
|
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
|
||||||
private var lastKnownRemotePositionMs: Long = 0L
|
private val selectUpnpMutex = Mutex()
|
||||||
|
|
||||||
val routesState: StateFlow<RouteSnapshot> = combine(
|
val routesState: StateFlow<RouteSnapshot> = combine(
|
||||||
systemRoutesInternal,
|
systemRoutesInternal,
|
||||||
upnpDiscovery.routes,
|
upnpDiscovery.routes,
|
||||||
upnpDiscovery.sonosTopology,
|
upnpDiscovery.sonosTopology,
|
||||||
selectedUpnpRouteIdInternal,
|
selectedUpnpRouteIdInternal,
|
||||||
) { sys, upnp, topology, upnpSelected ->
|
) { sys, upnp, _, upnpSelected ->
|
||||||
val suppressed = nonCoordinatorMemberUdns(topology)
|
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
|
||||||
val visibleUpnp = upnp
|
val visibleUpnp = upnp
|
||||||
.filter { it.id.removePrefix("uuid:") !in suppressed }
|
.filter { it.id.removePrefix("uuid:") !in suppressed } // suppressed set is bare UDNs
|
||||||
.map { OutputRoute.fromUpnpRoute(it) }
|
.map { OutputRoute.fromUpnpRoute(it) }
|
||||||
val merged = sys.available + visibleUpnp
|
val merged = sys.available + visibleUpnp
|
||||||
val current = if (upnpSelected != null) {
|
val current = if (upnpSelected != null) {
|
||||||
@@ -107,12 +109,6 @@ class OutputPickerController @Inject constructor(
|
|||||||
RouteSnapshot(current = current, available = sortRoutes(merged))
|
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()
|
||||||
@@ -189,14 +185,14 @@ class OutputPickerController @Inject constructor(
|
|||||||
val wasUpnp = selectedUpnpRouteIdInternal.value
|
val wasUpnp = selectedUpnpRouteIdInternal.value
|
||||||
if (wasUpnp != null) {
|
if (wasUpnp != null) {
|
||||||
val active = activeUpnpHolder.active.value
|
val active = activeUpnpHolder.active.value
|
||||||
lastKnownRemotePositionMs = remoteState.positionMs
|
val capturedPositionMs = remoteState.positionMs
|
||||||
val wasPlayingRemote = remoteState.isPlaying
|
val wasPlayingRemote = remoteState.isPlaying
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active?.avTransport?.stop() }
|
runCatching { active?.avTransport?.stop() }
|
||||||
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
|
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
playerController.seekTo(lastKnownRemotePositionMs)
|
playerController.seekTo(capturedPositionMs)
|
||||||
if (wasPlayingRemote) playerController.play()
|
if (wasPlayingRemote) playerController.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,11 +210,11 @@ class OutputPickerController @Inject constructor(
|
|||||||
* the cause in logcat (OkHttp's logger doesn't cover our own
|
* the cause in logcat (OkHttp's logger doesn't cover our own
|
||||||
* deserialize / SOAP-parse code paths).
|
* deserialize / SOAP-parse code paths).
|
||||||
*/
|
*/
|
||||||
private suspend fun selectUpnp(route: OutputRoute) {
|
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
|
||||||
val trackId = playerController.uiState.value.currentTrack?.id
|
val trackId = playerController.uiState.value.currentTrack?.id
|
||||||
if (trackId == null) {
|
if (trackId == null) {
|
||||||
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
|
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
|
||||||
return
|
return@withLock
|
||||||
}
|
}
|
||||||
// Honor Sonos topology: pick the coordinator's route when the user
|
// Honor Sonos topology: pick the coordinator's route when the user
|
||||||
// tapped a group row. Suppression in routesState already keeps the
|
// tapped a group row. Suppression in routesState already keeps the
|
||||||
@@ -232,7 +228,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
"UPnP select skipped: no transport for route id=${effectiveRoute.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@withLock
|
||||||
}
|
}
|
||||||
val rendering = renderingClientFor(effectiveRoute.id)
|
val rendering = renderingClientFor(effectiveRoute.id)
|
||||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
|
|||||||
Reference in New Issue
Block a user