From 964c17f1bffc3e9fffd2f9bf315ea2ddd4403005 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 15 Oct 2026 01:52:38 +0200 Subject: [PATCH] Add Android call controls navigation and expandable video thumbnails --- .../com/utun/chat/ui/screens/CallScreen.kt | 84 +++++++++++-------- .../utun/chat/ui/screens/CallVideoPanel.kt | 52 +++++++++--- 2 files changed, 88 insertions(+), 48 deletions(-) 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 549d1874..7f7bd9e0 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 @@ -126,12 +126,22 @@ fun CallScreen( val video by CallController.video.collectAsState() val videoActive by CallController.videoActive.collectAsState() val remote = video?.display?.collectAsState()?.value + val local = video?.preview?.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 sending = phase == CallPhase.ACTIVE && local != null + var selectedVideo by rememberSaveable(st?.callId) { mutableStateOf(CallVideoSource.REMOTE) } + val expandedVideo = selectedVideo?.takeIf { if (it == CallVideoSource.REMOTE) receiving else sending } + val viewingVideo = expandedVideo != null + val hasVideo = receiving || sending + val controlInsets = WindowInsets.systemBarsIgnoringVisibility.union(WindowInsets.displayCutout) + var controlsVisible by rememberSaveable(st?.callId) { mutableStateOf(true) } + var settingsVisible by rememberSaveable(st?.callId) { mutableStateOf(false) } + LaunchedEffect(expandedVideo) { + controlsVisible = true + LogManager.addLog("INFO", "call", "video presentation=${expandedVideo ?: "controls"} remote=$receiving local=$sending") + } + LaunchedEffect(viewingVideo, controlsVisible, settingsVisible) { + if (viewingVideo && controlsVisible && !settingsVisible) { delay(5000); controlsVisible = false } } val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true @@ -149,7 +159,7 @@ fun CallScreen( } } // Ориентация принадлежит экрану звонка; краткое переподключение Surface не должно поворачивать его обратно. - DisposableEffect(video, phase == CallPhase.ACTIVE) { + DisposableEffect(video, phase == CallPhase.ACTIVE, selectedVideo) { val activity = context.findActivity(); val previous = activity?.requestedOrientation onDispose { if (activity != null && previous != null && activity.requestedOrientation != previous) { @@ -158,18 +168,19 @@ fun CallScreen( } } } - LaunchedEffect(video, phase, remote?.width, remote?.height, remote?.rotation) { - val format = remote + val rotated = remote?.rotation?.rem(180) != 0 + val videoWidth = if (expandedVideo == CallVideoSource.LOCAL) local?.width + else if (rotated) remote?.height else remote?.width + val videoHeight = if (expandedVideo == CallVideoSource.LOCAL) local?.height + else if (rotated) remote?.width else remote?.height + LaunchedEffect(video, phase, expandedVideo, videoWidth, videoHeight) { val activity = context.findActivity() - if (phase == CallPhase.ACTIVE && format != null && activity != null) { - val rotated = format.rotation % 180 != 0 - val width = if (rotated) format.height else format.width - val height = if (rotated) format.width else format.height - val orientation = if (width > height) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + if (viewingVideo && videoWidth != null && videoHeight != null && activity != null) { + val orientation = if (videoWidth > videoHeight) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT if (activity.requestedOrientation != orientation) { activity.requestedOrientation = orientation - LogManager.addLog("INFO", "call", "video screen orientation=$orientation display=${width}x$height rotation=${format.rotation}") + LogManager.addLog("INFO", "call", "video screen orientation=$orientation source=$expandedVideo display=${videoWidth}x$videoHeight") } } } @@ -184,9 +195,10 @@ fun CallScreen( /* Пока телефон у уха (proximity) — глотаем Back, чтобы не свернуть звонок. */ BackHandler(enabled = locked) {} + BackHandler(enabled = viewingVideo && !locked && !settingsVisible) { selectedVideo = null } /* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */ - val hideBars = locked || (receiving && !controlsVisible) + val hideBars = locked || (viewingVideo && !controlsVisible) DisposableEffect(hideBars) { val act = context.findActivity() val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) } @@ -208,16 +220,19 @@ fun CallScreen( contentAlignment = Alignment.Center ) { if (phase == CallPhase.ACTIVE) video?.let { - CallVideoPlayback(it, onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize()) + CallVideoStage(it, expandedVideo, onSelect = { source -> selectedVideo = source }, + onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize()) } - if (!receiving || controlsVisible) { - Column( - 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), + if (!viewingVideo || controlsVisible) { + if (!viewingVideo) Column( + modifier = Modifier.fillMaxWidth().align(Alignment.Center) + .windowInsetsPadding(controlInsets) + .padding(top = if (hasVideo) 224.dp else 56.dp) + .background(Color(0xFF0F1B22)).verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - if (!receiving) { + if (!viewingVideo) { Box( modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape), contentAlignment = Alignment.Center @@ -250,7 +265,6 @@ fun CallScreen( ) } Spacer(Modifier.height(40.dp)) - if (phase == CallPhase.ACTIVE) video?.let { CallVideoPreview(it) } } if (phase == CallPhase.ENDED) { @@ -266,7 +280,7 @@ fun CallScreen( contentDescription = stringResource(R.string.earpiece), label = stringResource(R.string.earpiece), active = route == AudioRoute.EARPIECE, - showLabel = !receiving, + showLabel = !viewingVideo, onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) } ) } @@ -275,7 +289,7 @@ fun CallScreen( contentDescription = stringResource(R.string.speakerphone), label = stringResource(R.string.speaker_short), active = route == AudioRoute.SPEAKER, - showLabel = !receiving, + showLabel = !viewingVideo, onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) } ) if (headsetAvailable) { @@ -284,7 +298,7 @@ fun CallScreen( contentDescription = stringResource(R.string.headset), label = stringResource(R.string.headset), active = route == AudioRoute.HEADSET, - showLabel = !receiving, + showLabel = !viewingVideo, onClick = { if (hasBtPermission) { viewModel.setAudioRoute(AudioRoute.HEADSET) @@ -305,7 +319,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, + showLabel = !viewingVideo, activeColor = Color(0xFFB32632), onClick = { viewModel.toggleMute() } ) @@ -319,7 +333,7 @@ fun CallScreen( Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(36.dp)) } } - if (!receiving) { + if (!viewingVideo) { Spacer(Modifier.height(6.dp)) Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp) } @@ -329,22 +343,20 @@ fun CallScreen( } IconButton( - onClick = onMinimize, + onClick = { if (viewingVideo) selectedVideo = null else onMinimize() }, modifier = Modifier .align(Alignment.TopStart) - .statusBarsPadding() + .windowInsetsPadding(controlInsets) .padding(8.dp) ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.minimize_call), tint = Color.White, modifier = Modifier.size(24.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(if (viewingVideo) R.string.call_controls else 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)) { + if (phase == CallPhase.ACTIVE) IconButton(onClick = { selectedVideo = null; settingsVisible = true }, + modifier = Modifier.align(Alignment.TopEnd).windowInsetsPadding(controlInsets).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)) } }, 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 5f1398f8..7e6bb93e 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 @@ -11,16 +11,19 @@ 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.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment 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.zIndex import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import com.utun.chat.data.CallController @@ -70,23 +73,48 @@ internal fun CallVideoPanel(video: CallVideoEngine) { } } -/** Texture остаётся подключённой при скрытии controls и до первого кадра; пустого видеоблока в layout нет. */ +internal enum class CallVideoSource { LOCAL, REMOTE } + +/** Один TextureView меняет только геометрию: переход в миниатюру не переподключает decoder. */ @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() }) +internal fun CallVideoStage(video: CallVideoEngine, expanded: CallVideoSource?, onSelect: (CallVideoSource) -> Unit, + onTap: () -> Unit, modifier: Modifier = Modifier) { + val remote by video.display.collectAsState() + val local by video.preview.collectAsState() + val insets = WindowInsets.systemBarsIgnoringVisibility.union(WindowInsets.displayCutout) + Box(modifier) { + // Слоты не меняются местами в composition; миниатюра рисуется поверх большого кадра. + for (source in CallVideoSource.entries) key(source) { + val large = expanded == source + val available = if (source == CallVideoSource.LOCAL) local != null else remote != null + Box(Modifier.matchParentSize().zIndex(if (large) 0f else 1f) + .then(if (large) Modifier else Modifier.windowInsetsPadding(insets).padding(top = 64.dp, end = 12.dp))) { + val frameModifier = if (large) Modifier.matchParentSize() else Modifier + .align(if (expanded != null) Alignment.TopEnd else Alignment.TopCenter) + .offset(x = if (expanded != null || local == null || remote == null) 0.dp + else if (source == CallVideoSource.LOCAL) (-60).dp else 60.dp) + .size(104.dp, 144.dp) + val description = stringResource(if (source == CallVideoSource.LOCAL) R.string.call_video_local else R.string.call_video_remote) + val click = { if (large) onTap() else onSelect(source) } + if (source == CallVideoSource.REMOTE) { + CallVideoPlayback(video, click, frameModifier, description) + } else if (available) { + Image(local!!.asImageBitmap(), contentDescription = description, contentScale = ContentScale.Fit, + modifier = frameModifier.clickable(onClick = click)) + } + } + } } } @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 fun CallVideoPlayback(video: CallVideoEngine, onTap: () -> Unit, modifier: Modifier, description: String) { + 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.contentDescription = description + view.setOnClickListener { if (format != null) onTap() }; view.isClickable = format != null }, onRelease = { it.close() }) } }