Browse Source

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.
pull/3336/head
Drew Carlson 1 year ago
parent
commit
08cb7ec875
  1. 110
      experimental/components/VideoPlayer/demo/src/jvmMain/kotlin/org/jetbrains/compose/videoplayer/demo/Main.kt
  2. 94
      experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/DesktopVideoPlayer.kt
  3. 78
      experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/SkiaBitmapVideoSurface.kt

110
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)
)
}
}
}
}

94
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<Progress>) {
}
}
}
/**
* 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
}

78
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<ImageBitmap?>(null)
val bitmap: State<ImageBitmap?> = 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<ByteBuffer>) {
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<ByteBuffer>,
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,
)
}
Loading…
Cancel
Save