Skip to content

Instantly share code, notes, and snippets.

@Skaldebane
Created April 23, 2025 07:59
Show Gist options
  • Save Skaldebane/7c222e590455629d492eb9dd2d45d18d to your computer and use it in GitHub Desktop.
Save Skaldebane/7c222e590455629d492eb9dd2d45d18d to your computer and use it in GitHub Desktop.
Compose/JS video player demo. This is just a prototype with huge performance issues, DO NOT USE. Based on code by Alex Styl, with some changes.
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import memoir.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.decodeToImageBitmap
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.HTMLVideoElement
import org.w3c.files.Blob
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.js.Promise
@OptIn(ExperimentalResourceApi::class)
@Composable
fun Demo() {
val videoState = remember {
val videoSource = Res.getUri("files/video.mp4")
println("URL of video = $videoSource")
VideoState(videoSource)
}
Box(Modifier.fillMaxSize().background(Color.Black).padding(40.dp)) {
VideoView(
videoState = videoState,
modifier = Modifier.fillMaxSize()
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.align(Alignment.BottomCenter).padding(10.dp)
) {
@Composable
fun Button(onClick: () -> Unit, label: String) {
Box(Modifier.background(Color.LightGray).clickable { onClick() }.padding(10.dp)) {
BasicText(label)
}
}
Button({ videoState.pause() }, label = "Pause")
Button({ videoState.play() }, label = "Play")
}
}
}
class VideoState(videoSource: String) {
var videoSource by mutableStateOf(videoSource)
var listener: ((Boolean) -> Unit)? = null
fun play() {
listener?.invoke(true)
}
fun pause() {
listener?.invoke(false)
}
}
@OptIn(ExperimentalResourceApi::class, DelicateCoroutinesApi::class)
@Composable
fun VideoView(modifier: Modifier = Modifier, videoState: VideoState) {
//var frameInBase64 by remember { mutableStateOf<String?>(null) }
// val bitmap = frameInBase64?.decodeBase64ToArray()?.decodeToImageBitmap()
var frameBytes by remember { mutableStateOf<ByteArray?>(null) }
val bitmap = frameBytes?.decodeToImageBitmap()
var videoReference: HTMLVideoElement? by remember { mutableStateOf(null) }
LaunchedEffect(videoState.videoSource) {
println("Video source changed: ${videoState.videoSource}")
val currentRef = videoReference
if (currentRef != null) {
currentRef.src = videoState.videoSource
currentRef.load()
}
}
DisposableEffect(Unit) {
// Create a hidden <video> element
val currentVideo = (document.createElement("video") as HTMLVideoElement).apply {
src = videoState.videoSource
// autoplay = true
loop = true
// muted = true
playsInline = true
style.display = "none"
document.body?.appendChild(this)
}
videoState.listener = { play ->
println("Got event play = ${play}")
if (play) {
videoReference?.play()
} else {
videoReference?.pause()
}
}
videoReference = currentVideo
// Create an off-screen <canvas>
val canvas = document.createElement("canvas") as HTMLCanvasElement
val ctx = canvas.getContext("2d") as CanvasRenderingContext2D
// Ensure the canvas matches the video size when loaded
currentVideo.onloadeddata = {
println("Video loaded")
canvas.width = currentVideo.videoWidth
canvas.height = currentVideo.videoHeight
}
fun captureFrame() {
ctx.drawImage(currentVideo, 0.0, 0.0, canvas.width.toDouble(), canvas.height.toDouble())
// val base64Image = canvas.toDataURL("image/png").substringAfter("data:image/png;base64,")
//frameInBase64 = base64Image
canvas.toBlob({
GlobalScope.launch {
frameBytes = it?.toByteArray()
}
})
}
val sixtyFramesPerSecondInMilis = 16
val interval = window.setInterval({
if (!currentVideo.paused && !currentVideo.ended) {
captureFrame()
}
null
}, sixtyFramesPerSecondInMilis)
onDispose {
window.clearInterval(interval)
currentVideo.remove()
}
}
if (bitmap == null) {
Box(modifier)
} else {
Image(
bitmap = bitmap,
modifier = modifier,
contentDescription = null,
)
}
}
suspend fun Blob.toByteArray(): ByteArray {
return suspendCoroutine { continuation ->
@Suppress("UnusedVariable", "unused")
val blob = this
val promise = js("blob.arrayBuffer()") as Promise<ArrayBuffer>
promise.then { arrayBuffer ->
val uint8Array = Uint8Array(arrayBuffer)
val byteArray = ByteArray(uint8Array.length) { uint8Array[it] }
continuation.resume(byteArray)
}.catch { error ->
continuation.resumeWithException(Throwable("Failed to read Blob: $error"))
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment