fix(android): keep the display awake while video plays

Android counts its display timeout from the last user input, and watching
something is exactly the case where there is none — so the screen dimmed and
slept mid-playback unless the user kept tapping it.

Nothing held it. FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither
renderer supplies a hold for free: ExoPlayer's setWakeMode is a CPU/wifi wake
lock that says nothing about the display, and it draws into the TextureView we
own (DR-192) rather than media3's PlayerView, which is the widget that would
otherwise set keepScreenOn itself; the webview <video> path is no better,
because the display wake lock Chrome takes for video lives in the browser layer
and not in an embedded WebView.

ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window
scoped, so it stops applying the moment the app is not visible and cannot
outlive a crash the way an acquired PowerManager.WakeLock can, and it needs no
permission. The two rendering paths are independent holders OR-ed in the pure
ScreenWakeState: the native path follows onIsPlayingChanged plus surface
teardown, so the hold tracks what ExoPlayer reports rather than what the UI
intends, and the webview path reuses the setHtml5VideoState report the frontend
already sends for PiP. Audio is deliberately not a holder — screen-off music is
the point of that path.

Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free,
so the decision is testable off-device with

    ./gradlew :app:testUniversalDebugUnitTest

(note the variant — plain testDebugUnitTest is ambiguous here). sync-android
-sources.sh mirrors src/test into the gen tree alongside the main sources.

TRACES: UR-003, UR-004 | DR-202 | UT-199
This commit is contained in:
2026-08-18 14:56:08 +02:00
parent d5d0e35bca
commit caebf2d139
7 changed files with 300 additions and 3 deletions
@@ -85,6 +85,11 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
// A new WebView means a new page, which reports no video yet. Anything the
// previous one left held would otherwise pin the screen on for the life of
// the process, since a page that goes away never sends its final
// setHtml5VideoState(false, …). (DR-202)
ScreenWakeManager.releaseAll()
installJavascriptBridges(webView)
configureWebViewSettings(webView)
}
@@ -115,6 +120,11 @@ class MainActivity : TauriActivity() {
// TRACES: UR-003, UR-041 | DR-151
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
// The window whose FLAG_KEEP_SCREEN_ON is toggled while video plays. Set on
// every onCreate so a recreated Activity (rotation) re-applies the current
// hold to its new window. (UR-003, DR-202)
ScreenWakeManager.setActivity(this)
// Configure WebView for media playback after Tauri initialization
handler.postDelayed({
configureWebViewForMedia()
@@ -188,6 +198,7 @@ class MainActivity : TauriActivity() {
override fun onDestroy() {
NetworkTypeMonitor.stopWatching(this)
ScreenWakeManager.clearActivity(this)
super.onDestroy()
}
@@ -311,6 +322,10 @@ class MainActivity : TauriActivity() {
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
// The same report is what keeps the display awake on the webview
// rendering path — the WebView takes no display wake lock of its own
// for `<video>`. (DR-202)
ScreenWakeManager.onHtml5VideoState(active, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -0,0 +1,167 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.view.WindowManager
import java.lang.ref.WeakReference
/**
* Which playback paths currently want the screen kept awake.
*
* Pure state, deliberately free of any Android type so it can be unit-tested —
* see ScreenWakeStateTest. Two independent holders, because video can be
* rendered by either renderer and only one of them is active at a time:
*
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
* - **html5** — a `<video>` inside the WebView, reported by the frontend
*
* Audio is deliberately *not* a holder. Playing music with the screen off is the
* point of the audio path; only video needs the display alive.
*
* TRACES: UR-003 | DR-202 | UT-199
*/
class ScreenWakeState {
private var nativeVideoPlaying = false
private var html5VideoPlaying = false
/** True while any video renderer is actively playing. */
val keepScreenOn: Boolean
get() = nativeVideoPlaying || html5VideoPlaying
/**
* @param playing whether ExoPlayer is playing right now
* @param isVideo whether what it is playing is video rather than audio
*/
fun updateNative(playing: Boolean, isVideo: Boolean) {
nativeVideoPlaying = playing && isVideo
}
/**
* @param active whether a webview `<video>` is the current playback surface
* @param playing whether that element is playing right now
*/
fun updateHtml5(active: Boolean, playing: Boolean) {
html5VideoPlaying = active && playing
}
/** Drop every hold (teardown, or a page that can no longer be trusted). */
fun reset() {
nativeVideoPlaying = false
html5VideoPlaying = false
}
}
/**
* Keeps the display awake while video is playing.
*
* TRACES: UR-003 | DR-202
*
* ## Why this is needed at all
*
* Android turns the screen off on its own display timeout, counted from the last
* *user input*. Watching a film is precisely the case where there is none, so
* without an explicit hold the screen dimmed and slept mid-playback and the user
* had to keep tapping it. Nothing in the app held it: `FLAG_KEEP_SCREEN_ON`
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
* `<video>` path does not either: the display wake lock Chrome takes for video
* lives in the browser layer, not in an embedded WebView.
*
* ## Approach
*
* `FLAG_KEEP_SCREEN_ON` on the Activity window rather than a
* `PowerManager.WakeLock`: the flag is scoped to the window, so it stops
* applying the moment the app is not visible and cannot survive a crash or a
* missed release the way an explicitly acquired wake lock can. It needs no
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
*
* The two renderers report independently and are OR-ed together in
* [ScreenWakeState]:
*
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
* native path — ExoPlayer is the authoritative source of playback state, so
* the hold follows what it reports rather than what the UI intends.
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
* the webview path. The frontend already reports that state on every
* play/pause and on player teardown for PiP, so no new bridge is needed.
*
* The Activity reference is weak and re-set on every `onCreate`, so a
* recreation (rotation) re-applies the current hold to the new window.
*/
object ScreenWakeManager {
private const val TAG = "ScreenWakeManager"
private val mainHandler = Handler(Looper.getMainLooper())
private val state = ScreenWakeState()
private var activityRef: WeakReference<Activity>? = null
/**
* Adopt the Activity whose window carries the flag, and re-apply the current
* hold to it. Called from `MainActivity.onCreate`, so a rotation-recreated
* Activity keeps the screen awake without waiting for the next state report.
*/
@Synchronized
fun setActivity(activity: Activity) {
activityRef = WeakReference(activity)
apply()
}
/** Drop the Activity on destroy, unless a newer one has already replaced it. */
@Synchronized
fun clearActivity(activity: Activity) {
if (activityRef?.get() === activity) {
activityRef = null
}
}
/** ExoPlayer's playback state changed. */
@Synchronized
fun onNativePlaybackChanged(playing: Boolean, isVideo: Boolean) {
state.updateNative(playing, isVideo)
apply()
}
/**
* The frontend reported the webview `<video>` state. Arrives on a WebView
* binder thread, hence the synchronization and the post to the main thread.
*/
@Synchronized
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
state.updateHtml5(active, playing)
apply()
}
/**
* Drop every hold. Used when a new WebView/page load invalidates whatever the
* previous page last reported — a page that goes away without a final
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
* for the life of the process.
*/
@Synchronized
fun releaseAll() {
state.reset()
apply()
}
private fun apply() {
val desired = state.keepScreenOn
val activity = activityRef?.get() ?: return
mainHandler.post {
try {
if (activity.isFinishing || activity.isDestroyed) return@post
if (desired) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
android.util.Log.d(TAG, "keepScreenOn = $desired")
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to apply keep-screen-on flag", e)
}
}
}
}
@@ -336,6 +336,14 @@ class JellyTauPlayer(private val appContext: Context) {
val state = if (isPlaying) "playing" else "paused"
nativeOnStateChanged(state, currentMediaId)
// Hold the display awake for video, release it for a pause or for
// audio: the display timeout counts from the last user input, and
// watching something is exactly when there is none. (DR-202)
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(
isPlaying,
currentMediaType == MediaType.VIDEO
)
if (isPlaying) {
startPositionUpdates()
} else {
@@ -1027,6 +1035,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun release() {
mainHandler.post {
stopPositionUpdates()
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
coroutineScope.cancel()
releaseAudioEffects()
exoPlayer.release()
@@ -1324,6 +1333,10 @@ class JellyTauPlayer(private val appContext: Context) {
* TRACES: UR-003, UR-041 | DR-184
*/
private fun clearVideoSurface() {
// Whatever happens to the view, video is no longer what is on screen, so
// the display hold goes with it. Outside the let: the hold must be
// released even when no view was ever created. (DR-202)
com.dtourolle.jellytau.ScreenWakeManager.onNativePlaybackChanged(false, false)
videoView?.let {
exoPlayer.clearVideoSurface()
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
@@ -0,0 +1,86 @@
package com.dtourolle.jellytau
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The screen-wake decision, isolated from the Activity window it is applied to.
*
* TRACES: UR-003 | DR-202 | UT-199
*/
class ScreenWakeStateTest {
@Test
fun `starts released`() {
assertFalse(ScreenWakeState().keepScreenOn)
}
@Test
fun `native video playing holds the screen on`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `pausing native video releases the screen`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
/** Music with the screen off is the whole point of the audio path. */
@Test
fun `native audio playing does not hold the screen on`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = false)
assertFalse(state.keepScreenOn)
}
@Test
fun `webview video playing holds the screen on`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `webview video paused releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = true, playing = false)
assertFalse(state.keepScreenOn)
}
/** The element going away must release even if it never reported a pause. */
@Test
fun `webview video going inactive while playing releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = true)
assertFalse(state.keepScreenOn)
}
/** The two rendering paths are independent holders; either one is enough. */
@Test
fun `one path releasing does not release while the other still plays`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = false)
assertTrue(state.keepScreenOn)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
@Test
fun `teardown releases both paths`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.reset()
assertFalse(state.keepScreenOn)
}
}