diff --git a/build.gradle.kts b/build.gradle.kts index 07187d4..793a8f2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { } } + testImplementation(compose.materialIconsExtended) testImplementation(libs.bundles.kotest) testImplementation(libs.mockk) testImplementation(libs.logback.classic) diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/Player.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/Player.kt index 93c7f3f..47a15d7 100644 --- a/src/main/kotlin/dev/silenium/multimedia/compose/player/Player.kt +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/Player.kt @@ -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 @@ -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 { @@ -38,7 +39,12 @@ class VideoPlayer(hwdec: Boolean = false) : AutoCloseable { inline fun property(name: String): State = deferredFlowStateOf { mpv.propertyFlow(name) } @InternalMultimediaApi - suspend fun command(command: Array) = 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() @@ -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() } @@ -110,6 +110,7 @@ fun VideoSurface( player: VideoPlayer, showStats: Boolean = false, modifier: Modifier = Modifier, + onInitialized: () -> Unit = {}, ) { val surfaceState = rememberGLSurfaceState() @@ -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, ) diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/PlayerWithControls.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/PlayerWithControls.kt new file mode 100644 index 0000000..e3dd509 --- /dev/null +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/PlayerWithControls.kt @@ -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("seeking") + val paused by player.property("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 + } + ) + } +} diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerStats.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerStats.kt index 8a71a83..56f0562 100644 --- a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerStats.kt +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerStats.kt @@ -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", diff --git a/src/main/kotlin/dev/silenium/multimedia/mpv/MPV.kt b/src/main/kotlin/dev/silenium/multimedia/mpv/MPV.kt index a2a2788..54586a0 100644 --- a/src/main/kotlin/dev/silenium/multimedia/mpv/MPV.kt +++ b/src/main/kotlin/dev/silenium/multimedia/mpv/MPV.kt @@ -249,6 +249,9 @@ class MPV : NativeCleanable, MPVAsyncListener { fun command(command: Array) = 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): Result = suspendCancellableCoroutine { continuation -> val subscriptionId = commandReplyCallbackId.getAndIncrement() commandReplyCallbacks[subscriptionId] = { result -> diff --git a/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt b/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt index ed0e06f..0618237 100644 --- a/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt +++ b/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt @@ -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 @@ -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()) } }