feat(android): revert UPnP output to phone after 5-min idle

This commit is contained in:
2026-06-06 17:28:32 -04:00
parent d7b011e52f
commit 5386ae870f
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -147,6 +148,12 @@ class OutputPickerController @Inject constructor(
// can run the longest-common-prefix / common-suffix diff.
private var lastSyncedQueueIds: List<String>? = null
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
// non-playing. Cancelled on resume, route change, or explicit disconnect.
// See idleRevertAction for the arm/cancel decision.
private var idleRevertJob: Job? = null
init {
// Two-arg addCallback registers with no discovery flag —
// androidx.mediarouter 1.7.0's default passive behavior:
@@ -163,6 +170,54 @@ class OutputPickerController @Inject constructor(
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeIdleRevertWhileUpnp() }
}
/**
* Arm/cancel the idle-revert timer as playback state changes while a
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
* selectUpnp/resync window (active momentarily null, target set) doesn't
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
*/
private suspend fun observeIdleRevertWhileUpnp() {
playerController.uiState.collect { state ->
val engaged = activeUpnpHolder.active.value != null ||
activeUpnpHolder.target.value != null
val armed = idleRevertJob?.isActive == true
when (idleRevertAction(engaged, state.isPlaying, armed)) {
IdleRevertAction.ARM ->
idleRevertJob = scope.launch {
delay(IDLE_REVERT_MS)
revertToPhoneOnIdle()
}
IdleRevertAction.CANCEL -> {
idleRevertJob?.cancel()
idleRevertJob = null
}
IdleRevertAction.IGNORE -> Unit
}
}
}
/**
* Timer fired: the operator paused on a UPnP route and never resumed.
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
* clear the UPnP overlay so routesState falls back to the phone, and
* seek local ExoPlayer to the last remote position. No play(): the
* operator was paused, so we revert routing silently. Guarded by the
* selectUpnpMutex so it can't race selectUpnp/resync.
*/
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
if (remoteState.isPlaying) return@withLock // resumed at the boundary
val active = activeUpnpHolder.active.value
val capturedPositionMs = remoteState.positionMs
runCatching { active?.avTransport?.stop() }
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
idleRevertJob = null
playerController.seekTo(capturedPositionMs)
}
/**
@@ -640,5 +695,10 @@ class OutputPickerController @Inject constructor(
private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing.
const val IDLE_REVERT_MS = 5 * 60 * 1000L
}
}