Add Android call video display and live camera codec bitrate controls
This commit is contained in:
1 parent
a59ebcd6db
commit
ba2a66341a
2 files changed
+83
No files matched your search
@@ -153,6 +153,7 @@ fun CallScreen(
|
|||||||
.verticalScroll(rememberScrollState()).padding(top = 56.dp, bottom = 24.dp),
|
.verticalScroll(rememberScrollState()).padding(top = 56.dp, bottom = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
if (phase == CallPhase.ACTIVE) CallVideoPanel()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
|
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
|
|||||||
@@ -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) }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user