Created
April 23, 2025 07:59
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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