Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom VideoSurface for the VideoPlayer example #3336

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) }
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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,
)
}