Various picture in picture updates.

This commit is contained in:
Alex Hart
2025-12-08 10:43:56 -04:00
committed by Michelle Tang
parent 7969df4e4c
commit 37e77a53f9
11 changed files with 571 additions and 216 deletions

View File

@@ -5,8 +5,8 @@
package org.thoughtcrime.securesms.avatar package org.thoughtcrime.securesms.avatar
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -14,8 +14,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
@@ -44,9 +49,11 @@ fun AvatarImage(
contentDescription: String? = null contentDescription: String? = null
) { ) {
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
Spacer( Image(
painter = painterResource(R.drawable.ic_avatar_abstract_02),
contentDescription = null,
modifier = modifier modifier = modifier
.background(color = Color.Red, shape = CircleShape) .background(color = Color(AvatarColor.random().colorInt()), CircleShape)
) )
} else { } else {
val context = LocalContext.current val context = LocalContext.current
@@ -76,6 +83,16 @@ fun AvatarImage(
} }
} }
@DayNightPreviews
@Composable
private fun AvatarImagePreview() {
Previews.Preview {
AvatarImage(
recipientId = RecipientId.from(1)
)
}
}
private data class AvatarImageState( private data class AvatarImageState(
val displayName: String?, val displayName: String?,
val self: Recipient, val self: Recipient,

View File

@@ -127,7 +127,7 @@ object CallInfoView {
@NightPreview @NightPreview
@Composable @Composable
private fun CallInfoPreview() { private fun CallInfoPreview() {
Previews.Preview { Previews.BottomSheetContentPreview {
val remoteParticipants = listOf(CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales"))) val remoteParticipants = listOf(CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")))
CallInfo( CallInfo(
participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it, System.currentTimeMillis()) }), participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it, System.currentTimeMillis()) }),
@@ -162,9 +162,7 @@ private fun CallInfo(
item { item {
val text = if (controlAndInfoState.callLink == null) { val text = if (controlAndInfoState.callLink == null) {
stringResource(id = R.string.CallLinkInfoSheet__call_info) stringResource(id = R.string.CallLinkInfoSheet__call_info)
} else if (controlAndInfoState.callLink.state.name.isNotEmpty()) { } else controlAndInfoState.callLink.state.name.ifEmpty {
controlAndInfoState.callLink.state.name
} else {
stringResource(id = R.string.Recipient_signal_call) stringResource(id = R.string.Recipient_signal_call)
} }

View File

@@ -1,37 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.thoughtcrime.securesms.events.CallParticipant
/**
* Displays video for the local participant or an appropriate avatar.
*/
@Composable
fun CallParticipantRenderer(
callParticipant: CallParticipant,
renderInPip: Boolean,
modifier: Modifier = Modifier,
isRaiseHandAllowed: Boolean = false,
selfPipMode: SelfPipMode = SelfPipMode.NOT_SELF_PIP,
onToggleCameraDirection: () -> Unit = {}
) {
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

@@ -8,6 +8,8 @@ package org.thoughtcrime.securesms.components.webrtc.v2
import android.view.Gravity import android.view.Gravity
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -22,6 +24,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -50,26 +53,30 @@ import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge
import org.thoughtcrime.securesms.components.webrtc.AudioIndicatorView import org.thoughtcrime.securesms.components.webrtc.AudioIndicatorView
import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer
import org.thoughtcrime.securesms.compose.GlideImage
import org.thoughtcrime.securesms.compose.GlideImageScaleType
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto
import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.AvatarUtil
/** /**
* Encapsulates views needed to show a call participant including their * Displays a remote participant (or local participant in pre-join screen).
* avatar in full screen or pip mode, and their video feed. * Handles both full-size grid view and system PIP mode.
* *
* This is a Compose reimplementation of [org.thoughtcrime.securesms.components.webrtc.CallParticipantView]. * This is a Compose reimplementation of [org.thoughtcrime.securesms.components.webrtc.CallParticipantView].
*
* @param participant The call participant to display
* @param renderInPip Whether rendering in system PIP mode (smaller, simplified UI)
* @param raiseHandAllowed Whether to show raise hand indicator
* @param onInfoMoreInfoClick Callback when "More Info" is tapped on blocked/missing keys overlay
*/ */
@Composable @Composable
fun CallParticipantViewer( fun RemoteParticipantContent(
participant: CallParticipant, participant: CallParticipant,
modifier: Modifier = Modifier, renderInPip: Boolean,
renderInPip: Boolean = false, raiseHandAllowed: Boolean,
raiseHandAllowed: Boolean = false, onInfoMoreInfoClick: (() -> Unit)?,
selfPipMode: SelfPipMode = SelfPipMode.NOT_SELF_PIP, modifier: Modifier = Modifier
isMoreThanOneCameraAvailable: Boolean = false,
onSwitchCameraClick: (() -> Unit)? = null,
onInfoMoreInfoClick: (() -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val recipient = participant.recipient val recipient = participant.recipient
@@ -96,23 +103,62 @@ fun CallParticipantViewer(
participant = participant, participant = participant,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} else { } else if (renderInPip) {
if (!renderInPip) {
AvatarWithBadge(
recipient = recipient,
modifier = Modifier.align(Alignment.Center)
)
}
if (renderInPip) {
PipAvatar( PipAvatar(
recipient = recipient, recipient = recipient,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.align(Alignment.Center) .align(Alignment.Center)
) )
} else {
AvatarWithBadge(
recipient = recipient,
modifier = Modifier.align(Alignment.Center)
)
}
AudioIndicator(
participant = participant,
selfPipMode = SelfPipMode.NOT_SELF_PIP,
modifier = Modifier.align(Alignment.BottomStart)
)
if (raiseHandAllowed && !renderInPip && participant.isHandRaised) {
RaiseHandIndicator(
name = participant.getShortRecipientDisplayName(context),
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 8.dp, top = 8.dp)
)
} }
} }
}
}
/**
* Displays the local camera preview overlay (self PIP).
* Shows video feed with audio indicator and camera switch button when camera is enabled.
* Shows a blurred gray background with camera-off icon when camera is disabled.
*
* @param participant The local call participant
* @param selfPipMode The current self-pip display mode
* @param isMoreThanOneCameraAvailable Whether to show camera switch button
* @param onSwitchCameraClick Callback when camera switch is tapped
*/
@Composable
fun SelfPipContent(
participant: CallParticipant,
selfPipMode: SelfPipMode,
isMoreThanOneCameraAvailable: Boolean,
onSwitchCameraClick: (() -> Unit)?,
modifier: Modifier = Modifier
) {
if (participant.isVideoEnabled) {
Box(modifier = modifier) {
VideoRenderer(
participant = participant,
modifier = Modifier.fillMaxSize()
)
AudioIndicator( AudioIndicator(
participant = participant, participant = participant,
@@ -125,23 +171,100 @@ fun CallParticipantViewer(
) )
) )
if (selfPipMode != SelfPipMode.NOT_SELF_PIP && isMoreThanOneCameraAvailable && selfPipMode != SelfPipMode.MINI_SELF_PIP) { if (isMoreThanOneCameraAvailable && selfPipMode != SelfPipMode.MINI_SELF_PIP) {
SwitchCameraButton( SwitchCameraButton(
selfPipMode = selfPipMode, selfPipMode = selfPipMode,
onClick = onSwitchCameraClick, onClick = onSwitchCameraClick,
modifier = Modifier.align(Alignment.BottomEnd) modifier = Modifier.align(Alignment.BottomEnd)
) )
} }
}
if (raiseHandAllowed && participant.isHandRaised) { } else {
RaiseHandIndicator( SelfPipCameraOffContent(
name = participant.getShortRecipientDisplayName(context), participant = participant,
modifier = Modifier selfPipMode = selfPipMode,
.align(Alignment.TopStart) modifier = modifier
.padding(start = 8.dp, top = 8.dp)
) )
} }
} }
/**
* Camera-off state for self PIP.
* Shows a blurred avatar background with a semi-transparent gray overlay,
* centered video-off icon, and audio indicator in lower-start.
*/
@Composable
private fun SelfPipCameraOffContent(
participant: CallParticipant,
selfPipMode: SelfPipMode,
modifier: Modifier = Modifier
) {
Box(modifier = modifier) {
BlurredBackgroundAvatar(recipient = participant.recipient)
// Semi-transparent overlay
Box(
modifier = Modifier
.fillMaxSize()
.background(
color = Color(0x995E5E5E), // rgba(94, 94, 94, 0.6)
shape = RoundedCornerShape(24.dp)
)
)
Icon(
painter = painterResource(id = R.drawable.symbol_video_slash_fill_24),
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(24.dp)
.align(Alignment.Center)
)
AudioIndicator(
participant = participant,
selfPipMode = selfPipMode,
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
/**
* Displays a remote participant in the overflow strip.
* Shows video if enabled, otherwise shows the participant's avatar filling the tile.
*
* This is a simplified version of [RemoteParticipantContent] that:
* - Always renders in "pip mode" style (avatar fills the space when video is off)
* - Uses the same audio indicator metrics as [SelfPipMode.MINI_SELF_PIP]
* - Does not show raise hand indicators or info overlays
*
* @param participant The call participant to display
*/
@Composable
fun OverflowParticipantContent(
participant: CallParticipant,
modifier: Modifier = Modifier
) {
val recipient = participant.recipient
Box(modifier = modifier) {
BlurredBackgroundAvatar(recipient = recipient)
val hasContentToRender = participant.isVideoEnabled || participant.isScreenSharing
if (hasContentToRender) {
VideoRenderer(
participant = participant,
modifier = Modifier.fillMaxSize()
)
} else {
PipAvatar(
recipient = recipient,
modifier = Modifier
.size(rememberCallScreenMetrics().overflowParticipantRendererAvatarSize)
.align(Alignment.Center)
)
}
} }
} }
@@ -150,30 +273,36 @@ private fun BlurredBackgroundAvatar(
recipient: Recipient, recipient: Recipient,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isInPreview = LocalInspectionMode.current BlurContainer(
isBlurred = true,
// Use a simple background in preview mode, otherwise use Glide to load the blurred avatar modifier = modifier,
if (isInPreview) { blurRadius = 15.dp
Box( ) {
modifier = modifier if (LocalInspectionMode.current) {
.fillMaxSize() Image(
.background(Color(0xFF1B1B1D)) painter = painterResource(R.drawable.ic_avatar_abstract_02),
contentDescription = null,
modifier = Modifier.fillMaxSize()
) )
} else { } else {
// Use AndroidView to leverage AvatarUtil.loadBlurredIconIntoImageView val photo = remember(recipient.isSelf, recipient.contactPhoto) {
AndroidView( if (recipient.isSelf) {
factory = { context -> ProfileContactPhoto(recipient)
androidx.appcompat.widget.AppCompatImageView(context).apply { } else {
scaleType = android.widget.ImageView.ScaleType.CENTER_CROP recipient.contactPhoto
} }
}, }
update = { imageView ->
AvatarUtil.loadBlurredIconIntoImageView(recipient, imageView) GlideImage(
}, modifier = Modifier
modifier = modifier.fillMaxSize() .fillMaxSize()
.background(color = Color.Black),
model = photo,
scaleType = GlideImageScaleType.CENTER_CROP
) )
} }
} }
}
@Composable @Composable
private fun AvatarWithBadge( private fun AvatarWithBadge(
@@ -219,13 +348,6 @@ private fun PipAvatar(
recipient = recipient, recipient = recipient,
modifier = avatarModifier modifier = avatarModifier
) )
BadgeImageLarge(
badge = recipient.badges.firstOrNull(),
modifier = Modifier
.align(Alignment.BottomEnd)
.size(36.dp)
)
} }
} }
@@ -317,24 +439,29 @@ private fun SwitchCameraButton(
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val size = when (selfPipMode) { val targetSize = when (selfPipMode) {
SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 48.dp SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 48.dp
else -> 28.dp else -> 28.dp
} }
val margin = when (selfPipMode) { val size by animateDpAsState(targetSize)
val targetMargin = when (selfPipMode) {
SelfPipMode.FOCUSED_SELF_PIP -> 12.dp SelfPipMode.FOCUSED_SELF_PIP -> 12.dp
else -> 10.dp else -> 10.dp
} }
val iconInset = when (selfPipMode) { val margin by animateDpAsState(targetMargin)
val targetIconInset = when (selfPipMode) {
SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 12.dp SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 12.dp
SelfPipMode.MINI_SELF_PIP -> 7.dp SelfPipMode.MINI_SELF_PIP -> 7.dp
else -> 6.dp else -> 6.dp
} }
// Only clickable in EXPANDED_SELF_PIP mode (per setSelfPipMode logic) val iconInset by animateDpAsState(targetIconInset)
val clickModifier = if (selfPipMode == SelfPipMode.EXPANDED_SELF_PIP && onClick != null) {
val clickModifier = if ((selfPipMode == SelfPipMode.EXPANDED_SELF_PIP || selfPipMode == SelfPipMode.FOCUSED_SELF_PIP) && onClick != null) {
Modifier.clickable { onClick() } Modifier.clickable { onClick() }
} else { } else {
Modifier Modifier
@@ -354,7 +481,7 @@ private fun SwitchCameraButton(
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.symbol_switch_24), painter = painterResource(id = R.drawable.symbol_switch_24),
contentDescription = "Switch camera direction", contentDescription = stringResource(R.string.SwitchCameraButton__switch_camera_direction),
tint = Color.White tint = Color.White
) )
} }
@@ -479,11 +606,13 @@ enum class SelfPipMode {
FOCUSED_SELF_PIP FOCUSED_SELF_PIP
} }
// region Remote Participant Previews
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerPreview() { private fun RemoteParticipantGridPreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( RemoteParticipantContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "Alice Johnson"), recipient = Recipient(isResolving = false, systemContactName = "Alice Johnson"),
isMicrophoneEnabled = true, isMicrophoneEnabled = true,
@@ -491,6 +620,7 @@ private fun CallParticipantViewerPreview() {
), ),
raiseHandAllowed = false, raiseHandAllowed = false,
renderInPip = false, renderInPip = false,
onInfoMoreInfoClick = null,
modifier = Modifier.size(400.dp, 600.dp) modifier = Modifier.size(400.dp, 600.dp)
) )
} }
@@ -498,9 +628,9 @@ private fun CallParticipantViewerPreview() {
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerRaiseHandPreview() { private fun RemoteParticipantRaiseHandPreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( RemoteParticipantContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "Bob Smith"), recipient = Recipient(isResolving = false, systemContactName = "Bob Smith"),
isMicrophoneEnabled = true, isMicrophoneEnabled = true,
@@ -509,6 +639,7 @@ private fun CallParticipantViewerRaiseHandPreview() {
), ),
raiseHandAllowed = true, raiseHandAllowed = true,
renderInPip = false, renderInPip = false,
onInfoMoreInfoClick = null,
modifier = Modifier.size(400.dp, 600.dp) modifier = Modifier.size(400.dp, 600.dp)
) )
} }
@@ -516,14 +647,16 @@ private fun CallParticipantViewerRaiseHandPreview() {
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerPipPreview() { private fun RemoteParticipantSystemPipPreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( RemoteParticipantContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"), recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"),
isMicrophoneEnabled = false isMicrophoneEnabled = false
), ),
raiseHandAllowed = false,
renderInPip = true, renderInPip = true,
onInfoMoreInfoClick = null,
modifier = Modifier.size(200.dp, 200.dp) modifier = Modifier.size(200.dp, 200.dp)
) )
} }
@@ -531,14 +664,16 @@ private fun CallParticipantViewerPipPreview() {
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerPipLandscapePreview() { private fun RemoteParticipantSystemPipLandscapePreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( RemoteParticipantContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"), recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"),
isMicrophoneEnabled = false isMicrophoneEnabled = false
), ),
raiseHandAllowed = false,
renderInPip = true, renderInPip = true,
onInfoMoreInfoClick = null,
modifier = Modifier.size(200.dp, 100.dp) modifier = Modifier.size(200.dp, 100.dp)
) )
} }
@@ -546,12 +681,13 @@ private fun CallParticipantViewerPipLandscapePreview() {
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerBlockedPreview() { private fun RemoteParticipantBlockedPreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( RemoteParticipantContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "Diana Prince", isBlocked = true) recipient = Recipient(isResolving = false, systemContactName = "Diana Prince", isBlocked = true)
), ),
raiseHandAllowed = false,
renderInPip = false, renderInPip = false,
onInfoMoreInfoClick = {}, onInfoMoreInfoClick = {},
modifier = Modifier.size(400.dp, 600.dp) modifier = Modifier.size(400.dp, 600.dp)
@@ -559,57 +695,98 @@ private fun CallParticipantViewerBlockedPreview() {
} }
} }
// endregion
// region Self PIP Previews
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerSelfPipNormalPreview() { private fun SelfPipNormalPreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( SelfPipContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true), recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
isMicrophoneEnabled = true, isMicrophoneEnabled = true,
audioLevel = CallParticipant.AudioLevel.MEDIUM audioLevel = CallParticipant.AudioLevel.MEDIUM
), ),
renderInPip = true,
selfPipMode = SelfPipMode.NORMAL_SELF_PIP, selfPipMode = SelfPipMode.NORMAL_SELF_PIP,
isMoreThanOneCameraAvailable = true, isMoreThanOneCameraAvailable = true,
onSwitchCameraClick = {}, onSwitchCameraClick = {},
modifier = Modifier.size(CallScreenMetrics.NormalRendererDpSize) modifier = Modifier.size(rememberCallScreenMetrics().normalRendererDpSize)
) )
} }
} }
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerSelfPipExpandedPreview() { private fun SelfPipExpandedPreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( SelfPipContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true), recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
isMicrophoneEnabled = true, isMicrophoneEnabled = true,
audioLevel = CallParticipant.AudioLevel.HIGH audioLevel = CallParticipant.AudioLevel.HIGH
), ),
renderInPip = true,
selfPipMode = SelfPipMode.EXPANDED_SELF_PIP, selfPipMode = SelfPipMode.EXPANDED_SELF_PIP,
isMoreThanOneCameraAvailable = true, isMoreThanOneCameraAvailable = true,
onSwitchCameraClick = {}, onSwitchCameraClick = {},
modifier = Modifier.size(CallScreenMetrics.ExpandedRendererDpSize) modifier = Modifier.size(rememberCallScreenMetrics().expandedRendererDpSize)
) )
} }
} }
@NightPreview @NightPreview
@Composable @Composable
private fun CallParticipantViewerSelfPipMiniPreview() { private fun SelfPipMiniPreview() {
Previews.Preview { Previews.Preview {
CallParticipantViewer( SelfPipContent(
participant = CallParticipant.EMPTY.copy( participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true), recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
isMicrophoneEnabled = false isMicrophoneEnabled = false
), ),
renderInPip = true,
selfPipMode = SelfPipMode.MINI_SELF_PIP, selfPipMode = SelfPipMode.MINI_SELF_PIP,
isMoreThanOneCameraAvailable = false, isMoreThanOneCameraAvailable = false,
modifier = Modifier.size(CallScreenMetrics.SmallRendererDpSize) onSwitchCameraClick = null,
modifier = Modifier.size(rememberCallScreenMetrics().overflowParticipantRendererDpSize)
) )
} }
} }
@NightPreview
@Composable
private fun SelfPipCameraOffPreview() {
Previews.Preview {
SelfPipContent(
participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
isMicrophoneEnabled = false,
isVideoEnabled = false
),
selfPipMode = SelfPipMode.NORMAL_SELF_PIP,
isMoreThanOneCameraAvailable = false,
onSwitchCameraClick = null,
modifier = Modifier.size(rememberCallScreenMetrics().normalRendererDpSize)
)
}
}
// endregion
// region Overflow Participant Previews
@NightPreview
@Composable
private fun OverflowParticipantPreview() {
Previews.Preview {
OverflowParticipantContent(
participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "Eve Wilson"),
isMicrophoneEnabled = true,
audioLevel = CallParticipant.AudioLevel.MEDIUM
),
modifier = Modifier.size(rememberCallScreenMetrics().overflowParticipantRendererDpSize)
)
}
}
// endregion

View File

@@ -23,7 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.NightPreview import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipant
@@ -42,23 +42,26 @@ fun CallParticipantsOverflow(
overflowParticipants: List<CallParticipant>, overflowParticipants: List<CallParticipant>,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val callScreenMetrics = rememberCallScreenMetrics()
val rendererSize = callScreenMetrics.overflowParticipantRendererSize
if (lineType == LayoutStrategyLineType.ROW) { if (lineType == LayoutStrategyLineType.ROW) {
LazyRow( LazyRow(
reverseLayout = true, reverseLayout = true,
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(start = 16.dp, end = CallScreenMetrics.SmallRendererSize + 32.dp), contentPadding = PaddingValues(start = 16.dp, end = rendererSize + 32.dp),
horizontalArrangement = spacedBy(4.dp) horizontalArrangement = spacedBy(4.dp)
) { ) {
appendItems(CallScreenMetrics.SmallRendererSize, overflowParticipants) appendItems(rendererSize, overflowParticipants)
} }
} else { } else {
LazyColumn( LazyColumn(
reverseLayout = true, reverseLayout = true,
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(top = 16.dp, bottom = CallScreenMetrics.SmallRendererSize + 32.dp), contentPadding = PaddingValues(top = 16.dp, bottom = rendererSize + 32.dp),
verticalArrangement = spacedBy(4.dp) verticalArrangement = spacedBy(4.dp)
) { ) {
appendItems(CallScreenMetrics.SmallRendererSize, overflowParticipants) appendItems(rendererSize, overflowParticipants)
} }
} }
} }
@@ -71,17 +74,16 @@ private fun LazyListScope.appendItems(
items = overflowParticipants, items = overflowParticipants,
key = { it.callParticipantId } key = { it.callParticipantId }
) { participant -> ) { participant ->
CallParticipantRenderer( OverflowParticipantContent(
callParticipant = participant, participant = participant,
renderInPip = false,
modifier = Modifier modifier = Modifier
.size(contentSize) .size(contentSize)
.clip(CallScreenMetrics.SmallRendererShape) .clip(CallScreenMetrics.OverflowParticipantRendererShape)
) )
} }
} }
@NightPreview @AllNightPreviews
@Composable @Composable
private fun CallParticipantsOverflowPreview() { private fun CallParticipantsOverflowPreview() {
Previews.Preview { Previews.Preview {
@@ -100,18 +102,19 @@ private fun CallParticipantsOverflowPreview() {
} }
} }
val callScreenMetrics = rememberCallScreenMetrics()
CallParticipantsOverflow( CallParticipantsOverflow(
lineType = LayoutStrategyLineType.ROW, lineType = LayoutStrategyLineType.ROW,
overflowParticipants = participants, overflowParticipants = participants,
modifier = Modifier modifier = Modifier
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
.height(CallScreenMetrics.SmallRendererSize) .height(callScreenMetrics.overflowParticipantRendererSize)
.fillMaxWidth() .fillMaxWidth()
) )
} }
} }
@NightPreview @AllNightPreviews
@Composable @Composable
private fun CallParticipantsOverflowColumnPreview() { private fun CallParticipantsOverflowColumnPreview() {
Previews.Preview { Previews.Preview {
@@ -130,12 +133,13 @@ private fun CallParticipantsOverflowColumnPreview() {
} }
} }
val callScreenMetrics = rememberCallScreenMetrics()
CallParticipantsOverflow( CallParticipantsOverflow(
lineType = LayoutStrategyLineType.COLUMN, lineType = LayoutStrategyLineType.COLUMN,
overflowParticipants = participants, overflowParticipants = participants,
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.width(CallScreenMetrics.SmallRendererSize) .width(callScreenMetrics.overflowParticipantRendererSize)
.fillMaxHeight() .fillMaxHeight()
) )
} }

View File

@@ -66,9 +66,11 @@ fun CallParticipantsPager(
} }
1 -> { 1 -> {
CallParticipantRenderer( RemoteParticipantContent(
callParticipant = callParticipantsPagerState.focusedParticipant, participant = callParticipantsPagerState.focusedParticipant,
renderInPip = callParticipantsPagerState.isRenderInPip, renderInPip = callParticipantsPagerState.isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -260,9 +262,11 @@ private fun AutoSizedParticipant(
else -> Modifier.size(DpSize(maxSize.width, maxSize.height)) else -> Modifier.size(DpSize(maxSize.width, maxSize.height))
} }
CallParticipantRenderer( RemoteParticipantContent(
callParticipant = participant, participant = participant,
renderInPip = isRenderInPip, renderInPip = isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
modifier = sizeModifier modifier = sizeModifier
.clip(RoundedCornerShape(state.cornerRadius)) .clip(RoundedCornerShape(state.cornerRadius))
) )

View File

@@ -22,16 +22,21 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.BottomSheetScaffoldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -46,7 +51,10 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.signal.core.ui.compose.AllNightPreviews import org.signal.core.ui.compose.AllNightPreviews
@@ -137,7 +145,6 @@ fun CallScreen(
val scaffoldState = remember(callScreenController) { callScreenController.scaffoldState } val scaffoldState = remember(callScreenController) { callScreenController.scaffoldState }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val additionalActionsPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState() val additionalActionsPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState()
val additionalActionsState = remember( val additionalActionsState = remember(
@@ -179,7 +186,10 @@ fun CallScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = SHEET_TOP_PADDING.dp, bottom = SHEET_BOTTOM_PADDING.dp) .padding(top = SHEET_TOP_PADDING.dp, bottom = SHEET_BOTTOM_PADDING.dp)
.height(DimensionUnit.PIXELS.toDp(maxSheetHeight).dp) .heightIn(
min = with(LocalDensity.current) { maxSheetHeight.toDp() },
max = with(LocalDensity.current) { maxHeight.toDp() }
)
.onGloballyPositioned { .onGloballyPositioned {
val offset = it.positionInRoot().y val offset = it.positionInRoot().y
val current = maxHeight - offset - DimensionUnit.DP.toPixels(peekHeight) val current = maxHeight - offset - DimensionUnit.DP.toPixels(peekHeight)
@@ -192,7 +202,7 @@ fun CallScreen(
val callInfoAlpha = max(0f, peekPercentage) val callInfoAlpha = max(0f, peekPercentage)
if (callInfoAlpha > 0f) { if (callInfoAlpha > 0f) {
Surface { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
callInfoView(callInfoAlpha) callInfoView(callInfoAlpha)
} }
} }
@@ -221,6 +231,35 @@ fun CallScreen(
label = "animate-as-state" label = "animate-as-state"
) )
// Self-pip bottom inset should be based off of:
// A. The container width
// B. The sheet width
// A - B / 2 gives you the gutter width.
// If the pip in its current state would be bigger than the gutter width (accounting for padding)
// then we need to apply the inset.
val selfPipHorizontalPadding = 32.dp
val shouldNotApplyBottomPaddingToViewPort = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
val selfPipBottomInset: Dp = if (shouldNotApplyBottomPaddingToViewPort) {
val containerWidth = maxWidth
val sheetWidth = BottomSheetDefaults.SheetMaxWidth
val widthOfPip = rememberSelfPipSize(localRenderState).width
if (containerWidth <= sheetWidth) {
padding
} else {
val spaceRemaining: Dp = (containerWidth - sheetWidth) / 2f - selfPipHorizontalPadding
if (spaceRemaining > widthOfPip) {
0.dp
} else {
padding
}
}
} else {
0.dp
}
Viewport( Viewport(
localParticipant = localParticipant, localParticipant = localParticipant,
localRenderState = localRenderState, localRenderState = localRenderState,
@@ -235,9 +274,10 @@ fun CallScreen(
onControlsToggled = onControlsToggled, onControlsToggled = onControlsToggled,
callScreenController = callScreenController, callScreenController = callScreenController,
onToggleCameraDirection = callScreenControlsListener::onCameraDirectionChanged, onToggleCameraDirection = callScreenControlsListener::onCameraDirectionChanged,
modifier = if (isPortrait) { selfPipBottomInset = selfPipBottomInset,
Modifier.padding(bottom = padding) modifier = if (shouldNotApplyBottomPaddingToViewPort) {
} else Modifier Modifier
} else Modifier.padding(bottom = padding)
) )
val onCallInfoClick: () -> Unit = { val onCallInfoClick: () -> Unit = {
@@ -349,6 +389,7 @@ private fun Viewport(
onPipFocusClick: () -> Unit, onPipFocusClick: () -> Unit,
onControlsToggled: (Boolean) -> Unit, onControlsToggled: (Boolean) -> Unit,
onToggleCameraDirection: () -> Unit, onToggleCameraDirection: () -> Unit,
selfPipBottomInset: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isEmptyOngoingCall = webRtcCallState.inOngoingCall && callParticipantsPagerState.callParticipants.isEmpty() val isEmptyOngoingCall = webRtcCallState.inOngoingCall && callParticipantsPagerState.callParticipants.isEmpty()
@@ -377,6 +418,7 @@ private fun Viewport(
} }
} }
val callScreenMetrics = rememberCallScreenMetrics()
BlurContainer( BlurContainer(
isBlurred = localRenderState == WebRtcLocalRenderState.FOCUSED, isBlurred = localRenderState == WebRtcLocalRenderState.FOCUSED,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
@@ -408,7 +450,7 @@ private fun Viewport(
overflowParticipants = overflowParticipants, overflowParticipants = overflowParticipants,
modifier = Modifier modifier = Modifier
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
.height(CallScreenMetrics.SmallRendererSize) .height(callScreenMetrics.overflowParticipantRendererSize)
.weight(1f) .weight(1f)
) )
} }
@@ -422,7 +464,7 @@ private fun Viewport(
overflowParticipants = overflowParticipants, overflowParticipants = overflowParticipants,
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.width(CallScreenMetrics.SmallRendererSize) .width(callScreenMetrics.overflowParticipantRendererSize)
.weight(1f) .weight(1f)
) )
} }
@@ -438,7 +480,7 @@ private fun Viewport(
onClick = onPipClick, onClick = onPipClick,
onToggleCameraDirectionClick = onToggleCameraDirection, onToggleCameraDirectionClick = onToggleCameraDirection,
onFocusLocalParticipantClick = onPipFocusClick, onFocusLocalParticipantClick = onPipFocusClick,
modifier = modifier modifier = modifier.padding(bottom = selfPipBottomInset)
) )
} }
} }
@@ -451,9 +493,11 @@ private fun LargeLocalVideoRenderer(
localParticipant: CallParticipant, localParticipant: CallParticipant,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
CallParticipantRenderer( RemoteParticipantContent(
callParticipant = localParticipant, participant = localParticipant,
renderInPip = false, renderInPip = false,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
) )
@@ -536,7 +580,7 @@ private fun CallScreenPreview() {
2 2
) )
), ),
localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE, localRenderState = WebRtcLocalRenderState.FOCUSED,
callScreenDialogType = CallScreenDialogType.NONE, callScreenDialogType = CallScreenDialogType.NONE,
callInfoView = { callInfoView = {
Text(text = "Call Info View Preview", modifier = Modifier.alpha(it)) Text(text = "Call Info View Preview", modifier = Modifier.alpha(it))

View File

@@ -6,32 +6,106 @@
package org.thoughtcrime.securesms.components.webrtc.v2 package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
object CallScreenMetrics { @Stable
val SmallRendererSize = 90.dp class CallScreenMetrics @RememberInComposition constructor(
val SmallRendererCornerSize = 24.dp private val windowSizeClass: WindowSizeClass
) {
companion object {
val OverflowParticipantRendererCornerSize = 24.dp
val ExpandedRendererCornerSize = 28.dp val ExpandedRendererCornerSize = 28.dp
val FocusedRendererCornerSize = 32.dp val FocusedRendererCornerSize = 32.dp
/** /**
* Shape of self renderer when in large group calls. * Shape of self renderer when in large group calls.
*/ */
val SmallRendererShape = RoundedCornerShape(SmallRendererCornerSize) val OverflowParticipantRendererShape = RoundedCornerShape(OverflowParticipantRendererCornerSize)
}
/**
* Represents the size of the renderer for the participant overflow and the mini self-pip.
*/
val overflowParticipantRendererSize: Dp = forWindowSizeClass(
compact = 96.dp,
medium = 116.dp
)
val overflowParticipantRendererAvatarSize: Dp = forWindowSizeClass(
compact = 48.dp,
medium = 56.dp
)
private val normalRendererDpWidth: Dp = forWindowSizeClass(
compact = 96.dp,
medium = 132.dp
)
private val normalRendererDpHeight: Dp = forWindowSizeClass(
compact = 171.dp,
medium = 235.dp
)
private val expandedRendererDpWidth: Dp = forWindowSizeClass(
compact = 148.dp,
medium = 180.dp
)
private val expandedRendererDpHeight: Dp = forWindowSizeClass(
compact = 263.dp,
medium = 321.dp
)
/** /**
* Size of self renderer when in large group calls * Size of self renderer when in large group calls
*/ */
val SmallRendererDpSize = DpSize(SmallRendererSize, SmallRendererSize) val overflowParticipantRendererDpSize get() = DpSize(overflowParticipantRendererSize, overflowParticipantRendererSize)
/** /**
* Size of self renderer when in small group calls and 1:1 calls * Size of self renderer when in small group calls and 1:1 calls
*/ */
val NormalRendererDpSize = DpSize(90.dp, 160.dp) val normalRendererDpSize get() = DpSize(normalRendererDpWidth, normalRendererDpHeight)
/** /**
* Size of self renderer after clicking on it to expand * Size of self renderer after clicking on it to expand
*/ */
val ExpandedRendererDpSize = DpSize(170.dp, 300.dp) val expandedRendererDpSize get() = DpSize(expandedRendererDpWidth, expandedRendererDpHeight)
private fun <T> forWindowSizeClass(
compact: T,
medium: T = compact,
expanded: T = medium
): T {
return if (windowSizeClass.isAtLeastBreakpoint(
widthDpBreakpoint = WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND,
heightDpBreakpoint = WindowSizeClass.HEIGHT_DP_EXPANDED_LOWER_BOUND
)
) {
expanded
} else if (windowSizeClass.isAtLeastBreakpoint(
widthDpBreakpoint = WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND,
heightDpBreakpoint = WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND
)
) {
medium
} else {
compact
}
}
}
@Composable
fun rememberCallScreenMetrics(): CallScreenMetrics {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
return remember(windowSizeClass) { CallScreenMetrics(windowSizeClass) }
} }

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2 package org.thoughtcrime.securesms.components.webrtc.v2
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -37,6 +38,7 @@ import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
@@ -44,7 +46,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.NightPreview import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
@@ -60,20 +62,9 @@ fun MoveableLocalVideoRenderer(
onClick: () -> Unit, onClick: () -> Unit,
onToggleCameraDirectionClick: () -> Unit, onToggleCameraDirectionClick: () -> Unit,
onFocusLocalParticipantClick: () -> Unit, onFocusLocalParticipantClick: () -> Unit,
modifier: Modifier = Modifier.Companion modifier: Modifier = Modifier
) { ) {
// 1. We need to remember our small and expanded sizes based off of the call size. val size = rememberSelfPipSize(localRenderState)
val size = remember(localRenderState) {
when (localRenderState) {
WebRtcLocalRenderState.GONE -> DpSize.Zero
WebRtcLocalRenderState.SMALL_RECTANGLE -> CallScreenMetrics.NormalRendererDpSize
WebRtcLocalRenderState.SMALLER_RECTANGLE -> CallScreenMetrics.SmallRendererDpSize
WebRtcLocalRenderState.LARGE -> DpSize.Zero
WebRtcLocalRenderState.LARGE_NO_VIDEO -> DpSize.Zero
WebRtcLocalRenderState.EXPANDED -> CallScreenMetrics.ExpandedRendererDpSize
WebRtcLocalRenderState.FOCUSED -> DpSize.Unspecified
}
}
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
@@ -84,9 +75,28 @@ fun MoveableLocalVideoRenderer(
) { ) {
val targetSize = size.let { val targetSize = size.let {
if (it == DpSize.Unspecified) { if (it == DpSize.Unspecified) {
DpSize(maxWidth - 32.dp, maxHeight - 32.dp) val orientation = LocalConfiguration.current.orientation
val desiredWidth = maxWidth - 32.dp
val desiredHeight = maxHeight - 32.dp
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
16f / 9f
} else { } else {
it 9f / 16f
}
val widthFromHeight = desiredHeight * aspectRatio
val heightFromWidth = desiredWidth / aspectRatio
val size: DpSize = if (widthFromHeight <= desiredWidth) {
DpSize(widthFromHeight, desiredHeight)
} else {
DpSize(desiredWidth, heightFromWidth)
}
size
} else {
it.rotateForConfiguration()
} }
} }
@@ -96,12 +106,16 @@ fun MoveableLocalVideoRenderer(
val selfPipMode = when (localRenderState) { val selfPipMode = when (localRenderState) {
WebRtcLocalRenderState.EXPANDED -> { WebRtcLocalRenderState.EXPANDED -> {
SelfPipMode.EXPANDED_SELF_PIP SelfPipMode.EXPANDED_SELF_PIP
} WebRtcLocalRenderState.FOCUSED -> { }
WebRtcLocalRenderState.FOCUSED -> {
SelfPipMode.FOCUSED_SELF_PIP SelfPipMode.FOCUSED_SELF_PIP
} }
WebRtcLocalRenderState.SMALLER_RECTANGLE -> { WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
SelfPipMode.MINI_SELF_PIP SelfPipMode.MINI_SELF_PIP
} }
else -> { else -> {
SelfPipMode.NORMAL_SELF_PIP SelfPipMode.NORMAL_SELF_PIP
} }
@@ -111,16 +125,17 @@ fun MoveableLocalVideoRenderer(
val shadow by animateShadow(localRenderState) val shadow by animateShadow(localRenderState)
PictureInPicture( PictureInPicture(
centerContent = size == DpSize.Unspecified,
state = state, state = state,
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
) { ) {
CallParticipantRenderer( SelfPipContent(
callParticipant = localParticipant, participant = localParticipant,
renderInPip = true,
selfPipMode = selfPipMode, selfPipMode = selfPipMode,
onToggleCameraDirection = onToggleCameraDirectionClick, isMoreThanOneCameraAvailable = localParticipant.cameraState.cameraCount > 1,
onSwitchCameraClick = onToggleCameraDirectionClick,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.dropShadow( .dropShadow(
@@ -170,7 +185,7 @@ private fun animateClip(localRenderState: WebRtcLocalRenderState): State<Dp> {
val targetDp = when (localRenderState) { val targetDp = when (localRenderState) {
WebRtcLocalRenderState.FOCUSED -> CallScreenMetrics.FocusedRendererCornerSize WebRtcLocalRenderState.FOCUSED -> CallScreenMetrics.FocusedRendererCornerSize
WebRtcLocalRenderState.EXPANDED -> CallScreenMetrics.ExpandedRendererCornerSize WebRtcLocalRenderState.EXPANDED -> CallScreenMetrics.ExpandedRendererCornerSize
else -> CallScreenMetrics.SmallRendererCornerSize else -> CallScreenMetrics.OverflowParticipantRendererCornerSize
} }
return animateDpAsState(targetValue = targetDp) return animateDpAsState(targetValue = targetDp)
@@ -182,6 +197,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State<Shado
WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> { WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
14.dp 14.dp
} }
else -> { else -> {
0.dp 0.dp
} }
@@ -191,6 +207,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State<Shado
WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> { WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
4.dp 4.dp
} }
else -> { else -> {
0.dp 0.dp
} }
@@ -203,7 +220,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State<Shado
} }
} }
@NightPreview @AllNightPreviews
@Composable @Composable
private fun MoveableLocalVideoRendererPreview() { private fun MoveableLocalVideoRendererPreview() {
var localRenderState by remember { mutableStateOf(WebRtcLocalRenderState.SMALL_RECTANGLE) } var localRenderState by remember { mutableStateOf(WebRtcLocalRenderState.SMALL_RECTANGLE) }
@@ -260,3 +277,36 @@ private fun MoveableLocalVideoRendererPreview() {
} }
} }
} }
@Composable
fun rememberSelfPipSize(
localRenderState: WebRtcLocalRenderState
): DpSize {
val callScreenMetrics = rememberCallScreenMetrics()
return remember(localRenderState, callScreenMetrics) {
when (localRenderState) {
WebRtcLocalRenderState.GONE -> DpSize.Zero
WebRtcLocalRenderState.SMALL_RECTANGLE -> callScreenMetrics.normalRendererDpSize
WebRtcLocalRenderState.SMALLER_RECTANGLE -> callScreenMetrics.overflowParticipantRendererDpSize
WebRtcLocalRenderState.LARGE -> DpSize.Zero
WebRtcLocalRenderState.LARGE_NO_VIDEO -> DpSize.Zero
WebRtcLocalRenderState.EXPANDED -> callScreenMetrics.expandedRendererDpSize
WebRtcLocalRenderState.FOCUSED -> DpSize.Unspecified
}
}
}
/**
* Sets the proper DpSize rotation based off the window configuration.
*
* Call-Screen DpSizes for the movable pip are expected to be in portrait by default.
*/
@Composable
private fun DpSize.rotateForConfiguration(): DpSize {
val orientation = LocalConfiguration.current.orientation
return when (orientation) {
Configuration.ORIENTATION_LANDSCAPE -> DpSize(this.height, this.width)
else -> this
}
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.animation.core.AnimationVector2D
import androidx.compose.animation.core.TwoWayConverter import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -55,6 +56,7 @@ private const val DECELERATION_RATE = 0.99f
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PictureInPicture( fun PictureInPicture(
centerContent: Boolean,
state: PictureInPictureState, state: PictureInPictureState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit content: @Composable () -> Unit
@@ -67,6 +69,8 @@ fun PictureInPicture(
val maxWidth = constraints.maxWidth val maxWidth = constraints.maxWidth
val contentWidth = with(density) { state.contentSize.width.toPx().roundToInt() } val contentWidth = with(density) { state.contentSize.width.toPx().roundToInt() }
val contentHeight = with(density) { state.contentSize.height.toPx().roundToInt() } val contentHeight = with(density) { state.contentSize.height.toPx().roundToInt() }
val targetContentWidth = with(density) { state.targetSize.width.toPx().roundToInt() }
val targetContentHeight = with(density) { state.targetSize.height.toPx().roundToInt() }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var isDragging by remember { var isDragging by remember {
@@ -77,10 +81,6 @@ fun PictureInPicture(
mutableStateOf(false) mutableStateOf(false)
} }
val isContentFullScreen = remember(maxWidth, maxHeight, contentWidth, contentHeight) {
maxWidth == contentWidth && maxHeight == contentHeight
}
var offsetX by remember { var offsetX by remember {
mutableIntStateOf(maxWidth - contentWidth) mutableIntStateOf(maxWidth - contentWidth)
} }
@@ -92,37 +92,51 @@ fun PictureInPicture(
IntOffset(0, 0) IntOffset(0, 0)
} }
val topRight = remember(maxWidth, contentWidth) { val topRight = remember(maxWidth, targetContentWidth) {
IntOffset(maxWidth - contentWidth, 0) IntOffset(maxWidth - targetContentWidth, 0)
} }
val bottomLeft = remember(maxHeight, contentHeight) { val bottomLeft = remember(maxHeight, targetContentHeight) {
IntOffset(0, maxHeight - contentHeight) IntOffset(0, maxHeight - targetContentHeight)
} }
val bottomRight = remember(maxWidth, maxHeight, contentWidth, contentHeight) { val bottomRight = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
IntOffset(maxWidth - contentWidth, maxHeight - contentHeight) IntOffset(maxWidth - targetContentWidth, maxHeight - targetContentHeight)
} }
DisposableEffect(maxWidth, maxHeight, isAnimating, isDragging, contentWidth, contentHeight, isContentFullScreen) { DisposableEffect(maxWidth, maxHeight, isAnimating, isDragging, targetContentWidth, targetContentHeight, centerContent) {
if (!isAnimating && !isDragging) { if (!isAnimating && !isDragging) {
if (centerContent) {
offsetX = (maxWidth / 2f).roundToInt() - (targetContentWidth / 2f).roundToInt()
offsetY = (maxHeight / 2f).roundToInt() - (targetContentHeight / 2f).roundToInt()
} else {
val offset = getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight) val offset = getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
offsetX = offset.x offsetX = offset.x
offsetY = offset.y offsetY = offset.y
} }
}
onDispose { } onDispose { }
} }
val animatedOffset by animateIntOffsetAsState(
targetValue = IntOffset(offsetX, offsetY),
animationSpec = tween()
)
Box( Box(
modifier = Modifier modifier = Modifier
.size(state.contentSize) .size(state.contentSize)
.offset { .offset {
if (isDragging) {
IntOffset(offsetX, offsetY) IntOffset(offsetX, offsetY)
} else {
animatedOffset
}
} }
.draggable2D( .draggable2D(
enabled = !isAnimating && !isContentFullScreen, enabled = !isAnimating && !centerContent,
state = rememberDraggable2DState { offset -> state = rememberDraggable2DState { offset ->
offsetX += offset.x.roundToInt() offsetX += offset.x.roundToInt()
offsetY += offset.y.roundToInt() offsetY += offset.y.roundToInt()
@@ -201,6 +215,9 @@ class PictureInPictureState @RememberInComposition constructor(initialContentSiz
var contentSize: DpSize by mutableStateOf(initialContentSize) var contentSize: DpSize by mutableStateOf(initialContentSize)
private set private set
var targetSize: DpSize by mutableStateOf(initialContentSize)
private set
var corner: Corner by mutableStateOf(initialCorner) var corner: Corner by mutableStateOf(initialCorner)
enum class Corner { enum class Corner {
@@ -211,9 +228,11 @@ class PictureInPictureState @RememberInComposition constructor(initialContentSiz
} }
@Composable @Composable
fun animateTo(targetSize: DpSize) { fun animateTo(newTargetSize: DpSize) {
val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = targetSize.width, animationSpec = tween()) targetSize = newTargetSize
val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = targetSize.height, animationSpec = tween())
val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = newTargetSize.width, animationSpec = tween())
val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = newTargetSize.height, animationSpec = tween())
contentSize = DpSize(targetWidth, targetHeight) contentSize = DpSize(targetWidth, targetHeight)
} }
@@ -228,6 +247,7 @@ private fun distance(a: IntOffset, b: IntOffset): Float {
fun PictureInPicturePreview() { fun PictureInPicturePreview() {
Previews.Preview { Previews.Preview {
PictureInPicture( PictureInPicture(
centerContent = false,
state = remember { PictureInPictureState(initialContentSize = DpSize(90.dp, 160.dp)) }, state = remember { PictureInPictureState(initialContentSize = DpSize(90.dp, 160.dp)) },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@@ -2694,6 +2694,10 @@
<string name="CallParticipantView__cant_receive_audio_and_video_from_s">Can\'t receive audio and video from %1$s</string> <string name="CallParticipantView__cant_receive_audio_and_video_from_s">Can\'t receive audio and video from %1$s</string>
<string name="CallParticipantView__this_may_be_Because_they_have_not_verified_your_safety_number_change">This may be because they have not verified your safety number change, there\'s a problem with their device, or they have blocked you.</string> <string name="CallParticipantView__this_may_be_Because_they_have_not_verified_your_safety_number_change">This may be because they have not verified your safety number change, there\'s a problem with their device, or they have blocked you.</string>
<!-- SwitchCameraButton -->
<!-- Content description for the button that switches between front and back camera during a call -->
<string name="SwitchCameraButton__switch_camera_direction">Switch camera direction</string>
<!-- CallToastPopupWindow --> <!-- CallToastPopupWindow -->
<string name="CallToastPopupWindow__swipe_to_view_screen_share">Swipe to view screen share</string> <string name="CallToastPopupWindow__swipe_to_view_screen_share">Swipe to view screen share</string>