Browse Source

Add Android call controls navigation and expandable video thumbnails

master
evgeny 4 days ago
parent
commit
964c17f1bf
  1. 84
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  2. 52
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt

84
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 video by CallController.video.collectAsState()
val videoActive by CallController.videoActive.collectAsState() val videoActive by CallController.videoActive.collectAsState()
val remote = video?.display?.collectAsState()?.value val remote = video?.display?.collectAsState()?.value
val local = video?.preview?.collectAsState()?.value
val receiving = phase == CallPhase.ACTIVE && remote != null val receiving = phase == CallPhase.ACTIVE && remote != null
var controlsVisible by rememberSaveable { mutableStateOf(true) } val sending = phase == CallPhase.ACTIVE && local != null
var settingsVisible by rememberSaveable { mutableStateOf(false) } var selectedVideo by rememberSaveable(st?.callId) { mutableStateOf<CallVideoSource?>(CallVideoSource.REMOTE) }
LaunchedEffect(receiving) { controlsVisible = !receiving } val expandedVideo = selectedVideo?.takeIf { if (it == CallVideoSource.REMOTE) receiving else sending }
LaunchedEffect(receiving, controlsVisible) { val viewingVideo = expandedVideo != null
if (receiving && controlsVisible) { delay(5000); controlsVisible = false } 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 val locked = phase == CallPhase.ACTIVE && !videoActive && route == AudioRoute.EARPIECE && proximityNear == true
@ -149,7 +159,7 @@ fun CallScreen(
} }
} }
// Ориентация принадлежит экрану звонка; краткое переподключение Surface не должно поворачивать его обратно. // Ориентация принадлежит экрану звонка; краткое переподключение Surface не должно поворачивать его обратно.
DisposableEffect(video, phase == CallPhase.ACTIVE) { DisposableEffect(video, phase == CallPhase.ACTIVE, selectedVideo) {
val activity = context.findActivity(); val previous = activity?.requestedOrientation val activity = context.findActivity(); val previous = activity?.requestedOrientation
onDispose { onDispose {
if (activity != null && previous != null && activity.requestedOrientation != previous) { if (activity != null && previous != null && activity.requestedOrientation != previous) {
@ -158,18 +168,19 @@ fun CallScreen(
} }
} }
} }
LaunchedEffect(video, phase, remote?.width, remote?.height, remote?.rotation) { val rotated = remote?.rotation?.rem(180) != 0
val format = remote 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() val activity = context.findActivity()
if (phase == CallPhase.ACTIVE && format != null && activity != null) { if (viewingVideo && videoWidth != null && videoHeight != null && activity != null) {
val rotated = format.rotation % 180 != 0 val orientation = if (videoWidth > videoHeight) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
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
else ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
if (activity.requestedOrientation != orientation) { if (activity.requestedOrientation != orientation) {
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, чтобы не свернуть звонок. */ /* Пока телефон у уха (proximity) — глотаем Back, чтобы не свернуть звонок. */
BackHandler(enabled = locked) {} BackHandler(enabled = locked) {}
BackHandler(enabled = viewingVideo && !locked && !settingsVisible) { selectedVideo = null }
/* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */ /* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */
val hideBars = locked || (receiving && !controlsVisible) val hideBars = locked || (viewingVideo && !controlsVisible)
DisposableEffect(hideBars) { 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) }
@ -208,16 +220,19 @@ fun CallScreen(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (phase == CallPhase.ACTIVE) video?.let { 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) { if (!viewingVideo || controlsVisible) {
Column( if (!viewingVideo) Column(
modifier = Modifier.fillMaxWidth().align(if (receiving) Alignment.BottomCenter else Alignment.Center) modifier = Modifier.fillMaxWidth().align(Alignment.Center)
.background(Color(0xFF0F1B22)).statusBarsPadding().navigationBarsPadding() .windowInsetsPadding(controlInsets)
.verticalScroll(rememberScrollState()).padding(top = if (receiving) 12.dp else 56.dp, bottom = 24.dp), .padding(top = if (hasVideo) 224.dp else 56.dp)
.background(Color(0xFF0F1B22)).verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (!receiving) { if (!viewingVideo) {
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
@ -250,7 +265,6 @@ 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) {
@ -266,7 +280,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, showLabel = !viewingVideo,
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) } onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
) )
} }
@ -275,7 +289,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, showLabel = !viewingVideo,
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) } onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
) )
if (headsetAvailable) { if (headsetAvailable) {
@ -284,7 +298,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, showLabel = !viewingVideo,
onClick = { onClick = {
if (hasBtPermission) { if (hasBtPermission) {
viewModel.setAudioRoute(AudioRoute.HEADSET) viewModel.setAudioRoute(AudioRoute.HEADSET)
@ -305,7 +319,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, showLabel = !viewingVideo,
activeColor = Color(0xFFB32632), activeColor = Color(0xFFB32632),
onClick = { viewModel.toggleMute() } 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)) 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)) Spacer(Modifier.height(6.dp))
Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp) Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp)
} }
@ -329,22 +343,20 @@ fun CallScreen(
} }
IconButton( IconButton(
onClick = onMinimize, onClick = { if (viewingVideo) selectedVideo = null else onMinimize() },
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.statusBarsPadding() .windowInsetsPadding(controlInsets)
.padding(8.dp) .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 }, if (phase == CallPhase.ACTIVE) IconButton(onClick = { selectedVideo = null; settingsVisible = true },
modifier = Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(8.dp)) { modifier = Modifier.align(Alignment.TopEnd).windowInsetsPadding(controlInsets).padding(8.dp)) {
Icon(Icons.Filled.Settings, stringResource(R.string.settings), tint = Color.White) 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( if (settingsVisible && phase == CallPhase.ACTIVE && !locked) AlertDialog(
onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_settings)) }, onDismissRequest = { settingsVisible = false }, title = { Text(stringResource(R.string.call_settings)) },
confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } }, confirmButton = { TextButton(onClick = { settingsVisible = false }) { Text(stringResource(R.string.close)) } },

52
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.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
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.Alignment
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
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
@ -70,23 +73,48 @@ internal fun CallVideoPanel(video: CallVideoEngine) {
} }
} }
/** Texture остаётся подключённой при скрытии controls и до первого кадра; пустого видеоблока в layout нет. */ internal enum class CallVideoSource { LOCAL, REMOTE }
/** Один TextureView меняет только геометрию: переход в миниатюру не переподключает decoder. */
@Composable @Composable
internal fun CallVideoPlayback(video: CallVideoEngine, onTap: () -> Unit, modifier: Modifier) { internal fun CallVideoStage(video: CallVideoEngine, expanded: CallVideoSource?, onSelect: (CallVideoSource) -> Unit,
val format by video.display.collectAsState() onTap: () -> Unit, modifier: Modifier = Modifier) {
key(video) { val remote by video.display.collectAsState()
AndroidView(factory = { CallVideoTexture(it, video) }, modifier = modifier, val local by video.preview.collectAsState()
update = { view -> view.format = format; view.alpha = if (format == null) 0f else 1f val insets = WindowInsets.systemBarsIgnoringVisibility.union(WindowInsets.displayCutout)
view.setOnClickListener { if (format != null) onTap() } }, onRelease = { it.close() }) 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 @Composable
internal fun CallVideoPreview(video: CallVideoEngine, modifier: Modifier = Modifier) { private fun CallVideoPlayback(video: CallVideoEngine, onTap: () -> Unit, modifier: Modifier, description: String) {
val bitmap by video.preview.collectAsState() val format by video.display.collectAsState()
bitmap?.let { frame -> key(video) {
Image(frame.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, AndroidView(factory = { CallVideoTexture(it, video) }, modifier = modifier,
modifier = modifier.width(104.dp).aspectRatio(frame.width.toFloat() / frame.height)) 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() })
} }
} }

Loading…
Cancel
Save