Browse Source

Separate Android full screen call video from controls and settings

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

89
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt

@ -25,11 +25,16 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -48,6 +53,8 @@ import com.utun.chat.data.AudioRoute
import com.utun.chat.data.LogManager
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.data.CallPhase
import com.utun.chat.data.CallController
import kotlinx.coroutines.delay
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
@ -77,6 +84,7 @@ private fun CallControlButton(
label: String,
active: Boolean,
activeColor: Color = Color(0xFF1F6FEB),
showLabel: Boolean = true,
onClick: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -89,8 +97,10 @@ private fun CallControlButton(
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
}
}
Spacer(Modifier.height(6.dp))
Text(label, color = if (active) Color.White else Color(0xFFAEBCC8), fontSize = 11.sp)
if (showLabel) {
Spacer(Modifier.height(6.dp))
Text(label, color = if (active) Color.White else Color(0xFFAEBCC8), fontSize = 11.sp)
}
}
}
@ -111,6 +121,15 @@ fun CallScreen(
val duration = st?.durationSeconds ?: 0
val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
val video by CallController.video.collectAsState()
val remote = video?.display?.collectAsState()?.value
val receiving = phase == CallPhase.ACTIVE && remote != null
var controlsVisible by rememberSaveable { mutableStateOf(true) }
var settingsVisible by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(receiving) { controlsVisible = !receiving }
LaunchedEffect(receiving, controlsVisible) {
if (receiving && controlsVisible) { delay(5000); controlsVisible = false }
}
val locked = phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE && proximityNear
@ -128,18 +147,19 @@ fun CallScreen(
BackHandler(enabled = locked) {}
/* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */
DisposableEffect(locked) {
val hideBars = locked || (receiving && !controlsVisible)
DisposableEffect(hideBars) {
val act = context.findActivity()
val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) }
if (locked) {
if (hideBars) {
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller?.hide(WindowInsetsCompat.Type.systemBars())
LogManager.addLog("INFO", "CallScreen", "system bars hidden (proximity lock)")
LogManager.addLog("DEBUG", "CallScreen", "system bars hidden locked=$locked receiving=$receiving")
}
onDispose {
if (locked) {
if (hideBars) {
controller?.show(WindowInsetsCompat.Type.systemBars())
LogManager.addLog("INFO", "CallScreen", "system bars restored (proximity unlock)")
LogManager.addLog("DEBUG", "CallScreen", "system bars restored")
}
}
}
@ -148,12 +168,17 @@ fun CallScreen(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center
) {
if (phase == CallPhase.ACTIVE) video?.let {
CallVideoPlayback(it, onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
}
if (!receiving || controlsVisible) {
Column(
modifier = Modifier.fillMaxWidth().statusBarsPadding().navigationBarsPadding()
.verticalScroll(rememberScrollState()).padding(top = 56.dp, bottom = 24.dp),
modifier = Modifier.fillMaxWidth().align(if (receiving) Alignment.BottomCenter else Alignment.Center)
.background(Color(0xFF0F1B22)).statusBarsPadding().navigationBarsPadding()
.verticalScroll(rememberScrollState()).padding(top = if (receiving) 12.dp else 56.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (phase == CallPhase.ACTIVE) CallVideoPanel()
if (!receiving) {
Box(
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
@ -186,6 +211,8 @@ fun CallScreen(
)
}
Spacer(Modifier.height(40.dp))
if (phase == CallPhase.ACTIVE) video?.let { CallVideoPreview(it) }
}
if (phase == CallPhase.ENDED) {
Button(onClick = { viewModel.dismissCall(); onClose() }) {
@ -200,6 +227,7 @@ fun CallScreen(
contentDescription = stringResource(R.string.earpiece),
label = stringResource(R.string.earpiece),
active = route == AudioRoute.EARPIECE,
showLabel = !receiving,
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
)
}
@ -208,6 +236,7 @@ fun CallScreen(
contentDescription = stringResource(R.string.speakerphone),
label = stringResource(R.string.speaker_short),
active = route == AudioRoute.SPEAKER,
showLabel = !receiving,
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
)
if (headsetAvailable) {
@ -216,6 +245,7 @@ fun CallScreen(
contentDescription = stringResource(R.string.headset),
label = stringResource(R.string.headset),
active = route == AudioRoute.HEADSET,
showLabel = !receiving,
onClick = {
if (hasBtPermission) {
viewModel.setAudioRoute(AudioRoute.HEADSET)
@ -236,6 +266,7 @@ fun CallScreen(
contentDescription = stringResource(if (muted) R.string.microphone_enable else R.string.microphone_disable),
label = stringResource(if (muted) R.string.microphone_on_short else R.string.microphone_mute_short),
active = muted,
showLabel = !receiving,
activeColor = Color(0xFFB32632),
onClick = { viewModel.toggleMute() }
)
@ -249,25 +280,13 @@ fun CallScreen(
Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(36.dp))
}
}
Spacer(Modifier.height(6.dp))
Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp)
if (!receiving) {
Spacer(Modifier.height(6.dp))
Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp)
}
}
}
}
// Статистика под всеми кнопками звонка; обновляется событием ядра раз в секунду.
if (phase == CallPhase.ACTIVE) {
st?.stats?.let { stats ->
Spacer(Modifier.height(20.dp))
Text(
stringResource(R.string.call_stats, stats.bufferMs, stats.reserveMs, stats.minMs, stats.maxMs,
"%.2f".format(stats.tempoX100 / 100.0), stats.rttMs, stats.dropped, stats.underruns),
modifier = Modifier.padding(horizontal = 16.dp),
color = Color(0xFFAEBCC8),
fontSize = 12.sp,
textAlign = TextAlign.Center
)
}
}
}
IconButton(
@ -279,6 +298,24 @@ fun CallScreen(
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.minimize_call), tint = Color.White, modifier = Modifier.size(24.dp))
}
if (phase == CallPhase.ACTIVE) IconButton(onClick = { settingsVisible = true },
modifier = Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(8.dp)) {
Icon(Icons.Filled.Settings, stringResource(R.string.settings), tint = Color.White)
}
}
if (receiving) video?.let {
CallVideoPreview(it, Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(top = 64.dp, end = 12.dp))
}
if (settingsVisible && phase == CallPhase.ACTIVE && !locked) AlertDialog(
onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_settings)) },
confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
video?.let { CallVideoPanel(it) }
st?.stats?.let { stats -> Text(stringResource(R.string.call_stats, stats.bufferMs, stats.reserveMs,
stats.minMs, stats.maxMs, "%.2f".format(stats.tempoX100 / 100.0), stats.rttMs, stats.dropped, stats.underruns)) }
}
})
if (locked) {
Box(

93
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt

@ -2,74 +2,125 @@ package com.utun.chat.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.content.Context
import android.graphics.Matrix
import android.graphics.SurfaceTexture
import android.view.Surface
import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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
import com.utun.chat.data.CallVideoEngine
import com.utun.chat.data.CallVideoFormat
import com.utun.chat.R
@Composable
internal fun CallVideoPanel() {
val engine by CallController.video.collectAsState()
val video = engine ?: return
internal fun CallVideoPanel(video: CallVideoEngine) {
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))
if (granted && CallController.video.value === 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)
if (state.status.isNotEmpty()) Text(state.status)
if (denied) Text(stringResource(R.string.camera_permission_required), color = MaterialTheme.colorScheme.error)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Send video", color = Color.White, modifier = Modifier.weight(1f).padding(top = 12.dp))
Text(stringResource(R.string.send_video), 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 ->
VideoChoice(stringResource(R.string.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 ->
VideoChoice(stringResource(R.string.call_video_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" } },
VideoChoice(stringResource(R.string.call_video_codec), codecs.map { when (it) { 0 -> stringResource(R.string.call_video_auto); 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)
Text(stringResource(R.string.call_video_rate, selected.start.toInt(), selected.endInclusive.toInt(), state.bitrate / 1000))
RangeSlider(value = selected, onValueChange = { dragging = true; selected = it }, valueRange = 300f..4000f,
onValueChangeFinished = { dragging = false; video.range(CallVideoBitrateRange(selected.start.toInt(), selected.endInclusive.toInt())) })
}
}
/** Texture остаётся подключённой при скрытии controls и до первого кадра; пустого видеоблока в layout нет. */
@Composable
internal fun CallVideoPlayback(video: CallVideoEngine, onTap: () -> Unit, modifier: Modifier) {
val format by video.display.collectAsState()
key(video) {
AndroidView(factory = { CallVideoTexture(it, video) }, modifier = modifier,
update = { view -> view.format = format; view.alpha = if (format == null) 0f else 1f
view.setOnClickListener { if (format != null) onTap() } }, onRelease = { it.close() })
}
}
@Composable
internal fun CallVideoPreview(video: CallVideoEngine, modifier: Modifier = Modifier) {
val bitmap by video.preview.collectAsState()
bitmap?.let { frame ->
Image(frame.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit,
modifier = modifier.width(104.dp).aspectRatio(frame.width.toFloat() / frame.height))
}
}
private class CallVideoTexture(context: Context, private val video: CallVideoEngine) : TextureView(context) {
private var attached = false
private var closed = false
var format: CallVideoFormat? = null
set(value) { field = value; fit() }
init {
isOpaque = false
surfaceTextureListener = object : SurfaceTextureListener {
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
if (closed) return
val surface = Surface(texture); attached = true
// RX worker освобождает Surface/texture только после остановки MediaCodec.
video.surface(surface) { surface.release(); texture.release() }; fit()
}
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { fit() }
override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { closeOutput(); return false }
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {}
}
}
private fun fit() {
val f = format ?: return
if (width == 0 || height == 0) return
val rotated = f.rotation % 180 != 0
val w = if (rotated) f.height else f.width; val h = if (rotated) f.width else f.height
val scale = minOf(width.toFloat() / w, height.toFloat() / h)
setTransform(Matrix().apply { setScale(w * scale / width, h * scale / height, width / 2f, height / 2f) })
}
private fun closeOutput() { if (attached) { attached = false; video.surface(null) } }
fun close() { closed = true; closeOutput() }
}
@Composable
private fun VideoChoice(label: String, values: List<String>, selected: Int, choose: (Int) -> Unit) {
var expanded by remember { mutableStateOf(false) }

Loading…
Cancel
Save