DomEthaPanel.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.dometha |
| 2 | ||
| 3 | import android.annotation.SuppressLint | |
| 4 | import android.content.pm.PackageManager | |
| 5 | import android.os.SystemClock | |
| 6 | import android.view.MotionEvent | |
| 7 | import android.graphics.Color as AndroidColor | |
| 8 | import android.view.ViewGroup | |
| 9 | import android.webkit.WebResourceRequest | |
| 10 | import android.webkit.WebView | |
| 11 | import android.webkit.WebViewClient | |
| 12 | // Maintainer, 2026-10-29: imports for the Close control, load timeout and crash guards. | |
| 13 | import android.webkit.RenderProcessGoneDetail | |
| 14 | import androidx.compose.foundation.clickable | |
| 15 | import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 16 | import androidx.compose.foundation.layout.Column | |
| 17 | import androidx.compose.foundation.layout.Spacer | |
| 18 | import androidx.compose.foundation.layout.height | |
| 19 | import androidx.compose.foundation.layout.padding | |
| 20 | import androidx.compose.ui.focus.onFocusChanged | |
| 21 | import androidx.compose.ui.geometry.Rect | |
| 22 | import androidx.compose.ui.layout.boundsInRoot | |
| 23 | import androidx.compose.ui.layout.onGloballyPositioned | |
| 24 | import androidx.compose.ui.layout.positionInRoot | |
| 25 | import androidx.compose.ui.unit.dp | |
| 26 | import dev.stave.ui.components.staveFocusable | |
| 27 | import dev.stave.ui.theme.SurfaceRaised | |
| 28 | import androidx.activity.compose.BackHandler | |
| 29 | import androidx.compose.animation.core.animateFloatAsState | |
| 30 | import androidx.compose.animation.core.tween | |
| 31 | import androidx.compose.foundation.background | |
| 32 | import androidx.compose.foundation.layout.Box | |
| 33 | import androidx.compose.foundation.layout.fillMaxSize | |
| 34 | import androidx.compose.material3.CircularProgressIndicator | |
| 35 | import androidx.compose.runtime.Composable | |
| 36 | import androidx.compose.runtime.DisposableEffect | |
| 37 | import androidx.compose.runtime.getValue | |
| 38 | import androidx.compose.runtime.mutableStateOf | |
| 39 | import androidx.compose.runtime.remember | |
| 40 | import androidx.compose.runtime.setValue | |
| 41 | import androidx.compose.ui.Alignment | |
| 42 | import androidx.compose.ui.Modifier | |
| 43 | import androidx.compose.foundation.Canvas | |
| 44 | import androidx.compose.runtime.LaunchedEffect | |
| 45 | import androidx.compose.ui.focus.FocusRequester | |
| 46 | import androidx.compose.ui.focus.focusRequester | |
| 47 | import androidx.compose.ui.focus.focusTarget | |
| 48 | import androidx.compose.ui.geometry.Offset | |
| 49 | import androidx.compose.ui.graphics.Color | |
| 50 | import androidx.compose.ui.graphics.graphicsLayer | |
| 51 | import androidx.compose.ui.input.key.Key | |
| 52 | import androidx.compose.ui.input.key.KeyEventType | |
| 53 | import androidx.compose.ui.input.key.key | |
| 54 | import androidx.compose.ui.input.key.onPreviewKeyEvent | |
| 55 | import androidx.compose.ui.input.key.type | |
| 56 | import androidx.compose.ui.layout.onSizeChanged | |
| 57 | import androidx.compose.ui.unit.IntSize | |
| 58 | import androidx.compose.ui.viewinterop.AndroidView | |
| 59 | import androidx.compose.ui.window.Dialog | |
| 60 | import 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 | |
| 84 | fun 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 | |
| 416 | private 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 | } |


