Browse Source

Enable pinch zoom and bounded panning in Android live viewer

master
evgeny 5 days ago
parent
commit
8eef506eed
  1. 79
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt

79
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 android.view.TextureView
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@ -18,9 +19,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow 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 aspect by remember { mutableStateOf(0f) }
var status by remember { mutableStateOf(R.string.stream_connecting) } var status by remember { mutableStateOf(R.string.stream_connecting) }
var controls by remember { mutableStateOf(true) } var controls by remember { mutableStateOf(true) }
var hideTick by remember { mutableStateOf(0) }
val zoom = remember { VideoZoomState() }
DisposableEffect(owner) { DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@ -90,16 +97,38 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S
} }
} }
LaunchedEffect(status) { controls = true } 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 } 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(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)) { BoxWithConstraints(Modifier.fillMaxSize().background(Color.Black).clipToBounds()) {
if (visible) StreamViewerPlayback(group, source, epoch, { aspect = it }, { status = it }) val ratio = aspect.takeIf { it > 0f } ?: 16f / 9f
Box(Modifier.matchParentSize().pointerInput(status) { val width = minOf(maxWidth, maxHeight * ratio)
detectTapGestures { if (status == R.string.stream_live) controls = !controls else controls = true } 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) { if (controls) {
Row(Modifier.fillMaxWidth().align(Alignment.TopCenter).background(Color.Black.copy(alpha = 0.65f)) 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), .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. */ /** Смена размеров окна и скрытие controls сохраняют TextureView, decoder и сетевой reader. */
@Composable @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 engine = remember(group, source, epoch) { StreamPlayerEngine(group, source, epoch) }
val aspect by engine.aspect.collectAsState() val aspect by engine.aspect.collectAsState()
val status by engine.status.collectAsState() val status by engine.status.collectAsState()
var surface by remember { mutableStateOf<Surface?>(null) } var surface by remember { mutableStateOf<Surface?>(null) }
LaunchedEffect(aspect, status) { onAspect(aspect); onStatus(status) } LaunchedEffect(aspect, status) { onAspect(aspect); onStatus(status) }
DisposableEffect(engine) { onDispose { engine.stop() } } DisposableEffect(engine) { onDispose { engine.stop() } }
BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { AndroidView(factory = { context ->
val ratio = aspect.takeIf { it > 0f } ?: 16f / 9f TextureView(context).apply {
val width = minOf(maxWidth, maxHeight * ratio) keepScreenOn = true
val height = width / ratio surfaceTextureListener = object : TextureView.SurfaceTextureListener {
AndroidView(factory = { context -> override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
TextureView(context).apply { surface = Surface(texture); engine.start(surface!!)
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) {}
} }
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
} })
} }

Loading…
Cancel
Save