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 |
||||
|
||||
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 { |
||||
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 = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", |
||||
width = 640, |
||||
height = 480 |
||||
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()) |
||||
} |
||||
) |
||||
} |
||||
|
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 |
||||
|
||||
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<Progress>, |
||||
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> = _progress |
||||
|
||||
fun toggleResume() { |
||||
isResumed = !isResumed |
||||
} |
||||
|
||||
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 |
||||
|
||||
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) { |
||||
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() |
||||
val mediaPlayerComponent = remember { |
||||
// see https://github.com/caprica/vlcj/issues/887#issuecomment-503288294 for why we're using CallbackMediaPlayerComponent for macOS. |
||||
if (isMacOS()) { |
||||
return if (isMacOS()) { |
||||
CallbackMediaPlayerComponent() |
||||
} else { |
||||
EmbeddedMediaPlayerComponent() |
||||
} |
||||
} |
||||
SideEffect { |
||||
mediaPlayerComponent.mediaPlayer().media().play(url) |
||||
} |
||||
} |
||||
|
||||
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<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. |
||||
* 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) { |
||||
private fun Component.mediaPlayer() = when (this) { |
||||
is CallbackMediaPlayerComponent -> 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 { |
||||
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 |
||||
} |
||||
|
Loading…
Reference in new issue