Browse Source
* 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 propertypull/3083/head
Mahdi Hosseinzadeh
2 years ago
committed by
GitHub
9 changed files with 365 additions and 50 deletions
@ -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()) |
||||||
} |
} |
||||||
|
) |
||||||
} |
} |
||||||
|
After Width: | Height: | Size: 921 B |
After Width: | Height: | Size: 923 B |
After Width: | Height: | Size: 395 B |
After Width: | Height: | Size: 193 B |
After Width: | Height: | Size: 393 B |
After Width: | Height: | Size: 472 B |
@ -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, |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
@ -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…
Reference in new issue