diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BottomControlsWithOptionalBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BottomControlsWithOptionalBar.kt new file mode 100644 index 0000000000..8f865981fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BottomControlsWithOptionalBar.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt index 0163cc27ff..3642466c88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt index 297713af9c..8adafcb9b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index fe243bc1bc..491009cfeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt new file mode 100644 index 0000000000..b058496fcf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt index 5ccee556d5..9f0dc45af1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt index f01467a2fa..1aff92d8c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt @@ -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 = persistentListOf() ) { fun isDisplayingControlMenu(): Boolean = isDisplayingAudioToggleSheet || displayAdditionalActionsDialog diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt index 0ca4001aa6..d28ffc57ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 4913b4d7d7..4177802f34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -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 )