From 2ee3103c8c1f26a256aac37c6c99384baab84b2a Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 15:43:24 +0200 Subject: [PATCH] Avoid double sensor rotation in direct Android camera preview --- .../java/com/utun/chat/data/StreamPreview.kt | 6 +- .../com/utun/chat/ui/screens/StreamScreen.kt | 62 +++++++++++++------ .../com/utun/chat/data/StreamPreviewTest.kt | 18 ++++-- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPreview.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPreview.kt index 924dc610..841a66ba 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPreview.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPreview.kt @@ -13,8 +13,10 @@ data class StreamPreviewCamera(val sensor: Int, val front: Boolean) { data class StreamPreviewFormat(val width: Int, val height: Int, val camera: StreamPreviewCamera, val realtimeTimestamp: Boolean = false) { fun aspect(displayDegrees: Int): Float = if (camera.rotation(displayDegrees) % 180 == 0) width.toFloat() / height else height.toFloat() / width - /** Углы TextureView после поворота; размеры view уже учитывают поворот сенсора. */ - fun corners(displayDegrees: Int): FloatArray = when (camera.rotation(displayDegrees)) { + /** TextureView уже учитывает SENSOR_ORIENTATION; остаётся компенсация поворота экрана. */ + fun textureRotation(displayDegrees: Int): Int = (360 - displayDegrees) % 360 + + fun corners(displayDegrees: Int): FloatArray = when (textureRotation(displayDegrees)) { 90 -> floatArrayOf(1f, 0f, 1f, 1f, 0f, 1f, 0f, 0f) 180 -> floatArrayOf(1f, 1f, 0f, 1f, 0f, 0f, 1f, 0f) 270 -> floatArrayOf(0f, 1f, 0f, 0f, 1f, 0f, 1f, 1f) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt index 4db67579..53003fc2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -6,6 +6,9 @@ import android.content.pm.PackageManager import android.content.Context import android.graphics.Matrix import android.graphics.SurfaceTexture +import android.hardware.display.DisplayManager +import android.os.Handler +import android.os.Looper import android.os.SystemClock import android.view.Surface import android.view.TextureView @@ -69,6 +72,7 @@ import kotlinx.coroutines.withContext fun StreamScreen(channelId: String, channelName: String, sources: List, selected: StreamSource?, sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) { val context = LocalContext.current + val displayDegrees = StreamDisplayDegrees() val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L } val state by StreamController.state.collectAsState() val local = state?.takeIf { it.group == group } @@ -117,7 +121,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List, config: StreamCaptureConfig?, enabled: Boolean, +private fun CaptureOptions(cameras: List, config: StreamCaptureConfig?, enabled: Boolean, display: Int, onChange: (StreamCaptureConfig) -> Unit) { val mode = config?.mode Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -239,9 +243,6 @@ private fun CaptureOptions(cameras: List, config: StreamCaptureCon } } } - val context = LocalContext.current - val configuration = LocalConfiguration.current - val display = remember(configuration) { ((context as? Activity)?.windowManager?.defaultDisplay?.rotation ?: 0) * 90 } var expanded by remember { mutableStateOf(false) } Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { Box { @@ -295,7 +296,7 @@ private fun StreamRotationMark(top: Int) { } @Composable -private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: StreamPreview?) { +private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: StreamPreview?, displayDegrees: Int) { val owner = LocalLifecycleOwner.current var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } DisposableEffect(owner, localPreview) { @@ -307,17 +308,35 @@ private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: owner.lifecycle.addObserver(observer) onDispose { owner.lifecycle.removeObserver(observer); localPreview?.setVisible(false) } } - val context = LocalContext.current - val configuration = LocalConfiguration.current - val displayDegrees = if (localPreview != null) remember(configuration) { - ((context as? Activity)?.windowManager?.defaultDisplay?.rotation ?: Surface.ROTATION_0) * 90 - } else 0 if (visible) { if (localPreview != null) CameraPreviewSurface(localPreview, displayDegrees) else LiveStreamSurface(group, source, epoch) } } +/** DisplayListener замечает также 180°, когда Configuration и размеры окна не меняются. */ +@Composable +private fun StreamDisplayDegrees(): Int { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val display = (context as? Activity)?.windowManager?.defaultDisplay + var degrees by remember(display) { mutableStateOf((display?.rotation ?: 0) * 90) } + DisposableEffect(context, display, configuration) { + degrees = (display?.rotation ?: 0) * 90 + val manager = context.getSystemService(DisplayManager::class.java) + val listener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(id: Int) {} + override fun onDisplayRemoved(id: Int) {} + override fun onDisplayChanged(id: Int) { + if (id == display?.displayId) degrees = display.rotation * 90 + } + } + manager.registerDisplayListener(listener, Handler(Looper.getMainLooper())) + onDispose { manager.unregisterDisplayListener(listener) } + } + return degrees +} + @Composable private fun LiveStreamSurface(group: Long, source: Long, epoch: Long) { val engine = remember(group, source, epoch) { StreamPlayerEngine(group, source, epoch) } @@ -356,7 +375,7 @@ private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) { val state by preview.state.collectAsState() val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f Column(Modifier.fillMaxSize()) { - Text(stringResource(if (state.failed) R.string.stream_failed else if (state.active) R.string.stream_live else R.string.stream_connecting)) + Text(stringResource(if (state.failed) R.string.stream_preview_failed else if (state.active) R.string.stream_live else R.string.stream_connecting)) Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) { AndroidView(factory = { CameraPreviewView(it, preview) }, modifier = Modifier.matchParentSize(), @@ -375,13 +394,14 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre private var frames = 0L private var reportFrames = 0L private var reportNs = 0L + private var observedFormat: StreamPreviewFormat? = null init { keepScreenOn = true surfaceTextureListener = object : SurfaceTextureListener { override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { if (closed) return - target = preview.attach(texture); frames = 0; reportFrames = 0; reportNs = System.nanoTime() + target = preview.attach(texture); frames = 0; reportFrames = 0; reportNs = System.nanoTime(); observedFormat = null transform() } override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { transform() } @@ -393,6 +413,12 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre override fun onSurfaceTextureUpdated(texture: SurfaceTexture) { val timestamp = texture.timestamp if (timestamp == 0L) return // Очистка TextureView до первого кадра камеры. + if (format != observedFormat) { + val native = FloatArray(16); texture.getTransformMatrix(native) + LogManager.addLog("INFO", "video", "direct preview native transform sensor=${format?.camera?.sensor} " + + "front=${format?.camera?.front} native=${listOf(native[0], native[1], native[4], native[5])}") + observedFormat = format + } frames++ val now = System.nanoTime() if (frames == 1L || now - reportNs >= 1_000_000_000) { @@ -401,7 +427,7 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre LogManager.addLog(if (frames == 1L) "INFO" else "DEBUG", "video", "direct preview frame " + "target=${target?.let(System::identityHashCode)} frames=$frames fps=${"%.1f".format(fps)} " + "ageUs=$age timestampNs=$timestamp realtime=${format?.realtimeTimestamp} " + - "rotation=${format?.camera?.rotation(displayDegrees)}") + "sensor=${format?.camera?.sensor} display=$displayDegrees textureRotation=${format?.textureRotation(displayDegrees)}") reportNs = now; reportFrames = frames } } @@ -425,7 +451,7 @@ private class CameraPreviewView(context: Context, private val preview: StreamPre } setTransform(matrix) LogManager.addLog("DEBUG", "video", "direct preview transform view=${width}x$height buffer=${next.width}x${next.height} " + - "rotation=${next.camera.rotation(displayDegrees)} front=${next.camera.front}") + "sensor=${next.camera.sensor} display=$displayDegrees textureRotation=${next.textureRotation(displayDegrees)} front=${next.camera.front}") } fun close() { closed = true; keepScreenOn = false; target?.let(preview::detach) } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamPreviewTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamPreviewTest.kt index 6c7a9341..3ed5ada9 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamPreviewTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamPreviewTest.kt @@ -11,10 +11,20 @@ class StreamPreviewTest { assertEquals(16f / 9f, rear.aspect(90), 0f) assertEquals(9f / 16f, front.aspect(0), 0f) assertEquals(16f / 9f, front.aspect(90), 0f) - assertArrayEquals(floatArrayOf(1f, 0f, 1f, 1f, 0f, 1f, 0f, 0f), rear.corners(0), 0f) - assertArrayEquals(floatArrayOf(0f, 1f, 0f, 0f, 1f, 0f, 1f, 1f), front.corners(0), 0f) - assertArrayEquals(floatArrayOf(0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f), rear.corners(90), 0f) - assertArrayEquals(floatArrayOf(0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f), front.corners(90), 0f) + assertArrayEquals(floatArrayOf(0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f), rear.corners(0), 0f) + assertArrayEquals(rear.corners(0), front.corners(0), 0f) + assertArrayEquals(floatArrayOf(0f, 1f, 0f, 0f, 1f, 0f, 1f, 1f), rear.corners(90), 0f) + assertArrayEquals(rear.corners(90), front.corners(90), 0f) + } + + @Test fun textureViewNeverAppliesSensorRotationTwice() { + for (front in listOf(false, true)) for (sensor in listOf(0, 90, 180, 270)) { + val format = StreamPreviewFormat(1280, 720, StreamPreviewCamera(sensor, front)) + assertEquals(0, format.textureRotation(0)) + assertEquals(270, format.textureRotation(90)) + assertEquals(180, format.textureRotation(180)) + assertEquals(90, format.textureRotation(270)) + } } @Test fun allPreviewRotationsPreserveRectangleCorners() {