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

DomEthaPanel.kt

package dev.stave.ui.dometha

import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.SystemClock
import android.view.MotionEvent
import android.graphics.Color as AndroidColor
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
// Maintainer, 2026-10-29: imports for the Close control, load timeout and crash guards.
import android.webkit.RenderProcessGoneDetail
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.SurfaceRaised
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties

/**
 * Full-screen in-app panel hosting the Dom-Etha PWA (voice/CCA granular
 * controls) inside Earhorn.
 *
 * Why a WebView and not the browser: the kiosk focus choreography depends on
 * Earhorn being the RESUMED foreground app — hand-back can only fire, and is
 * only *allowed* to fire by the OS, while Earhorn holds the screen. Launching
 * an external browser would background Earhorn: hand-back could never run and
 * a stopped session would strand the screen in the browser. The panel keeps
 * Earhorn foreground; the caller additionally suppresses hand-back while the
 * panel is open so the screen is never yanked out from under someone using
 * the controls.
 *
 * Load-in UX (the small-then-flash problem on tall portrait screens): the
 * WebView starts INVISIBLE over a theme-black scrim with a spinner, and only
 * fades in after the renderer reports the post-layout frame via
 * [WebView.postVisualStateCallback] — i.e. after the media queries have
 * already resolved for the real viewport. The user never sees the mobile
 * fallback layout or the reflow jump.
 */
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun DomEthaPanel(
    url: String,
    onDismiss: () -> Unit,
) {
    Dialog(
        onDismissRequest = onDismiss,
        properties = DialogProperties(
            usePlatformDefaultWidth = false,
            dismissOnClickOutside = false,
        ),
    ) {
        var pageReady by remember { mutableStateOf(false) }
        var loadFailed by remember { mutableStateOf(false) }
        val alpha by animateFloatAsState(
            targetValue = if (pageReady) 1f else 0f,
            animationSpec = tween(220),
            label = "dometha-fade",
        )
        var webView by remember { mutableStateOf<WebView?>(null) }

        BackHandler { onDismiss() }

        // Maintainer, 2026-10-29: the spinner had no timeout — a page that never
        // finishes now counts as unreachable after 20 s (shows the Close path).
        LaunchedEffect(Unit) {
            kotlinx.coroutines.delay(20_000)
            if (!pageReady) {
                loadFailed = true
                // Maintainer, 2026-10-29: stop the load so a late page can't
                // fade in under the "unreachable" overlay.
                webView?.stopLoading()
            }
        }
        // Maintainer, 2026-10-29: visible Close controls (top-end always; another
        // under the failure text). Their focus state lets OK reach them past the
        // remote key handler; their bounds let the D-pad pointer tap them.
        var closeFocused by remember { mutableStateOf(false) }
        var failCloseFocused by remember { mutableStateOf(false) }
        var closeBounds by remember { mutableStateOf(Rect.Zero) }
        var failCloseBounds by remember { mutableStateOf(Rect.Zero) }
        // Panel origin in root coords: Close bounds are root-relative, the
        // pointer is panel-relative.
        var panelOrigin by remember { mutableStateOf(Offset.Zero) }
        val failCloseFocus = remember { FocusRequester() }
        LaunchedEffect(loadFailed) {
            if (loadFailed) runCatching { failCloseFocus.requestFocus() }
        }

        // ── D-pad virtual cursor (remote-only devices) ───────────
        // Dom-Etha is a touch-designed PWA; on boxes with no touchscreen a
        // remote gets a pointer instead: arrows move it (accelerating on
        // held keys), OK dispatches a REAL touch tap at its position so the
        // page needs zero cooperation, and pushing past an edge scrolls.
        // Touch devices skip all of this and behave as before. The proper
        // long-term fix is focusability inside Dom-Etha itself; this makes
        // any page usable meanwhile.
        val context = androidx.compose.ui.platform.LocalContext.current
        val remoteOnly = remember {
            !context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
        }
        // Native focus traversal is the DEFAULT on remotes: Dom-Etha's
        // controls are real <button>/<input type=range> elements (review,
        // 2026-08-29), so arrows move focus and adjust the volume slider
        // natively — which the cursor would otherwise swallow. Long-press
        // OK toggles the pointer for anything focus can't reach.
        var cursorMode by remember { mutableStateOf(false) }
        var modeFlash by remember { mutableStateOf<String?>(null) }
        var suppressCenterRelease by remember { mutableStateOf(false) }
        LaunchedEffect(modeFlash) {
            if (modeFlash != null) {
                kotlinx.coroutines.delay(1_200)
                modeFlash = null
            }
        }

        fun synthDpadCenter() {
            val wv = webView ?: return
            val t = SystemClock.uptimeMillis()
            wv.dispatchKeyEvent(android.view.KeyEvent(t, t,
                android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
            wv.dispatchKeyEvent(android.view.KeyEvent(t, t + 40,
                android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0))
        }
        var boxSize by remember { mutableStateOf(IntSize.Zero) }
        var cursor by remember { mutableStateOf(Offset.Zero) }
        LaunchedEffect(boxSize) {
            if (cursor == Offset.Zero && boxSize != IntSize.Zero) {
                cursor = Offset(boxSize.width / 2f, boxSize.height / 2f)
            }
        }
        val keyFocus = remember { FocusRequester() }
        LaunchedEffect(Unit) { runCatching { keyFocus.requestFocus() } }

        fun synthTap(x: Float, y: Float) {
            val wv = webView ?: return
            val t = SystemClock.uptimeMillis()
            val down = MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0)
            val up = MotionEvent.obtain(t, t + 60, MotionEvent.ACTION_UP, x, y, 0)
            wv.dispatchTouchEvent(down)
            wv.dispatchTouchEvent(up)
            down.recycle()
            up.recycle()
        }

        fun moveCursor(dx: Float, dy: Float) {
            val w = boxSize.width.toFloat().coerceAtLeast(1f)
            val h = boxSize.height.toFloat().coerceAtLeast(1f)
            var nx = cursor.x + dx
            var ny = cursor.y + dy
            // Pushing past an edge scrolls the page by the overflow.
            val wv = webView
            if (wv != null) {
                if (ny > h - 24) { wv.scrollBy(0, (ny - (h - 24)).toInt() * 3); ny = h - 24 }
                if (ny < 24) { wv.scrollBy(0, (ny - 24).toInt() * 3); ny = 24f }
                if (nx > w - 24) { wv.scrollBy((nx - (w - 24)).toInt() * 3, 0); nx = w - 24 }
                if (nx < 24) { wv.scrollBy((nx - 24).toInt() * 3, 0); nx = 24f }
            }
            cursor = Offset(nx.coerceIn(0f, w), ny.coerceIn(0f, h))
        }

        Box(
            Modifier
                .fillMaxSize()
                .background(Color.Black)
                .onSizeChanged { boxSize = it }
                // Maintainer, 2026-10-29: origin for pointer-over-Close hit tests.
                .onGloballyPositioned { panelOrigin = it.positionInRoot() }
                .focusRequester(keyFocus)
                .focusTarget()
                .onPreviewKeyEvent { ev ->
                    if (!remoteOnly) return@onPreviewKeyEvent false
                    // Maintainer, 2026-10-29: a focused Close control handles its
                    // own keys (OK clicks it) instead of being swallowed here.
                    if (closeFocused || failCloseFocused) return@onPreviewKeyEvent false
                    val isCenter = ev.key == Key.DirectionCenter || ev.key == Key.Enter
                    val isArrow = ev.key in setOf(
                        Key.DirectionLeft, Key.DirectionRight,
                        Key.DirectionUp, Key.DirectionDown,
                    )
                    if (ev.type != KeyEventType.KeyDown) {
                        // Key-up. Center is always ours: a long-press
                        // already toggled the mode and this release is
                        // swallowed; a short press acts now — synthetic tap
                        // in cursor mode, synthetic DPAD_CENTER (click the
                        // focused element) in native mode.
                        if (isCenter) {
                            if (suppressCenterRelease) {
                                suppressCenterRelease = false
                            } else if (cursorMode) {
                                // Maintainer, 2026-10-29: pointer over a Close
                                // control closes; anywhere else taps the page.
                                val pointer = cursor + panelOrigin
                                if (closeBounds.contains(pointer) ||
                                    (loadFailed && failCloseBounds.contains(pointer))
                                ) {
                                    onDismiss()
                                } else {
                                    synthTap(cursor.x, cursor.y)
                                }
                            } else {
                                synthDpadCenter()
                            }
                            return@onPreviewKeyEvent true
                        }
                        // Arrow key-ups: ours only in cursor mode.
                        return@onPreviewKeyEvent cursorMode && isArrow
                    }
                    // Key-down.
                    if (isCenter) {
                        if (ev.nativeKeyEvent.isLongPress) {
                            cursorMode = !cursorMode
                            modeFlash = if (cursorMode) "Pointer" else "Focus"
                            suppressCenterRelease = true
                        }
                        return@onPreviewKeyEvent true // decided on release
                    }
                    if (!cursorMode) return@onPreviewKeyEvent false // native arrows
                    if (!isArrow) return@onPreviewKeyEvent false    // BACK etc.
                    val repeat = ev.nativeKeyEvent.repeatCount
                    val step = (18f + (repeat * 6f)).coerceAtMost(64f)
                    when (ev.key) {
                        Key.DirectionLeft -> moveCursor(-step, 0f)
                        Key.DirectionRight -> moveCursor(step, 0f)
                        Key.DirectionUp -> moveCursor(0f, -step)
                        Key.DirectionDown -> moveCursor(0f, step)
                        else -> {}
                    }
                    true
                },
        ) {
            AndroidView(
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer { this.alpha = alpha },
                factory = { ctx ->
                    // Maintainer, 2026-10-29: a missing/updating WebView provider
                    // throws from the constructor — show the failure state with a
                    // placeholder View instead of crashing the app.
                    runCatching {
                    WebView(ctx).apply {
                        layoutParams = ViewGroup.LayoutParams(
                            ViewGroup.LayoutParams.MATCH_PARENT,
                            ViewGroup.LayoutParams.MATCH_PARENT,
                        )
                        // Black before content: even pre-fade, no white flash.
                        setBackgroundColor(AndroidColor.BLACK)
                        settings.javaScriptEnabled = true
                        settings.domStorageEnabled = true
                        settings.databaseEnabled = true
                        settings.mediaPlaybackRequiresUserGesture = false
                        webViewClient = object : WebViewClient() {
                            override fun onPageFinished(view: WebView, u: String) {
                                // Reveal only after the renderer has produced
                                // the post-layout visual state for the real
                                // viewport — media queries settled, no flash.
                                view.postVisualStateCallback(
                                    0,
                                    object : WebView.VisualStateCallback() {
                                        override fun onComplete(requestId: Long) {
                                            // Maintainer, 2026-10-29: never reveal after the timeout declared failure.
                                            if (!loadFailed) pageReady = true
                                        }
                                    },
                                )
                            }

                            override fun onReceivedError(
                                view: WebView,
                                request: WebResourceRequest,
                                error: android.webkit.WebResourceError,
                            ) {
                                if (request.isForMainFrame) loadFailed = true
                            }

                            // Maintainer, 2026-10-29: a killed/crashed renderer must
                            // not take the app down. Returning true keeps the app
                            // alive; the dead WebView is detached, destroyed and
                            // dropped so nothing touches it again.
                            override fun onRenderProcessGone(
                                view: WebView,
                                detail: RenderProcessGoneDetail,
                            ): Boolean {
                                loadFailed = true
                                if (webView === view) webView = null
                                (view.parent as? ViewGroup)?.removeView(view)
                                view.destroy()
                                return true
                            }
                        }
                        loadUrl(url)
                        webView = this
                    }
                    }.getOrElse {
                        loadFailed = true
                        android.view.View(ctx)
                    }
                },
            )

            // Maintainer, 2026-10-29: always-visible top-end Close (no way out
            // existed besides Back). Focusable for TV via clickable.
            DomEthaCloseButton(
                onClick = onDismiss,
                modifier = Modifier
                    .align(Alignment.TopEnd)
                    .padding(16.dp)
                    .onGloballyPositioned { closeBounds = it.boundsInRoot() }
                    .onFocusChanged { closeFocused = it.isFocused },
            )

            if (!pageReady && !loadFailed) {
                CircularProgressIndicator(Modifier.align(Alignment.Center))
            }
            if (modeFlash != null) {
                androidx.compose.material3.Text(
                    modeFlash!!,
                    color = Color.White,
                    modifier = Modifier.align(Alignment.TopCenter),
                )
            }
            if (remoteOnly && pageReady && cursorMode) {
                // High-contrast pointer: dark outline ring + light fill dot,
                // visible on any page background.
                Canvas(Modifier.fillMaxSize()) {
                    drawCircle(Color.Black.copy(alpha = 0.55f), radius = 16f, center = cursor)
                    drawCircle(Color.White, radius = 11f, center = cursor)
                    drawCircle(Color.Black, radius = 3.5f, center = cursor)
                }
            }
            if (loadFailed) {
                // Wall screens never get an error dialog; a dead panel just
                // closes on Back. Show nothing dramatic.
                // Maintainer, 2026-10-29: "press Back" replaced by a Close control
                // under the text (focused automatically for remotes).
                Column(
                    Modifier.align(Alignment.Center),
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    androidx.compose.material3.Text(
                        "Dom-Etha unreachable",
                        color = Color.White,
                    )
                    Spacer(Modifier.height(16.dp))
                    DomEthaCloseButton(
                        onClick = onDismiss,
                        modifier = Modifier
                            .onGloballyPositioned { failCloseBounds = it.boundsInRoot() }
                            .focusRequester(failCloseFocus)
                            .onFocusChanged { failCloseFocused = it.isFocused },
                    )
                }
            }
        }

        DisposableEffect(Unit) {
            onDispose {
                // WebViews leak if merely dereferenced; destroy explicitly.
                webView?.apply {
                    loadUrl("about:blank")
                    stopLoading()
                    destroy()
                }
                webView = null
            }
        }
    }
}

/** Maintainer, 2026-10-29: visible way out of the panel, built like
 *  CastPickerSheet's CloseButton — focusable on TV via clickable, amber
 *  focus border via staveFocusable. */
@Composable
private fun DomEthaCloseButton(onClick: () -> Unit, modifier: Modifier) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        modifier
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 16.dp, vertical = 10.dp),
    ) {
        androidx.compose.material3.Text(
            "✕  Close",
            color = Color.White,
        )
    }
}