diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt index 6ce1a7e710..1819fd6161 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt @@ -41,6 +41,14 @@ fun BadgeImageMedium( BadgeImage(badge, BadgeImageSize.MEDIUM, modifier) } +@Composable +fun BadgeImageLarge( + badge: Badge?, + modifier: Modifier = Modifier +) { + BadgeImage(badge, BadgeImageSize.LARGE, modifier) +} + @Composable fun BadgeImage112( badge: Badge?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioIndicatorView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioIndicatorView.kt index 04d530d5be..126df2475d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioIndicatorView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioIndicatorView.kt @@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.util.visible /** * An indicator shown for each participant in a call which shows the state of their audio. */ -class AudioIndicatorView(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) { +class AudioIndicatorView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { companion object { private const val SIDE_BAR_SHRINK_FACTOR = 0.75f diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt index 214856daca..2bd40b1ec2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt @@ -6,7 +6,6 @@ package org.thoughtcrime.securesms.components.webrtc.controls import android.content.Context -import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -44,7 +43,6 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.toLiveData @@ -54,6 +52,7 @@ import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.flow.map import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.NightPreview import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Rows import org.thoughtcrime.securesms.R @@ -108,20 +107,14 @@ object CallInfoView { } } - SignalTheme( - isDarkMode = true - ) { - Surface { - CallInfo( - participantsState = participantsState, - controlAndInfoState = controlAndInfoState, - onShareLinkClicked = callbacks::onShareLinkClicked, - onEditNameClicked = onEditNameClicked, - onBlock = callbacks::onBlock, - modifier = modifier - ) - } - } + CallInfo( + participantsState = participantsState, + controlAndInfoState = controlAndInfoState, + onShareLinkClicked = callbacks::onShareLinkClicked, + onEditNameClicked = onEditNameClicked, + onBlock = callbacks::onBlock, + modifier = modifier + ) } interface Callbacks { @@ -131,20 +124,18 @@ object CallInfoView { } } -@Preview +@NightPreview @Composable private fun CallInfoPreview() { Previews.Preview { - Surface { - val remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)) - CallInfo( - participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it, System.currentTimeMillis()) }), - controlAndInfoState = ControlAndInfoState(), - onShareLinkClicked = { }, - onEditNameClicked = { }, - onBlock = { } - ) - } + val remoteParticipants = listOf(CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales"))) + CallInfo( + participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it, System.currentTimeMillis()) }), + controlAndInfoState = ControlAndInfoState(), + onShareLinkClicked = { }, + onEditNameClicked = { }, + onBlock = { } + ) } } @@ -340,20 +331,20 @@ private fun getCallSheetLabel(state: ParticipantsState): String { } } -@Preview +@NightPreview @Composable private fun CallParticipantRowPreview() { Previews.Preview { Surface { CallParticipantRow( - CallParticipant(recipient = Recipient.UNKNOWN), + CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")), isSelfAdmin = true ) {} } } } -@Preview +@NightPreview @Composable private fun HandRaisedRowPreview() { Previews.Preview { @@ -636,7 +627,7 @@ private fun ThreeUnknownAvatars() { } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@NightPreview @Composable private fun UnknownMembersRowPreview() { Previews.BottomSheetPreview { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt index aabb469fb8..c072492945 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt @@ -17,6 +17,7 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.IdRes import androidx.annotation.Px +import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.ComposeView @@ -57,6 +58,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcControls import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsVisibilityListener import org.thoughtcrime.securesms.components.webrtc.v2.CallInfoCallbacks import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel +import org.thoughtcrime.securesms.compose.SignalTheme import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult import org.thoughtcrime.securesms.util.padding import org.thoughtcrime.securesms.util.visible @@ -244,7 +246,14 @@ class ControlsAndInfoController private constructor( setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val nestedScrollInterop = rememberNestedScrollInteropConnection() - CallInfoView.View(viewModel, controlsAndInfoViewModel, callInfoCallbacks, Modifier.nestedScroll(nestedScrollInterop)) + + SignalTheme( + isDarkMode = true + ) { + Surface { + CallInfoView.View(viewModel, controlsAndInfoViewModel, callInfoCallbacks, Modifier.nestedScroll(nestedScrollInterop)) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantRenderer.kt index b5be1141b5..f722f6ff48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantRenderer.kt @@ -5,23 +5,9 @@ package org.thoughtcrime.securesms.components.webrtc.v2 -import android.view.LayoutInflater -import android.widget.FrameLayout -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -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.platform.LocalInspectionMode -import androidx.compose.ui.viewinterop.AndroidView -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.webrtc.CallParticipantView import org.thoughtcrime.securesms.events.CallParticipant -import org.thoughtcrime.securesms.ringrtc.CameraState -import org.webrtc.RendererCommon /** * Displays video for the local participant or an appropriate avatar. @@ -31,46 +17,21 @@ fun CallParticipantRenderer( callParticipant: CallParticipant, renderInPip: Boolean, modifier: Modifier = Modifier, - isLocalParticipant: Boolean = false, isRaiseHandAllowed: Boolean = false, - selfPipMode: CallParticipantView.SelfPipMode = CallParticipantView.SelfPipMode.NOT_SELF_PIP, + selfPipMode: SelfPipMode = SelfPipMode.NOT_SELF_PIP, onToggleCameraDirection: () -> Unit = {} ) { - if (LocalInspectionMode.current) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier.background(color = MaterialTheme.colorScheme.secondaryContainer) - ) { - Text( - text = "${callParticipant.callParticipantId.recipientId.toLong()}", - style = MaterialTheme.typography.titleLarge - ) - } - } else { - AndroidView( - factory = { LayoutInflater.from(it).inflate(R.layout.call_participant_item, FrameLayout(it), false) as CallParticipantView }, - modifier = modifier.fillMaxSize(), - onRelease = { it.releaseRenderer() }, - onReset = {} // Allows reuse in lazy lists - ) { view -> - view.setCallParticipant(callParticipant) - view.setMirror(isLocalParticipant && callParticipant.cameraState.activeDirection == CameraState.Direction.FRONT) - view.setScalingType( - if (callParticipant.isScreenSharing && !isLocalParticipant) { - RendererCommon.ScalingType.SCALE_ASPECT_FIT - } else { - RendererCommon.ScalingType.SCALE_ASPECT_FILL - } - ) - view.setRenderInPip(renderInPip) - view.setRaiseHandAllowed(isRaiseHandAllowed) - - if (selfPipMode != CallParticipantView.SelfPipMode.NOT_SELF_PIP) { - view.setSelfPipMode(selfPipMode, callParticipant.cameraState.cameraCount > 1) - view.setCameraToggleOnClickListener { onToggleCameraDirection() } - } else { - view.setCameraToggleOnClickListener(null) - } - } - } + CallParticipantViewer( + participant = callParticipant, + renderInPip = renderInPip, + raiseHandAllowed = isRaiseHandAllowed, + selfPipMode = selfPipMode, + isMoreThanOneCameraAvailable = callParticipant.cameraState.cameraCount > 1, + onSwitchCameraClick = if (selfPipMode != SelfPipMode.NOT_SELF_PIP) { + { onToggleCameraDirection() } + } else { + null + }, + modifier = modifier + ) } 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 new file mode 100644 index 0000000000..1924f970ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt @@ -0,0 +1,615 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import org.signal.core.ui.compose.Buttons +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.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge +import org.thoughtcrime.securesms.components.webrtc.AudioIndicatorView +import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.AvatarUtil + +/** + * Encapsulates views needed to show a call participant including their + * avatar in full screen or pip mode, and their video feed. + * + * This is a Compose reimplementation of [org.thoughtcrime.securesms.components.webrtc.CallParticipantView]. + */ +@Composable +fun CallParticipantViewer( + participant: CallParticipant, + modifier: Modifier = Modifier, + renderInPip: Boolean = false, + raiseHandAllowed: Boolean = false, + selfPipMode: SelfPipMode = SelfPipMode.NOT_SELF_PIP, + isMoreThanOneCameraAvailable: Boolean = false, + onSwitchCameraClick: (() -> Unit)? = null, + onInfoMoreInfoClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val recipient = participant.recipient + val isBlocked = recipient.isBlocked + val isMissingMediaKeys = !participant.isMediaKeysReceived && + (System.currentTimeMillis() - participant.addedToCallTime) > 5000 + val infoMode = isBlocked || isMissingMediaKeys + + Box(modifier = modifier) { + BlurredBackgroundAvatar(recipient = recipient) + + if (infoMode) { + InfoOverlay( + recipient = recipient, + isBlocked = isBlocked, + renderInPip = renderInPip, + onMoreInfoClick = onInfoMoreInfoClick + ) + } else { + val hasContentToRender = participant.isVideoEnabled || participant.isScreenSharing + + if (hasContentToRender) { + VideoRenderer( + participant = participant, + modifier = Modifier.fillMaxSize() + ) + } else { + if (!renderInPip) { + AvatarWithBadge( + recipient = recipient, + modifier = Modifier.align(Alignment.Center) + ) + } + + if (renderInPip) { + PipAvatar( + recipient = recipient, + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + ) + } + } + + AudioIndicator( + participant = participant, + selfPipMode = selfPipMode, + modifier = Modifier.align( + when (selfPipMode) { + SelfPipMode.MINI_SELF_PIP -> Alignment.BottomCenter + else -> Alignment.BottomStart + } + ) + ) + + if (selfPipMode != SelfPipMode.NOT_SELF_PIP && isMoreThanOneCameraAvailable && selfPipMode != SelfPipMode.MINI_SELF_PIP) { + SwitchCameraButton( + selfPipMode = selfPipMode, + onClick = onSwitchCameraClick, + modifier = Modifier.align(Alignment.BottomEnd) + ) + } + + if (raiseHandAllowed && participant.isHandRaised) { + RaiseHandIndicator( + name = participant.getShortRecipientDisplayName(context), + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 8.dp, top = 8.dp) + ) + } + } + } +} + +@Composable +private fun BlurredBackgroundAvatar( + recipient: Recipient, + modifier: Modifier = Modifier +) { + val isInPreview = LocalInspectionMode.current + + // Use a simple background in preview mode, otherwise use Glide to load the blurred avatar + if (isInPreview) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color(0xFF1B1B1D)) + ) + } else { + // Use AndroidView to leverage AvatarUtil.loadBlurredIconIntoImageView + AndroidView( + factory = { context -> + androidx.appcompat.widget.AppCompatImageView(context).apply { + scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + } + }, + update = { imageView -> + AvatarUtil.loadBlurredIconIntoImageView(recipient, imageView) + }, + modifier = modifier.fillMaxSize() + ) + } +} + +@Composable +private fun AvatarWithBadge( + recipient: Recipient, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + AvatarImage( + recipient = recipient, + modifier = Modifier.size(112.dp) + ) + + BadgeImageLarge( + badge = recipient.badges.firstOrNull(), + modifier = Modifier + .align(Alignment.BottomEnd) + .size(36.dp) + ) + } +} + +@Composable +private fun PipAvatar( + recipient: Recipient, + modifier: Modifier = Modifier +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + // Use the smaller dimension to maintain 1:1 aspect ratio + val avatarModifier = if (maxWidth < maxHeight) { + Modifier + .width(maxWidth) + .aspectRatio(1f) + } else { + Modifier + .height(maxHeight) + .aspectRatio(1f, true) + } + + AvatarImage( + recipient = recipient, + modifier = avatarModifier + ) + + BadgeImageLarge( + badge = recipient.badges.firstOrNull(), + modifier = Modifier + .align(Alignment.BottomEnd) + .size(36.dp) + ) + } +} + +@Composable +private fun VideoRenderer( + participant: CallParticipant, + modifier: Modifier = Modifier +) { + var renderer by remember { mutableStateOf(null) } + + AndroidView( + factory = { context -> + FrameLayout(context).apply { + setBackgroundColor(android.graphics.Color.parseColor("#CC000000")) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + val textureRenderer = TextureViewRenderer(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + Gravity.CENTER + ) + + if (participant.isVideoEnabled) { + participant.videoSink.lockableEglBase.performWithValidEglBase { eglBase -> + init(eglBase) + } + attachBroadcastVideoSink(participant.videoSink) + } + } + + renderer = textureRenderer + addView(textureRenderer) + } + }, + update = { + val textureRenderer = renderer + if (textureRenderer != null) { + if (participant.isVideoEnabled) { + participant.videoSink.lockableEglBase.performWithValidEglBase { eglBase -> + textureRenderer.init(eglBase) + } + textureRenderer.attachBroadcastVideoSink(participant.videoSink) + } else { + textureRenderer.attachBroadcastVideoSink(null) + } + } + }, + onRelease = { + renderer?.release() + }, + modifier = modifier + ) +} + +@Composable +private fun AudioIndicator( + participant: CallParticipant, + selfPipMode: SelfPipMode, + modifier: Modifier = Modifier +) { + val margin = when (selfPipMode) { + SelfPipMode.NORMAL_SELF_PIP -> 10.dp + SelfPipMode.EXPANDED_SELF_PIP -> 10.dp + SelfPipMode.MINI_SELF_PIP -> 10.dp + SelfPipMode.FOCUSED_SELF_PIP -> 12.dp + SelfPipMode.NOT_SELF_PIP -> 12.dp + } + + AndroidView( + factory = { context -> + AudioIndicatorView(context, null) + }, + update = { view -> + view.bind(participant.isMicrophoneEnabled, participant.audioLevel) + }, + modifier = modifier + .padding(margin) + .size(28.dp) + ) +} + +@Composable +private fun SwitchCameraButton( + selfPipMode: SelfPipMode, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier +) { + val size = when (selfPipMode) { + SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 48.dp + else -> 28.dp + } + + val margin = when (selfPipMode) { + SelfPipMode.FOCUSED_SELF_PIP -> 12.dp + else -> 10.dp + } + + val iconInset = when (selfPipMode) { + SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 12.dp + SelfPipMode.MINI_SELF_PIP -> 7.dp + else -> 6.dp + } + + // Only clickable in EXPANDED_SELF_PIP mode (per setSelfPipMode logic) + val clickModifier = if (selfPipMode == SelfPipMode.EXPANDED_SELF_PIP && onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + } + + Box( + modifier = modifier + .padding(end = margin, bottom = margin) + .size(size) + .background( + color = Color(0xFF383838), + shape = CircleShape + ) + .padding(iconInset) + .then(clickModifier), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_switch_24), + contentDescription = "Switch camera direction", + tint = Color.White + ) + } +} + +@Composable +private fun RaiseHandIndicator( + name: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = colorResource(R.color.signal_light_colorSurface), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_raise_hand_24), + contentDescription = null, + tint = Color.Unspecified, // Let the drawable use its default color + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = name, + color = colorResource(R.color.signal_light_colorOnPrimary), + fontSize = 14.sp, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun InfoOverlay( + recipient: Recipient, + isBlocked: Boolean, + renderInPip: Boolean, + onMoreInfoClick: (() -> Unit)? +) { + val context = LocalContext.current + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x66000000)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource( + id = if (isBlocked) R.drawable.ic_block_tinted_24 else R.drawable.ic_error_solid_24 + ), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + + if (!renderInPip) { + Spacer(modifier = Modifier.size(12.dp)) + + // Use AndroidView for EmojiTextView + AndroidView( + factory = { ctx -> + EmojiTextView(ctx).apply { + setTextColor(android.graphics.Color.WHITE) + gravity = Gravity.CENTER_HORIZONTAL + maxLines = 3 + setPadding( + context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter), + 0, + context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter), + 0 + ) + } + }, + update = { view -> + view.text = if (isBlocked) { + context.getString( + R.string.CallParticipantView__s_is_blocked, + recipient.getShortDisplayName(context) + ) + } else { + context.getString( + R.string.CallParticipantView__cant_receive_audio_video_from_s, + recipient.getShortDisplayName(context) + ) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Buttons.Small( + onClick = { onMoreInfoClick?.invoke() }, + modifier = Modifier + ) { + Text(text = stringResource(R.string.CallParticipantView__more_info)) + } + } + } + } +} + +enum class SelfPipMode { + NOT_SELF_PIP, + NORMAL_SELF_PIP, + EXPANDED_SELF_PIP, + MINI_SELF_PIP, + FOCUSED_SELF_PIP +} + +@NightPreview +@Composable +private fun CallParticipantViewerPreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "Alice Johnson"), + isMicrophoneEnabled = true, + audioLevel = CallParticipant.AudioLevel.MEDIUM + ), + raiseHandAllowed = false, + renderInPip = false, + modifier = Modifier.size(400.dp, 600.dp) + ) + } +} + +@NightPreview +@Composable +private fun CallParticipantViewerRaiseHandPreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "Bob Smith"), + isMicrophoneEnabled = true, + audioLevel = CallParticipant.AudioLevel.HIGH, + handRaisedTimestamp = System.currentTimeMillis() + ), + raiseHandAllowed = true, + renderInPip = false, + modifier = Modifier.size(400.dp, 600.dp) + ) + } +} + +@NightPreview +@Composable +private fun CallParticipantViewerPipPreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"), + isMicrophoneEnabled = false + ), + renderInPip = true, + modifier = Modifier.size(200.dp, 200.dp) + ) + } +} + +@NightPreview +@Composable +private fun CallParticipantViewerPipLandscapePreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"), + isMicrophoneEnabled = false + ), + renderInPip = true, + modifier = Modifier.size(200.dp, 100.dp) + ) + } +} + +@NightPreview +@Composable +private fun CallParticipantViewerBlockedPreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "Diana Prince", isBlocked = true) + ), + renderInPip = false, + onInfoMoreInfoClick = {}, + modifier = Modifier.size(400.dp, 600.dp) + ) + } +} + +@NightPreview +@Composable +private fun CallParticipantViewerSelfPipNormalPreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true), + isMicrophoneEnabled = true, + audioLevel = CallParticipant.AudioLevel.MEDIUM + ), + renderInPip = true, + selfPipMode = SelfPipMode.NORMAL_SELF_PIP, + isMoreThanOneCameraAvailable = true, + onSwitchCameraClick = {}, + modifier = Modifier.size(CallScreenMetrics.NormalRendererDpSize) + ) + } +} + +@NightPreview +@Composable +private fun CallParticipantViewerSelfPipExpandedPreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true), + isMicrophoneEnabled = true, + audioLevel = CallParticipant.AudioLevel.HIGH + ), + renderInPip = true, + selfPipMode = SelfPipMode.EXPANDED_SELF_PIP, + isMoreThanOneCameraAvailable = true, + onSwitchCameraClick = {}, + modifier = Modifier.size(CallScreenMetrics.ExpandedRendererDpSize) + ) + } +} + +@NightPreview +@Composable +private fun CallParticipantViewerSelfPipMiniPreview() { + Previews.Preview { + CallParticipantViewer( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true), + isMicrophoneEnabled = false + ), + renderInPip = true, + selfPipMode = SelfPipMode.MINI_SELF_PIP, + isMoreThanOneCameraAvailable = false, + modifier = Modifier.size(CallScreenMetrics.SmallRendererDpSize) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt index b265559d0b..071dad206d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt @@ -10,9 +10,11 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.shape.RoundedCornerShape @@ -52,6 +54,8 @@ fun CallParticipantsPager( VerticalPager( state = pagerState, modifier = modifier + .displayCutoutPadding() + .statusBarsPadding() ) { page -> when (page) { 0 -> { 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 6dc5c67647..36ed78340b 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 @@ -29,6 +29,7 @@ import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -55,6 +56,7 @@ import org.signal.core.ui.compose.TriggerAlignedPopupState import org.signal.core.util.DimensionUnit import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar +import org.thoughtcrime.securesms.compose.SignalTheme import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipantId @@ -163,6 +165,7 @@ fun CallScreen( scaffoldState = callScreenController.scaffoldState, sheetDragHandle = null, sheetPeekHeight = peekHeight.dp, + sheetContainerColor = SignalTheme.colors.colorSurface1, sheetMaxWidth = 540.dp, sheetContent = { BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally)) @@ -189,7 +192,9 @@ fun CallScreen( val callInfoAlpha = max(0f, peekPercentage) if (callInfoAlpha > 0f) { - callInfoView(callInfoAlpha) + Surface { + callInfoView(callInfoAlpha) + } } if (callControlsAlpha > 0f) { @@ -348,10 +353,12 @@ private fun Viewport( ) { val isEmptyOngoingCall = webRtcCallState.inOngoingCall && callParticipantsPagerState.callParticipants.isEmpty() if (webRtcCallState.isPreJoinOrNetworkUnavailable || isEmptyOngoingCall) { - LargeLocalVideoRenderer( - localParticipant = localParticipant, - modifier = modifier - ) + if (localParticipant.isVideoEnabled) { + LargeLocalVideoRenderer( + localParticipant = localParticipant, + modifier = modifier + ) + } return } @@ -446,7 +453,6 @@ private fun LargeLocalVideoRenderer( ) { CallParticipantRenderer( callParticipant = localParticipant, - isLocalParticipant = true, renderInPip = false, modifier = modifier .fillMaxSize() 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 80a70df9e9..5ccee556d5 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,8 +6,10 @@ package org.thoughtcrime.securesms.components.webrtc.v2 import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -22,6 +24,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.remember import androidx.compose.ui.Alignment @@ -33,7 +36,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowWidthSizeClass +import org.signal.core.ui.compose.AllNightPreviews import org.signal.core.ui.compose.NightPreview import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.R @@ -95,19 +101,17 @@ fun CallScreenPreJoinOverlay( if (!isLocalVideoEnabled) { Spacer(modifier = Modifier.weight(1f)) - Icon( - painter = painterResource( - id = R.drawable.symbol_video_slash_24 - ), - contentDescription = null, - tint = Color.White, - modifier = Modifier.padding(bottom = 8.dp) - ) - - Text( - text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), - color = Color.White - ) + 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)) } @@ -118,13 +122,34 @@ fun CallScreenPreJoinOverlay( Box(modifier = Modifier.fillMaxWidth()) { CallCameraDirectionToggle( onClick = onCameraToggleClick, - modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp) + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) ) } } } } +@Composable +private fun YourCameraIsOff( + spacedBy: Dp = 0.dp +) { + Icon( + painter = painterResource( + id = R.drawable.symbol_video_slash_24 + ), + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(bottom = spacedBy) + ) + + Text( + text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), + color = Color.White + ) +} + @Composable private fun CallCameraDirectionToggle( onClick: () -> Unit, @@ -222,7 +247,7 @@ fun CallScreenTopBarPreview() { } } -@NightPreview +@AllNightPreviews @Composable fun CallScreenPreJoinOverlayPreview() { Previews.Preview { @@ -235,7 +260,7 @@ fun CallScreenPreJoinOverlayPreview() { } } -@NightPreview +@AllNightPreviews @Composable fun CallScreenPreJoinOverlayWithTogglePreview() { Previews.Preview { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt index 16b0ec659b..1b5184ec05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.unit.dp import org.signal.core.ui.compose.NightPreview import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.webrtc.CallParticipantView import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState import org.thoughtcrime.securesms.events.CallParticipant @@ -95,14 +94,16 @@ fun MoveableLocalVideoRenderer( state.animateTo(targetSize) val selfPipMode = when (localRenderState) { - WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED -> { - CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP + WebRtcLocalRenderState.EXPANDED -> { + SelfPipMode.EXPANDED_SELF_PIP + } WebRtcLocalRenderState.FOCUSED -> { + SelfPipMode.FOCUSED_SELF_PIP } WebRtcLocalRenderState.SMALLER_RECTANGLE -> { - CallParticipantView.SelfPipMode.MINI_SELF_PIP + SelfPipMode.MINI_SELF_PIP } else -> { - CallParticipantView.SelfPipMode.NORMAL_SELF_PIP + SelfPipMode.NORMAL_SELF_PIP } } @@ -117,7 +118,6 @@ fun MoveableLocalVideoRenderer( ) { CallParticipantRenderer( callParticipant = localParticipant, - isLocalParticipant = true, renderInPip = true, selfPipMode = selfPipMode, onToggleCameraDirection = onToggleCameraDirectionClick,