From c39610f07990d5e663152abd4967bd783d5d868d Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 17:36:38 +0200 Subject: [PATCH] Share video zoom geometry between Android viewers --- .../utun/chat/ui/screens/VideoPlayerScreen.kt | 36 ++++--------- .../utun/chat/ui/screens/VideoZoomState.kt | 30 +++++++++++ .../chat/ui/screens/VideoZoomStateTest.kt | 51 +++++++++++++++++++ 3 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoZoomState.kt create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoZoomStateTest.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt index 6d16f539..f73512fd 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput @@ -67,12 +67,6 @@ private tailrec fun Context.findActivity(): Activity? = when (this) { else -> null } -private fun clampOffset(scale: Float, o: Offset, vW: Float, vH: Float, sW: Float, sH: Float): Offset { - val mx = ((vW * scale - sW) / 2f).coerceAtLeast(0f) - val my = ((vH * scale - sH) / 2f).coerceAtLeast(0f) - return Offset(o.x.coerceIn(-mx, mx), o.y.coerceIn(-my, my)) -} - @Composable fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { val player = remember(filePath) { MediaPlayer() } @@ -88,8 +82,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { var dragPosition by remember { mutableStateOf(0) } var wasPlayingBeforeDrag by remember { mutableStateOf(false) } var videoInfo by remember { mutableStateOf(null) } - var zoomScale by remember(filePath) { mutableStateOf(1f) } - var zoomOffset by remember(filePath) { mutableStateOf(Offset.Zero) } + val zoom = remember(filePath) { VideoZoomState() } var hideTick by remember { mutableStateOf(0) } val context = LocalContext.current @@ -290,8 +283,8 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { val videoH = with(density) { h.toPx() } /* re-clamp zoom offset when screen size changes (e.g. flip 180) */ - LaunchedEffect(screenW, screenH) { - zoomOffset = clampOffset(zoomScale, zoomOffset, videoW, videoH, screenW, screenH) + LaunchedEffect(screenW, screenH, videoW, videoH) { + zoom.constrain(Size(videoW, videoH), Size(screenW, screenH)) } AndroidView( @@ -317,10 +310,10 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } }, modifier = videoModifier.graphicsLayer { - scaleX = zoomScale - scaleY = zoomScale - translationX = zoomOffset.x - translationY = zoomOffset.y + scaleX = zoom.scale + scaleY = zoom.scale + translationX = zoom.offset.x + translationY = zoom.offset.y } ) @@ -344,18 +337,9 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) { } } .pointerInput(screenW, screenH, videoW, videoH) { - detectTransformGestures { centroid, pan, zoom, _ -> + detectTransformGestures { centroid, pan, factor, _ -> onInteraction() - val old = zoomScale - val new = (zoomScale * zoom).coerceIn(1f, 4f) - val z = new / old - val rel = centroid - Offset(screenW / 2f, screenH / 2f) - val raw = Offset( - zoomOffset.x * z + rel.x * (1f - z) + pan.x, - zoomOffset.y * z + rel.y * (1f - z) + pan.y - ) - zoomScale = new - zoomOffset = clampOffset(new, raw, videoW, videoH, screenW, screenH) + zoom.transform(centroid, pan, factor, Size(videoW, videoH), Size(screenW, screenH)) } } ) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoZoomState.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoZoomState.kt new file mode 100644 index 00000000..ad5d9fc3 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoZoomState.kt @@ -0,0 +1,30 @@ +package com.utun.chat.ui.screens + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size + +/** Общая геометрия увеличения для файла и live: точка под пальцами сохраняется, края ограничивают сдвиг. */ +internal class VideoZoomState { + var scale by mutableStateOf(1f) + private set + var offset by mutableStateOf(Offset.Zero) + private set + + fun transform(centroid: Offset, pan: Offset, factor: Float, video: Size, viewport: Size) { + val next = (scale * factor).coerceIn(1f, 4f) + val ratio = next / scale + val relative = centroid - Offset(viewport.width / 2f, viewport.height / 2f) + offset = offset * ratio + relative * (1f - ratio) + pan + scale = next + constrain(video, viewport) + } + + fun constrain(video: Size, viewport: Size) { + val x = ((video.width * scale - viewport.width) / 2f).coerceAtLeast(0f) + val y = ((video.height * scale - viewport.height) / 2f).coerceAtLeast(0f) + offset = Offset(offset.x.coerceIn(-x, x), offset.y.coerceIn(-y, y)) + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoZoomStateTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoZoomStateTest.kt new file mode 100644 index 00000000..c80c1051 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/VideoZoomStateTest.kt @@ -0,0 +1,51 @@ +package com.utun.chat.ui.screens + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import org.junit.Assert.assertEquals +import org.junit.Test + +class VideoZoomStateTest { + private val video = Size(1920f, 1080f) + private val viewport = Size(2400f, 1080f) + private val center = Offset(1200f, 540f) + + @Test fun pinchKeepsPointUnderFingers() { + val zoom = VideoZoomState() + zoom.transform(center + Offset(100f, 50f), Offset.Zero, 2f, video, viewport) + assertEquals(2f, zoom.scale) + assertEquals(Offset(-100f, -50f), zoom.offset) + } + + @Test fun panCannotExposeSpaceBeyondVideoEdges() { + val zoom = VideoZoomState() + zoom.transform(center, Offset(10000f, -10000f), 2f, video, viewport) + assertEquals(Offset(720f, -540f), zoom.offset) + } + + @Test fun returningToOneCentersVideoAndPreventsDragging() { + val zoom = VideoZoomState() + zoom.transform(center, Offset(300f, 300f), 3f, video, viewport) + zoom.transform(center, Offset(100f, 100f), 0.1f, video, viewport) + assertEquals(1f, zoom.scale) + assertEquals(Offset.Zero, zoom.offset) + } + + @Test fun zoomLimitUsesActualScaleForAnchoring() { + val zoom = VideoZoomState() + zoom.transform(center, Offset.Zero, 3f, video, viewport) + zoom.transform(center + Offset(300f, 0f), Offset.Zero, 2f, video, viewport) + assertEquals(4f, zoom.scale) + assertEquals(-100f, zoom.offset.x, 0.001f) + } + + @Test fun changingVideoOrWindowSizeClampsExistingOffset() { + val zoom = VideoZoomState() + zoom.transform(center, Offset(700f, 500f), 2f, video, viewport) + zoom.constrain(Size(600f, 1080f), viewport) + assertEquals(Offset(0f, 500f), zoom.offset) + zoom.constrain(Size(1080f, 607.5f), Size(1080f, 2400f)) + assertEquals(Offset.Zero, zoom.offset) + assertEquals(2f, zoom.scale) + } +}