Separate Android full screen call video from controls and settings

This commit is contained in:
evgeny committed 2026-10-15 00:46:26 +02:00
1 parent 898154b2b6
commit 2db038beee
2 files changed
+135 -47

No files matched your search

@@ -25,11 +25,16 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.data.LogManager
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.data.CallPhase import com.utun.chat.data.CallPhase
import com.utun.chat.data.CallController
import kotlinx.coroutines.delay
private tailrec fun Context.findActivity(): Activity? = when (this) { private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this is Activity -> this
@@ -77,6 +84,7 @@ private fun CallControlButton(
label: String, label: String,
active: Boolean, active: Boolean,
activeColor: Color = Color(0xFF1F6FEB), activeColor: Color = Color(0xFF1F6FEB),
showLabel: Boolean = true,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
@@ -89,8 +97,10 @@ private fun CallControlButton(
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp)) Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
} }
} }
Spacer(Modifier.height(6.dp)) if (showLabel) {
Text(label, color = if (active) Color.White else Color(0xFFAEBCC8), fontSize = 11.sp) 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 duration = st?.durationSeconds ?: 0
val peerNodeId = st?.peerNodeId ?: 0L val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: "" 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 val locked = phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE && proximityNear
@@ -128,18 +147,19 @@ fun CallScreen(
BackHandler(enabled = locked) {} BackHandler(enabled = locked) {}
/* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */ /* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */
DisposableEffect(locked) { val hideBars = locked || (receiving && !controlsVisible)
DisposableEffect(hideBars) {
val act = context.findActivity() val act = context.findActivity()
val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) } 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?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller?.hide(WindowInsetsCompat.Type.systemBars()) 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 { onDispose {
if (locked) { if (hideBars) {
controller?.show(WindowInsetsCompat.Type.systemBars()) 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)), modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (phase == CallPhase.ACTIVE) video?.let {
CallVideoPlayback(it, onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
}
if (!receiving || controlsVisible) {
Column( Column(
modifier = Modifier.fillMaxWidth().statusBarsPadding().navigationBarsPadding() modifier = Modifier.fillMaxWidth().align(if (receiving) Alignment.BottomCenter else Alignment.Center)
.verticalScroll(rememberScrollState()).padding(top = 56.dp, bottom = 24.dp), .background(Color(0xFF0F1B22)).statusBarsPadding().navigationBarsPadding()
.verticalScroll(rememberScrollState()).padding(top = if (receiving) 12.dp else 56.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (phase == CallPhase.ACTIVE) CallVideoPanel() if (!receiving) {
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
@@ -186,6 +211,8 @@ fun CallScreen(
) )
} }
Spacer(Modifier.height(40.dp)) Spacer(Modifier.height(40.dp))
if (phase == CallPhase.ACTIVE) video?.let { CallVideoPreview(it) }
}
if (phase == CallPhase.ENDED) { if (phase == CallPhase.ENDED) {
Button(onClick = { viewModel.dismissCall(); onClose() }) { Button(onClick = { viewModel.dismissCall(); onClose() }) {
@@ -200,6 +227,7 @@ fun CallScreen(
contentDescription = stringResource(R.string.earpiece), contentDescription = stringResource(R.string.earpiece),
label = stringResource(R.string.earpiece), label = stringResource(R.string.earpiece),
active = route == AudioRoute.EARPIECE, active = route == AudioRoute.EARPIECE,
showLabel = !receiving,
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) } onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
) )
} }
@@ -208,6 +236,7 @@ fun CallScreen(
contentDescription = stringResource(R.string.speakerphone), contentDescription = stringResource(R.string.speakerphone),
label = stringResource(R.string.speaker_short), label = stringResource(R.string.speaker_short),
active = route == AudioRoute.SPEAKER, active = route == AudioRoute.SPEAKER,
showLabel = !receiving,
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) } onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
) )
if (headsetAvailable) { if (headsetAvailable) {
@@ -216,6 +245,7 @@ fun CallScreen(
contentDescription = stringResource(R.string.headset), contentDescription = stringResource(R.string.headset),
label = stringResource(R.string.headset), label = stringResource(R.string.headset),
active = route == AudioRoute.HEADSET, active = route == AudioRoute.HEADSET,
showLabel = !receiving,
onClick = { onClick = {
if (hasBtPermission) { if (hasBtPermission) {
viewModel.setAudioRoute(AudioRoute.HEADSET) viewModel.setAudioRoute(AudioRoute.HEADSET)
@@ -236,6 +266,7 @@ fun CallScreen(
contentDescription = stringResource(if (muted) R.string.microphone_enable else R.string.microphone_disable), 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), label = stringResource(if (muted) R.string.microphone_on_short else R.string.microphone_mute_short),
active = muted, active = muted,
showLabel = !receiving,
activeColor = Color(0xFFB32632), activeColor = Color(0xFFB32632),
onClick = { viewModel.toggleMute() } 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)) Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(36.dp))
} }
} }
Spacer(Modifier.height(6.dp)) if (!receiving) {
Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp) 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( 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)) 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) { if (locked) {
Box( Box(
@@ -2,74 +2,125 @@ package com.utun.chat.ui.screens
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.view.SurfaceHolder import android.content.Context
import android.view.SurfaceView import android.graphics.Matrix
import android.graphics.SurfaceTexture
import android.view.Surface
import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.utun.chat.data.CallController import com.utun.chat.data.CallController
import com.utun.chat.data.CallVideoBitrateRange import com.utun.chat.data.CallVideoBitrateRange
import com.utun.chat.data.CallVideoEngine
import com.utun.chat.data.CallVideoFormat
import com.utun.chat.R
@Composable @Composable
internal fun CallVideoPanel() { internal fun CallVideoPanel(video: CallVideoEngine) {
val engine by CallController.video.collectAsState()
val video = engine ?: return
val state by video.state.collectAsState() val state by video.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var denied by remember(video) { mutableStateOf(false) } var denied by remember(video) { mutableStateOf(false) }
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
denied = !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)) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
AndroidView(factory = { ctx -> SurfaceView(ctx).apply { if (state.status.isNotEmpty()) Text(state.status)
holder.addCallback(object : SurfaceHolder.Callback { if (denied) Text(stringResource(R.string.camera_permission_required), color = MaterialTheme.colorScheme.error)
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)) { 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 -> Switch(state.settings.enabled, enabled = state.ready && state.codecs.isNotEmpty(), onCheckedChange = { enabled ->
if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
CallController.setVideo(state.settings.copy(enabled = enabled)) CallController.setVideo(state.settings.copy(enabled = enabled))
else permission.launch(Manifest.permission.CAMERA) 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 camera = state.cameras[index]
val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first() 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)) 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() 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)) CallController.setVideo(state.settings.copy(width = sizes[i].width, height = sizes[i].height))
} }
val codecs = listOf(0) + state.codecs.sortedDescending() 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])) } 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 selected by remember(video) { mutableStateOf(state.range.minimum.toFloat()..state.range.maximum.toFloat()) }
var dragging by remember(video) { mutableStateOf(false) } var dragging by remember(video) { mutableStateOf(false) }
LaunchedEffect(state.range) { if (!dragging) selected = state.range.minimum.toFloat()..state.range.maximum.toFloat() } 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, RangeSlider(value = selected, onValueChange = { dragging = true; selected = it }, valueRange = 300f..4000f,
onValueChangeFinished = { dragging = false; video.range(CallVideoBitrateRange(selected.start.toInt(), selected.endInclusive.toInt())) }) 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 @Composable
private fun VideoChoice(label: String, values: List<String>, selected: Int, choose: (Int) -> Unit) { private fun VideoChoice(label: String, values: List<String>, selected: Int, choose: (Int) -> Unit) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }