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 d0c6063b..76241015 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 @@ -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( 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 index 2759fbc8..93f68073 100644 --- 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 @@ -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, selected: Int, choose: (Int) -> Unit) { var expanded by remember { mutableStateOf(false) }