From 5386ae870f6db7d2c8bdab55595ef47fdb1374a3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 6 Jun 2026 17:28:32 -0400 Subject: [PATCH] feat(android): revert UPnP output to phone after 5-min idle --- .../player/output/OutputPickerController.kt | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 9d0dddec..e322dd0f 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 @@ -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? = 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 } }