Hi all, im doing a project using compose/wasm. My ...
# multiplatform
m
Hi all, im doing a project using compose/wasm. My problem is i want to have a video player and a composable overlay (similar to UIKitView + AVPlayer with a composable overlay). I can draw a video player but i cant have anything interactable over it.
Copy code
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateObserver
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.round
import kotlinx.browser.document
import org.w3c.dom.Document
import org.w3c.dom.Element


val NoOpUpdate: Element.() -> Unit = {}

private class ComponentInfo<T : Element> {
    lateinit var container: Element
    lateinit var component: T
    lateinit var updater: Updater<T>
}


private class FocusSwitcher<T : Element>(
    private val info: ComponentInfo<T>,
    private val focusManager: FocusManager
) {
    private val backwardRequester = FocusRequester()
    private val forwardRequester = FocusRequester()
    private var isRequesting = false

    fun moveBackward() {
        try {
            isRequesting = true
            backwardRequester.requestFocus()
        } finally {
            isRequesting = false
        }
        focusManager.moveFocus(FocusDirection.Previous)
    }

    fun moveForward() {
        try {
            isRequesting = true
            forwardRequester.requestFocus()
        } finally {
            isRequesting = false
        }
        focusManager.moveFocus(FocusDirection.Next)
    }

    @Composable
    fun Content() {
        Box(
            Modifier
                .focusRequester(backwardRequester)
                .onFocusChanged {
                    if (it.isFocused && !isRequesting) {
                        focusManager.clearFocus(force = true)
                        val component = info.container.firstElementChild
                        if(component != null) {
                            requestFocus(component)
                        }else {
                            moveForward()
                        }
                    }
                }
                .focusTarget()
        )
        Box(
            Modifier
                .focusRequester(forwardRequester)
                .onFocusChanged {
                    if (it.isFocused && !isRequesting) {
                        focusManager.clearFocus(force = true)

                        val component = info.container.lastElementChild
                        if(component != null) {
                            requestFocus(component)
                        }else {
                            moveBackward()
                        }
                    }
                }
                .focusTarget()
        )
    }
}

private fun requestFocus(element: Element) : Unit = js("""
    {
        element.focus();
    }
""")

private fun initializingElement(element: Element) : Unit = js("""
    {
        element.style.position = 'absolute';
        element.style.margin = '0px';
    }
""")

private fun changeCoordinates(element: Element,width: Float,height: Float,x: Float,y: Float) : Unit = js("""
    {
        element.style.width = width + 'px';
        element.style.height = height + 'px';
        element.style.left = x + 'px';
        <http://element.style.top|element.style.top> = y + 'px';
    }
""")



@Composable
fun <T : Element> HtmlView(
    factory: Document.() -> T,
    modifier: Modifier = Modifier,
    update: (T) -> Unit = NoOpUpdate
) {
    val componentInfo = remember { ComponentInfo<T>() }
    val root = LocalLayerContainer.current
    val density = LocalDensity.current.density
    val focusManager = LocalFocusManager.current
    val focusSwitcher = remember { FocusSwitcher(componentInfo, focusManager) }

    Box(
        modifier = modifier.onGloballyPositioned { coordinates ->
            val location = coordinates.positionInWindow().round()
            val size = coordinates.size
            changeCoordinates(
                componentInfo.component,
                size.width / density,
                size.height / density,
                location.x / density,
                location.y / density
            )
        }
    ) {
        focusSwitcher.Content()
    }

    DisposableEffect(factory) {
        componentInfo.container = document.createElement("div")
        componentInfo.component = document.factory()
        root.insertBefore(componentInfo.container, root.firstChild)
        componentInfo.container.append(componentInfo.component)
        componentInfo.updater = Updater(componentInfo.component, update)
        initializingElement(componentInfo.component)
        onDispose {
            root.removeChild(componentInfo.container)
            componentInfo.updater.dispose()
        }
    }

    SideEffect {
        componentInfo.updater.update = update
    }
}


private class Updater<T : Element>(
    private val component: T,
    update: (T) -> Unit
) {
    private var isDisposed = false

    private val snapshotObserver = SnapshotStateObserver { command ->
        command()
    }

    private val scheduleUpdate = { _: T ->
        if(isDisposed.not()) {
            performUpdate()
        }
    }

    var update: (T) -> Unit = update
        set(value) {
            if (field != value) {
                field = value
                performUpdate()
            }
        }

    private fun performUpdate() {
        snapshotObserver.observeReads(component, scheduleUpdate) {
            update(component)
        }
    }

    init {
        snapshotObserver.start()
        performUpdate()
    }

    fun dispose() {
        snapshotObserver.stop()
        snapshotObserver.clear()
        isDisposed = true
    }
}


val LocalLayerContainer = staticCompositionLocalOf<Element> {
    document.body ?:
    error("CompositionLocal LayerContainer not provided")
    // you can replace this with document.body!!
}
This is the code i am using to draw html elements
f
m
Unfortunately, i can replicate my issue in this example. Make the column a Box and place the button below the video, the button is not clickable.
s
See this thread: https://kotlinlang.slack.com/archives/C01F2HV7868/p1735511627004419?thread_ts=1735511627.004419&cid=C01F2HV7868 We found a way to have a composable overlay on top of html interop, only if you don't need to interact with the HTML. You can interact with the overlay.
m
Thanks, after using your maven 0.5.1 dependency and replacing my HtmlView with your HtmlElement im getting this error:
Copy code
WebAssembly.instantiate(): Import #4924 "./skiko.mjs" "org_jetbrains_skia_Canvas__1nSaveLayerSaveLayerRecRect": function import requires a callable
LinkError: WebAssembly.instantiate(): Import #4924 "./skiko.mjs" "org_jetbrains_skia_Canvas__1nSaveLayerSaveLayerRecRect": function import requires a callable
Note: I have removed the
CompositionLocalProvider
Copy code
CompositionLocalProvider(LocalLayerContainer provides document.body!!) {
            App()
        }
Im extremely clueless about how to debug this.
s
What if you just add the zIndex trick to your old code?
m
any particular value?
Callsite looks like this:
Copy code
HtmlElement(
        modifier = modifier,
        zIndex = "0",
        factory = {
            playerProvider.player.apply {
                controls = showControls
            }
        }
    )
Perhaps my ChatGPT generated video player code is the culprit? I initially doubt it because it worked with the old
HtmlView
and i can’t see a connection between that code and the error.
s
I'm not sure if my (very experimental) library works with Wasm yet. I'm only using it with JS right now. If you read the thread, you can learn about the technique I'm using to make the Compose overlay work. It's essentially: • Use a negative zIndex to put the HTML behind the compose canvas • Render a transparent Box in Compose with blend mode clear to carve a window through the canvas to see the HTML Maybe you can use that technique with your old HtmlView code.
m
IT WORKS!!!
🎉 1
Ehrm, for anyone else wandering into this hole: this code + add this
Copy code
private fun initializingElement(element: Element, zIndex: String) : Unit = js(""" {
        element.style.position = 'absolute';
        element.style.margin = '0px';
        element.style.zIndex = zIndex;
    }
""")
Do you know why it works with “$zIndex” when element can be without string embedding?
s
Sorry, I don't understand your question
m
element as a parameter is not used, but i believe it is through some dsl magic. Does zIndex become an unused parameter on the Js side then?
A better phrased question: Why is element as a parameter unused?
s
Oh I see, it's only used inside the
js
block. The Kotlin compiler just sees that JS code as a string and doesn't know you've used the parameter. For zIndex, you're using a template string to put the .toString() of the variable directly into the JS code (which doesn't work). If you didn't use a template string, zIndex would appear unused too.
m
I realized that the code above does not work, because Js must be a constant
s
element.style.zIndex = zIndex
should work, without the templating
🎉 1
m
You are correct. Thanks alot!