Merge pull request 'fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation)' (#97) from dev into main
This commit was merged in pull request #97.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
|
||||
*
|
||||
* While casting, the wrapped ExoPlayer is paused, so its automatic
|
||||
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
|
||||
* awake. On a locked, on-battery phone the WiFi enters power-save within
|
||||
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
|
||||
* renderer and the queue-extend calls to the server. The poll then trips the
|
||||
* drop threshold and playback falls back to a phone that *also* has no
|
||||
* network — silence, while the renderer was streaming fine the whole time.
|
||||
*
|
||||
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
|
||||
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
|
||||
* coroutine scheduled. Both are acquired when a route goes active and released
|
||||
* the moment it drops or the user switches back to the phone.
|
||||
*
|
||||
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
|
||||
* synchronized, so they are safe to call from any thread.
|
||||
*/
|
||||
class CastNetworkLock(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val wifiLock: WifiManager.WifiLock? =
|
||||
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
|
||||
?.createWifiLock(wifiLockMode(), LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? =
|
||||
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
|
||||
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
@Synchronized
|
||||
fun acquire() {
|
||||
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
|
||||
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
|
||||
Timber.w(
|
||||
"CastNetworkLock acquired (wifi=%s wake=%s)",
|
||||
wifiLock?.isHeld, wakeLock?.isHeld,
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release() {
|
||||
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
|
||||
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
|
||||
Timber.w("CastNetworkLock released")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOCK_TAG = "minstrel:upnp-cast"
|
||||
|
||||
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
|
||||
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
|
||||
// the radio fully awake.
|
||||
fun wifiLockMode(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
private val holder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
@@ -438,6 +439,11 @@ class MinstrelForwardingPlayer(
|
||||
lastNotifiedTrackIdx = -1
|
||||
if (active != null) {
|
||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||
// without this the radio power-saves on a locked screen and the
|
||||
// poll below starves -- see [CastNetworkLock].
|
||||
castNetworkLock.acquire()
|
||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||
// simultaneously with the remote renderer. handler.post targets the
|
||||
// application looper, so this runs on the same thread that processes
|
||||
@@ -447,6 +453,7 @@ class MinstrelForwardingPlayer(
|
||||
handler.post { delegate.pause() }
|
||||
pollJob = scope.launch { pollLoop(active) }
|
||||
} else {
|
||||
castNetworkLock.release()
|
||||
remoteState.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ class PlayerFactory @Inject constructor(
|
||||
delegate = exo,
|
||||
holder = activeUpnpHolder,
|
||||
remoteState = remoteState,
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user