Separate Android full screen call video from controls and settings
This commit is contained in:
1 parent
898154b2b6
commit
2db038beee
2 files changed
+135
-47
No files matched your search
@@ -25,11 +25,16 @@ import androidx.compose.material.icons.filled.Lock
|
|||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.MicOff
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
import androidx.compose.material.icons.filled.PhoneInTalk
|
import androidx.compose.material.icons.filled.PhoneInTalk
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.data.LogManager
|
||||||
import com.utun.chat.viewmodel.ChatViewModel
|
import com.utun.chat.viewmodel.ChatViewModel
|
||||||
import com.utun.chat.data.CallPhase
|
import com.utun.chat.data.CallPhase
|
||||||
|
import com.utun.chat.data.CallController
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||||
is Activity -> this
|
is Activity -> this
|
||||||
@@ -77,6 +84,7 @@ private fun CallControlButton(
|
|||||||
label: String,
|
label: String,
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
activeColor: Color = Color(0xFF1F6FEB),
|
activeColor: Color = Color(0xFF1F6FEB),
|
||||||
|
showLabel: Boolean = true,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@@ -89,8 +97,10 @@ private fun CallControlButton(
|
|||||||
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
|
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
if (showLabel) {
|
||||||
Text(label, color = if (active) Color.White else Color(0xFFAEBCC8), fontSize = 11.sp)
|
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 duration = st?.durationSeconds ?: 0
|
||||||
val peerNodeId = st?.peerNodeId ?: 0L
|
val peerNodeId = st?.peerNodeId ?: 0L
|
||||||
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
|
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
|
val locked = phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE && proximityNear
|
||||||
|
|
||||||
@@ -128,18 +147,19 @@ fun CallScreen(
|
|||||||
BackHandler(enabled = locked) {}
|
BackHandler(enabled = locked) {}
|
||||||
|
|
||||||
/* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */
|
/* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */
|
||||||
DisposableEffect(locked) {
|
val hideBars = locked || (receiving && !controlsVisible)
|
||||||
|
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) }
|
||||||
if (locked) {
|
if (hideBars) {
|
||||||
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
controller?.hide(WindowInsetsCompat.Type.systemBars())
|
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 {
|
onDispose {
|
||||||
if (locked) {
|
if (hideBars) {
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
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)),
|
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
if (phase == CallPhase.ACTIVE) video?.let {
|
||||||
|
CallVideoPlayback(it, onTap = { controlsVisible = !controlsVisible }, modifier = Modifier.matchParentSize())
|
||||||
|
}
|
||||||
|
if (!receiving || controlsVisible) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().navigationBarsPadding()
|
modifier = Modifier.fillMaxWidth().align(if (receiving) Alignment.BottomCenter else Alignment.Center)
|
||||||
.verticalScroll(rememberScrollState()).padding(top = 56.dp, bottom = 24.dp),
|
.background(Color(0xFF0F1B22)).statusBarsPadding().navigationBarsPadding()
|
||||||
|
.verticalScroll(rememberScrollState()).padding(top = if (receiving) 12.dp else 56.dp, bottom = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
if (phase == CallPhase.ACTIVE) CallVideoPanel()
|
if (!receiving) {
|
||||||
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
|
||||||
@@ -186,6 +211,8 @@ 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) {
|
||||||
Button(onClick = { viewModel.dismissCall(); onClose() }) {
|
Button(onClick = { viewModel.dismissCall(); onClose() }) {
|
||||||
@@ -200,6 +227,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,
|
||||||
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
|
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -208,6 +236,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,
|
||||||
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
|
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
|
||||||
)
|
)
|
||||||
if (headsetAvailable) {
|
if (headsetAvailable) {
|
||||||
@@ -216,6 +245,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,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (hasBtPermission) {
|
if (hasBtPermission) {
|
||||||
viewModel.setAudioRoute(AudioRoute.HEADSET)
|
viewModel.setAudioRoute(AudioRoute.HEADSET)
|
||||||
@@ -236,6 +266,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,
|
||||||
activeColor = Color(0xFFB32632),
|
activeColor = Color(0xFFB32632),
|
||||||
onClick = { viewModel.toggleMute() }
|
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))
|
Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(36.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
if (!receiving) {
|
||||||
Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp)
|
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(
|
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))
|
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) {
|
if (locked) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
+72
-21
@@ -2,74 +2,125 @@ package com.utun.chat.ui.screens
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.view.SurfaceHolder
|
import android.content.Context
|
||||||
import android.view.SurfaceView
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.TextureView
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
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.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.graphics.Color
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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
|
||||||
import com.utun.chat.data.CallVideoBitrateRange
|
import com.utun.chat.data.CallVideoBitrateRange
|
||||||
|
import com.utun.chat.data.CallVideoEngine
|
||||||
|
import com.utun.chat.data.CallVideoFormat
|
||||||
|
import com.utun.chat.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CallVideoPanel() {
|
internal fun CallVideoPanel(video: CallVideoEngine) {
|
||||||
val engine by CallController.video.collectAsState()
|
|
||||||
val video = engine ?: return
|
|
||||||
val state by video.state.collectAsState()
|
val state by video.state.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var denied by remember(video) { mutableStateOf(false) }
|
var denied by remember(video) { mutableStateOf(false) }
|
||||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
denied = !granted
|
denied = !granted
|
||||||
if (granted && engine === video) CallController.setVideo(state.settings.copy(enabled = true))
|
if (granted && CallController.video.value === video) CallController.setVideo(state.settings.copy(enabled = true))
|
||||||
}
|
}
|
||||||
DisposableEffect(video) { onDispose { video.surface(null) } }
|
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||||
AndroidView(factory = { ctx -> SurfaceView(ctx).apply {
|
if (state.status.isNotEmpty()) Text(state.status)
|
||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
if (denied) Text(stringResource(R.string.camera_permission_required), color = MaterialTheme.colorScheme.error)
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) { video.surface(holder.surface) }
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { video.surface(holder.surface) }
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) { video.surface(null) }
|
|
||||||
})
|
|
||||||
} }, modifier = Modifier.fillMaxWidth().height(240.dp).background(Color.Black))
|
|
||||||
Text(state.status, color = Color.White)
|
|
||||||
if (denied) Text("Camera permission is required to send video", color = MaterialTheme.colorScheme.error)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Send video", color = Color.White, modifier = Modifier.weight(1f).padding(top = 12.dp))
|
Text(stringResource(R.string.send_video), modifier = Modifier.weight(1f).padding(top = 12.dp))
|
||||||
Switch(state.settings.enabled, enabled = state.ready && state.codecs.isNotEmpty(), onCheckedChange = { enabled ->
|
Switch(state.settings.enabled, enabled = state.ready && state.codecs.isNotEmpty(), onCheckedChange = { enabled ->
|
||||||
if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
if (!enabled || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||||
CallController.setVideo(state.settings.copy(enabled = enabled))
|
CallController.setVideo(state.settings.copy(enabled = enabled))
|
||||||
else permission.launch(Manifest.permission.CAMERA)
|
else permission.launch(Manifest.permission.CAMERA)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
VideoChoice("Camera", state.cameras.map { it.name }, state.cameras.indexOfFirst { it.id == state.settings.camera }) { index ->
|
VideoChoice(stringResource(R.string.camera), state.cameras.map { it.name }, state.cameras.indexOfFirst { it.id == state.settings.camera }) { index ->
|
||||||
val camera = state.cameras[index]
|
val camera = state.cameras[index]
|
||||||
val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first()
|
val size = camera.sizes.firstOrNull { it.width == state.settings.width && it.height == state.settings.height } ?: camera.sizes.first()
|
||||||
CallController.setVideo(state.settings.copy(camera = camera.id, width = size.width, height = size.height))
|
CallController.setVideo(state.settings.copy(camera = camera.id, width = size.width, height = size.height))
|
||||||
}
|
}
|
||||||
val sizes = state.cameras.firstOrNull { it.id == state.settings.camera }?.sizes.orEmpty()
|
val sizes = state.cameras.firstOrNull { it.id == state.settings.camera }?.sizes.orEmpty()
|
||||||
VideoChoice("Resolution", sizes.map { "${it.width}×${it.height}" }, sizes.indexOfFirst { it.width == state.settings.width && it.height == state.settings.height }) { i ->
|
VideoChoice(stringResource(R.string.call_video_resolution), sizes.map { "${it.width}×${it.height}" }, sizes.indexOfFirst { it.width == state.settings.width && it.height == state.settings.height }) { i ->
|
||||||
CallController.setVideo(state.settings.copy(width = sizes[i].width, height = sizes[i].height))
|
CallController.setVideo(state.settings.copy(width = sizes[i].width, height = sizes[i].height))
|
||||||
}
|
}
|
||||||
val codecs = listOf(0) + state.codecs.sortedDescending()
|
val codecs = listOf(0) + state.codecs.sortedDescending()
|
||||||
VideoChoice("Codec", codecs.map { when (it) { 0 -> "Auto (H.265 → H.264)"; 2 -> "H.265"; else -> "H.264" } },
|
VideoChoice(stringResource(R.string.call_video_codec), codecs.map { when (it) { 0 -> stringResource(R.string.call_video_auto); 2 -> "H.265"; else -> "H.264" } },
|
||||||
codecs.indexOf(state.settings.codec)) { i -> CallController.setVideo(state.settings.copy(codec = codecs[i])) }
|
codecs.indexOf(state.settings.codec)) { i -> CallController.setVideo(state.settings.copy(codec = codecs[i])) }
|
||||||
var selected by remember(video) { mutableStateOf(state.range.minimum.toFloat()..state.range.maximum.toFloat()) }
|
var selected by remember(video) { mutableStateOf(state.range.minimum.toFloat()..state.range.maximum.toFloat()) }
|
||||||
var dragging by remember(video) { mutableStateOf(false) }
|
var dragging by remember(video) { mutableStateOf(false) }
|
||||||
LaunchedEffect(state.range) { if (!dragging) selected = state.range.minimum.toFloat()..state.range.maximum.toFloat() }
|
LaunchedEffect(state.range) { if (!dragging) selected = state.range.minimum.toFloat()..state.range.maximum.toFloat() }
|
||||||
Text("Bitrate: ${selected.start.toInt()}–${selected.endInclusive.toInt()} kbit/s · ${state.bitrate / 1000} kbit/s", color = Color.White)
|
Text(stringResource(R.string.call_video_rate, selected.start.toInt(), selected.endInclusive.toInt(), state.bitrate / 1000))
|
||||||
RangeSlider(value = selected, onValueChange = { dragging = true; selected = it }, valueRange = 300f..4000f,
|
RangeSlider(value = selected, onValueChange = { dragging = true; selected = it }, valueRange = 300f..4000f,
|
||||||
onValueChangeFinished = { dragging = false; video.range(CallVideoBitrateRange(selected.start.toInt(), selected.endInclusive.toInt())) })
|
onValueChangeFinished = { dragging = false; video.range(CallVideoBitrateRange(selected.start.toInt(), selected.endInclusive.toInt())) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Texture остаётся подключённой при скрытии controls и до первого кадра; пустого видеоблока в layout нет. */
|
||||||
|
@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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 class CallVideoTexture(context: Context, private val video: CallVideoEngine) : TextureView(context) {
|
||||||
|
private var attached = false
|
||||||
|
private var closed = false
|
||||||
|
var format: CallVideoFormat? = null
|
||||||
|
set(value) { field = value; fit() }
|
||||||
|
init {
|
||||||
|
isOpaque = false
|
||||||
|
surfaceTextureListener = object : SurfaceTextureListener {
|
||||||
|
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
|
||||||
|
if (closed) return
|
||||||
|
val surface = Surface(texture); attached = true
|
||||||
|
// RX worker освобождает Surface/texture только после остановки MediaCodec.
|
||||||
|
video.surface(surface) { surface.release(); texture.release() }; fit()
|
||||||
|
}
|
||||||
|
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { fit() }
|
||||||
|
override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { closeOutput(); return false }
|
||||||
|
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun fit() {
|
||||||
|
val f = format ?: return
|
||||||
|
if (width == 0 || height == 0) return
|
||||||
|
val rotated = f.rotation % 180 != 0
|
||||||
|
val w = if (rotated) f.height else f.width; val h = if (rotated) f.width else f.height
|
||||||
|
val scale = minOf(width.toFloat() / w, height.toFloat() / h)
|
||||||
|
setTransform(Matrix().apply { setScale(w * scale / width, h * scale / height, width / 2f, height / 2f) })
|
||||||
|
}
|
||||||
|
private fun closeOutput() { if (attached) { attached = false; video.surface(null) } }
|
||||||
|
fun close() { closed = true; closeOutput() }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VideoChoice(label: String, values: List<String>, selected: Int, choose: (Int) -> Unit) {
|
private fun VideoChoice(label: String, values: List<String>, selected: Int, choose: (Int) -> Unit) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|||||||
Reference in new issue
Block a user