From 3a7971d10d6057a2b3bf76d8b32735acd3cc27e7 Mon Sep 17 00:00:00 2001 From: Mahdi Hosseinzadeh <29678011+mahozad@users.noreply.github.com> Date: Thu, 20 Apr 2023 16:01:02 +0330 Subject: [PATCH] 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 --- .../compose/videoplayer/demo/Main.kt | 145 +++++++++++++++-- .../jvmMain/resources/enter-fullscreen.svg | 1 + .../src/jvmMain/resources/exit-fullscreen.svg | 1 + .../demo/src/jvmMain/resources/pause.svg | 1 + .../demo/src/jvmMain/resources/play.svg | 1 + .../demo/src/jvmMain/resources/speed.svg | 1 + .../demo/src/jvmMain/resources/volume.svg | 1 + .../compose/videoplayer/VideoPlayer.kt | 118 +++++++++++++- .../compose/videoplayer/DesktopVideoPlayer.kt | 146 +++++++++++++----- 9 files changed, 365 insertions(+), 50 deletions(-) create mode 100644 experimental/components/VideoPlayer/demo/src/jvmMain/resources/enter-fullscreen.svg create mode 100644 experimental/components/VideoPlayer/demo/src/jvmMain/resources/exit-fullscreen.svg create mode 100644 experimental/components/VideoPlayer/demo/src/jvmMain/resources/pause.svg create mode 100644 experimental/components/VideoPlayer/demo/src/jvmMain/resources/play.svg create mode 100644 experimental/components/VideoPlayer/demo/src/jvmMain/resources/speed.svg create mode 100644 experimental/components/VideoPlayer/demo/src/jvmMain/resources/volume.svg 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 7cdec64a32..e7ec6322cd 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,22 +1,145 @@ 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.window.WindowState import androidx.compose.ui.window.singleWindowApplication 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() { - singleWindowApplication( - title = "Video Player", - state = WindowState(width = 800.dp, height = 800.dp) - ) { + singleWindowApplication(title = "Video Player") { + // See https://github.com/JetBrains/compose-multiplatform/issues/2285 + window.minimumSize = Dimension(700, 560) MaterialTheme { - VideoPlayer( - url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", - width = 640, - height = 480 - ) + 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( + url = VIDEO_URL, + state = state, + 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()) + } + ) +} diff --git a/experimental/components/VideoPlayer/demo/src/jvmMain/resources/enter-fullscreen.svg b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/enter-fullscreen.svg new file mode 100644 index 0000000000..088bd892ff --- /dev/null +++ b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/enter-fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/experimental/components/VideoPlayer/demo/src/jvmMain/resources/exit-fullscreen.svg b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/exit-fullscreen.svg new file mode 100644 index 0000000000..2e9533f6d3 --- /dev/null +++ b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/exit-fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/experimental/components/VideoPlayer/demo/src/jvmMain/resources/pause.svg b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/pause.svg new file mode 100644 index 0000000000..bbc21ec85e --- /dev/null +++ b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/pause.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/experimental/components/VideoPlayer/demo/src/jvmMain/resources/play.svg b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/play.svg new file mode 100644 index 0000000000..7a4d37dbeb --- /dev/null +++ b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/experimental/components/VideoPlayer/demo/src/jvmMain/resources/speed.svg b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/speed.svg new file mode 100644 index 0000000000..b3986a4c54 --- /dev/null +++ b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/speed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/experimental/components/VideoPlayer/demo/src/jvmMain/resources/volume.svg b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/volume.svg new file mode 100644 index 0000000000..4ac55f74fd --- /dev/null +++ b/experimental/components/VideoPlayer/demo/src/jvmMain/resources/volume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/experimental/components/VideoPlayer/library/src/commonMain/kotlin/org/jetbrains/compose/videoplayer/VideoPlayer.kt b/experimental/components/VideoPlayer/library/src/commonMain/kotlin/org/jetbrains/compose/videoplayer/VideoPlayer.kt index 68007a12b2..d9858f81df 100644 --- a/experimental/components/VideoPlayer/library/src/commonMain/kotlin/org/jetbrains/compose/videoplayer/VideoPlayer.kt +++ b/experimental/components/VideoPlayer/library/src/commonMain/kotlin/org/jetbrains/compose/videoplayer/VideoPlayer.kt @@ -1,10 +1,120 @@ 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, width: Int, height: Int) { - VideoPlayerImpl(url, width, height) +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, + modifier: Modifier, + onFinish: (() -> Unit)? +) + +@Composable +fun rememberVideoPlayerState( + 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) + ) } -internal expect fun VideoPlayerImpl(url: String, width: Int, height: Int) +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 + + fun toggleResume() { + isResumed = !isResumed + } + + fun toggleFullscreen() { + isFullscreen = !isFullscreen + } + + fun stopPlayback() { + isResumed = false + } + + companion object { + /** + * The default [Saver] implementation for [VideoPlayerState]. + */ + fun Saver() = listSaver( + 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, + ) + } + ) + } +} 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 2c19741c02..c5f999c161 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,63 +1,139 @@ package org.jetbrains.compose.videoplayer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel 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.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 @Composable -internal actual fun VideoPlayerImpl(url: String, width: Int, height: Int) { - NativeDiscovery().discover() - val mediaPlayerComponent = remember { - // see https://github.com/caprica/vlcj/issues/887#issuecomment-503288294 for why we're using CallbackMediaPlayerComponent for macOS. - if (isMacOS()) { - CallbackMediaPlayerComponent() - } else { - EmbeddedMediaPlayerComponent() +internal actual fun VideoPlayerImpl( + url: String, + isResumed: Boolean, + volume: Float, + speed: Float, + seek: Float, + isFullscreen: Boolean, + progressState: MutableState, + 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() } } - SideEffect { - mediaPlayerComponent.mediaPlayer().media().play(url) + 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() + return if (isMacOS()) { + CallbackMediaPlayerComponent() + } else { + EmbeddedMediaPlayerComponent() } +} - DisposableEffect(Unit) { - onDispose { - mediaPlayerComponent.mediaPlayer().release() +/** + * We play the video again on finish (so the player is kind of idempotent), + * 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, - modifier = Modifier.fillMaxSize(), - factory = { - mediaPlayerComponent +/** + * Checks for and emits video progress every 50 milliseconds. + * Note that it seems vlcj updates the progress only every 250 milliseconds or so. + * + * 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) { + 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. - * The method names are same, but they don't share the same parent/interface. - * That's why need this method. + * 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 Any.mediaPlayer(): MediaPlayer { - return when (this) { - is CallbackMediaPlayerComponent -> mediaPlayer() - is EmbeddedMediaPlayerComponent -> mediaPlayer() - else -> throw IllegalArgumentException("You can only call mediaPlayer() on vlcj player component") - } +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 os.indexOf("mac") >= 0 || os.indexOf("darwin") >= 0 + val os = System + .getProperty("os.name", "generic") + .lowercase(Locale.ENGLISH) + return "mac" in os || "darwin" in os }