feat(android): revert UPnP output to phone after 5-min idle
This commit is contained in:
+60
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -147,6 +148,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
// can run the longest-common-prefix / common-suffix diff.
|
// can run the longest-common-prefix / common-suffix diff.
|
||||||
private var lastSyncedQueueIds: List<String>? = null
|
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 {
|
init {
|
||||||
// Two-arg addCallback registers with no discovery flag —
|
// Two-arg addCallback registers with no discovery flag —
|
||||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||||
@@ -163,6 +170,54 @@ class OutputPickerController @Inject constructor(
|
|||||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||||
}
|
}
|
||||||
scope.launch { observeQueueChangesForSonosResync() }
|
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 {
|
private companion object {
|
||||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||||
const val EXTEND_THROTTLE_MS = 50L
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user