EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

DomEthaPanel.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.dometha
2
3import android.annotation.SuppressLint
4import android.content.pm.PackageManager
5import android.os.SystemClock
6import android.view.MotionEvent
7import android.graphics.Color as AndroidColor
8import android.view.ViewGroup
9import android.webkit.WebResourceRequest
10import android.webkit.WebView
11import android.webkit.WebViewClient
12// Maintainer, 2026-10-29: imports for the Close control, load timeout and crash guards.
13import android.webkit.RenderProcessGoneDetail
14import androidx.compose.foundation.clickable
15import androidx.compose.foundation.interaction.MutableInteractionSource
16import androidx.compose.foundation.layout.Column
17import androidx.compose.foundation.layout.Spacer
18import androidx.compose.foundation.layout.height
19import androidx.compose.foundation.layout.padding
20import androidx.compose.ui.focus.onFocusChanged
21import androidx.compose.ui.geometry.Rect
22import androidx.compose.ui.layout.boundsInRoot
23import androidx.compose.ui.layout.onGloballyPositioned
24import androidx.compose.ui.layout.positionInRoot
25import androidx.compose.ui.unit.dp
26import dev.stave.ui.components.staveFocusable
27import dev.stave.ui.theme.SurfaceRaised
28import androidx.activity.compose.BackHandler
29import androidx.compose.animation.core.animateFloatAsState
30import androidx.compose.animation.core.tween
31import androidx.compose.foundation.background
32import androidx.compose.foundation.layout.Box
33import androidx.compose.foundation.layout.fillMaxSize
34import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.runtime.Composable
36import androidx.compose.runtime.DisposableEffect
37import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
40import androidx.compose.runtime.setValue
41import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
43import androidx.compose.foundation.Canvas
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.ui.focus.FocusRequester
46import androidx.compose.ui.focus.focusRequester
47import androidx.compose.ui.focus.focusTarget
48import androidx.compose.ui.geometry.Offset
49import androidx.compose.ui.graphics.Color
50import androidx.compose.ui.graphics.graphicsLayer
51import androidx.compose.ui.input.key.Key
52import androidx.compose.ui.input.key.KeyEventType
53import androidx.compose.ui.input.key.key
54import androidx.compose.ui.input.key.onPreviewKeyEvent
55import androidx.compose.ui.input.key.type
56import androidx.compose.ui.layout.onSizeChanged
57import androidx.compose.ui.unit.IntSize
58import androidx.compose.ui.viewinterop.AndroidView
59import androidx.compose.ui.window.Dialog
60import androidx.compose.ui.window.DialogProperties
61
62/**
63 * Full-screen in-app panel hosting the Dom-Etha PWA (voice/CCA granular
64 * controls) inside Earhorn.
65 *
66 * Why a WebView and not the browser: the kiosk focus choreography depends on
67 * Earhorn being the RESUMED foreground app — hand-back can only fire, and is
68 * only *allowed* to fire by the OS, while Earhorn holds the screen. Launching
69 * an external browser would background Earhorn: hand-back could never run and
70 * a stopped session would strand the screen in the browser. The panel keeps
71 * Earhorn foreground; the caller additionally suppresses hand-back while the
72 * panel is open so the screen is never yanked out from under someone using
73 * the controls.
74 *
75 * Load-in UX (the small-then-flash problem on tall portrait screens): the
76 * WebView starts INVISIBLE over a theme-black scrim with a spinner, and only
77 * fades in after the renderer reports the post-layout frame via
78 * [WebView.postVisualStateCallback] — i.e. after the media queries have
79 * already resolved for the real viewport. The user never sees the mobile
80 * fallback layout or the reflow jump.
81 */
82@SuppressLint("SetJavaScriptEnabled")
83@Composable
84fun DomEthaPanel(
85 url: String,
86 onDismiss: () -> Unit,
87) {
88 Dialog(
89 onDismissRequest = onDismiss,
90 properties = DialogProperties(
91 usePlatformDefaultWidth = false,
92 dismissOnClickOutside = false,
93 ),
94 ) {
95 var pageReady by remember { mutableStateOf(false) }
96 var loadFailed by remember { mutableStateOf(false) }
97 val alpha by animateFloatAsState(
98 targetValue = if (pageReady) 1f else 0f,
99 animationSpec = tween(220),
100 label = "dometha-fade",
101 )
102 var webView by remember { mutableStateOf<WebView?>(null) }
103
104 BackHandler { onDismiss() }
105
106 // Maintainer, 2026-10-29: the spinner had no timeout — a page that never
107 // finishes now counts as unreachable after 20 s (shows the Close path).
108 LaunchedEffect(Unit) {
109 kotlinx.coroutines.delay(20_000)
110 if (!pageReady) {
111 loadFailed = true
112 // Maintainer, 2026-10-29: stop the load so a late page can't
113 // fade in under the "unreachable" overlay.
114 webView?.stopLoading()
115 }
116 }
117 // Maintainer, 2026-10-29: visible Close controls (top-end always; another
118 // under the failure text). Their focus state lets OK reach them past the
119 // remote key handler; their bounds let the D-pad pointer tap them.
120 var closeFocused by remember { mutableStateOf(false) }
121 var failCloseFocused by remember { mutableStateOf(false) }
122 var closeBounds by remember { mutableStateOf(Rect.Zero) }
123 var failCloseBounds by remember { mutableStateOf(Rect.Zero) }
124 // Panel origin in root coords: Close bounds are root-relative, the
125 // pointer is panel-relative.
126 var panelOrigin by remember { mutableStateOf(Offset.Zero) }
127 val failCloseFocus = remember { FocusRequester() }
128 LaunchedEffect(loadFailed) {
129 if (loadFailed) runCatching { failCloseFocus.requestFocus() }
130 }
131
132 // ── D-pad virtual cursor (remote-only devices) ───────────
133 // Dom-Etha is a touch-designed PWA; on boxes with no touchscreen a
134 // remote gets a pointer instead: arrows move it (accelerating on
135 // held keys), OK dispatches a REAL touch tap at its position so the
136 // page needs zero cooperation, and pushing past an edge scrolls.
137 // Touch devices skip all of this and behave as before. The proper
138 // long-term fix is focusability inside Dom-Etha itself; this makes
139 // any page usable meanwhile.
140 val context = androidx.compose.ui.platform.LocalContext.current
141 val remoteOnly = remember {
142 !context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
143 }
144 // Native focus traversal is the DEFAULT on remotes: Dom-Etha's
145 // controls are real <button>/<input type=range> elements (review,
146 // 2026-08-29), so arrows move focus and adjust the volume slider
147 // natively — which the cursor would otherwise swallow. Long-press
148 // OK toggles the pointer for anything focus can't reach.
149 var cursorMode by remember { mutableStateOf(false) }
150 var modeFlash by remember { mutableStateOf<String?>(null) }
151 var suppressCenterRelease by remember { mutableStateOf(false) }
152 LaunchedEffect(modeFlash) {
153 if (modeFlash != null) {
154 kotlinx.coroutines.delay(1_200)
155 modeFlash = null
156 }
157 }
158
159 fun synthDpadCenter() {
160 val wv = webView ?: return
161 val t = SystemClock.uptimeMillis()
162 wv.dispatchKeyEvent(android.view.KeyEvent(t, t,
163 android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
164 wv.dispatchKeyEvent(android.view.KeyEvent(t, t + 40,
165 android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
166 }
167 var boxSize by remember { mutableStateOf(IntSize.Zero) }
168 var cursor by remember { mutableStateOf(Offset.Zero) }
169 LaunchedEffect(boxSize) {
170 if (cursor == Offset.Zero && boxSize != IntSize.Zero) {
171 cursor = Offset(boxSize.width / 2f, boxSize.height / 2f)
172 }
173 }
174 val keyFocus = remember { FocusRequester() }
175 LaunchedEffect(Unit) { runCatching { keyFocus.requestFocus() } }
176
177 fun synthTap(x: Float, y: Float) {
178 val wv = webView ?: return
179 val t = SystemClock.uptimeMillis()
180 val down = MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0)
181 val up = MotionEvent.obtain(t, t + 60, MotionEvent.ACTION_UP, x, y, 0)
182 wv.dispatchTouchEvent(down)
183 wv.dispatchTouchEvent(up)
184 down.recycle()
185 up.recycle()
186 }
187
188 fun moveCursor(dx: Float, dy: Float) {
189 val w = boxSize.width.toFloat().coerceAtLeast(1f)
190 val h = boxSize.height.toFloat().coerceAtLeast(1f)
191 var nx = cursor.x + dx
192 var ny = cursor.y + dy
193 // Pushing past an edge scrolls the page by the overflow.
194 val wv = webView
195 if (wv != null) {
196 if (ny > h - 24) { wv.scrollBy(0, (ny - (h - 24)).toInt() * 3); ny = h - 24 }
197 if (ny < 24) { wv.scrollBy(0, (ny - 24).toInt() * 3); ny = 24f }
198 if (nx > w - 24) { wv.scrollBy((nx - (w - 24)).toInt() * 3, 0); nx = w - 24 }
199 if (nx < 24) { wv.scrollBy((nx - 24).toInt() * 3, 0); nx = 24f }
200 }
201 cursor = Offset(nx.coerceIn(0f, w), ny.coerceIn(0f, h))
202 }
203
204 Box(
205 Modifier
206 .fillMaxSize()
207 .background(Color.Black)
208 .onSizeChanged { boxSize = it }
209 // Maintainer, 2026-10-29: origin for pointer-over-Close hit tests.
210 .onGloballyPositioned { panelOrigin = it.positionInRoot() }
211 .focusRequester(keyFocus)
212 .focusTarget()
213 .onPreviewKeyEvent { ev ->
214 if (!remoteOnly) return@onPreviewKeyEvent false
215 // Maintainer, 2026-10-29: a focused Close control handles its
216 // own keys (OK clicks it) instead of being swallowed here.
217 if (closeFocused || failCloseFocused) return@onPreviewKeyEvent false
218 val isCenter = ev.key == Key.DirectionCenter || ev.key == Key.Enter
219 val isArrow = ev.key in setOf(
220 Key.DirectionLeft, Key.DirectionRight,
221 Key.DirectionUp, Key.DirectionDown,
222 )
223 if (ev.type != KeyEventType.KeyDown) {
224 // Key-up. Center is always ours: a long-press
225 // already toggled the mode and this release is
226 // swallowed; a short press acts now — synthetic tap
227 // in cursor mode, synthetic DPAD_CENTER (click the
228 // focused element) in native mode.
229 if (isCenter) {
230 if (suppressCenterRelease) {
231 suppressCenterRelease = false
232 } else if (cursorMode) {
233 // Maintainer, 2026-10-29: pointer over a Close
234 // control closes; anywhere else taps the page.
235 val pointer = cursor + panelOrigin
236 if (closeBounds.contains(pointer) ||
237 (loadFailed && failCloseBounds.contains(pointer))
238 ) {
239 onDismiss()
240 } else {
241 synthTap(cursor.x, cursor.y)
242 }
243 } else {
244 synthDpadCenter()
245 }
246 return@onPreviewKeyEvent true
247 }
248 // Arrow key-ups: ours only in cursor mode.
249 return@onPreviewKeyEvent cursorMode && isArrow
250 }
251 // Key-down.
252 if (isCenter) {
253 if (ev.nativeKeyEvent.isLongPress) {
254 cursorMode = !cursorMode
255 modeFlash = if (cursorMode) "Pointer" else "Focus"
256 suppressCenterRelease = true
257 }
258 return@onPreviewKeyEvent true // decided on release
259 }
260 if (!cursorMode) return@onPreviewKeyEvent false // native arrows
261 if (!isArrow) return@onPreviewKeyEvent false // BACK etc.
262 val repeat = ev.nativeKeyEvent.repeatCount
263 val step = (18f + (repeat * 6f)).coerceAtMost(64f)
264 when (ev.key) {
265 Key.DirectionLeft -> moveCursor(-step, 0f)
266 Key.DirectionRight -> moveCursor(step, 0f)
267 Key.DirectionUp -> moveCursor(0f, -step)
268 Key.DirectionDown -> moveCursor(0f, step)
269 else -> {}
270 }
271 true
272 },
273 ) {
274 AndroidView(
275 modifier = Modifier
276 .fillMaxSize()
277 .graphicsLayer { this.alpha = alpha },
278 factory = { ctx ->
279 // Maintainer, 2026-10-29: a missing/updating WebView provider
280 // throws from the constructor — show the failure state with a
281 // placeholder View instead of crashing the app.
282 runCatching {
283 WebView(ctx).apply {
284 layoutParams = ViewGroup.LayoutParams(
285 ViewGroup.LayoutParams.MATCH_PARENT,
286 ViewGroup.LayoutParams.MATCH_PARENT,
287 )
288 // Black before content: even pre-fade, no white flash.
289 setBackgroundColor(AndroidColor.BLACK)
290 settings.javaScriptEnabled = true
291 settings.domStorageEnabled = true
292 settings.databaseEnabled = true
293 settings.mediaPlaybackRequiresUserGesture = false
294 webViewClient = object : WebViewClient() {
295 override fun onPageFinished(view: WebView, u: String) {
296 // Reveal only after the renderer has produced
297 // the post-layout visual state for the real
298 // viewport — media queries settled, no flash.
299 view.postVisualStateCallback(
300 0,
301 object : WebView.VisualStateCallback() {
302 override fun onComplete(requestId: Long) {
303 // Maintainer, 2026-10-29: never reveal after the timeout declared failure.
304 if (!loadFailed) pageReady = true
305 }
306 },
307 )
308 }
309
310 override fun onReceivedError(
311 view: WebView,
312 request: WebResourceRequest,
313 error: android.webkit.WebResourceError,
314 ) {
315 if (request.isForMainFrame) loadFailed = true
316 }
317
318 // Maintainer, 2026-10-29: a killed/crashed renderer must
319 // not take the app down. Returning true keeps the app
320 // alive; the dead WebView is detached, destroyed and
321 // dropped so nothing touches it again.
322 override fun onRenderProcessGone(
323 view: WebView,
324 detail: RenderProcessGoneDetail,
325 ): Boolean {
326 loadFailed = true
327 if (webView === view) webView = null
328 (view.parent as? ViewGroup)?.removeView(view)
329 view.destroy()
330 return true
331 }
332 }
333 loadUrl(url)
334 webView = this
335 }
336 }.getOrElse {
337 loadFailed = true
338 android.view.View(ctx)
339 }
340 },
341 )
342
343 // Maintainer, 2026-10-29: always-visible top-end Close (no way out
344 // existed besides Back). Focusable for TV via clickable.
345 DomEthaCloseButton(
346 onClick = onDismiss,
347 modifier = Modifier
348 .align(Alignment.TopEnd)
349 .padding(16.dp)
350 .onGloballyPositioned { closeBounds = it.boundsInRoot() }
351 .onFocusChanged { closeFocused = it.isFocused },
352 )
353
354 if (!pageReady && !loadFailed) {
355 CircularProgressIndicator(Modifier.align(Alignment.Center))
356 }
357 if (modeFlash != null) {
358 androidx.compose.material3.Text(
359 modeFlash!!,
360 color = Color.White,
361 modifier = Modifier.align(Alignment.TopCenter),
362 )
363 }
364 if (remoteOnly && pageReady && cursorMode) {
365 // High-contrast pointer: dark outline ring + light fill dot,
366 // visible on any page background.
367 Canvas(Modifier.fillMaxSize()) {
368 drawCircle(Color.Black.copy(alpha = 0.55f), radius = 16f, center = cursor)
369 drawCircle(Color.White, radius = 11f, center = cursor)
370 drawCircle(Color.Black, radius = 3.5f, center = cursor)
371 }
372 }
373 if (loadFailed) {
374 // Wall screens never get an error dialog; a dead panel just
375 // closes on Back. Show nothing dramatic.
376 // Maintainer, 2026-10-29: "press Back" replaced by a Close control
377 // under the text (focused automatically for remotes).
378 Column(
379 Modifier.align(Alignment.Center),
380 horizontalAlignment = Alignment.CenterHorizontally,
381 ) {
382 androidx.compose.material3.Text(
383 "Dom-Etha unreachable",
384 color = Color.White,
385 )
386 Spacer(Modifier.height(16.dp))
387 DomEthaCloseButton(
388 onClick = onDismiss,
389 modifier = Modifier
390 .onGloballyPositioned { failCloseBounds = it.boundsInRoot() }
391 .focusRequester(failCloseFocus)
392 .onFocusChanged { failCloseFocused = it.isFocused },
393 )
394 }
395 }
396 }
397
398 DisposableEffect(Unit) {
399 onDispose {
400 // WebViews leak if merely dereferenced; destroy explicitly.
401 webView?.apply {
402 loadUrl("about:blank")
403 stopLoading()
404 destroy()
405 }
406 webView = null
407 }
408 }
409 }
410}
411
412/** Maintainer, 2026-10-29: visible way out of the panel, built like
413 * CastPickerSheet's CloseButton — focusable on TV via clickable, amber
414 * focus border via staveFocusable. */
415@Composable
416private fun DomEthaCloseButton(onClick: () -> Unit, modifier: Modifier) {
417 val interaction = remember { MutableInteractionSource() }
418 Box(
419 modifier
420 .background(SurfaceRaised)
421 .staveFocusable(interaction, cornerRadiusDp = 6)
422 .clickable(interaction, indication = null) { onClick() }
423 .padding(horizontal = 16.dp, vertical = 10.dp),
424 ) {
425 androidx.compose.material3.Text(
426 "✕ Close",
427 color = Color.White,
428 )
429 }
430}