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

View File

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

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.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,24 +103,63 @@ fun CallParticipantViewer(
participant = participant,
modifier = Modifier.fillMaxSize()
)
} else if (renderInPip) {
PipAvatar(
recipient = recipient,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
)
} else {
if (!renderInPip) {
AvatarWithBadge(
recipient = recipient,
modifier = Modifier.align(Alignment.Center)
)
}
if (renderInPip) {
PipAvatar(
recipient = recipient,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
)
}
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,
selfPipMode = selfPipMode,
@@ -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)
)
}
}
} else {
SelfPipCameraOffContent(
participant = participant,
selfPipMode = selfPipMode,
modifier = modifier
)
}
}
if (raiseHandAllowed && participant.isHandRaised) {
RaiseHandIndicator(
name = participant.getShortRecipientDisplayName(context),
modifier = Modifier
.align(Alignment.TopStart)
.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,28 +273,34 @@ private fun BlurredBackgroundAvatar(
recipient: Recipient,
modifier: Modifier = Modifier
) {
val isInPreview = LocalInspectionMode.current
// Use a simple background in preview mode, otherwise use Glide to load the blurred avatar
if (isInPreview) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color(0xFF1B1B1D))
)
} else {
// Use AndroidView to leverage AvatarUtil.loadBlurredIconIntoImageView
AndroidView(
factory = { context ->
androidx.appcompat.widget.AppCompatImageView(context).apply {
scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
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 {
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
)
}
}
}
@@ -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

View File

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

View File

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

View File

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

View File

@@ -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
val ExpandedRendererCornerSize = 28.dp
val FocusedRendererCornerSize = 32.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 OverflowParticipantRendererShape = RoundedCornerShape(OverflowParticipantRendererCornerSize)
}
/**
* Shape of self renderer when in large group calls.
* Represents the size of the renderer for the participant overflow and the mini self-pip.
*/
val SmallRendererShape = RoundedCornerShape(SmallRendererCornerSize)
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) }
}

View File

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

View File

@@ -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) {
val offset = getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
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
offsetX = offset.x
offsetY = offset.y
}
}
onDispose { }
}
val animatedOffset by animateIntOffsetAsState(
targetValue = IntOffset(offsetX, offsetY),
animationSpec = tween()
)
Box(
modifier = Modifier
.size(state.contentSize)
.offset {
IntOffset(offsetX, offsetY)
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()

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