Skip to content

Commit 193766a

Browse files
ApoloAppseymar
andauthored
Support Modifier.keepScreenOn in Web Platforms (JetBrains#2784)
Support for Modifier.keepScreenOn for Web platform using [Screen Wake Lock Api](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) Manages request when one or more clients (number of Modifier.Nodes requesting it) and the underlying call to Web Api. It also manages release when no more modifiers are in Composition plus the managing of Locks when window becomes blur and subsequently focused again (since the Api will release the lock when that happens) Fixes [Web-Support-Modifier.keepScreenOn](https://youtrack.jetbrains.com/issue/CMP-8473/Web-Support-Modifier.keepScreenOn) ## Testing Used newly introduced tests This should be tested by QA ## Release Notes ### Features - Web - Support for [Modifier.keepScreenOn](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).keepScreenOn()) for web using Screen Wake Lock API --------- Co-authored-by: Oleksandr Karpovich <a.n.karpovich@gmail.com>
1 parent e7eb798 commit 193766a

3 files changed

Lines changed: 443 additions & 1 deletion

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.platform
18+
19+
import androidx.annotation.VisibleForTesting
20+
import androidx.compose.ui.window.documentIsVisible
21+
import kotlin.js.JsAny
22+
import kotlin.js.Promise
23+
import kotlin.js.asJsException
24+
import kotlin.js.js
25+
import kotlinx.browser.document
26+
27+
private val webWakeLockSupported: Boolean by lazy {
28+
isSecureContext() && isFullWakeLockApiSupported()
29+
}
30+
31+
/**
32+
* Manages the WakeLock API for keeping the screen on in web browsers.
33+
*
34+
* This class handles requesting and releasing wake locks using the Screen Wake Lock API,
35+
* which prevents the device screen from turning off while content is being displayed.
36+
* The API is only available in secure contexts (such as HTTPS or localhost).
37+
*/
38+
39+
internal object WebWakeLockManager {
40+
41+
private var wakeLockSentinel: WakeLockSentinel? = null
42+
43+
@VisibleForTesting
44+
internal var requestingLock = false
45+
private var alreadyLoggedWarning = false
46+
47+
private val requests = mutableSetOf<Any>()
48+
49+
init {
50+
document.addEventListener("visibilitychange") {
51+
if (documentIsVisible() && enoughRequestsForLock() && webWakeLockSupported) {
52+
requestWakeLock()
53+
}
54+
}
55+
}
56+
57+
fun sendWakeLockRequest(client: Any, keepScreenOn: Boolean) {
58+
if (!webWakeLockSupported) {
59+
if (!alreadyLoggedWarning) {
60+
alreadyLoggedWarning = true
61+
println("Wake Lock API not supported or not in a secure context")
62+
}
63+
return
64+
}
65+
if (keepScreenOn) {
66+
requests.add(client)
67+
} else {
68+
requests.remove(client)
69+
}
70+
if (enoughRequestsForLock()) {
71+
requestWakeLock()
72+
} else {
73+
releaseWakeLock()
74+
}
75+
}
76+
77+
78+
private fun requestWakeLock() {
79+
if (wakeLockSentinel != null || requestingLock) {
80+
//A lock is already active or a request is in progress
81+
return
82+
}
83+
84+
requestingLock = true
85+
requestScreenWakeLock()
86+
.then { sentinel ->
87+
//Prevents race condition where a release requestLock could come in before the lock is granted
88+
if (requestingLock) {
89+
requestingLock = false
90+
wakeLockSentinel = sentinel
91+
92+
sentinel.addEventListener("release") {
93+
wakeLockSentinel = null
94+
}
95+
} else {
96+
sentinel.release()
97+
}
98+
99+
sentinel
100+
}
101+
.catch { error ->
102+
requestingLock = false
103+
println("Failed to acquire wake lock: ${error.asJsException().message}")
104+
null
105+
}
106+
}
107+
108+
private fun releaseWakeLock() {
109+
if (requestingLock) {
110+
requestingLock = false
111+
}
112+
wakeLockSentinel?.let { sentinel ->
113+
sentinel
114+
.release()
115+
.then {
116+
wakeLockSentinel = null
117+
null
118+
}
119+
.catch { error ->
120+
println("Failed to release wake lock: ${error.asJsException().message}")
121+
wakeLockSentinel = null
122+
null
123+
}
124+
}
125+
}
126+
127+
@Suppress("NOTHING_TO_INLINE")
128+
private inline fun enoughRequestsForLock(): Boolean = requests.isNotEmpty()
129+
130+
fun isWakeLockActive(): Boolean =
131+
wakeLockSentinel != null && !(wakeLockSentinel?.released ?: true) && enoughRequestsForLock()
132+
133+
internal fun reset() {
134+
requestingLock = false
135+
requests.clear()
136+
releaseWakeLock()
137+
}
138+
139+
}
140+
141+
//language=javascript
142+
private fun requestScreenWakeLock(): Promise<WakeLockSentinel> = js(
143+
"""{
144+
return navigator.wakeLock.request('screen')
145+
}
146+
"""
147+
)
148+
149+
//language=javascript
150+
internal fun isFullWakeLockApiSupported(): Boolean =
151+
js(
152+
"""Boolean(
153+
window.navigator.wakeLock &&
154+
typeof(WakeLockSentinel) !== 'undefined'
155+
)
156+
"""
157+
)
158+
159+
private external interface WakeLockSentinel : JsAny {
160+
@Suppress("unused")
161+
val released: Boolean
162+
163+
@Suppress("unused")
164+
val type: String
165+
fun release(): Promise<JsAny?>
166+
fun addEventListener(type: String, listener: () -> Unit)
167+
}
168+
169+
170+

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import androidx.compose.ui.platform.TextToolbar
6060
import androidx.compose.ui.platform.ViewConfiguration
6161
import androidx.compose.ui.platform.WebTextInputService
6262
import androidx.compose.ui.platform.WebTextToolbar
63+
import androidx.compose.ui.platform.WebWakeLockManager
6364
import androidx.compose.ui.platform.WindowInfoImpl
6465
import androidx.compose.ui.platform.accessibility.ComposeWebSemanticsListener
6566
import androidx.compose.ui.scene.CanvasLayersComposeScene
@@ -309,6 +310,10 @@ internal class ComposeWindow(
309310
get() = with(density) { 8000.dp.toPx() }
310311
}
311312

313+
override var isKeepScreenOnEnabled: Boolean
314+
get() = WebWakeLockManager.isWakeLockActive()
315+
set(value) = WebWakeLockManager.sendWakeLockRequest(this@ComposeWindow, value)
316+
312317
override fun setPointerIcon(pointerIcon: PointerIcon) {
313318
if (pointerIcon is BrowserCursor) {
314319
canvas.style.cursor = pointerIcon.id
@@ -740,7 +745,7 @@ internal class ComposeWindow(
740745
}
741746

742747
//https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState
743-
private fun documentIsVisible(): Boolean = js("document.visibilityState === 'visible'")
748+
internal fun documentIsVisible(): Boolean = js("document.visibilityState === 'visible'")
744749

745750
// In K/JS target, an application can't start right away. We should wait until skiko.wasm is ready.
746751
// We'll do it implicitly, rather than asking the app developers to call it.

0 commit comments

Comments
 (0)