Skip to content

Commit

Permalink
feat: start working on a player composable with controls
Browse files Browse the repository at this point in the history
  • Loading branch information
silenium-dev committed Oct 10, 2024
1 parent 0da153a commit c178e57
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 34 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies {
}
}

testImplementation(compose.materialIconsExtended)
testImplementation(libs.bundles.kotest)
testImplementation(libs.mockk)
testImplementation(libs.logback.classic)
Expand Down
34 changes: 20 additions & 14 deletions src/main/kotlin/dev/silenium/multimedia/compose/player/Player.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package dev.silenium.multimedia.compose.player

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
Expand All @@ -16,6 +16,7 @@ import dev.silenium.multimedia.core.util.deferredFlowStateOf
import dev.silenium.multimedia.core.util.mapState
import dev.silenium.multimedia.mpv.MPV
import org.lwjgl.opengl.GL30.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

class VideoPlayer(hwdec: Boolean = false) : AutoCloseable {
Expand All @@ -38,7 +39,12 @@ class VideoPlayer(hwdec: Boolean = false) : AutoCloseable {
inline fun <reified T : Any> property(name: String): State<T?> = deferredFlowStateOf { mpv.propertyFlow(name) }

@InternalMultimediaApi
suspend fun command(command: Array<String>) = mpv.commandAsync(command)
suspend fun command(vararg command: String) = mpv.commandAsync(command.toList().toTypedArray())

suspend fun togglePause() = mpv.commandAsync("cycle", "pause")

suspend fun seekAbsolute(position: Duration) =
mpv.commandAsync(arrayOf("seek", position.inWholeMilliseconds.div(1000.0).toString(), "absolute"))

private fun createMPV(hwdec: Boolean = true): MPV {
val mpv = MPV()
Expand All @@ -53,29 +59,23 @@ class VideoPlayer(hwdec: Boolean = false) : AutoCloseable {
return mpv
}

context(GLDrawScope)
private fun initialize(state: GLSurfaceState) {
if (initialized) return
render = mpv.createRender(advancedControl = true, state::requestUpdate)
initialized = true
}

context(GLDrawScope)
fun onRender(state: GLSurfaceState) {
fun onRender(scope: GLDrawScope, state: GLSurfaceState) {
initialize(state)

glClearColor(0f, 0f, 0f, 0f)
glClear(GL_COLOR_BUFFER_BIT)
render?.render(fbo)?.getOrThrow()
redrawAfter(null)
}

fun stop() {
mpv.command("stop").getOrThrow()
render?.render(scope.fbo)?.getOrThrow()
scope.redrawAfter(null)
}

override fun close() {
stop()
mpv.command("stop")
render?.close()
mpv.close()
}
Expand Down Expand Up @@ -110,6 +110,7 @@ fun VideoSurface(
player: VideoPlayer,
showStats: Boolean = false,
modifier: Modifier = Modifier,
onInitialized: () -> Unit = {},
) {
val surfaceState = rememberGLSurfaceState()

Expand All @@ -126,14 +127,19 @@ fun VideoSurface(
}
}

Box(modifier = modifier) {
BoxWithConstraints(modifier = modifier) {
var initialized by remember { mutableStateOf(false) }
GLSurfaceView(
surfaceState,
modifier = Modifier.fillMaxSize(),
presentMode = GLSurfaceView.PresentMode.MAILBOX,
swapChainSize = 3,
draw = {
player.onRender(surfaceState)
player.onRender(this, surfaceState)
if (!initialized) {
initialized = true
onInitialized()
}
},
fboSizeOverride = fboSizeOverride,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package dev.silenium.multimedia.compose.player

import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.Slider
import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.unit.dp
import dev.silenium.multimedia.core.annotation.InternalMultimediaApi
import dev.silenium.multimedia.core.util.deferredFlowStateOf
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds

@OptIn(InternalMultimediaApi::class, ExperimentalComposeUiApi::class)
@Composable
fun VideoSurfaceWithControls(
player: VideoPlayer,
modifier: Modifier = Modifier,
showStats: Boolean = false,
onInitialized: () -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
BoxWithConstraints(modifier = modifier) {
var ready by remember { mutableStateOf(false) }
val loading by player.property<Boolean>("seeking")
val paused by player.property<Boolean>("pause")
val duration by deferredFlowStateOf(player::duration)
val position by deferredFlowStateOf(player::position)
var positionSlider: Float? by remember { mutableStateOf(null) }
val focus = remember { FocusRequester() }
var setSpeedJob: Job? by remember { mutableStateOf(null) }
var spedUp by remember { mutableStateOf(false) }
VideoSurface(
player, showStats,
onInitialized = {
onInitialized()
ready = true
},
modifier = Modifier.matchParentSize()
// .clickable(enabled = true, interactionSource = MutableInteractionSource(), indication = null) {
// coroutineScope.launch {
// player.togglePause()
// }
// }
.onPointerEvent(PointerEventType.Press) {
if (it.button == PointerButton.Primary) {
setSpeedJob = coroutineScope.launch {
delay(500)
spedUp = true
player.setProperty("speed", 2.0)
}
}
}
.onPointerEvent(PointerEventType.Release) {
if (it.button == PointerButton.Primary) {
setSpeedJob?.cancel()
if (spedUp) {
spedUp = false
setSpeedJob = null
coroutineScope.launch {
player.setProperty("speed", 1.0)
}
} else {
coroutineScope.launch {
player.togglePause()
}
}
}
}
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (it.key) {
Key.Spacebar -> {
println("${it.type}: ${it.key}")
coroutineScope.launch { player.togglePause() }
true
}

else -> false
}
}
.focusRequester(focus)
.focusable(enabled = true, interactionSource = MutableInteractionSource()),
)
if (paused == true && loading == false) {
Surface(
shape = CircleShape,
modifier = Modifier.align(Alignment.Center).size(48.dp),
color = Color.Black.copy(alpha = 0.25f),
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Paused",
tint = Color.White,
modifier = Modifier.fillMaxSize().padding(8.dp),
)
}
}
if (loading != false) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center).size(56.dp),
strokeWidth = 4.dp,
strokeCap = StrokeCap.Round,
)
}
Slider(
positionSlider ?: position?.inWholeMilliseconds?.div(1000.0f) ?: 0f,
valueRange = 0f..(duration?.inWholeMilliseconds?.div(1000.0f) ?: 0f),
modifier = Modifier.align(Alignment.BottomCenter),
onValueChange = { positionSlider = it },
onValueChangeFinished = {
positionSlider?.let {
coroutineScope.launch {
player.seekAbsolute(it.toDouble().seconds)
}
}
positionSlider = null
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ fun VideoPlayerStats(player: VideoPlayer, state: GLSurfaceState, textColor: Colo
Text("Duration: ${duration?.format() ?: "N/A"}", color = textColor)

val display by state.displayStatistics.collectAsState()
Text("Display datapoints: ${display.frameTimes.values.size}")
Text("Display datapoints: ${display.frameTimes.values.size}", color = textColor)
Text("Display frame time: ${display.frameTimes.median.inWholeMicroseconds / 1000.0} ms", color = textColor)
Text(
"Display frame time (99th): ${display.frameTimes.percentile(0.99).inWholeMicroseconds / 1000.0} ms",
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/dev/silenium/multimedia/mpv/MPV.kt
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ class MPV : NativeCleanable, MPVAsyncListener {

fun command(command: Array<String>) = commandN(nativePointer.address, command)
fun command(command: String) = commandStringN(nativePointer.address, command)

@JvmName("commandAsyncVararg")
suspend fun commandAsync(vararg command: String) = commandAsync(command.toList().toTypedArray())
suspend fun commandAsync(command: Array<String>): Result<Unit> = suspendCancellableCoroutine { continuation ->
val subscriptionId = commandReplyCallbackId.getAndIncrement()
commandReplyCallbacks[subscriptionId] = { result ->
Expand Down
35 changes: 16 additions & 19 deletions src/test/kotlin/dev/silenium/multimedia/compose/Main.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package dev.silenium.multimedia.compose

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.awaitApplication
import dev.silenium.multimedia.compose.player.VideoSurface
import dev.silenium.multimedia.compose.player.VideoSurfaceWithControls
import dev.silenium.multimedia.compose.player.rememberVideoPlayer
import dev.silenium.multimedia.core.annotation.InternalMultimediaApi
import kotlinx.coroutines.Dispatchers
Expand All @@ -16,41 +17,37 @@ import kotlinx.coroutines.withContext
import java.nio.file.Files
import kotlin.io.path.absolutePathString
import kotlin.io.path.outputStream
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Duration.Companion.milliseconds

@OptIn(InternalMultimediaApi::class)
@Composable
fun App() {
MaterialTheme {
val file1 = remember {
val file = remember {
val videoFile = Files.createTempFile("video", ".webm")
Thread.currentThread().contextClassLoader.getResourceAsStream("1080p.webm").use {
videoFile.outputStream().use(it::copyTo)
}
videoFile.apply { toFile().deleteOnExit() }
}
val file2 = remember {
val videoFile = Files.createTempFile("video", ".mp4")
Thread.currentThread().contextClassLoader.getResourceAsStream("1080p.mp4").use {
videoFile.outputStream().use(it::copyTo)
}
videoFile.apply { toFile().deleteOnExit() }
}
val player = rememberVideoPlayer()
var file by remember { mutableStateOf(file1) }
LaunchedEffect(Unit) {
delay(5.seconds)
while (isActive) {
file = if (file == file1) file2 else file1
delay(5.seconds)
}
var ready by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize()
) {
VideoSurfaceWithControls(
player, showStats = true, modifier = Modifier.fillMaxSize(),
onInitialized = {
ready = true
}
)
}
LaunchedEffect(file) {
withContext(Dispatchers.Default) {
player.command(arrayOf("loadfile", file.absolutePathString()))
while (!ready && isActive) delay(10.milliseconds)
player.command("loadfile", file.absolutePathString())
}
}
VideoSurface(player, showStats = true, modifier = Modifier.fillMaxSize())
}
}

Expand Down

0 comments on commit c178e57

Please sign in to comment.