mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-20 19:18:37 +00:00
Various picture in picture updates.
This commit is contained in:
@@ -5,8 +5,8 @@
|
||||
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -14,8 +14,13 @@ 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.painterResource
|
||||
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.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -44,9 +49,11 @@ fun AvatarImage(
|
||||
contentDescription: String? = null
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Spacer(
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_avatar_abstract_02),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
.background(color = Color.Red, shape = CircleShape)
|
||||
.background(color = Color(AvatarColor.random().colorInt()), CircleShape)
|
||||
)
|
||||
} else {
|
||||
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(
|
||||
val displayName: String?,
|
||||
val self: Recipient,
|
||||
|
||||
@@ -127,7 +127,7 @@ object CallInfoView {
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallInfoPreview() {
|
||||
Previews.Preview {
|
||||
Previews.BottomSheetContentPreview {
|
||||
val remoteParticipants = listOf(CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")))
|
||||
CallInfo(
|
||||
participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it, System.currentTimeMillis()) }),
|
||||
@@ -162,9 +162,7 @@ private fun CallInfo(
|
||||
item {
|
||||
val text = if (controlAndInfoState.callLink == null) {
|
||||
stringResource(id = R.string.CallLinkInfoSheet__call_info)
|
||||
} else if (controlAndInfoState.callLink.state.name.isNotEmpty()) {
|
||||
controlAndInfoState.callLink.state.name
|
||||
} else {
|
||||
} else controlAndInfoState.callLink.state.name.ifEmpty {
|
||||
stringResource(id = R.string.Recipient_signal_call)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.webrtc.AudioIndicatorView
|
||||
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.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.
|
||||
* Displays a remote participant (or local participant in pre-join screen).
|
||||
* Handles both full-size grid view and system PIP mode.
|
||||
*
|
||||
* 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
|
||||
fun CallParticipantViewer(
|
||||
fun RemoteParticipantContent(
|
||||
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
|
||||
renderInPip: Boolean,
|
||||
raiseHandAllowed: Boolean,
|
||||
onInfoMoreInfoClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val recipient = participant.recipient
|
||||
@@ -96,23 +103,62 @@ fun CallParticipantViewer(
|
||||
participant = participant,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
if (!renderInPip) {
|
||||
AvatarWithBadge(
|
||||
recipient = recipient,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
if (renderInPip) {
|
||||
} else if (renderInPip) {
|
||||
PipAvatar(
|
||||
recipient = recipient,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.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(
|
||||
participant = participant,
|
||||
@@ -125,22 +171,99 @@ fun CallParticipantViewer(
|
||||
)
|
||||
)
|
||||
|
||||
if (selfPipMode != SelfPipMode.NOT_SELF_PIP && isMoreThanOneCameraAvailable && selfPipMode != SelfPipMode.MINI_SELF_PIP) {
|
||||
if (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)
|
||||
}
|
||||
} else {
|
||||
SelfPipCameraOffContent(
|
||||
participant = participant,
|
||||
selfPipMode = selfPipMode,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,29 +273,35 @@ 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))
|
||||
BlurContainer(
|
||||
isBlurred = true,
|
||||
modifier = modifier,
|
||||
blurRadius = 15.dp
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_avatar_abstract_02),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
// Use AndroidView to leverage AvatarUtil.loadBlurredIconIntoImageView
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
androidx.appcompat.widget.AppCompatImageView(context).apply {
|
||||
scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
val photo = remember(recipient.isSelf, recipient.contactPhoto) {
|
||||
if (recipient.isSelf) {
|
||||
ProfileContactPhoto(recipient)
|
||||
} else {
|
||||
recipient.contactPhoto
|
||||
}
|
||||
},
|
||||
update = { imageView ->
|
||||
AvatarUtil.loadBlurredIconIntoImageView(recipient, imageView)
|
||||
},
|
||||
modifier = modifier.fillMaxSize()
|
||||
}
|
||||
|
||||
GlideImage(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Black),
|
||||
model = photo,
|
||||
scaleType = GlideImageScaleType.CENTER_CROP
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -219,13 +348,6 @@ private fun PipAvatar(
|
||||
recipient = recipient,
|
||||
modifier = avatarModifier
|
||||
)
|
||||
|
||||
BadgeImageLarge(
|
||||
badge = recipient.badges.firstOrNull(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(36.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,24 +439,29 @@ private fun SwitchCameraButton(
|
||||
onClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val size = when (selfPipMode) {
|
||||
val targetSize = when (selfPipMode) {
|
||||
SelfPipMode.EXPANDED_SELF_PIP, SelfPipMode.FOCUSED_SELF_PIP -> 48.dp
|
||||
else -> 28.dp
|
||||
}
|
||||
|
||||
val margin = when (selfPipMode) {
|
||||
val size by animateDpAsState(targetSize)
|
||||
|
||||
val targetMargin = when (selfPipMode) {
|
||||
SelfPipMode.FOCUSED_SELF_PIP -> 12.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.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) {
|
||||
val iconInset by animateDpAsState(targetIconInset)
|
||||
|
||||
val clickModifier = if ((selfPipMode == SelfPipMode.EXPANDED_SELF_PIP || selfPipMode == SelfPipMode.FOCUSED_SELF_PIP) && onClick != null) {
|
||||
Modifier.clickable { onClick() }
|
||||
} else {
|
||||
Modifier
|
||||
@@ -354,7 +481,7 @@ private fun SwitchCameraButton(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_switch_24),
|
||||
contentDescription = "Switch camera direction",
|
||||
contentDescription = stringResource(R.string.SwitchCameraButton__switch_camera_direction),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
@@ -479,11 +606,13 @@ enum class SelfPipMode {
|
||||
FOCUSED_SELF_PIP
|
||||
}
|
||||
|
||||
// region Remote Participant Previews
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerPreview() {
|
||||
private fun RemoteParticipantGridPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
RemoteParticipantContent(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Alice Johnson"),
|
||||
isMicrophoneEnabled = true,
|
||||
@@ -491,6 +620,7 @@ private fun CallParticipantViewerPreview() {
|
||||
),
|
||||
raiseHandAllowed = false,
|
||||
renderInPip = false,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = Modifier.size(400.dp, 600.dp)
|
||||
)
|
||||
}
|
||||
@@ -498,9 +628,9 @@ private fun CallParticipantViewerPreview() {
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerRaiseHandPreview() {
|
||||
private fun RemoteParticipantRaiseHandPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
RemoteParticipantContent(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Bob Smith"),
|
||||
isMicrophoneEnabled = true,
|
||||
@@ -509,6 +639,7 @@ private fun CallParticipantViewerRaiseHandPreview() {
|
||||
),
|
||||
raiseHandAllowed = true,
|
||||
renderInPip = false,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = Modifier.size(400.dp, 600.dp)
|
||||
)
|
||||
}
|
||||
@@ -516,14 +647,16 @@ private fun CallParticipantViewerRaiseHandPreview() {
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerPipPreview() {
|
||||
private fun RemoteParticipantSystemPipPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
RemoteParticipantContent(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"),
|
||||
isMicrophoneEnabled = false
|
||||
),
|
||||
raiseHandAllowed = false,
|
||||
renderInPip = true,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = Modifier.size(200.dp, 200.dp)
|
||||
)
|
||||
}
|
||||
@@ -531,14 +664,16 @@ private fun CallParticipantViewerPipPreview() {
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerPipLandscapePreview() {
|
||||
private fun RemoteParticipantSystemPipLandscapePreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
RemoteParticipantContent(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Charlie Davis"),
|
||||
isMicrophoneEnabled = false
|
||||
),
|
||||
raiseHandAllowed = false,
|
||||
renderInPip = true,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = Modifier.size(200.dp, 100.dp)
|
||||
)
|
||||
}
|
||||
@@ -546,12 +681,13 @@ private fun CallParticipantViewerPipLandscapePreview() {
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerBlockedPreview() {
|
||||
private fun RemoteParticipantBlockedPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
RemoteParticipantContent(
|
||||
participant = CallParticipant.EMPTY.copy(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Diana Prince", isBlocked = true)
|
||||
),
|
||||
raiseHandAllowed = false,
|
||||
renderInPip = false,
|
||||
onInfoMoreInfoClick = {},
|
||||
modifier = Modifier.size(400.dp, 600.dp)
|
||||
@@ -559,57 +695,98 @@ private fun CallParticipantViewerBlockedPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Self PIP Previews
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerSelfPipNormalPreview() {
|
||||
private fun SelfPipNormalPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
SelfPipContent(
|
||||
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)
|
||||
modifier = Modifier.size(rememberCallScreenMetrics().normalRendererDpSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerSelfPipExpandedPreview() {
|
||||
private fun SelfPipExpandedPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
SelfPipContent(
|
||||
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)
|
||||
modifier = Modifier.size(rememberCallScreenMetrics().expandedRendererDpSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun CallParticipantViewerSelfPipMiniPreview() {
|
||||
private fun SelfPipMiniPreview() {
|
||||
Previews.Preview {
|
||||
CallParticipantViewer(
|
||||
SelfPipContent(
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -23,7 +23,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
@@ -42,23 +42,26 @@ fun CallParticipantsOverflow(
|
||||
overflowParticipants: List<CallParticipant>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val callScreenMetrics = rememberCallScreenMetrics()
|
||||
val rendererSize = callScreenMetrics.overflowParticipantRendererSize
|
||||
|
||||
if (lineType == LayoutStrategyLineType.ROW) {
|
||||
LazyRow(
|
||||
reverseLayout = true,
|
||||
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)
|
||||
) {
|
||||
appendItems(CallScreenMetrics.SmallRendererSize, overflowParticipants)
|
||||
appendItems(rendererSize, overflowParticipants)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
reverseLayout = true,
|
||||
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)
|
||||
) {
|
||||
appendItems(CallScreenMetrics.SmallRendererSize, overflowParticipants)
|
||||
appendItems(rendererSize, overflowParticipants)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,17 +74,16 @@ private fun LazyListScope.appendItems(
|
||||
items = overflowParticipants,
|
||||
key = { it.callParticipantId }
|
||||
) { participant ->
|
||||
CallParticipantRenderer(
|
||||
callParticipant = participant,
|
||||
renderInPip = false,
|
||||
OverflowParticipantContent(
|
||||
participant = participant,
|
||||
modifier = Modifier
|
||||
.size(contentSize)
|
||||
.clip(CallScreenMetrics.SmallRendererShape)
|
||||
.clip(CallScreenMetrics.OverflowParticipantRendererShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun CallParticipantsOverflowPreview() {
|
||||
Previews.Preview {
|
||||
@@ -100,18 +102,19 @@ private fun CallParticipantsOverflowPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
val callScreenMetrics = rememberCallScreenMetrics()
|
||||
CallParticipantsOverflow(
|
||||
lineType = LayoutStrategyLineType.ROW,
|
||||
overflowParticipants = participants,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.height(CallScreenMetrics.SmallRendererSize)
|
||||
.height(callScreenMetrics.overflowParticipantRendererSize)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun CallParticipantsOverflowColumnPreview() {
|
||||
Previews.Preview {
|
||||
@@ -130,12 +133,13 @@ private fun CallParticipantsOverflowColumnPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
val callScreenMetrics = rememberCallScreenMetrics()
|
||||
CallParticipantsOverflow(
|
||||
lineType = LayoutStrategyLineType.COLUMN,
|
||||
overflowParticipants = participants,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.width(CallScreenMetrics.SmallRendererSize)
|
||||
.width(callScreenMetrics.overflowParticipantRendererSize)
|
||||
.fillMaxHeight()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,9 +66,11 @@ fun CallParticipantsPager(
|
||||
}
|
||||
|
||||
1 -> {
|
||||
CallParticipantRenderer(
|
||||
callParticipant = callParticipantsPagerState.focusedParticipant,
|
||||
RemoteParticipantContent(
|
||||
participant = callParticipantsPagerState.focusedParticipant,
|
||||
renderInPip = callParticipantsPagerState.isRenderInPip,
|
||||
raiseHandAllowed = false,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
@@ -260,9 +262,11 @@ private fun AutoSizedParticipant(
|
||||
else -> Modifier.size(DpSize(maxSize.width, maxSize.height))
|
||||
}
|
||||
|
||||
CallParticipantRenderer(
|
||||
callParticipant = participant,
|
||||
RemoteParticipantContent(
|
||||
participant = participant,
|
||||
renderInPip = isRenderInPip,
|
||||
raiseHandAllowed = false,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = sizeModifier
|
||||
.clip(RoundedCornerShape(state.cornerRadius))
|
||||
)
|
||||
|
||||
@@ -22,16 +22,21 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.BottomSheetScaffoldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.positionInRoot
|
||||
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.window.core.layout.WindowSizeClass
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.AllNightPreviews
|
||||
@@ -137,7 +145,6 @@ fun CallScreen(
|
||||
|
||||
val scaffoldState = remember(callScreenController) { callScreenController.scaffoldState }
|
||||
val scope = rememberCoroutineScope()
|
||||
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
|
||||
|
||||
val additionalActionsPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState()
|
||||
val additionalActionsState = remember(
|
||||
@@ -179,7 +186,10 @@ fun CallScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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 {
|
||||
val offset = it.positionInRoot().y
|
||||
val current = maxHeight - offset - DimensionUnit.DP.toPixels(peekHeight)
|
||||
@@ -192,7 +202,7 @@ fun CallScreen(
|
||||
val callInfoAlpha = max(0f, peekPercentage)
|
||||
|
||||
if (callInfoAlpha > 0f) {
|
||||
Surface {
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||
callInfoView(callInfoAlpha)
|
||||
}
|
||||
}
|
||||
@@ -221,6 +231,35 @@ fun CallScreen(
|
||||
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(
|
||||
localParticipant = localParticipant,
|
||||
localRenderState = localRenderState,
|
||||
@@ -235,9 +274,10 @@ fun CallScreen(
|
||||
onControlsToggled = onControlsToggled,
|
||||
callScreenController = callScreenController,
|
||||
onToggleCameraDirection = callScreenControlsListener::onCameraDirectionChanged,
|
||||
modifier = if (isPortrait) {
|
||||
Modifier.padding(bottom = padding)
|
||||
} else Modifier
|
||||
selfPipBottomInset = selfPipBottomInset,
|
||||
modifier = if (shouldNotApplyBottomPaddingToViewPort) {
|
||||
Modifier
|
||||
} else Modifier.padding(bottom = padding)
|
||||
)
|
||||
|
||||
val onCallInfoClick: () -> Unit = {
|
||||
@@ -349,6 +389,7 @@ private fun Viewport(
|
||||
onPipFocusClick: () -> Unit,
|
||||
onControlsToggled: (Boolean) -> Unit,
|
||||
onToggleCameraDirection: () -> Unit,
|
||||
selfPipBottomInset: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isEmptyOngoingCall = webRtcCallState.inOngoingCall && callParticipantsPagerState.callParticipants.isEmpty()
|
||||
@@ -377,6 +418,7 @@ private fun Viewport(
|
||||
}
|
||||
}
|
||||
|
||||
val callScreenMetrics = rememberCallScreenMetrics()
|
||||
BlurContainer(
|
||||
isBlurred = localRenderState == WebRtcLocalRenderState.FOCUSED,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
@@ -408,7 +450,7 @@ private fun Viewport(
|
||||
overflowParticipants = overflowParticipants,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.height(CallScreenMetrics.SmallRendererSize)
|
||||
.height(callScreenMetrics.overflowParticipantRendererSize)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
@@ -422,7 +464,7 @@ private fun Viewport(
|
||||
overflowParticipants = overflowParticipants,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.width(CallScreenMetrics.SmallRendererSize)
|
||||
.width(callScreenMetrics.overflowParticipantRendererSize)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
@@ -438,7 +480,7 @@ private fun Viewport(
|
||||
onClick = onPipClick,
|
||||
onToggleCameraDirectionClick = onToggleCameraDirection,
|
||||
onFocusLocalParticipantClick = onPipFocusClick,
|
||||
modifier = modifier
|
||||
modifier = modifier.padding(bottom = selfPipBottomInset)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -451,9 +493,11 @@ private fun LargeLocalVideoRenderer(
|
||||
localParticipant: CallParticipant,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
CallParticipantRenderer(
|
||||
callParticipant = localParticipant,
|
||||
RemoteParticipantContent(
|
||||
participant = localParticipant,
|
||||
renderInPip = false,
|
||||
raiseHandAllowed = false,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
)
|
||||
@@ -536,7 +580,7 @@ private fun CallScreenPreview() {
|
||||
2
|
||||
)
|
||||
),
|
||||
localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE,
|
||||
localRenderState = WebRtcLocalRenderState.FOCUSED,
|
||||
callScreenDialogType = CallScreenDialogType.NONE,
|
||||
callInfoView = {
|
||||
Text(text = "Call Info View Preview", modifier = Modifier.alpha(it))
|
||||
|
||||
@@ -6,32 +6,106 @@
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
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.dp
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
|
||||
object CallScreenMetrics {
|
||||
val SmallRendererSize = 90.dp
|
||||
val SmallRendererCornerSize = 24.dp
|
||||
@Stable
|
||||
class CallScreenMetrics @RememberInComposition constructor(
|
||||
private val windowSizeClass: WindowSizeClass
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val OverflowParticipantRendererCornerSize = 24.dp
|
||||
val ExpandedRendererCornerSize = 28.dp
|
||||
val FocusedRendererCornerSize = 32.dp
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
val SmallRendererDpSize = DpSize(SmallRendererSize, SmallRendererSize)
|
||||
val overflowParticipantRendererDpSize get() = DpSize(overflowParticipantRendererSize, overflowParticipantRendererSize)
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
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.shadow.Shadow
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.DpSize
|
||||
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.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
|
||||
@@ -60,20 +62,9 @@ fun MoveableLocalVideoRenderer(
|
||||
onClick: () -> Unit,
|
||||
onToggleCameraDirectionClick: () -> 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 = 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
|
||||
}
|
||||
}
|
||||
val size = rememberSelfPipSize(localRenderState)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
@@ -84,9 +75,28 @@ fun MoveableLocalVideoRenderer(
|
||||
) {
|
||||
val targetSize = size.let {
|
||||
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 {
|
||||
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) {
|
||||
WebRtcLocalRenderState.EXPANDED -> {
|
||||
SelfPipMode.EXPANDED_SELF_PIP
|
||||
} WebRtcLocalRenderState.FOCUSED -> {
|
||||
}
|
||||
|
||||
WebRtcLocalRenderState.FOCUSED -> {
|
||||
SelfPipMode.FOCUSED_SELF_PIP
|
||||
}
|
||||
|
||||
WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
|
||||
SelfPipMode.MINI_SELF_PIP
|
||||
}
|
||||
|
||||
else -> {
|
||||
SelfPipMode.NORMAL_SELF_PIP
|
||||
}
|
||||
@@ -111,16 +125,17 @@ fun MoveableLocalVideoRenderer(
|
||||
val shadow by animateShadow(localRenderState)
|
||||
|
||||
PictureInPicture(
|
||||
centerContent = size == DpSize.Unspecified,
|
||||
state = state,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
CallParticipantRenderer(
|
||||
callParticipant = localParticipant,
|
||||
renderInPip = true,
|
||||
SelfPipContent(
|
||||
participant = localParticipant,
|
||||
selfPipMode = selfPipMode,
|
||||
onToggleCameraDirection = onToggleCameraDirectionClick,
|
||||
isMoreThanOneCameraAvailable = localParticipant.cameraState.cameraCount > 1,
|
||||
onSwitchCameraClick = onToggleCameraDirectionClick,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.dropShadow(
|
||||
@@ -170,7 +185,7 @@ private fun animateClip(localRenderState: WebRtcLocalRenderState): State<Dp> {
|
||||
val targetDp = when (localRenderState) {
|
||||
WebRtcLocalRenderState.FOCUSED -> CallScreenMetrics.FocusedRendererCornerSize
|
||||
WebRtcLocalRenderState.EXPANDED -> CallScreenMetrics.ExpandedRendererCornerSize
|
||||
else -> CallScreenMetrics.SmallRendererCornerSize
|
||||
else -> CallScreenMetrics.OverflowParticipantRendererCornerSize
|
||||
}
|
||||
|
||||
return animateDpAsState(targetValue = targetDp)
|
||||
@@ -182,6 +197,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State<Shado
|
||||
WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
|
||||
14.dp
|
||||
}
|
||||
|
||||
else -> {
|
||||
0.dp
|
||||
}
|
||||
@@ -191,6 +207,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State<Shado
|
||||
WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
|
||||
4.dp
|
||||
}
|
||||
|
||||
else -> {
|
||||
0.dp
|
||||
}
|
||||
@@ -203,7 +220,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State<Shado
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun MoveableLocalVideoRendererPreview() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.animation.core.AnimationVector2D
|
||||
import androidx.compose.animation.core.TwoWayConverter
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateIntOffsetAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
@@ -55,6 +56,7 @@ private const val DECELERATION_RATE = 0.99f
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PictureInPicture(
|
||||
centerContent: Boolean,
|
||||
state: PictureInPictureState,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
@@ -67,6 +69,8 @@ fun PictureInPicture(
|
||||
val maxWidth = constraints.maxWidth
|
||||
val contentWidth = with(density) { state.contentSize.width.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()
|
||||
|
||||
var isDragging by remember {
|
||||
@@ -77,10 +81,6 @@ fun PictureInPicture(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val isContentFullScreen = remember(maxWidth, maxHeight, contentWidth, contentHeight) {
|
||||
maxWidth == contentWidth && maxHeight == contentHeight
|
||||
}
|
||||
|
||||
var offsetX by remember {
|
||||
mutableIntStateOf(maxWidth - contentWidth)
|
||||
}
|
||||
@@ -92,37 +92,51 @@ fun PictureInPicture(
|
||||
IntOffset(0, 0)
|
||||
}
|
||||
|
||||
val topRight = remember(maxWidth, contentWidth) {
|
||||
IntOffset(maxWidth - contentWidth, 0)
|
||||
val topRight = remember(maxWidth, targetContentWidth) {
|
||||
IntOffset(maxWidth - targetContentWidth, 0)
|
||||
}
|
||||
|
||||
val bottomLeft = remember(maxHeight, contentHeight) {
|
||||
IntOffset(0, maxHeight - contentHeight)
|
||||
val bottomLeft = remember(maxHeight, targetContentHeight) {
|
||||
IntOffset(0, maxHeight - targetContentHeight)
|
||||
}
|
||||
|
||||
val bottomRight = remember(maxWidth, maxHeight, contentWidth, contentHeight) {
|
||||
IntOffset(maxWidth - contentWidth, maxHeight - contentHeight)
|
||||
val bottomRight = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
|
||||
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 (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)
|
||||
|
||||
offsetX = offset.x
|
||||
offsetY = offset.y
|
||||
}
|
||||
}
|
||||
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
val animatedOffset by animateIntOffsetAsState(
|
||||
targetValue = IntOffset(offsetX, offsetY),
|
||||
animationSpec = tween()
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(state.contentSize)
|
||||
.offset {
|
||||
if (isDragging) {
|
||||
IntOffset(offsetX, offsetY)
|
||||
} else {
|
||||
animatedOffset
|
||||
}
|
||||
}
|
||||
.draggable2D(
|
||||
enabled = !isAnimating && !isContentFullScreen,
|
||||
enabled = !isAnimating && !centerContent,
|
||||
state = rememberDraggable2DState { offset ->
|
||||
offsetX += offset.x.roundToInt()
|
||||
offsetY += offset.y.roundToInt()
|
||||
@@ -201,6 +215,9 @@ class PictureInPictureState @RememberInComposition constructor(initialContentSiz
|
||||
var contentSize: DpSize by mutableStateOf(initialContentSize)
|
||||
private set
|
||||
|
||||
var targetSize: DpSize by mutableStateOf(initialContentSize)
|
||||
private set
|
||||
|
||||
var corner: Corner by mutableStateOf(initialCorner)
|
||||
|
||||
enum class Corner {
|
||||
@@ -211,9 +228,11 @@ class PictureInPictureState @RememberInComposition constructor(initialContentSiz
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun animateTo(targetSize: DpSize) {
|
||||
val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = targetSize.width, animationSpec = tween())
|
||||
val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = targetSize.height, animationSpec = tween())
|
||||
fun animateTo(newTargetSize: DpSize) {
|
||||
targetSize = newTargetSize
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -228,6 +247,7 @@ private fun distance(a: IntOffset, b: IntOffset): Float {
|
||||
fun PictureInPicturePreview() {
|
||||
Previews.Preview {
|
||||
PictureInPicture(
|
||||
centerContent = false,
|
||||
state = remember { PictureInPictureState(initialContentSize = DpSize(90.dp, 160.dp)) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -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__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 -->
|
||||
<string name="CallToastPopupWindow__swipe_to_view_screen_share">Swipe to view screen share</string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user