Browse Source

Improve the experimental video player (#2906)

* Refactor video player`isMacOS` function

* Refactor video player `mediaPayer` extension function

* Re-implement the experimental video player

* Use icons instead of labels and buttons for video player controls

* Center video player controls

* Add a TODO to video player to make the slider logarithmic

* Add `rememberVideoPlayerState` function for video player

To simplify state management for the client

* Extend a comment with more info

* Improve a KDoc

* Specify video player minimum size for the window

* Use `VideoPlayerState` instead of explicit parameters for video player

* Reorder a statement in video player

* Use the implicit `it` instead of named lambda parameter

* Use `roundToInt` instead of `toInt` for converting a `Float` to percentage

* Update some documentation

* Remove a redundant trailing comma

* Add more comments about video player fullscreen option

* Add some functions to VideoPlayerState

And use them in the code

* Add `ms` unit label to video player timestamp

* Remove a redundant import statement

* Simplify video player `produceProgress` function

* Extract video player URL as a constant

And add some docs about local files

* Add some comments

About placing controls on the video player which is NOT currently possible.

* Add video player minor code improvements

* Ensure video player volume icon and slider are aligned

* Make video player control icons smaller

* Add some comments for video player Progress class

* Improve video player speed input

* Extract a remember function call to an outer function

* Convert a lambda to method reference

* Update the video player component

* Convert video player progress from return value to state property
pull/3083/head
Mahdi Hosseinzadeh 2 years ago committed by GitHub
parent
commit
3a7971d10d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 141
      experimental/components/VideoPlayer/demo/src/jvmMain/kotlin/org/jetbrains/compose/videoplayer/demo/Main.kt
  2. 1
      experimental/components/VideoPlayer/demo/src/jvmMain/resources/enter-fullscreen.svg
  3. 1
      experimental/components/VideoPlayer/demo/src/jvmMain/resources/exit-fullscreen.svg
  4. 1
      experimental/components/VideoPlayer/demo/src/jvmMain/resources/pause.svg
  5. 1
      experimental/components/VideoPlayer/demo/src/jvmMain/resources/play.svg
  6. 1
      experimental/components/VideoPlayer/demo/src/jvmMain/resources/speed.svg
  7. 1
      experimental/components/VideoPlayer/demo/src/jvmMain/resources/volume.svg
  8. 118
      experimental/components/VideoPlayer/library/src/commonMain/kotlin/org/jetbrains/compose/videoplayer/VideoPlayer.kt
  9. 136
      experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/DesktopVideoPlayer.kt

141
experimental/components/VideoPlayer/demo/src/jvmMain/kotlin/org/jetbrains/compose/videoplayer/demo/Main.kt

@ -1,22 +1,145 @@
package org.jetbrains.compose.videoplayer.demo package org.jetbrains.compose.videoplayer.demo
import androidx.compose.material.MaterialTheme 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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.singleWindowApplication import androidx.compose.ui.window.singleWindowApplication
import org.jetbrains.compose.videoplayer.VideoPlayer import org.jetbrains.compose.videoplayer.VideoPlayer
import org.jetbrains.compose.videoplayer.rememberVideoPlayerState
import java.awt.Dimension
/**
* To play a local file, use a URL notation like this:
* ```kotlin
* const val VIDEO_URL = "file:///C:/Users/John/Desktop/example.mp4"
* ```
* Relative paths like this may also work (relative to subproject directory aka `demo/`):
* ```kotlin
* val VIDEO_URL = """file:///${Path("videos/example.mp4")}"""
* ```
* To package a video with the app distributable,
* see [this tutorial](https://github.com/JetBrains/compose-jb/tree/master/tutorials/Native_distributions_and_local_execution#adding-files-to-packaged-application)
* and then use a URL syntax like this:
* ```kotlin
* val VIDEO_URL = """file:///${Path(System.getProperty("compose.application.resources.dir")) / "example.mp4"}"""
* ```
*/
const val VIDEO_URL = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
fun main() { fun main() {
singleWindowApplication( singleWindowApplication(title = "Video Player") {
title = "Video Player", // See https://github.com/JetBrains/compose-multiplatform/issues/2285
state = WindowState(width = 800.dp, height = 800.dp) window.minimumSize = Dimension(700, 560)
) {
MaterialTheme { MaterialTheme {
App()
}
}
}
@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 {
VideoPlayer( VideoPlayer(
url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", url = VIDEO_URL,
width = 640, state = state,
height = 480 onFinish = state::stopPlayback,
modifier = Modifier
.fillMaxWidth()
.height(400.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()
) {
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)
)
}
}
}
}
/**
* See [this Stack Overflow post](https://stackoverflow.com/a/67765652).
*/
@Composable
fun Speed(
initialValue: Float,
modifier: Modifier = Modifier,
onChange: (Float?) -> Unit
) {
var input by remember { mutableStateOf(initialValue.toString()) }
OutlinedTextField(
value = input,
modifier = modifier,
singleLine = true,
leadingIcon = {
Icon(
painter = painterResource("speed.svg"),
contentDescription = "Speed",
modifier = Modifier.size(28.dp)
) )
},
onValueChange = {
input = if (it.isEmpty()) {
it
} else if (it.toFloatOrNull() == null) {
input // Old value
} else {
it // New value
} }
onChange(input.toFloatOrNull())
} }
)
} }

1
experimental/components/VideoPlayer/demo/src/jvmMain/resources/enter-fullscreen.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path d="M230 856q-12.75 0-21.375-8.625T200 826V693q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T260 693v103h103q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T363 856H230Zm-.175-367Q217 489 208.5 480.375T200 459V326q0-12.75 8.625-21.375T230 296h133q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T363 356H260v103q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625ZM597 856q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T597 796h103V693q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T760 693v133q0 12.75-8.625 21.375T730 856H597Zm132.825-367Q717 489 708.5 480.375T700 459V356H597q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T597 296h133q12.75 0 21.375 8.625T760 326v133q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625Z"/></svg>

After

Width:  |  Height:  |  Size: 921 B

1
experimental/components/VideoPlayer/demo/src/jvmMain/resources/exit-fullscreen.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path d="M362.825 856Q350 856 341.5 847.375T333 826V723H230q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T230 663h133q12.75 0 21.375 8.625T393 693v133q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625ZM230 489q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T230 429h103V326q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T393 326v133q0 12.75-8.625 21.375T363 489H230Zm366.825 367Q584 856 575.5 847.375T567 826V693q0-12.75 8.625-21.375T597 663h133q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T730 723H627v103q0 12.75-8.675 21.375-8.676 8.625-21.5 8.625ZM597 489q-12.75 0-21.375-8.625T567 459V326q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T627 326v103h103q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T730 489H597Z"/></svg>

After

Width:  |  Height:  |  Size: 923 B

1
experimental/components/VideoPlayer/demo/src/jvmMain/resources/pause.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path d="M585 856q-24.75 0-42.375-17.625T525 796V356q0-24.75 17.625-42.375T585 296h115q24.75 0 42.375 17.625T760 356v440q0 24.75-17.625 42.375T700 856H585Zm-325 0q-24.75 0-42.375-17.625T200 796V356q0-24.75 17.625-42.375T260 296h115q24.75 0 42.375 17.625T435 356v440q0 24.75-17.625 42.375T375 856H260Z"/></svg>

After

Width:  |  Height:  |  Size: 395 B

1
experimental/components/VideoPlayer/demo/src/jvmMain/resources/play.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path d="M366 824q-15 10-30.5 1T320 798V348q0-18 15.5-27t30.5 1l354 226q14 9 14 25t-14 25L366 824Z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

1
experimental/components/VideoPlayer/demo/src/jvmMain/resources/speed.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path d="M421 716q24 24 62 23.5t56-27.5l180-271q7-11-1.5-19.5T698 420L427 600q-27 18-28.5 55t22.5 61ZM195 896q-17 0-33.5-8.5T137 863q-26-48-40-97.5T83 660q0-83 31.5-156.5t85.5-128Q254 321 326.5 289T481 257q82 0 154 32t126 86.5q54 54.5 85.5 128T878 660q0 56-13 107t-40 96q-12 23-25.5 28t-33.5 5H195Z"/></svg>

After

Width:  |  Height:  |  Size: 393 B

1
experimental/components/VideoPlayer/demo/src/jvmMain/resources/volume.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 96 960 960" width="48"><path d="M602 913q-16 5-29-5t-13-27q0-8 4.5-14.5T577 858q91-32 147-109t56-174q0-97-56-174.5T577 292q-8-2-12.5-9t-4.5-15q0-17 13.5-26.5T602 237q107 38 172.5 130.5T840 575q0 115-65.5 207.5T602 913ZM150 696q-13 0-21.5-8.5T120 666V486q0-13 8.5-21.5T150 456h130l149-149q14-14 32.5-6.5T480 328v496q0 20-18.5 27.5T429 845L280 696H150Zm390 48V407q54 17 87 64t33 105q0 59-33 105t-87 63Z"/></svg>

After

Width:  |  Height:  |  Size: 472 B

118
experimental/components/VideoPlayer/library/src/commonMain/kotlin/org/jetbrains/compose/videoplayer/VideoPlayer.kt

@ -1,10 +1,120 @@
package org.jetbrains.compose.videoplayer package org.jetbrains.compose.videoplayer
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
data class Progress(
val fraction: Float,
// TODO: Use kotlin.time.Duration when Kotlin version is updated.
// See https://github.com/Kotlin/api-guidelines/issues/6
val timeMillis: Long
)
@Composable
fun VideoPlayer(
url: String,
state: VideoPlayerState,
modifier: Modifier = Modifier,
onFinish: (() -> Unit)? = null
) = VideoPlayerImpl(
url = url,
isResumed = state.isResumed,
volume = state.volume,
speed = state.speed,
seek = state.seek,
isFullscreen = state.isFullscreen,
progressState = state._progress,
modifier = modifier,
onFinish = onFinish
)
internal expect fun VideoPlayerImpl(
url: String,
isResumed: Boolean,
volume: Float,
speed: Float,
seek: Float,
isFullscreen: Boolean,
progressState: MutableState<Progress>,
modifier: Modifier,
onFinish: (() -> Unit)?
)
@Composable @Composable
fun VideoPlayer(url: String, width: Int, height: Int) { fun rememberVideoPlayerState(
VideoPlayerImpl(url, width, height) seek: Float = 0f,
speed: Float = 1f,
volume: Float = 1f,
isResumed: Boolean = true,
isFullscreen: Boolean = false
): VideoPlayerState = rememberSaveable(saver = VideoPlayerState.Saver()) {
VideoPlayerState(
seek,
speed,
volume,
isResumed,
isFullscreen,
Progress(0f, 0)
)
}
class VideoPlayerState(
seek: Float = 0f,
speed: Float = 1f,
volume: Float = 1f,
isResumed: Boolean = true,
isFullscreen: Boolean = false,
progress: Progress
) {
var seek by mutableStateOf(seek)
var speed by mutableStateOf(speed)
var volume by mutableStateOf(volume)
var isResumed by mutableStateOf(isResumed)
var isFullscreen by mutableStateOf(isFullscreen)
internal val _progress = mutableStateOf(progress)
val progress: State<Progress> = _progress
fun toggleResume() {
isResumed = !isResumed
} }
internal expect fun VideoPlayerImpl(url: String, width: Int, height: Int) fun toggleFullscreen() {
isFullscreen = !isFullscreen
}
fun stopPlayback() {
isResumed = false
}
companion object {
/**
* The default [Saver] implementation for [VideoPlayerState].
*/
fun Saver() = listSaver<VideoPlayerState, Any>(
save = {
listOf(
it.seek,
it.speed,
it.volume,
it.isResumed,
it.isFullscreen,
it.progress.value
)
},
restore = {
VideoPlayerState(
seek = it[0] as Float,
speed = it[1] as Float,
volume = it[2] as Float,
isResumed = it[3] as Boolean,
isFullscreen = it[3] as Boolean,
progress = it[4] as Progress,
)
}
)
}
}

136
experimental/components/VideoPlayer/library/src/desktopMain/kotlin/org/jetbrains/compose/videoplayer/DesktopVideoPlayer.kt

@ -1,63 +1,139 @@
package org.jetbrains.compose.videoplayer package org.jetbrains.compose.videoplayer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.player.base.MediaPlayer 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.CallbackMediaPlayerComponent
import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
import java.awt.Component
import java.util.* import java.util.*
import kotlin.math.roundToInt
@Composable @Composable
internal actual fun VideoPlayerImpl(url: String, width: Int, height: Int) { internal actual fun VideoPlayerImpl(
url: String,
isResumed: Boolean,
volume: Float,
speed: Float,
seek: Float,
isFullscreen: Boolean,
progressState: MutableState<Progress>,
modifier: Modifier,
onFinish: (() -> Unit)?
) {
val mediaPlayerComponent = remember { initializeMediaPlayerComponent() }
val mediaPlayer = remember { mediaPlayerComponent.mediaPlayer() }
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) }
LaunchedEffect(volume) { mediaPlayer.audio().setVolume(volume.toPercentage()) }
LaunchedEffect(isResumed) { mediaPlayer.controls().setPause(!isResumed) }
LaunchedEffect(isFullscreen) {
if (mediaPlayer is EmbeddedMediaPlayer) {
/*
* To be able to access window in the commented code below,
* extend the player composable function from WindowScope.
* See https://github.com/JetBrains/compose-jb/issues/176#issuecomment-812514936
* and its subsequent comments.
*
* We could also just fullscreen the whole window:
* `window.placement = WindowPlacement.Fullscreen`
* See https://github.com/JetBrains/compose-multiplatform/issues/1489
*/
// mediaPlayer.fullScreen().strategy(ExclusiveModeFullScreenStrategy(window))
mediaPlayer.fullScreen().toggle()
}
}
DisposableEffect(Unit) { onDispose(mediaPlayer::release) }
SwingPanel(
factory = factory,
background = Color.Transparent,
modifier = modifier
)
}
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() NativeDiscovery().discover()
val mediaPlayerComponent = remember { return if (isMacOS()) {
// see https://github.com/caprica/vlcj/issues/887#issuecomment-503288294 for why we're using CallbackMediaPlayerComponent for macOS.
if (isMacOS()) {
CallbackMediaPlayerComponent() CallbackMediaPlayerComponent()
} else { } else {
EmbeddedMediaPlayerComponent() EmbeddedMediaPlayerComponent()
} }
} }
SideEffect {
mediaPlayerComponent.mediaPlayer().media().play(url)
}
DisposableEffect(Unit) { /**
onDispose { * We play the video again on finish (so the player is kind of idempotent),
mediaPlayerComponent.mediaPlayer().release() * unless the [onFinish] callback stops the playback.
* Using `mediaPlayer.controls().repeat = true` did not work as expected.
*/
@Composable
private fun MediaPlayer.setupVideoFinishHandler(onFinish: (() -> Unit)?) {
DisposableEffect(onFinish) {
val listener = object : MediaPlayerEventAdapter() {
override fun stopped(mediaPlayer: MediaPlayer) {
onFinish?.invoke()
mediaPlayer.controls().play()
}
}
events().addMediaPlayerEventListener(listener)
onDispose { events().removeMediaPlayerEventListener(listener) }
} }
} }
return SwingPanel( /**
background = Color.Transparent, * Checks for and emits video progress every 50 milliseconds.
modifier = Modifier.fillMaxSize(), * Note that it seems vlcj updates the progress only every 250 milliseconds or so.
factory = { *
mediaPlayerComponent * Instead of using `Unit` as the `key1` for [LaunchedEffect],
* we could use `media().info()?.mrl()` if it's needed to re-launch
* the effect (for whatever reason) when the url (aka video) changes.
*/
@Composable
private fun MediaPlayer.emitProgressTo(state: MutableState<Progress>) {
LaunchedEffect(key1 = Unit) {
while (isActive) {
val fraction = status().position()
val time = status().time()
state.value = Progress(fraction, time)
delay(50)
}
} }
)
} }
/** /**
* To return mediaPlayer from player components. * Returns [MediaPlayer] from player components.
* The method names are same, but they don't share the same parent/interface. * The method names are the same, but they don't share the same parent/interface.
* That's why need this method. * That's why we need this method.
*/ */
private fun Any.mediaPlayer(): MediaPlayer { private fun Component.mediaPlayer() = when (this) {
return when (this) {
is CallbackMediaPlayerComponent -> mediaPlayer() is CallbackMediaPlayerComponent -> mediaPlayer()
is EmbeddedMediaPlayerComponent -> mediaPlayer() is EmbeddedMediaPlayerComponent -> mediaPlayer()
else -> throw IllegalArgumentException("You can only call mediaPlayer() on vlcj player component") else -> error("mediaPlayer() can only be called on vlcj player components")
}
} }
private fun isMacOS(): Boolean { private fun isMacOS(): Boolean {
val os = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) val os = System
return os.indexOf("mac") >= 0 || os.indexOf("darwin") >= 0 .getProperty("os.name", "generic")
.lowercase(Locale.ENGLISH)
return "mac" in os || "darwin" in os
} }

Loading…
Cancel
Save