From 08cb7ec875bdc13af1f874dbc29ad90e2e113cc9 Mon Sep 17 00:00:00 2001 From: Drew Carlson Date: Mon, 10 Jul 2023 11:15:26 -0700 Subject: [PATCH] Add custom VideoSurface for the VideoPlayer example With this approach the Video can be properly displayed within your Compose UI and removes the need for a SwingPanel. --- .../compose/videoplayer/demo/Main.kt | 110 +++++++++--------- .../compose/videoplayer/DesktopVideoPlayer.kt | 94 +++++++-------- .../videoplayer/SkiaBitmapVideoSurface.kt | 78 +++++++++++++ 3 files changed, 179 insertions(+), 103 deletions(-) create mode 100644 experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/SkiaBitmapVideoSurface.kt diff --git a/experimental/components/VideoPlayer/demo/src/jvmMain/kotlin/org/jetbrains/compose/videoplayer/demo/Main.kt b/experimental/components/VideoPlayer/demo/src/jvmMain/kotlin/org/jetbrains/compose/videoplayer/demo/Main.kt index e7ec6322cd..84a22e2705 100644 --- a/experimental/components/VideoPlayer/demo/src/jvmMain/kotlin/org/jetbrains/compose/videoplayer/demo/Main.kt +++ b/experimental/components/VideoPlayer/demo/src/jvmMain/kotlin/org/jetbrains/compose/videoplayer/demo/Main.kt @@ -1,10 +1,12 @@ package org.jetbrains.compose.videoplayer.demo +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.singleWindowApplication @@ -43,68 +45,68 @@ fun main() { @Composable fun App() { val state = rememberVideoPlayerState() - /* - * Could not use a [Box] to overlay the controls on top of the video. - * See https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Swing_Integration - * Related issues: - * https://github.com/JetBrains/compose-multiplatform/issues/1521 - * https://github.com/JetBrains/compose-multiplatform/issues/2926 - */ - Column { + Box { VideoPlayer( url = VIDEO_URL, state = state, onFinish = state::stopPlayback, modifier = Modifier - .fillMaxWidth() - .height(400.dp) + .background(Color.Black) + .fillMaxSize() + .align(Alignment.Center) ) - Slider( - value = state.progress.value.fraction, - onValueChange = { state.seek = it }, - modifier = Modifier.fillMaxWidth() - ) - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .background(Color.White.copy(alpha = 0.7f)) ) { - Text("Timestamp: ${state.progress.value.timeMillis} ms", modifier = Modifier.width(180.dp)) - IconButton(onClick = state::toggleResume) { - Icon( - painter = painterResource("${if (state.isResumed) "pause" else "play"}.svg"), - contentDescription = "Play/Pause", - modifier = Modifier.size(32.dp) - ) - } - IconButton(onClick = state::toggleFullscreen) { - Icon( - painter = painterResource("${if (state.isFullscreen) "exit" else "enter"}-fullscreen.svg"), - contentDescription = "Toggle fullscreen", - modifier = Modifier.size(32.dp) - ) - } - Speed( - initialValue = state.speed, - modifier = Modifier.width(104.dp) + Slider( + value = state.progress.value.fraction, + onValueChange = { state.seek = it }, + modifier = Modifier.fillMaxWidth() + ) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - state.speed = it ?: state.speed - } - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource("volume.svg"), - contentDescription = "Volume", - modifier = Modifier.size(32.dp) - ) - // TODO: Make the slider change volume in logarithmic manner - // See https://www.dr-lex.be/info-stuff/volumecontrols.html - // and https://ux.stackexchange.com/q/79672/117386 - // and https://dcordero.me/posts/logarithmic_volume_control.html - Slider( - value = state.volume, - onValueChange = { state.volume = it }, - modifier = Modifier.width(100.dp) - ) + Text("Timestamp: ${state.progress.value.timeMillis} ms", modifier = Modifier.width(180.dp)) + IconButton(onClick = state::toggleResume) { + Icon( + painter = painterResource("${if (state.isResumed) "pause" else "play"}.svg"), + contentDescription = "Play/Pause", + modifier = Modifier.size(32.dp) + ) + } + IconButton(onClick = state::toggleFullscreen) { + Icon( + painter = painterResource("${if (state.isFullscreen) "exit" else "enter"}-fullscreen.svg"), + contentDescription = "Toggle fullscreen", + modifier = Modifier.size(32.dp) + ) + } + Speed( + initialValue = state.speed, + modifier = Modifier.width(104.dp) + ) { + state.speed = it ?: state.speed + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource("volume.svg"), + contentDescription = "Volume", + modifier = Modifier.size(32.dp) + ) + // TODO: Make the slider change volume in logarithmic manner + // See https://www.dr-lex.be/info-stuff/volumecontrols.html + // and https://ux.stackexchange.com/q/79672/117386 + // and https://dcordero.me/posts/logarithmic_volume_control.html + Slider( + value = state.volume, + onValueChange = { state.volume = it }, + modifier = Modifier.width(100.dp) + ) + } } } } diff --git a/experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/DesktopVideoPlayer.kt b/experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/DesktopVideoPlayer.kt index c5f999c161..8ecf41ec7e 100644 --- a/experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/DesktopVideoPlayer.kt +++ b/experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/DesktopVideoPlayer.kt @@ -1,21 +1,30 @@ package org.jetbrains.compose.videoplayer +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent -import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer -import java.awt.Component -import java.util.* import kotlin.math.roundToInt +// Same as MediaPlayerComponentDefaults.EMBEDDED_MEDIA_PLAYER_ARGS +private val PLAYER_ARGS = listOf( + "--video-title=vlcj video output", + "--no-snapshot-preview", + "--quiet", + "--intf=dummy" +) + @Composable internal actual fun VideoPlayerImpl( url: String, @@ -28,15 +37,20 @@ internal actual fun VideoPlayerImpl( modifier: Modifier, onFinish: (() -> Unit)? ) { - val mediaPlayerComponent = remember { initializeMediaPlayerComponent() } - val mediaPlayer = remember { mediaPlayerComponent.mediaPlayer() } + val mediaPlayerFactory = remember { MediaPlayerFactory(PLAYER_ARGS) } + val mediaPlayer = remember { + mediaPlayerFactory + .mediaPlayers() + .newEmbeddedMediaPlayer() + } + val surface = remember { + SkiaBitmapVideoSurface().also { + mediaPlayer.videoSurface().set(it) + } + } mediaPlayer.emitProgressTo(progressState) mediaPlayer.setupVideoFinishHandler(onFinish) - val factory = remember { { mediaPlayerComponent } } - /* OR the following code and using SwingPanel(factory = { factory }, ...) */ - // val factory by rememberUpdatedState(mediaPlayerComponent) - LaunchedEffect(url) { mediaPlayer.media().play/*OR .start*/(url) } LaunchedEffect(seek) { mediaPlayer.controls().setPosition(seek) } LaunchedEffect(speed) { mediaPlayer.controls().setRate(speed) } @@ -58,29 +72,29 @@ internal actual fun VideoPlayerImpl( mediaPlayer.fullScreen().toggle() } } - DisposableEffect(Unit) { onDispose(mediaPlayer::release) } - SwingPanel( - factory = factory, - background = Color.Transparent, - modifier = modifier - ) + DisposableEffect(Unit) { + onDispose { + mediaPlayer.release() + mediaPlayerFactory.release() + } + } + Box(modifier = modifier) { + surface.bitmap.value?.let { bitmap -> + Image( + bitmap, + modifier = Modifier + .background(Color.Transparent) + .fillMaxSize(), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + ) + } + } } private fun Float.toPercentage(): Int = (this * 100).roundToInt() -/** - * See https://github.com/caprica/vlcj/issues/887#issuecomment-503288294 - * for why we're using CallbackMediaPlayerComponent for macOS. - */ -private fun initializeMediaPlayerComponent(): Component { - NativeDiscovery().discover() - return if (isMacOS()) { - CallbackMediaPlayerComponent() - } else { - EmbeddedMediaPlayerComponent() - } -} - /** * We play the video again on finish (so the player is kind of idempotent), * unless the [onFinish] callback stops the playback. @@ -119,21 +133,3 @@ private fun MediaPlayer.emitProgressTo(state: MutableState) { } } } - -/** - * Returns [MediaPlayer] from player components. - * The method names are the same, but they don't share the same parent/interface. - * That's why we need this method. - */ -private fun Component.mediaPlayer() = when (this) { - is CallbackMediaPlayerComponent -> mediaPlayer() - is EmbeddedMediaPlayerComponent -> mediaPlayer() - else -> error("mediaPlayer() can only be called on vlcj player components") -} - -private fun isMacOS(): Boolean { - val os = System - .getProperty("os.name", "generic") - .lowercase(Locale.ENGLISH) - return "mac" in os || "darwin" in os -} diff --git a/experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/SkiaBitmapVideoSurface.kt b/experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/SkiaBitmapVideoSurface.kt new file mode 100644 index 0000000000..fdb0415fc1 --- /dev/null +++ b/experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/SkiaBitmapVideoSurface.kt @@ -0,0 +1,78 @@ +package org.jetbrains.compose.videoplayer + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import javax.swing.SwingUtilities + +internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) { + + private val videoSurface = SkiaBitmapVideoSurface() + private lateinit var imageInfo: ImageInfo + private lateinit var frameBytes: ByteArray + private val skiaBitmap: Bitmap = Bitmap() + private val composeBitmap = mutableStateOf(null) + + val bitmap: State = composeBitmap + + override fun attach(mediaPlayer: MediaPlayer) { + videoSurface.attach(mediaPlayer) + } + + private inner class SkiaBitmapBufferFormatCallback : BufferFormatCallback { + private var sourceWidth: Int = 0 + private var sourceHeight: Int = 0 + + override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { + this.sourceWidth = sourceWidth + this.sourceHeight = sourceHeight + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) { + frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) } + imageInfo = ImageInfo( + sourceWidth, + sourceHeight, + ColorType.BGRA_8888, + ColorAlphaType.PREMUL, + ) + } + } + + private inner class SkiaBitmapRenderCallback : RenderCallback { + override fun display( + mediaPlayer: MediaPlayer, + nativeBuffers: Array, + bufferFormat: BufferFormat, + ) { + SwingUtilities.invokeLater { + nativeBuffers[0].rewind() + nativeBuffers[0].get(frameBytes) + skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4) + composeBitmap.value = skiaBitmap.asComposeImageBitmap() + } + } + } + + private inner class SkiaBitmapVideoSurface : CallbackVideoSurface( + SkiaBitmapBufferFormatCallback(), + SkiaBitmapRenderCallback(), + true, + videoSurfaceAdapter, + ) +}