diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/CastNetworkLock.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/CastNetworkLock.kt new file mode 100644 index 00000000..0d96ebf8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/CastNetworkLock.kt @@ -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 + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 053fb8df..52977949 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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() } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 3420c498..47ecfc39 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -81,6 +81,7 @@ class PlayerFactory @Inject constructor( delegate = exo, holder = activeUpnpHolder, remoteState = remoteState, + castNetworkLock = CastNetworkLock(context), onDrop = { name -> emitDrop(name) }, ) }