mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 08:09:12 +01:00
Add missing call link ui.
This commit is contained in:
committed by
jeffrey-signal
parent
c2ec9e579e
commit
a7f239576f
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user