From 8eef506eed57525673d38c0fe030c4b05292b14e Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 17:41:12 +0200 Subject: [PATCH] Enable pinch zoom and bounded panning in Android live viewer --- .../chat/ui/screens/StreamViewerScreen.kt | 79 ++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt index 5eed1e29..c090b547 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt @@ -8,6 +8,7 @@ import android.view.Surface import android.view.TextureView import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -18,9 +19,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +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 import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -50,6 +55,8 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S var aspect by remember { mutableStateOf(0f) } var status by remember { mutableStateOf(R.string.stream_connecting) } var controls by remember { mutableStateOf(true) } + var hideTick by remember { mutableStateOf(0) } + val zoom = remember { VideoZoomState() } DisposableEffect(owner) { val observer = LifecycleEventObserver { _, event -> @@ -90,16 +97,38 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S } } LaunchedEffect(status) { controls = true } - LaunchedEffect(controls, status, visible) { + LaunchedEffect(controls, status, visible, hideTick) { if (controls && status == R.string.stream_live && visible) { delay(3000); controls = false } } LaunchedEffect(controls) { LogManager.addLog("DEBUG", "stream", "viewer controls source=$source visible=$controls") } + LaunchedEffect(zoom.scale, zoom.offset) { + delay(300) + LogManager.addLog("DEBUG", "stream", "viewer zoom source=$source scale=${zoom.scale} offset=${zoom.offset}") + } - Box(Modifier.fillMaxSize().background(Color.Black)) { - if (visible) StreamViewerPlayback(group, source, epoch, { aspect = it }, { status = it }) - Box(Modifier.matchParentSize().pointerInput(status) { - detectTapGestures { if (status == R.string.stream_live) controls = !controls else controls = true } - }) + BoxWithConstraints(Modifier.fillMaxSize().background(Color.Black).clipToBounds()) { + val ratio = aspect.takeIf { it > 0f } ?: 16f / 9f + val width = minOf(maxWidth, maxHeight * ratio) + val height = width / ratio + val density = LocalDensity.current + val videoSize = with(density) { Size(width.toPx(), height.toPx()) } + val viewport = with(density) { Size(maxWidth.toPx(), maxHeight.toPx()) } + LaunchedEffect(videoSize, viewport) { zoom.constrain(videoSize, viewport) } + if (visible) StreamViewerPlayback(group, source, epoch, { aspect = it }, { status = it }, + Modifier.size(width, height).align(Alignment.Center).graphicsLayer { + scaleX = zoom.scale; scaleY = zoom.scale + translationX = zoom.offset.x; translationY = zoom.offset.y + }) + Box(Modifier.matchParentSize() + .pointerInput(status) { + detectTapGestures { if (status == R.string.stream_live) controls = !controls else controls = true } + } + .pointerInput(videoSize, viewport) { + detectTransformGestures { centroid, pan, factor, _ -> + controls = true; hideTick++ + zoom.transform(centroid, pan, factor, videoSize, viewport) + } + }) if (controls) { Row(Modifier.fillMaxWidth().align(Alignment.TopCenter).background(Color.Black.copy(alpha = 0.65f)) .windowInsetsPadding(WindowInsets.safeDrawing).padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), @@ -117,33 +146,29 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S /** Смена размеров окна и скрытие controls сохраняют TextureView, decoder и сетевой reader. */ @Composable -private fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspect: (Float) -> Unit, onStatus: (Int) -> Unit) { +private fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspect: (Float) -> Unit, onStatus: (Int) -> Unit, + modifier: Modifier) { val engine = remember(group, source, epoch) { StreamPlayerEngine(group, source, epoch) } val aspect by engine.aspect.collectAsState() val status by engine.status.collectAsState() var surface by remember { mutableStateOf(null) } LaunchedEffect(aspect, status) { onAspect(aspect); onStatus(status) } DisposableEffect(engine) { onDispose { engine.stop() } } - BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val ratio = aspect.takeIf { it > 0f } ?: 16f / 9f - val width = minOf(maxWidth, maxHeight * ratio) - val height = width / ratio - AndroidView(factory = { context -> - TextureView(context).apply { - keepScreenOn = true - surfaceTextureListener = object : TextureView.SurfaceTextureListener { - override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { - surface = Surface(texture); engine.start(surface!!) - } - override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {} - override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { - engine.stop(); surface?.release(); surface = null; return true - } - override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {} + AndroidView(factory = { context -> + TextureView(context).apply { + keepScreenOn = true + surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { + surface = Surface(texture); engine.start(surface!!) } + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {} + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { + engine.stop(); surface?.release(); surface = null; return true + } + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {} } - }, modifier = Modifier.size(width, height), onRelease = { view -> - engine.stop(); view.surfaceTextureListener = null; surface?.release(); surface = null - }) - } + } + }, modifier = modifier, onRelease = { view -> + engine.stop(); view.surfaceTextureListener = null; surface?.release(); surface = null + }) }