Add Android call controls navigation and expandable video thumbnails
This commit is contained in:
1 parent
64f1fe4dfb
commit
964c17f1bf
2 files changed
+88
-48
No files matched your search
@@ -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)) } },
|
||||
|
||||
+40
-12
@@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user