|
|
|
|
@ -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( |
|
|
|
|
|