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 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?>(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)) } },

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.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() })
}
}

Loading…
Cancel
Save