Add missing call link ui.

This commit is contained in:
Alex Hart
2026-01-07 15:49:58 -04:00
committed by jeffrey-signal
parent c2ec9e579e
commit a7f239576f
9 changed files with 524 additions and 94 deletions

View File

@@ -0,0 +1,116 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.math.min
internal enum class BottomControlsLayoutId {
CONTROLS,
BAR
}
/**
* A custom layout that positions an optional bar (like WaitingToBeLetInBar or CallLinkInfoCard)
* and a controls row (audio indicator + camera toggle).
*
* Positioning logic:
* - The bar is always positioned above the bottom sheet (offset by [bottomSheetPadding]) and
* constrained to the sheet's max width.
* - If there's enough gutter space on the sides of the centered bottom sheet, the controls
* are placed at the screen edge (bottom of screen).
* - If there's not enough gutter space and there's a bar, controls are stacked above the bar.
*
* Usage: Apply `Modifier.layoutId(BottomControlsLayoutId.CONTROLS)` to the controls content
* and `Modifier.layoutId(BottomControlsLayoutId.BAR)` to the bar content.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun BottomControlsWithOptionalBar(
bottomSheetPadding: Dp,
modifier: Modifier = Modifier,
controlsRow: @Composable () -> Unit,
barSlot: @Composable () -> Unit
) {
val sheetMaxWidthPx = with(LocalDensity.current) { BottomSheetDefaults.SheetMaxWidth.roundToPx() }
val spacingPx = with(LocalDensity.current) { 16.dp.roundToPx() }
val elementBottomPaddingPx = with(LocalDensity.current) { 16.dp.roundToPx() }
val bottomSheetPaddingPx = with(LocalDensity.current) { bottomSheetPadding.roundToPx() }
// Estimated space needed for the larger control (camera toggle ~48dp + spacing)
val spaceNeededForControlPx = with(LocalDensity.current) { (48.dp + 16.dp).roundToPx() }
Layout(
content = {
controlsRow()
barSlot()
},
modifier = modifier
) { measurables, constraints ->
val controlsMeasurable = measurables.find { it.layoutId == BottomControlsLayoutId.CONTROLS }
val barMeasurable = measurables.find { it.layoutId == BottomControlsLayoutId.BAR }
// Calculate gutter space: space on each side of the centered bottom sheet
val sheetWidth = min(constraints.maxWidth, sheetMaxWidthPx)
val gutterSpace = (constraints.maxWidth - sheetWidth) / 2
val controlsCanBeAtScreenEdge = gutterSpace >= spaceNeededForControlPx
// Bar is constrained to sheet width
val barConstraints = constraints.copy(
minWidth = 0,
maxWidth = sheetWidth
)
val barPlaceable = barMeasurable?.measure(barConstraints)
val barWidth = barPlaceable?.width ?: 0
val barHeight = barPlaceable?.height ?: 0
val controlsPlaceable = controlsMeasurable?.measure(constraints.copy(minWidth = 0, minHeight = 0))
val controlsHeight = controlsPlaceable?.height ?: 0
if (controlsCanBeAtScreenEdge) {
// Controls at screen edge with 16dp bottom padding, bar above sheet with 16dp bottom padding
val controlsTotalHeight = controlsHeight + elementBottomPaddingPx
val barTotalHeight = barHeight + elementBottomPaddingPx
val totalHeight = max(controlsTotalHeight, bottomSheetPaddingPx + barTotalHeight)
val barX = (constraints.maxWidth - barWidth) / 2
val barY = totalHeight - bottomSheetPaddingPx - barTotalHeight
val controlsY = totalHeight - controlsTotalHeight
layout(constraints.maxWidth, totalHeight) {
controlsPlaceable?.placeRelative(0, controlsY)
barPlaceable?.placeRelative(barX, barY)
}
} else if (barPlaceable != null) {
// Not enough gutter space, controls stacked above bar, both above sheet with 16dp bottom padding
val contentHeight = controlsHeight + (if (controlsHeight > 0) spacingPx else 0) + barHeight
val totalHeight = contentHeight + elementBottomPaddingPx + bottomSheetPaddingPx
val barX = (constraints.maxWidth - barWidth) / 2
val controlsY = 0
val barY = controlsHeight + (if (controlsHeight > 0) spacingPx else 0)
layout(constraints.maxWidth, totalHeight) {
controlsPlaceable?.placeRelative(0, controlsY)
barPlaceable.placeRelative(barX, barY)
}
} else {
// No bar, not enough gutter space - controls above sheet with 16dp bottom padding
val totalHeight = controlsHeight + elementBottomPaddingPx + bottomSheetPaddingPx
layout(constraints.maxWidth, totalHeight) {
controlsPlaceable?.placeRelative(0, 0)
}
}
}
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
@@ -28,7 +27,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
@@ -50,7 +48,6 @@ fun CallElementsLayout(
modifier: Modifier = Modifier
) {
val isPortrait = LocalConfiguration.current.orientation != Configuration.ORIENTATION_LANDSCAPE
val isCompactPortrait = !currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
val isFocused = localRenderState == WebRtcLocalRenderState.FOCUSED
@Composable
@@ -66,17 +63,13 @@ fun CallElementsLayout(
(rememberSelfPipSize(localRenderState) + DpSize(32.dp, 0.dp)).toSize()
}
val bottomInsetPx = with(density) {
if (isCompactPortrait) 0 else bottomInset.roundToPx()
}
val bottomInsetPx = with(density) { bottomInset.roundToPx() }
val bottomSheetWidthPx = with(density) {
bottomSheetWidth.roundToPx()
}
val layoutModifier = if (isCompactPortrait) Modifier.padding(bottom = bottomInset).then(modifier) else modifier
Box(modifier = layoutModifier) {
Box(modifier = modifier) {
BlurrableContentLayer(
isFocused = isFocused,
isPortrait = isPortrait,

View File

@@ -80,7 +80,9 @@ fun RemoteParticipantContent(
renderInPip: Boolean,
raiseHandAllowed: Boolean,
onInfoMoreInfoClick: (() -> Unit)?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
mirrorVideo: Boolean = false,
showAudioIndicator: Boolean = true
) {
val context = LocalContext.current
val recipient = participant.recipient
@@ -138,15 +140,18 @@ fun RemoteParticipantContent(
participant = participant,
onFirstFrameRendered = { isVideoReady = true },
showLetterboxing = isVideoReady,
mirror = mirrorVideo,
modifier = Modifier.fillMaxSize()
)
}
AudioIndicator(
participant = participant,
selfPipMode = SelfPipMode.NOT_SELF_PIP,
modifier = Modifier.align(Alignment.BottomStart)
)
if (showAudioIndicator) {
AudioIndicator(
participant = participant,
selfPipMode = SelfPipMode.NOT_SELF_PIP,
modifier = Modifier.align(Alignment.BottomStart)
)
}
if (raiseHandAllowed && !renderInPip && participant.isHandRaised) {
RaiseHandIndicator(
@@ -457,7 +462,7 @@ private fun VideoRenderer(
}
@Composable
private fun AudioIndicator(
internal fun AudioIndicator(
participant: CallParticipant,
selfPipMode: SelfPipMode,
modifier: Modifier = Modifier
@@ -468,6 +473,7 @@ private fun AudioIndicator(
SelfPipMode.MINI_SELF_PIP -> 10.dp
SelfPipMode.FOCUSED_SELF_PIP -> 12.dp
SelfPipMode.NOT_SELF_PIP -> 12.dp
SelfPipMode.OVERLAY_SELF_PIP -> 0.dp
}
AndroidView(
@@ -662,7 +668,8 @@ enum class SelfPipMode {
NORMAL_SELF_PIP,
EXPANDED_SELF_PIP,
MINI_SELF_PIP,
FOCUSED_SELF_PIP
FOCUSED_SELF_PIP,
OVERLAY_SELF_PIP
}
@NightPreview

View File

@@ -31,7 +31,6 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -51,7 +50,6 @@ import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.AllNightPreviews
@@ -251,7 +249,23 @@ fun CallScreen(
}
}
val isCompactPortrait = !currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
@Composable
fun PendingParticipantsInternal(modifier: Modifier = Modifier) {
val state = remember(callScreenState.pendingParticipantsState) {
callScreenState.pendingParticipantsState
}
if (state != null) {
PendingParticipants(
pendingParticipantsState = state,
pendingParticipantsListener = pendingParticipantsListener,
modifier = Modifier
.fillMaxWidth()
.then(modifier)
)
}
}
if (webRtcCallState.isPreJoinOrNetworkUnavailable ||
webRtcCallState == WebRtcViewModel.State.CALL_OUTGOING ||
webRtcCallState == WebRtcViewModel.State.CALL_RINGING ||
@@ -259,8 +273,7 @@ fun CallScreen(
) {
if (localParticipant.isVideoEnabled) {
LargeLocalVideoRenderer(
localParticipant = localParticipant,
modifier = if (isCompactPortrait) Modifier.padding(bottom = padding) else Modifier
localParticipant = localParticipant
)
}
@@ -268,20 +281,27 @@ fun CallScreen(
CallScreenPreJoinOverlay(
callRecipient = callRecipient,
callStatus = callScreenState.callStatus,
localParticipant = localParticipant,
onNavigationClick = onNavigationClick,
onCallInfoClick = onCallInfoClick,
onCameraToggleClick = callScreenControlsListener::onCameraDirectionChanged,
isLocalVideoEnabled = localParticipant.isVideoEnabled,
isMoreThanOneCameraAvailable = localParticipant.isMoreThanOneCameraAvailable,
modifier = Modifier.padding(bottom = padding)
bottomSheetPadding = padding
)
} else {
CallScreenTopBar(
CallScreenJoiningOverlay(
callRecipient = callRecipient,
callStatus = callScreenState.callStatus,
localParticipant = localParticipant,
isLocalVideoEnabled = localParticipant.isVideoEnabled,
isMoreThanOneCameraAvailable = localParticipant.isMoreThanOneCameraAvailable,
isWaitingToBeLetIn = callScreenState.isWaitingToBeLetIn,
bottomSheetPadding = padding,
onNavigationClick = onNavigationClick,
onCallInfoClick = onCallInfoClick,
modifier = Modifier.padding(bottom = padding)
onCameraToggleClick = callScreenControlsListener::onCameraDirectionChanged,
pendingParticipantsSlot = ::PendingParticipantsInternal
)
}
} else if (webRtcCallState.isPassedPreJoin) {
@@ -326,20 +346,7 @@ fun CallScreen(
)
},
callLinkBarSlot = {
val state = remember(callScreenState.pendingParticipantsState) {
callScreenState.pendingParticipantsState
}
if (state != null) {
PendingParticipants(
pendingParticipantsState = state,
pendingParticipantsListener = pendingParticipantsListener,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
)
}
PendingParticipantsInternal(modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp))
},
callOverflowSlot = {
val metrics = rememberCallScreenMetrics()
@@ -417,6 +424,7 @@ fun CallScreen(
/**
* Full-screen local video renderer displayed when the user is in pre-call state.
* Audio indicator is handled by the overlay composables.
*/
@Composable
private fun LargeLocalVideoRenderer(
@@ -427,6 +435,8 @@ private fun LargeLocalVideoRenderer(
participant = localParticipant,
renderInPip = false,
raiseHandAllowed = false,
mirrorVideo = true,
showAudioIndicator = false,
onInfoMoreInfoClick = null,
modifier = modifier
.fillMaxSize()

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Overlay displayed when the user has initiated joining a call but is waiting to be admitted,
* such as when waiting to be let into a call link.
*
* This overlay is displayed after the pre-join stage but before the user can see other participants.
*/
@Composable
fun CallScreenJoiningOverlay(
callRecipient: Recipient,
callStatus: String?,
localParticipant: CallParticipant,
isLocalVideoEnabled: Boolean,
isMoreThanOneCameraAvailable: Boolean,
isWaitingToBeLetIn: Boolean,
bottomSheetPadding: Dp = 0.dp,
modifier: Modifier = Modifier,
onNavigationClick: () -> Unit = {},
onCallInfoClick: () -> Unit = {},
onCameraToggleClick: () -> Unit = {},
pendingParticipantsSlot: @Composable () -> Unit = {}
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(modifier)
) {
CallScreenTopBar(
callRecipient = callRecipient,
callStatus = callStatus,
onNavigationClick = onNavigationClick,
onCallInfoClick = onCallInfoClick
)
val showCameraToggle = isLocalVideoEnabled && isMoreThanOneCameraAvailable
BottomControlsWithOptionalBar(
bottomSheetPadding = bottomSheetPadding,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp),
controlsRow = {
if (showCameraToggle || isLocalVideoEnabled) {
Row(
modifier = Modifier
.layoutId(BottomControlsLayoutId.CONTROLS)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
if (isLocalVideoEnabled) {
AudioIndicator(
participant = localParticipant,
selfPipMode = SelfPipMode.OVERLAY_SELF_PIP
)
}
if (showCameraToggle) {
CallCameraDirectionToggle(onClick = onCameraToggleClick)
}
}
}
},
barSlot = {
Column(
modifier = Modifier.layoutId(BottomControlsLayoutId.BAR)
) {
pendingParticipantsSlot()
if (isWaitingToBeLetIn) {
WaitingToBeLetInBar()
}
}
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WaitingToBeLetInBar(
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.widthIn(max = BottomSheetDefaults.SheetMaxWidth)
.fillMaxWidth()
.background(
color = SignalTheme.colors.colorSurface1,
shape = MaterialTheme.shapes.medium
)
.padding(horizontal = 16.dp, vertical = 13.dp)
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onSurface,
strokeWidth = 2.dp,
modifier = Modifier.size(20.dp)
)
Text(
text = stringResource(R.string.WebRtcCallView__waiting_to_be_let_in),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@AllNightPreviews
@Composable
private fun CallScreenJoiningOverlayPreview() {
Previews.Preview {
CallScreenJoiningOverlay(
callRecipient = Recipient(systemContactName = "Test User"),
callStatus = "Joining...",
localParticipant = CallParticipant.EMPTY,
isLocalVideoEnabled = false,
isMoreThanOneCameraAvailable = false,
isWaitingToBeLetIn = false
)
}
}
@AllNightPreviews
@Composable
private fun CallScreenJoiningOverlayWithCameraTogglePreview() {
Previews.Preview {
CallScreenJoiningOverlay(
callRecipient = Recipient(systemContactName = "Test User"),
callStatus = "Joining...",
localParticipant = CallParticipant.EMPTY.copy(
isVideoEnabled = true,
isMicrophoneEnabled = true,
audioLevel = CallParticipant.AudioLevel.MEDIUM
),
isLocalVideoEnabled = true,
isMoreThanOneCameraAvailable = true,
isWaitingToBeLetIn = false
)
}
}
@AllNightPreviews
@Composable
private fun CallScreenJoiningOverlayWaitingPreview() {
Previews.Preview {
CallScreenJoiningOverlay(
callRecipient = Recipient(systemContactName = "Test User"),
callStatus = "Waiting to be let in...",
localParticipant = CallParticipant.EMPTY.copy(
isVideoEnabled = true,
isMicrophoneEnabled = true,
audioLevel = CallParticipant.AudioLevel.MEDIUM
),
isLocalVideoEnabled = true,
isMoreThanOneCameraAvailable = true,
isWaitingToBeLetIn = true
)
}
}

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -26,16 +27,21 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowWidthSizeClass
@@ -44,7 +50,11 @@ import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.rememberRecipientField
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
/**
* Pre-join call screen overlay.
@@ -56,78 +66,147 @@ import org.thoughtcrime.securesms.recipients.Recipient
fun CallScreenPreJoinOverlay(
callRecipient: Recipient,
callStatus: String?,
localParticipant: CallParticipant,
isMoreThanOneCameraAvailable: Boolean,
isLocalVideoEnabled: Boolean,
bottomSheetPadding: Dp = 0.dp,
modifier: Modifier = Modifier,
onNavigationClick: () -> Unit = {},
onCallInfoClick: () -> Unit = {},
onCameraToggleClick: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
val showCameraToggle = isLocalVideoEnabled && isMoreThanOneCameraAvailable
val showInfoCard = callRecipient.isCallLink
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color(0f, 0f, 0f, 0.4f))
.then(modifier)
) {
CallScreenTopAppBar(
onNavigationClick = onNavigationClick,
onCallInfoClick = onCallInfoClick
)
AvatarImage(
recipient = callRecipient,
modifier = Modifier
.padding(top = 8.dp)
.size(96.dp)
)
Text(
text = callRecipient.getDisplayName(LocalContext.current),
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
modifier = Modifier.padding(top = 16.dp)
)
if (callStatus != null) {
Text(
text = callStatus,
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
modifier = Modifier.padding(top = 8.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
) {
CallScreenTopAppBar(
onNavigationClick = onNavigationClick,
onCallInfoClick = onCallInfoClick
)
}
if (!isLocalVideoEnabled) {
Spacer(modifier = Modifier.weight(1f))
AvatarImage(
recipient = callRecipient,
modifier = Modifier
.padding(top = 8.dp)
.size(96.dp)
)
val isCompactWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
if (isCompactWidth) {
YourCameraIsOff(spacedBy = 8.dp)
} else {
Row(
horizontalArrangement = spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
YourCameraIsOff()
}
}
Text(
text = callRecipient.getDisplayName(LocalContext.current),
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
modifier = Modifier.padding(top = 16.dp)
)
Spacer(modifier = Modifier.weight(1f))
}
if (isLocalVideoEnabled && isMoreThanOneCameraAvailable) {
Spacer(modifier = Modifier.weight(1f))
Box(modifier = Modifier.fillMaxWidth()) {
CallCameraDirectionToggle(
onClick = onCameraToggleClick,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
if (callStatus != null) {
Text(
text = callStatus,
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
modifier = Modifier.padding(top = 8.dp)
)
}
if (!isLocalVideoEnabled) {
Spacer(modifier = Modifier.weight(1f))
val isCompactWidth = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
if (isCompactWidth) {
YourCameraIsOff(spacedBy = 8.dp)
} else {
Row(
horizontalArrangement = spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
YourCameraIsOff()
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
// Bottom controls in a separate layer for proper screen-edge positioning
if (showCameraToggle || showInfoCard || isLocalVideoEnabled) {
BottomControlsWithOptionalBar(
bottomSheetPadding = bottomSheetPadding,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp),
controlsRow = {
if (showCameraToggle || isLocalVideoEnabled) {
Row(
modifier = Modifier
.layoutId(BottomControlsLayoutId.CONTROLS)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
if (isLocalVideoEnabled) {
AudioIndicator(
participant = localParticipant,
selfPipMode = SelfPipMode.OVERLAY_SELF_PIP
)
}
if (showCameraToggle) {
CallCameraDirectionToggle(onClick = onCameraToggleClick)
}
}
}
},
barSlot = {
if (showInfoCard) {
CallLinkInfoCard(
modifier = Modifier.layoutId(BottomControlsLayoutId.BAR)
)
}
}
)
}
}
}
@Composable
private fun CallLinkInfoCard(
modifier: Modifier = Modifier
) {
val isPhoneNumberSharingEnabled: Boolean by if (LocalInspectionMode.current) {
remember { mutableStateOf(false) }
} else {
rememberRecipientField(Recipient.self()) { phoneNumberSharing.enabled }
}
val text = if (isPhoneNumberSharingEnabled) {
stringResource(R.string.WebRtcCallView__anyone_who_joins_pnp_disabled)
} else {
stringResource(R.string.WebRtcCallView__anyone_who_joins_pnp_enabled)
}
Box(
modifier = modifier
.background(
color = SignalTheme.colors.colorSurface1,
shape = MaterialTheme.shapes.medium
)
.padding(horizontal = 16.dp, vertical = 13.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@@ -151,7 +230,7 @@ private fun YourCameraIsOff(
}
@Composable
private fun CallCameraDirectionToggle(
internal fun CallCameraDirectionToggle(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -254,6 +333,7 @@ fun CallScreenPreJoinOverlayPreview() {
CallScreenPreJoinOverlay(
callRecipient = Recipient(systemContactName = "Test User"),
callStatus = stringResource(R.string.Recipient_unknown),
localParticipant = CallParticipant.EMPTY,
isLocalVideoEnabled = false,
isMoreThanOneCameraAvailable = false
)
@@ -267,6 +347,29 @@ fun CallScreenPreJoinOverlayWithTogglePreview() {
CallScreenPreJoinOverlay(
callRecipient = Recipient(systemContactName = "Test User"),
callStatus = stringResource(R.string.Recipient_unknown),
localParticipant = CallParticipant.EMPTY.copy(
isVideoEnabled = true,
isMicrophoneEnabled = true,
audioLevel = CallParticipant.AudioLevel.MEDIUM
),
isLocalVideoEnabled = true,
isMoreThanOneCameraAvailable = true
)
}
}
@AllNightPreviews
@Composable
fun CallScreenPreJoinOverlayWithCallLinkPreview() {
Previews.Preview {
CallScreenPreJoinOverlay(
callRecipient = Recipient(systemContactName = "Test User", callLinkRoomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3))),
callStatus = stringResource(R.string.Recipient_unknown),
localParticipant = CallParticipant.EMPTY.copy(
isVideoEnabled = true,
isMicrophoneEnabled = true,
audioLevel = CallParticipant.AudioLevel.MEDIUM
),
isLocalVideoEnabled = true,
isMoreThanOneCameraAvailable = true
)

View File

@@ -32,6 +32,7 @@ data class CallScreenState(
val pendingParticipantsState: PendingParticipantsState? = null,
val isParticipantUpdatePopupEnabled: Boolean = true,
val isCallStateUpdatePopupEnabled: Boolean = false,
val isWaitingToBeLetIn: Boolean = false,
val reactions: PersistentList<String> = persistentListOf()
) {
fun isDisplayingControlMenu(): Boolean = isDisplayingAudioToggleSheet || displayAdditionalActionsDialog

View File

@@ -261,6 +261,9 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
override fun updateCallParticipants(callParticipantsViewState: CallParticipantsViewState) {
callScreenViewModel.callParticipantsViewState.update { callParticipantsViewState }
setStatusFromCallParticipantsState(activity, callParticipantsViewState)
val isWaitingToBeLetIn = callParticipantsViewState.callParticipantsState.groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_PENDING
callScreenViewModel.callScreenState.update { it.copy(isWaitingToBeLetIn = isWaitingToBeLetIn) }
}
override fun maybeDismissAudioPicker() {

View File

@@ -1117,7 +1117,7 @@ object RemoteConfig {
@JvmStatic
@get:JvmName("newCallUi")
val newCallUi: Boolean by remoteBoolean(
key = "android.newCallUi",
key = "android.newCallUi.2",
defaultValue = false,
hotSwappable = false
)