From b540abdec58c1eaca0accca03b53583d3af22f37 Mon Sep 17 00:00:00 2001 From: Silas Della Contrada Date: Mon, 14 Oct 2024 12:26:02 +0200 Subject: [PATCH] feat: scale video in Surface with controls, improve focus handling, add fullscreen handling --- .../multimedia/compose/player/VideoPlayer.kt | 3 + .../compose/player/VideoPlayerControls.kt | 5 +- .../compose/player/VideoSurfaceEvents.kt | 83 +++++++++++-------- .../player/VideoSurfaceWithControls.kt | 32 +++++-- .../multimedia/compose/player/VolumeSlider.kt | 4 +- .../compose/util/FullscreenProvider.kt | 22 +++++ .../dev/silenium/multimedia/compose/Main.kt | 56 +++++++++++-- 7 files changed, 149 insertions(+), 56 deletions(-) create mode 100644 src/main/kotlin/dev/silenium/multimedia/compose/util/FullscreenProvider.kt diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayer.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayer.kt index 351d991..a096978 100644 --- a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayer.kt +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayer.kt @@ -37,6 +37,7 @@ class VideoPlayer(hwdec: Boolean = false) : AutoCloseable { @InternalMultimediaApi suspend fun command(vararg command: String) = mpv.commandAsync(command.toList().toTypedArray()) + suspend fun toggleFullscreen() = mpv.commandAsync("cycle", "fullscreen") suspend fun togglePause() = mpv.commandAsync("cycle", "pause") suspend fun toggleMute() = mpv.commandAsync("cycle", "mute") suspend fun setVolume(volume: Long) = mpv.commandAsync("set", "volume", volume.toString()) @@ -69,6 +70,8 @@ class VideoPlayer(hwdec: Boolean = false) : AutoCloseable { fun onRender(scope: GLDrawScope, state: GLSurfaceState) { initialize(state) + // TODO: fix render block if screen is disconnected and reconnected + glClearColor(0f, 0f, 0f, 0f) glClear(GL_COLOR_BUFFER_BIT) render?.render(scope.fbo)?.getOrThrow() diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerControls.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerControls.kt index c2d874f..91a3325 100644 --- a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerControls.kt +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoPlayerControls.kt @@ -48,9 +48,9 @@ fun VideoSurfaceControls( Box(modifier = modifier) { Box( modifier = Modifier.matchParentSize() - .handleInputs(player) + .handleInputs(player, focus) .focusRequester(focus) - .focusable(enabled = true, interactionSource = MutableInteractionSource()) + .focusable(enabled = true, interactionSource = remember { MutableInteractionSource() }) ) StateIndicatorIcon(player, Modifier.align(Alignment.Center)) if (loading != false) { @@ -109,7 +109,6 @@ fun VideoSurfaceControls( onClick = { coroutineScope.launch { player.togglePause() - println("Paused: ${player.getProperty("pause")}") } }, modifier = Modifier.padding(horizontal = 4.dp), diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceEvents.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceEvents.kt index e685a91..6c5472b 100644 --- a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceEvents.kt +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceEvents.kt @@ -3,57 +3,72 @@ package dev.silenium.multimedia.compose.player import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester 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.input.pointer.pointerInput +import dev.silenium.multimedia.compose.util.LocalFullscreenProvider import dev.silenium.multimedia.core.annotation.InternalMultimediaApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalComposeUiApi::class, InternalMultimediaApi::class) @Composable -fun Modifier.handleInputs(player: VideoPlayer): Modifier { +fun Modifier.handleInputs(player: VideoPlayer, focusRequester: FocusRequester): Modifier { val coroutineScope = rememberCoroutineScope() var setSpeedJob: Job? by remember { mutableStateOf(null) } var spedUp by remember { mutableStateOf(false) } - return this - .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() + var lastRelease by remember { mutableStateOf(Instant.DISTANT_PAST) } + val fullscreenProvider = LocalFullscreenProvider.current + return this.pointerInput(player) { + awaitPointerEventScope { + val longPressTimeout = viewConfiguration.longPressTimeoutMillis.milliseconds + while (true) { + val event = awaitPointerEvent() + if (event.button == PointerButton.Primary) { + focusRequester.requestFocus() + if (event.type == PointerEventType.Press) { + setSpeedJob = coroutineScope.launch { + delay(longPressTimeout) + spedUp = true + player.setProperty("speed", 2.0) + } + } else if (event.type == PointerEventType.Release) { + setSpeedJob?.cancel() + if (spedUp) { + spedUp = false + setSpeedJob = null + coroutineScope.launch { + player.setProperty("speed", 1.0) + } + } else { + coroutineScope.launch { + player.togglePause() + } + } + val now = Clock.System.now() + if (lastRelease + longPressTimeout > now) { + fullscreenProvider.toggleFullscreen() + } + lastRelease = Clock.System.now() } } } } - .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 + }.onPreviewKeyEvent { + if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (it.key) { + Key.Spacebar -> { + coroutineScope.launch { player.togglePause() } + true } + + else -> false } + } } diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceWithControls.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceWithControls.kt index 0a59982..ff43300 100644 --- a/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceWithControls.kt +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/VideoSurfaceWithControls.kt @@ -1,9 +1,13 @@ package dev.silenium.multimedia.compose.player import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import dev.silenium.multimedia.core.annotation.InternalMultimediaApi +@OptIn(InternalMultimediaApi::class) @Composable fun VideoSurfaceWithControls( player: VideoPlayer, @@ -11,12 +15,26 @@ fun VideoSurfaceWithControls( showStats: Boolean = false, onInitialized: () -> Unit = {}, ) { - BoxWithConstraints(modifier = modifier) { - VideoSurface( - player, showStats, - onInitialized = onInitialized, - modifier = Modifier.matchParentSize(), - ) - VideoSurfaceControls(player, Modifier.matchParentSize()) + BoxWithConstraints(modifier) { + BoxWithConstraints( + modifier = Modifier.requiredSizeIn( + this@BoxWithConstraints.minWidth, + this@BoxWithConstraints.minHeight, + this@BoxWithConstraints.maxWidth, + this@BoxWithConstraints.maxHeight, + ) + ) { + VideoSurface( + player, showStats, + onInitialized = onInitialized, + modifier = Modifier.align(Alignment.Center).requiredSizeIn( + minWidth = minWidth, + minHeight = minHeight, + maxWidth = maxWidth, + maxHeight = maxHeight, + ), + ) + VideoSurfaceControls(player, Modifier.matchParentSize()) + } } } diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/player/VolumeSlider.kt b/src/main/kotlin/dev/silenium/multimedia/compose/player/VolumeSlider.kt index f7d75d3..985af7c 100644 --- a/src/main/kotlin/dev/silenium/multimedia/compose/player/VolumeSlider.kt +++ b/src/main/kotlin/dev/silenium/multimedia/compose/player/VolumeSlider.kt @@ -43,9 +43,7 @@ fun VolumeSlider(player: VideoPlayer, coroutineScope: CoroutineScope, modifier: IconButton( onClick = { coroutineScope.launch { - player.toggleMute().onFailure { - println("Failed to toggle mute: $it") - } + player.toggleMute() } }, modifier = Modifier.padding(horizontal = 4.dp), diff --git a/src/main/kotlin/dev/silenium/multimedia/compose/util/FullscreenProvider.kt b/src/main/kotlin/dev/silenium/multimedia/compose/util/FullscreenProvider.kt new file mode 100644 index 0000000..af1e608 --- /dev/null +++ b/src/main/kotlin/dev/silenium/multimedia/compose/util/FullscreenProvider.kt @@ -0,0 +1,22 @@ +package dev.silenium.multimedia.compose.util + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState + +class FullscreenProvider { + var isFullscreen by mutableStateOf(false) + private set + val windowState = WindowState() + + @Synchronized + fun toggleFullscreen() { + isFullscreen = !isFullscreen + windowState.placement = if (isFullscreen) WindowPlacement.Fullscreen else WindowPlacement.Floating + } +} + +val LocalFullscreenProvider = staticCompositionLocalOf { FullscreenProvider() } diff --git a/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt b/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt index 5a4bdb8..b5faec9 100644 --- a/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt +++ b/src/test/kotlin/dev/silenium/multimedia/compose/Main.kt @@ -2,17 +2,21 @@ package dev.silenium.multimedia.compose import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.material.darkColors import androidx.compose.material.lightColors import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.awaitApplication import dev.silenium.multimedia.compose.player.VideoSurfaceWithControls import dev.silenium.multimedia.compose.player.rememberVideoPlayer +import dev.silenium.multimedia.compose.util.LocalFullscreenProvider import dev.silenium.multimedia.core.annotation.InternalMultimediaApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -36,15 +40,48 @@ fun App() { } val player = rememberVideoPlayer() var ready by remember { mutableStateOf(false) } - Box( + BoxWithConstraints( modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background) ) { - VideoSurfaceWithControls( - player, showStats = true, modifier = Modifier.fillMaxSize(), - onInitialized = { - ready = true + val fullscreen = LocalFullscreenProvider.current.isFullscreen + val scroll = rememberScrollState() + Column( + modifier = Modifier.verticalScroll(scroll, !LocalFullscreenProvider.current.isFullscreen).fillMaxSize() + ) { + VideoSurfaceWithControls( + player = player, + modifier = Modifier.let { + when { + fullscreen -> it.size(this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight) + else -> it.requiredSizeIn( + this@BoxWithConstraints.minWidth, + this@BoxWithConstraints.minHeight, + this@BoxWithConstraints.maxWidth, + this@BoxWithConstraints.maxHeight, + ) + } + }, + showStats = true, + onInitialized = { + ready = true + } + ) + Text( + text = "This is a test video player", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.h6, + color = MaterialTheme.colors.onBackground + ) + } + var previousPosition by remember { mutableStateOf(0) } + LaunchedEffect(fullscreen) { + if (fullscreen) { + previousPosition = scroll.value + scroll.scrollTo(0) + } else { + scroll.scrollTo(previousPosition) } - ) + } } LaunchedEffect(file) { withContext(Dispatchers.Default) { @@ -56,7 +93,8 @@ fun App() { } suspend fun main(): Unit = awaitApplication { - Window(onCloseRequest = ::exitApplication) { + val state = LocalFullscreenProvider.current.windowState + Window(state = state, onCloseRequest = ::exitApplication) { App() } }