Browse Source

Add Android call video display and live camera codec bitrate controls

master
evgeny 5 days ago
parent
commit
ba2a66341a
  1. 1
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  2. 82
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt

1
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

82
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<String>, 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) }) }
}
}
}
Loading…
Cancel
Save