diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt index 7d0a532cfb..7f5f6d66a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt index 8515882d8d..bab62b373d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantRenderer.kt deleted file mode 100644 index f722f6ff48..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantRenderer.kt +++ /dev/null @@ -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 - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt index 1924f970ab..874fee0719 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt index 9ca56589ab..1a7477230d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt @@ -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, 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() ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt index 071dad206d..1e162cbd28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt @@ -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)) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index 0cd761bae7..42bd51e617 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt index b04c2f9460..8f25e96fe5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt @@ -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 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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt index 1b5184ec05..98db96e9c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt @@ -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 { 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 { 14.dp } + else -> { 0.dp } @@ -191,6 +207,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State { 4.dp } + else -> { 0.dp } @@ -203,7 +220,7 @@ private fun animateShadow(localRenderState: WebRtcLocalRenderState): State 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt index 6a80f6859c..fd3cb046fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt @@ -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() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd90f481b5..d2fddbf60e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2694,6 +2694,10 @@ Can\'t receive audio and video from %1$s This may be because they have not verified your safety number change, there\'s a problem with their device, or they have blocked you. + + + Switch camera direction + Swipe to view screen share