Merge pull request 'fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation)' (#97) from dev into main
android / Build + lint + test (push) Successful in 4m5s
release / Build signed APK (tag releases only) (push) Successful in 4m4s
release / Build + push container image (push) Successful in 13s

This commit was merged in pull request #97.
This commit is contained in:
2026-06-12 19:45:44 -04:00
3 changed files with 85 additions and 0 deletions
@@ -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 delegate: Player,
private val holder: ActiveUpnpHolder, private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock,
private val onDrop: (routeName: String) -> Unit, private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) { ) : ForwardingPlayer(delegate) {
@@ -438,6 +439,11 @@ class MinstrelForwardingPlayer(
lastNotifiedTrackIdx = -1 lastNotifiedTrackIdx = -1
if (active != null) { if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName) 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 // Pause the wrapped ExoPlayer so we are not playing local audio
// simultaneously with the remote renderer. handler.post targets the // simultaneously with the remote renderer. handler.post targets the
// application looper, so this runs on the same thread that processes // application looper, so this runs on the same thread that processes
@@ -447,6 +453,7 @@ class MinstrelForwardingPlayer(
handler.post { delegate.pause() } handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) } pollJob = scope.launch { pollLoop(active) }
} else { } else {
castNetworkLock.release()
remoteState.reset() remoteState.reset()
} }
} }
@@ -81,6 +81,7 @@ class PlayerFactory @Inject constructor(
delegate = exo, delegate = exo,
holder = activeUpnpHolder, holder = activeUpnpHolder,
remoteState = remoteState, remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
onDrop = { name -> emitDrop(name) }, onDrop = { name -> emitDrop(name) },
) )
} }