Address design feedback for calling UI.

This commit is contained in:
Alex Hart
2025-11-24 09:18:50 -04:00
committed by jeffrey-signal
parent acd82353b1
commit b3b934e009
10 changed files with 733 additions and 114 deletions

View File

@@ -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?,

View File

@@ -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

View File

@@ -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 {

View File

@@ -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))
}
}
}
}

View File

@@ -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
)
}

View File

@@ -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<TextureViewRenderer?>(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)
)
}
}

View File

@@ -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 -> {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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,