From ba2a66341a0bc35a5c5a7dab1c093dbe43aeeb6a Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 22:45:34 +0200 Subject: [PATCH] Add Android call video display and live camera codec bitrate controls --- .../com/utun/chat/ui/screens/CallScreen.kt | 1 + .../utun/chat/ui/screens/CallVideoPanel.kt | 82 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index 8279b3fb..d0c6063b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -153,6 +153,7 @@ fun CallScreen( .verticalScroll(rememberScrollState()).padding(top = 56.dp, bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + if (phase == CallPhase.ACTIVE) CallVideoPanel() Box( modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape), contentAlignment = Alignment.Center diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt new file mode 100644 index 00000000..2759fbc8 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt @@ -0,0 +1,82 @@ +package com.utun.chat.ui.screens + +import android.Manifest +import android.content.pm.PackageManager +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.utun.chat.data.CallController +import com.utun.chat.data.CallVideoBitrateRange + +@Composable +internal fun CallVideoPanel() { + val engine by CallController.video.collectAsState() + val video = engine ?: return + val state by video.state.collectAsState() + val context = LocalContext.current + var denied by remember(video) { mutableStateOf(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + denied = !granted + if (granted && engine === video) CallController.setVideo(state.settings.copy(enabled = true)) + } + DisposableEffect(video) { onDispose { video.surface(null) } } + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + AndroidView(factory = { ctx -> SurfaceView(ctx).apply { + holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { video.surface(holder.surface) } + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { video.surface(holder.surface) } + override fun surfaceDestroyed(holder: SurfaceHolder) { video.surface(null) } + }) + } }, modifier = Modifier.fillMaxWidth().height(240.dp).background(Color.Black)) + Text(state.status, color = Color.White) + if (denied) Text("Camera permission is required to send video", color = MaterialTheme.colorScheme.error) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Send video", color = Color.White, modifier = Modifier.weight(1f).padding(top = 12.dp)) + Switch(state.settings.enabled, enabled = state.ready && state.codecs.isNotEmpty(), onCheckedChange = { enabled -> + if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) + CallController.setVideo(state.settings.copy(enabled = enabled)) + else permission.launch(Manifest.permission.CAMERA) + }) + } + VideoChoice("Camera", state.cameras.map { it.name }, state.cameras.indexOfFirst { it.id == state.settings.camera }) { index -> + val camera = state.cameras[index] + val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first() + CallController.setVideo(state.settings.copy(camera = camera.id, width = size.width, height = size.height)) + } + val sizes = state.cameras.firstOrNull { it.id == state.settings.camera }?.sizes.orEmpty() + VideoChoice("Resolution", sizes.map { "${it.width}×${it.height}" }, sizes.indexOfFirst { it.width == state.settings.width && it.height == state.settings.height }) { i -> + CallController.setVideo(state.settings.copy(width = sizes[i].width, height = sizes[i].height)) + } + val codecs = listOf(0) + state.codecs.sortedDescending() + VideoChoice("Codec", codecs.map { when (it) { 0 -> "Auto (H.265 → H.264)"; 2 -> "H.265"; else -> "H.264" } }, + codecs.indexOf(state.settings.codec)) { i -> CallController.setVideo(state.settings.copy(codec = codecs[i])) } + var selected by remember(video) { mutableStateOf(state.range.minimum.toFloat()..state.range.maximum.toFloat()) } + var dragging by remember(video) { mutableStateOf(false) } + LaunchedEffect(state.range) { if (!dragging) selected = state.range.minimum.toFloat()..state.range.maximum.toFloat() } + Text("Bitrate: ${selected.start.toInt()}–${selected.endInclusive.toInt()} kbit/s · ${state.bitrate / 1000} kbit/s", color = Color.White) + RangeSlider(value = selected, onValueChange = { dragging = true; selected = it }, valueRange = 300f..4000f, + onValueChangeFinished = { dragging = false; video.range(CallVideoBitrateRange(selected.start.toInt(), selected.endInclusive.toInt())) }) + } +} + +@Composable +private fun VideoChoice(label: String, values: List, selected: Int, choose: (Int) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { expanded = true }, enabled = values.isNotEmpty()) { Text("$label: ${values.getOrNull(selected).orEmpty()}") } + DropdownMenu(expanded, onDismissRequest = { expanded = false }) { + values.forEachIndexed { index, value -> DropdownMenuItem(text = { Text(value) }, onClick = { expanded = false; choose(index) }) } + } + } +}