diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt index 92981a1f5d..52526d3bd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt @@ -40,11 +40,13 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map +import org.signal.core.ui.compose.AllNightPreviews +import org.signal.core.ui.compose.SignalPreviewWrapper import org.signal.core.ui.compose.theme.SignalTheme import org.signal.ringrtc.GroupCall import org.thoughtcrime.securesms.R @@ -118,7 +120,8 @@ object RaiseHandSnackbar { } } -@Preview +@PreviewWrapper(SignalPreviewWrapper::class) +@AllNightPreviews @Composable private fun RaiseHandSnackbarPreview() { RaiseHand( @@ -139,67 +142,63 @@ private fun RaiseHand( exit = shrinkOut(shrinkTowards = Alignment.CenterEnd) + fadeOut(), modifier = modifier ) { - SignalTheme( - isDarkMode = true + Surface( + modifier = Modifier + .padding(horizontal = 16.dp) + .clip(shape = RoundedCornerShape(16.dp, 16.dp, 16.dp, 16.dp)) + .background(SignalTheme.colors.colorSurface1) + .animateContentSize() ) { - Surface( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(shape = RoundedCornerShape(16.dp, 16.dp, 16.dp, 16.dp)) - .background(SignalTheme.colors.colorSurface1) - .animateContentSize() + val boxModifier = Modifier + .padding(horizontal = 16.dp) + .clickable( + !state.isExpanded, + stringResource(id = R.string.CallOverflowPopupWindow__expand_snackbar_accessibility_label), + Role.Button + ) { setExpanded(true) } + + Box( + contentAlignment = Alignment.CenterStart, + modifier = if (state.isExpanded) { + boxModifier.fillMaxWidth() + } else { + boxModifier.wrapContentWidth() + } ) { - val boxModifier = Modifier - .padding(horizontal = 16.dp) - .clickable( - !state.isExpanded, - stringResource(id = R.string.CallOverflowPopupWindow__expand_snackbar_accessibility_label), - Role.Button - ) { setExpanded(true) } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_raise_hand_24), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(vertical = 8.dp) + ) - Box( - contentAlignment = Alignment.CenterStart, - modifier = if (state.isExpanded) { - boxModifier.fillMaxWidth() - } else { - boxModifier.wrapContentWidth() - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.symbol_raise_hand_24), - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(vertical = 8.dp) - ) - - Text( - text = getSnackbarText(state), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(start = 16.dp) - .weight(1f, fill = state.isExpanded) - .wrapContentWidth(Alignment.Start) - .padding(vertical = 16.dp) - ) - if (state.isExpanded) { - if (state.raisedHands.any { it.sender.isSelf && it.sender.isPrimary }) { - TextButton( - onClick = { - AppDependencies.signalCallManager.raiseHand(false) - }, - modifier = Modifier.wrapContentWidth(Alignment.End) - ) { - Text(text = stringResource(id = R.string.CallOverflowPopupWindow__lower_hand), maxLines = 1) - } - } else { - TextButton( - onClick = showCallInfoListener, - modifier = Modifier.wrapContentWidth(Alignment.End) - ) { - Text(text = stringResource(id = R.string.CallOverflowPopupWindow__view), maxLines = 1) - } + Text( + text = getSnackbarText(state), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(start = 16.dp) + .weight(1f, fill = state.isExpanded) + .wrapContentWidth(Alignment.Start) + .padding(vertical = 16.dp) + ) + if (state.isExpanded) { + if (state.raisedHands.any { it.sender.isSelf && it.sender.isPrimary }) { + TextButton( + onClick = { + AppDependencies.signalCallManager.raiseHand(false) + }, + modifier = Modifier.wrapContentWidth(Alignment.End) + ) { + Text(text = stringResource(id = R.string.CallOverflowPopupWindow__lower_hand), maxLines = 1) + } + } else { + TextButton( + onClick = showCallInfoListener, + modifier = Modifier.wrapContentWidth(Alignment.End) + ) { + Text(text = stringResource(id = R.string.CallOverflowPopupWindow__view), maxLines = 1) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt index d383f090c1..a22c50cd1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt @@ -8,26 +8,31 @@ package org.thoughtcrime.securesms.components.webrtc.v2 import android.Manifest import android.content.pm.PackageManager import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import org.signal.core.ui.compose.NightPreview -import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreviewWrapper import org.signal.core.ui.compose.TriggerAlignedPopupState.Companion.popupTrigger import org.signal.core.ui.compose.TriggerAlignedPopupState.Companion.rememberTriggerAlignedPopupState +import org.signal.core.ui.compose.spaceBetweenUpTo import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState import org.thoughtcrime.securesms.components.webrtc.ToggleButtonOutputState @@ -61,7 +66,8 @@ fun CallControls( modifier = modifier.padding(bottom = bottom) ) { Row( - horizontalArrangement = spacedBy(20.dp) + horizontalArrangement = Arrangement.spaceBetweenUpTo(20.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) ) { if (callControlsState.displayAudioOutputToggle) { CallAudioToggleButton( @@ -134,39 +140,266 @@ fun CallControls( } } -@NightPreview +/** + * The distinct control layouts the call screen can produce, as derived by + * [CallControlsState.fromViewModelData] from a given [WebRtcControls]. The rules that separate them: + * + * - the audio, video, and mic toggles appear in every pre-join and in-call state, except that the audio toggle + * drops out once your own camera is on and there's no headset to switch to + * - the ring toggle is pre-join only, and only for a group that isn't a call link and that nobody has joined yet + * - the overflow button is in-call only, and only once someone else is on the call + * - the start call button is pre-join only, and the hangup button replaces it once the call is up + * + * Note that a connected call link and a connected group produce the same strip -- a call link is a group call with + * `isCallLink` set, and that flag only suppresses the ring toggle, which is pre-join only. So the connected case is + * covered once, by [GROUP_ONGOING]. + */ +private enum class CallControlsPreviewState(val controls: CallControlsState) { + /** 1:1 lobby. No ring toggle (not a group), no hangup yet. */ + ONE_TO_ONE_PRE_JOIN( + CallControlsState( + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.HANDSET, + displayVideoToggle = true, + displayMicToggle = true, + isMicEnabled = true, + displayStartCallButton = true, + startCallButtonText = R.string.WebRtcCallView__start_call + ) + ), + + /** The smallest strip: camera on with no headset attached leaves nowhere for the audio toggle to route to. */ + ONE_TO_ONE_PRE_JOIN_VIDEO( + CallControlsState( + audioOutput = WebRtcAudioOutput.SPEAKER, + displayVideoToggle = true, + isVideoEnabled = true, + displayMicToggle = true, + isMicEnabled = true, + displayStartCallButton = true, + startCallButtonText = R.string.WebRtcCallView__start_call + ) + ), + + /** Connected 1:1. Overflow only shows here when screen sharing is enabled remotely. */ + ONE_TO_ONE_ONGOING( + CallControlsState( + isBluetoothHeadsetAvailable = true, + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.BLUETOOTH_HEADSET, + displayVideoToggle = true, + displayMicToggle = true, + isMicEnabled = true, + displayAdditionalActions = true, + displayEndCallButton = true + ) + ), + + /** Group lobby with nobody on the call yet: the only state that offers the ring toggle. */ + GROUP_PRE_JOIN( + CallControlsState( + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.HANDSET, + displayVideoToggle = true, + displayMicToggle = true, + isMicEnabled = true, + displayGroupRingingToggle = true, + isGroupRingingEnabled = true, + isGroupRingingAllowed = true, + isGroupCall = true, + displayStartCallButton = true, + startCallButtonText = R.string.WebRtcCallView__start_call + ) + ), + + /** Same lobby, but the group is past [RemoteConfig.maxGroupCallRingSize], so the ring toggle is shown disabled. */ + GROUP_PRE_JOIN_RING_DISALLOWED( + CallControlsState( + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.HANDSET, + displayVideoToggle = true, + displayMicToggle = true, + isMicEnabled = true, + displayGroupRingingToggle = true, + isGroupCall = true, + displayStartCallButton = true, + startCallButtonText = R.string.WebRtcCallView__start_call + ) + ), + + /** Group lobby for a call that's already running: the ring toggle is gone and the button reads "Join call". */ + GROUP_PRE_JOIN_CALL_IN_PROGRESS( + CallControlsState( + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.HANDSET, + displayVideoToggle = true, + displayMicToggle = true, + isMicEnabled = true, + isGroupCall = true, + displayStartCallButton = true, + startCallButtonText = R.string.WebRtcCallView__join_call + ) + ), + + /** Call link lobby. Identical to an empty group lobby except that call links never ring. */ + CALL_LINK_PRE_JOIN( + CallControlsState( + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.HANDSET, + displayVideoToggle = true, + displayMicToggle = true, + isMicEnabled = true, + isGroupCall = true, + displayStartCallButton = true, + startCallButtonText = R.string.WebRtcCallView__start_call + ) + ), + + /** Connected group or call link. The widest strip. */ + GROUP_ONGOING( + CallControlsState( + isWiredHeadsetAvailable = true, + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.WIRED_HEADSET, + displayVideoToggle = true, + isVideoEnabled = true, + displayMicToggle = true, + isMicEnabled = true, + isGroupCall = true, + displayAdditionalActions = true, + displayEndCallButton = true + ) + ), + + /** Connected group while sharing your screen, which takes the video toggle away. */ + GROUP_ONGOING_SCREEN_SHARING( + CallControlsState( + isEarpieceAvailable = true, + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.SPEAKER, + displayVideoToggle = true, + displayMicToggle = true, + isMicEnabled = true, + isGroupCall = true, + displayAdditionalActions = true, + displayEndCallButton = true, + isLocalScreenSharing = true + ) + ) +} + +/** + * The remembered collaborators [CallControls] needs beyond its state, built to match the given controls so that + * the audio picker offers the same devices the strip claims are available. + */ @Composable -fun CallControlsPreview() { - Previews.Preview { - CallControls( - callControlsState = CallControlsState( - displayAudioOutputToggle = true, - audioOutput = WebRtcAudioOutput.WIRED_HEADSET, - displayMicToggle = true, - isMicEnabled = true, - displayVideoToggle = true, - isVideoEnabled = true, - displayGroupRingingToggle = true, - isGroupRingingEnabled = true, - displayAdditionalActions = true, - displayStartCallButton = true, - startCallButtonText = R.string.WebRtcCallView__start_call, - displayEndCallButton = true - ), - displayVideoTooltip = false, - callScreenControlsListener = CallScreenControlsListener.Empty, - callScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty, - additionalActionsState = AdditionalActionsState( - triggerAlignedPopupState = rememberTriggerAlignedPopupState() - ), - audioOutputPickerController = AudioOutputPickerController( - outputState = ToggleButtonOutputState(), - onSelectedDeviceChanged = {} - ) +private fun rememberPreviewState(controls: CallControlsState): Pair { + val triggerAlignedPopupState = rememberTriggerAlignedPopupState() + val outputState = remember(controls) { + ToggleButtonOutputState().apply { + isEarpieceAvailable = controls.isEarpieceAvailable + isWiredHeadsetAvailable = controls.isWiredHeadsetAvailable + isBluetoothHeadsetAvailable = controls.isBluetoothHeadsetAvailable + setCurrentOutput(controls.audioOutput) + } + } + + return remember(controls, triggerAlignedPopupState, outputState) { + AdditionalActionsState( + triggerAlignedPopupState = triggerAlignedPopupState, + isGroupCall = controls.isGroupCall, + isScreenSharing = controls.isLocalScreenSharing + ) to AudioOutputPickerController( + outputState = outputState, + onSelectedDeviceChanged = {} ) } } +@Composable +private fun CallControlsPreview(previewState: CallControlsPreviewState) { + val (additionalActionsState, audioOutputPickerController) = rememberPreviewState(previewState.controls) + + CallControls( + callControlsState = previewState.controls, + displayVideoTooltip = false, + callScreenControlsListener = CallScreenControlsListener.Empty, + callScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty, + additionalActionsState = additionalActionsState, + audioOutputPickerController = audioOutputPickerController + ) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun OneToOnePreJoinCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.ONE_TO_ONE_PRE_JOIN) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun OneToOnePreJoinVideoCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.ONE_TO_ONE_PRE_JOIN_VIDEO) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun OneToOneOngoingCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.ONE_TO_ONE_ONGOING) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun GroupPreJoinCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.GROUP_PRE_JOIN) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun GroupPreJoinRingDisallowedCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.GROUP_PRE_JOIN_RING_DISALLOWED) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun GroupPreJoinCallInProgressCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.GROUP_PRE_JOIN_CALL_IN_PROGRESS) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun CallLinkPreJoinCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.CALL_LINK_PRE_JOIN) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun GroupOngoingCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.GROUP_ONGOING) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@NightPreview +@Composable +private fun GroupOngoingScreenSharingCallControlsPreview() { + CallControlsPreview(CallControlsPreviewState.GROUP_ONGOING_SCREEN_SHARING) +} + /** * Callbacks for call controls actions. */ diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ArrangementExtensions.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ArrangementExtensions.kt new file mode 100644 index 0000000000..7b2a5011be --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/ArrangementExtensions.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +/** + * Spreads children across the full width like [Arrangement.SpaceBetween], but never opens a gap wider than [max]. + * Once the gap is capped, the children are centered as a group rather than pinned to the edges, and if there isn't + * even room for [max] the gap shrinks to fit instead of overflowing. + */ +fun Arrangement.spaceBetweenUpTo(max: Dp): Arrangement.Horizontal = object : Arrangement.Horizontal { + override val spacing: Dp = max + + override fun Density.arrange( + totalSize: Int, + sizes: IntArray, + layoutDirection: LayoutDirection, + outPositions: IntArray + ) { + if (sizes.isEmpty()) { + return + } + + val gapCount = sizes.size - 1 + val slack = totalSize - sizes.sum() + val gap = if (gapCount == 0) 0 else (slack / gapCount).coerceIn(0, max.roundToPx()) + + var position = ((slack - gap * gapCount) / 2).coerceAtLeast(0) + for (index in if (layoutDirection == LayoutDirection.Ltr) sizes.indices else sizes.indices.reversed()) { + outPositions[index] = position + position += sizes[index] + gap + } + } +}