DomEthaPanel.kt
430 lines · 20.6 KB
Changed in: 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 | } |


