diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3298ff5e57..1d4c6422f3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -122,7 +122,6 @@ 0.1f - CallParticipant.AudioLevel.LOW -> 0.3f - CallParticipant.AudioLevel.MEDIUM -> 0.5f - CallParticipant.AudioLevel.HIGH -> 0.65f - CallParticipant.AudioLevel.HIGHEST -> 0.8f - } - - middleBarAnimation?.end() - - middleBarAnimation = createAnimation(middleBarAnimation, height * scaleFactor) - middleBarAnimation?.start() - - sideBarAnimation?.end() - - var finalHeight = height * scaleFactor - if (level != CallParticipant.AudioLevel.LOWEST) { - finalHeight *= SIDE_BAR_SHRINK_FACTOR - } - - sideBarAnimation = createAnimation(sideBarAnimation, finalHeight) - sideBarAnimation?.start() - } - - if (showAudioLevel != wasShowingAudioLevel || level != lastAudioLevel) { - invalidate() - } - - lastMicrophoneEnabled = microphoneEnabled - lastAudioLevel = level - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - if (h > 0 && oldh == 0) { - bind(lastMicrophoneEnabled, lastAudioLevel) - } - } - - private fun createAnimation(current: ValueAnimator?, finalHeight: Float): ValueAnimator { - val currentHeight = current?.animatedValue as? Float ?: 0f - - return ValueAnimator.ofFloat(currentHeight, finalHeight).apply { - duration = WebRtcActionProcessor.AUDIO_LEVELS_INTERVAL.toLong() - interpolator = DecelerateInterpolator() - } - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - val middleBarHeight = middleBarAnimation?.animatedValue as? Float - val sideBarHeight = sideBarAnimation?.animatedValue as? Float - if (showAudioLevel && middleBarHeight != null && sideBarHeight != null) { - val audioLevelWidth = 3 * barWidth + 2 * barPadding - val xOffsetBase = (width - audioLevelWidth) / 2 - - canvas.drawBar( - xOffset = xOffsetBase, - size = sideBarHeight - ) - - canvas.drawBar( - xOffset = barPadding + barWidth + xOffsetBase, - size = middleBarHeight - ) - - canvas.drawBar( - xOffset = 2 * (barPadding + barWidth) + xOffsetBase, - size = sideBarHeight - ) - - if (middleBarAnimation?.isRunning == true || sideBarAnimation?.isRunning == true) { - invalidate() - } - } - } - - private fun Canvas.drawBar(xOffset: Float, size: Float) { - val yOffset = (height - size) / 2 - barRect.set(xOffset, yOffset, xOffset + barWidth, height - yOffset) - drawRoundRect(barRect, barRadius, barRadius, barPaint) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/AudioIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/AudioIndicator.kt new file mode 100644 index 0000000000..15420460a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/AudioIndicator.kt @@ -0,0 +1,269 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.events.CallParticipantId +import org.thoughtcrime.securesms.recipients.RecipientId + +private val BAR_WIDTH = 3.dp +private val BAR_PADDING = 3.dp +private const val AUDIO_LEVELS_INTERVAL_MS = 200 +private val ANIMATION_SPEC = tween(durationMillis = AUDIO_LEVELS_INTERVAL_MS, easing = FastOutSlowInEasing) + +/** + * Displays the audio state of a call participant. Shows a muted mic icon when the participant's + * microphone is disabled, animated audio level bars when speaking, or nothing when mic is on but silent. + */ +@Composable +fun AudioIndicator( + participant: CallParticipant, + modifier: Modifier = Modifier +) { + AnimatedVisibility(!participant.isMicrophoneEnabled || participant.audioLevel != null, modifier = modifier) { + AnimatedContent( + targetState = participant.isMicrophoneEnabled && participant.audioLevel != null + ) { showAudioLevel -> + if (showAudioLevel) { + AudioLevelBars(participant.audioLevel ?: CallParticipant.AudioLevel.LOWEST) + } else { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_mic_slash_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun AudioLevelBars( + audioLevel: CallParticipant.AudioLevel +) { + val scaleFactor = when (audioLevel) { + CallParticipant.AudioLevel.LOWEST -> 0.1f + CallParticipant.AudioLevel.LOW -> 0.3f + CallParticipant.AudioLevel.MEDIUM -> 0.5f + CallParticipant.AudioLevel.HIGH -> 0.65f + CallParticipant.AudioLevel.HIGHEST -> 0.8f + } + + BoxWithConstraints { + val maxHeight = maxHeight + val maxWidth = maxWidth + + val sideBarShrinkFactor = if (audioLevel != CallParticipant.AudioLevel.LOWEST) 0.75f else 1f + val targetSideBarHeight = maxHeight * scaleFactor * sideBarShrinkFactor + val targetMiddleBarHeight = maxHeight * scaleFactor + + val sideBarHeight by animateDpAsState(targetSideBarHeight, ANIMATION_SPEC) + val middleBaHeight by animateDpAsState(targetMiddleBarHeight, ANIMATION_SPEC) + val barColor = MaterialTheme.colorScheme.onSurface + + Box( + modifier = Modifier.fillMaxSize().drawWithContent { + val sideBarHeightPx = sideBarHeight.toPx() + val middleBarHeightPx = middleBaHeight.toPx() + val audioLevelWidth = BAR_WIDTH * 3 + BAR_PADDING * 2 + val xOffsetBase = (maxWidth - audioLevelWidth) / 2 + BAR_PADDING / 2 + + drawBar(barColor, xOffsetBase.toPx(), sideBarHeightPx) + drawBar(barColor, (xOffsetBase + BAR_WIDTH + BAR_PADDING).toPx(), middleBarHeightPx) + drawBar(barColor, (xOffsetBase + (BAR_WIDTH + BAR_PADDING) * 2).toPx(), sideBarHeightPx) + } + ) + } +} + +private fun ContentDrawScope.drawBar(barColor: Color, xOffset: Float, size: Float) { + val yOffset = (drawContext.size.height - size) / 2 + drawLine( + color = barColor, + cap = StrokeCap.Round, + strokeWidth = BAR_WIDTH.toPx(), + start = Offset(x = xOffset, y = yOffset), + end = Offset(x = xOffset, y = drawContext.size.height - yOffset) + ) +} + +@NightPreview +@Composable +fun AudioIndicatorPreview() { + Previews.Preview { + AudioIndicator( + participant = CallParticipant( + callParticipantId = CallParticipantId( + 1L, + RecipientId.from(1L) + ), + isMicrophoneEnabled = false, + audioLevel = null + ), + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) + ) + } +} + +@NightPreview +@Composable +fun AudioIndicatorGonePreview() { + Previews.Preview { + AudioIndicator( + participant = CallParticipant( + callParticipantId = CallParticipantId( + 1L, + RecipientId.from(1L) + ), + isMicrophoneEnabled = true, + audioLevel = null + ), + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) + ) + } +} + +@NightPreview +@Composable +fun AudioLevelBarsLowestPreview() { + Previews.Preview { + Box( + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) + ) { + AudioLevelBars( + audioLevel = CallParticipant.AudioLevel.LOWEST + ) + } + } +} + +@NightPreview +@Composable +fun AudioLevelBarsLowPreview() { + Previews.Preview { + Box( + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) + ) { + AudioLevelBars( + audioLevel = CallParticipant.AudioLevel.LOW + ) + } + } +} + +@NightPreview +@Composable +fun AudioLevelBarsMediumPreview() { + Previews.Preview { + Box( + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) + ) { + AudioLevelBars( + audioLevel = CallParticipant.AudioLevel.MEDIUM + ) + } + } +} + +@NightPreview +@Composable +fun AudioLevelBarsHighPreview() { + Previews.Preview { + Box( + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) + ) { + AudioLevelBars( + audioLevel = CallParticipant.AudioLevel.HIGH + ) + } + } +} + +@NightPreview +@Composable +fun AudioLevelBarsHighestPreview() { + Previews.Preview { + Box( + modifier = Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) + ) { + AudioLevelBars( + audioLevel = CallParticipant.AudioLevel.HIGHEST + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt index 255c309498..392622226f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt @@ -49,7 +49,7 @@ fun BlurContainer( val blur by animateDpAsState(blurRadius) Box( - modifier = modifier.blur(blur, edgeTreatment = BlurredEdgeTreatment.Unbounded) + modifier = modifier.blur(blur, edgeTreatment = BlurredEdgeTreatment.Rectangle) ) { content() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt index c27951dcaf..096301201c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt @@ -214,10 +214,11 @@ private fun CallElementsLayoutPreview() { localParticipant = CallParticipant( recipient = Recipient(id = RecipientId.from(1L), isResolving = false, systemContactName = "Test") ), + localRenderState = localRenderState, + savedLocalParticipantLandscape = false, onClick = {}, onFocusLocalParticipantClick = {}, - onToggleCameraDirectionClick = {}, - localRenderState = localRenderState + onToggleCameraDirectionClick = {} ) }, reactionsSlot = { 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 a443699da0..79d32eed6d 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 @@ -56,7 +56,6 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.AvatarImage import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge -import org.thoughtcrime.securesms.components.webrtc.AudioIndicatorView import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer import org.thoughtcrime.securesms.compose.GlideImage import org.thoughtcrime.securesms.compose.GlideImageScaleType @@ -119,16 +118,13 @@ fun RemoteParticipantContent( label = "video-ready-crossfade" ) { shouldShowAvatar -> if (shouldShowAvatar) { - if (renderInPip) { - PipAvatar( - recipient = recipient, - modifier = Modifier.fillMaxSize() - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (renderInPip) { + SystemPipAvatar(recipient = recipient) + } else { AvatarWithBadge(recipient = recipient) } } @@ -148,7 +144,7 @@ fun RemoteParticipantContent( } if (showAudioIndicator) { - AudioIndicator( + ParticipantAudioIndicator( participant = participant, selfPipMode = SelfPipMode.NOT_SELF_PIP, modifier = Modifier.align(Alignment.BottomStart) @@ -186,7 +182,8 @@ fun SelfPipContent( selfPipMode: SelfPipMode, isMoreThanOneCameraAvailable: Boolean, onSwitchCameraClick: (() -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showAudioIndicator: Boolean = true ) { if (participant.isVideoEnabled) { Box(modifier = modifier) { @@ -196,11 +193,13 @@ fun SelfPipContent( modifier = Modifier.fillMaxSize() ) - AudioIndicator( - participant = participant, - selfPipMode = selfPipMode, - modifier = Modifier.align(Alignment.BottomStart) - ) + if (showAudioIndicator) { + ParticipantAudioIndicator( + participant = participant, + selfPipMode = selfPipMode, + modifier = Modifier.align(Alignment.BottomStart) + ) + } if (isMoreThanOneCameraAvailable) { SwitchCameraButton( @@ -252,7 +251,7 @@ private fun SelfPipCameraOffContent( .align(Alignment.Center) ) - AudioIndicator( + ParticipantAudioIndicator( participant = participant, selfPipMode = selfPipMode, modifier = Modifier.align(Alignment.BottomStart) @@ -279,22 +278,31 @@ fun OverflowParticipantContent( val recipient = participant.recipient Box(modifier = modifier) { + val isBlocked = recipient.isBlocked + val isMissingMediaKeys = !participant.isMediaKeysReceived && + (System.currentTimeMillis() - participant.addedToCallTime) > 5000 + val infoMode = isBlocked || isMissingMediaKeys + BlurredBackgroundAvatar(recipient = recipient) - val hasContentToRender = participant.isVideoEnabled || participant.isScreenSharing - - if (hasContentToRender) { - VideoRenderer( - participant = participant, - modifier = Modifier.fillMaxSize() - ) + if (infoMode) { + OverflowInfoOverlay(isBlocked = isBlocked) } else { - PipAvatar( - recipient = recipient, - modifier = Modifier - .size(rememberCallScreenMetrics().overflowParticipantRendererAvatarSize) - .align(Alignment.Center) - ) + val hasContentToRender = participant.isVideoEnabled || participant.isScreenSharing + + if (hasContentToRender) { + VideoRenderer( + participant = participant, + modifier = Modifier.fillMaxSize() + ) + } else { + OverflowAvatar( + recipient = recipient, + modifier = Modifier + .size(rememberCallScreenMetrics().overflowParticipantRendererAvatarSize) + .align(Alignment.Center) + ) + } } if (participant.isHandRaised) { @@ -310,7 +318,7 @@ fun OverflowParticipantContent( } @Composable -private fun BlurredBackgroundAvatar( +fun BlurredBackgroundAvatar( recipient: Recipient, modifier: Modifier = Modifier ) { @@ -347,6 +355,19 @@ private fun BlurredBackgroundAvatar( } } +@Composable +private fun SystemPipAvatar( + recipient: Recipient, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + AvatarImage( + recipient = recipient, + modifier = Modifier.size(64.dp) + ) + } +} + @Composable private fun AvatarWithBadge( recipient: Recipient, @@ -368,7 +389,7 @@ private fun AvatarWithBadge( } @Composable -private fun PipAvatar( +private fun OverflowAvatar( recipient: Recipient, modifier: Modifier = Modifier ) { @@ -479,7 +500,7 @@ private fun VideoRenderer( } @Composable -internal fun AudioIndicator( +internal fun ParticipantAudioIndicator( participant: CallParticipant, selfPipMode: SelfPipMode, modifier: Modifier = Modifier @@ -493,16 +514,16 @@ internal fun AudioIndicator( SelfPipMode.OVERLAY_SELF_PIP -> 0.dp } - AndroidView( - factory = { context -> - AudioIndicatorView(context, null) - }, - update = { view -> - view.bind(participant.isMicrophoneEnabled, participant.audioLevel) - }, + AudioIndicator( + participant = participant, modifier = modifier .padding(margin) .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + .padding(6.dp) ) } @@ -620,8 +641,8 @@ private fun InfoOverlay( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - painter = painterResource( - id = if (isBlocked) R.drawable.ic_block_tinted_24 else R.drawable.ic_error_solid_24 + imageVector = ImageVector.vectorResource( + id = if (isBlocked) R.drawable.ic_block_tinted_24 else R.drawable.ic_error_outline_24 ), contentDescription = null, tint = Color.White, @@ -675,6 +696,33 @@ private fun InfoOverlay( } } +/** + * Simplified info overlay for overflow participant tiles. + * Shows only the icon (blocked or error) centered on a semi-transparent background. + */ +@Composable +private fun OverflowInfoOverlay( + isBlocked: Boolean +) { + val metrics = rememberCallScreenMetrics() + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x66000000)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = ImageVector.vectorResource( + id = if (isBlocked) R.drawable.ic_block_tinted_24 else R.drawable.ic_error_outline_24 + ), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(metrics.overflowInfoIconSize) + ) + } +} + enum class SelfPipMode { NOT_SELF_PIP, NORMAL_SELF_PIP, @@ -920,4 +968,39 @@ private fun OverflowParticipantRaisedHandPreview() { } } +@NightPreview +@Composable +private fun OverflowParticipantBlockedPreview() { + Previews.Preview { + OverflowParticipantContent( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient( + isResolving = false, + systemContactName = "Blocked Contact", + isBlocked = true + ) + ), + modifier = Modifier.size(rememberCallScreenMetrics().overflowParticipantRendererDpSize) + ) + } +} + +@NightPreview +@Composable +private fun OverflowParticipantVideoErrorPreview() { + Previews.Preview { + OverflowParticipantContent( + participant = CallParticipant.EMPTY.copy( + recipient = Recipient( + isResolving = false, + systemContactName = "Error Contact" + ), + isMediaKeysReceived = false, + addedToCallTime = System.currentTimeMillis() - 10000 + ), + modifier = Modifier.size(rememberCallScreenMetrics().overflowParticipantRendererDpSize) + ) + } +} + // endregion 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 3614232ea3..dce0f1f590 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 @@ -89,6 +89,7 @@ fun CallScreen( webRtcCallState: WebRtcViewModel.State, isRemoteVideoOffer: Boolean, isInPipMode: Boolean, + savedLocalParticipantLandscape: Boolean = false, callScreenState: CallScreenState, callControlsState: CallControlsState, callScreenController: CallScreenController = CallScreenController.rememberCallScreenController( @@ -132,8 +133,10 @@ fun CallScreen( if (isInPipMode) { PictureInPictureCallScreen( + localParticipant = localParticipant, + pendingParticipantsCount = callScreenState.pendingParticipantsState?.pendingParticipantCollection?.getUnresolvedPendingParticipants()?.size ?: 0, callParticipantsPagerState = callParticipantsPagerState, - callScreenController = callScreenController + savedLocalParticipantLandscape = savedLocalParticipantLandscape ) return @@ -328,6 +331,7 @@ fun CallScreen( MoveableLocalVideoRenderer( localParticipant = localParticipant, localRenderState = localRenderState, + savedLocalParticipantLandscape = savedLocalParticipantLandscape, onClick = onLocalPictureInPictureClicked, onToggleCameraDirectionClick = callScreenControlsListener::onCameraDirectionChanged, onFocusLocalParticipantClick = onLocalPictureInPictureFocusClicked, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt index 8a918f0e40..835d61a85f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenJoiningOverlay.kt @@ -104,7 +104,7 @@ fun CallScreenJoiningOverlay( verticalAlignment = Alignment.Bottom ) { if (isLocalVideoEnabled) { - AudioIndicator( + ParticipantAudioIndicator( participant = localParticipant, selfPipMode = SelfPipMode.OVERLAY_SELF_PIP ) 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 1b045c4f43..146d2a7a22 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 @@ -50,6 +50,11 @@ class CallScreenMetrics @RememberInComposition constructor( medium = 56.dp ) + val overflowInfoIconSize: Dp = forWindowSizeClass( + compact = 24.dp, + medium = 28.dp + ) + private val normalRendererDpWidth: Dp = forWindowSizeClass( compact = 96.dp, medium = 132.dp diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt index 53c7860f15..86143c1c17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenPreJoinOverlay.kt @@ -134,7 +134,7 @@ fun CallScreenPreJoinOverlay( verticalAlignment = Alignment.Bottom ) { if (isLocalVideoEnabled) { - AudioIndicator( + ParticipantAudioIndicator( participant = localParticipant, selfPipMode = SelfPipMode.OVERLAY_SELF_PIP ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt index 9a3ac0dee8..44d7f890d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -158,6 +158,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo } val pendingParticipantsListener by this.pendingParticipantsViewListener.collectAsStateWithLifecycle() + val savedLocalParticipantLandscape by viewModel.savedLocalParticipantLandscape.collectAsStateWithLifecycle() val callScreenController = CallScreenController.rememberCallScreenController( skipHiddenState = callControlsState.skipHiddenState, @@ -178,6 +179,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo webRtcCallState = webRtcCallState, isRemoteVideoOffer = viewModel.isAnswerWithVideoAvailable(), isInPipMode = rememberIsInPipMode(), + savedLocalParticipantLandscape = savedLocalParticipantLandscape, callScreenState = callScreenState, callControlsState = callControlsState, callScreenController = callScreenController, 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 d01925e5aa..42b12e8a1d 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 @@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.events.CallParticipant fun MoveableLocalVideoRenderer( localParticipant: CallParticipant, localRenderState: WebRtcLocalRenderState, + savedLocalParticipantLandscape: Boolean, onClick: () -> Unit, onToggleCameraDirectionClick: () -> Unit, onFocusLocalParticipantClick: () -> Unit, @@ -65,6 +66,10 @@ fun MoveableLocalVideoRenderer( val size = rememberSelfPipSize(localRenderState) val isFocused = localRenderState == WebRtcLocalRenderState.FOCUSED + val localAspectRatio = rememberParticipantAspectRatio(localParticipant.videoSink) + val configurationLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val isVideoLandscape = localAspectRatio?.let { it > 1f } ?: configurationLandscape + BoxWithConstraints( modifier = Modifier .fillMaxSize() @@ -72,12 +77,11 @@ fun MoveableLocalVideoRenderer( .statusBarsPadding() .displayCutoutPadding() ) { - val orientation = LocalConfiguration.current.orientation - val focusedSize = remember(maxWidth, maxHeight, orientation) { + val focusedSize = remember(maxWidth, maxHeight, isVideoLandscape) { val desiredWidth = maxWidth - 32.dp val desiredHeight = maxHeight - 32.dp - val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + val aspectRatio = if (isVideoLandscape) { 16f / 9f } else { 9f / 16f @@ -93,7 +97,7 @@ fun MoveableLocalVideoRenderer( } } - val targetSize = if (isFocused) focusedSize else size.rotateForConfiguration() + val targetSize = if (isFocused) focusedSize else size.rotateForVideoOrientation(isVideoLandscape) val state = remember { PictureInPictureState(initialContentSize = targetSize) } state.animateTo(targetSize) @@ -208,6 +212,7 @@ private fun MoveableLocalVideoRendererPreview() { CallParticipant() }, localRenderState = localRenderState, + savedLocalParticipantLandscape = false, onClick = { localRenderState = when (localRenderState) { WebRtcLocalRenderState.SMALL_RECTANGLE -> { @@ -253,16 +258,17 @@ fun rememberSelfPipSize( } /** - * Sets the proper DpSize rotation based off the window configuration. + * Sets the proper DpSize rotation based off the video aspect ratio. * * Call-Screen DpSizes for the movable pip are expected to be in portrait by default. + * When the video is landscape (aspect ratio > 1), the width and height are swapped. + * + * @param isVideoLandscape Whether the video is in landscape orientation (width > height) */ -@Composable -private fun DpSize.rotateForConfiguration(): DpSize { - val orientation = LocalConfiguration.current.orientation - - return when (orientation) { - Configuration.ORIENTATION_LANDSCAPE -> DpSize(this.height, this.width) - else -> this +private fun DpSize.rotateForVideoOrientation(isVideoLandscape: Boolean): DpSize { + return if (isVideoLandscape) { + DpSize(this.height, this.width) + } else { + this } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt index f33aa1c39c..a66608cb4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt @@ -5,43 +5,433 @@ package org.thoughtcrime.securesms.components.webrtc.v2 -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +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 +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import kotlinx.coroutines.launch +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.NightPreview +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.events.CallParticipantId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +private val PIP_METRICS_SELF_PORTRAIT_WIDTH = 48.dp +private val PIP_METRICS_SELF_PORTRAIT_HEIGHT = 86.dp +private val PIP_METRICS_SELF_LANDSCAPE_WIDTH = 86.dp +private val PIP_METRICS_SELF_LANDSCAPE_HEIGHT = 48.dp /** * Displayed when the user minimizes the call screen while a call is ongoing. */ @Composable fun PictureInPictureCallScreen( + localParticipant: CallParticipant, + pendingParticipantsCount: Int, callParticipantsPagerState: CallParticipantsPagerState, - callScreenController: CallScreenController + savedLocalParticipantLandscape: Boolean = false ) { - val scope = rememberCoroutineScope() + Box( + modifier = Modifier.fillMaxSize() + ) { + val remoteParticipant = callParticipantsPagerState.focusedParticipant ?: callParticipantsPagerState.callParticipants.first() + val fullScreenParticipant = if (remoteParticipant == CallParticipant.EMPTY) { + localParticipant + } else { + remoteParticipant + } + + val isFullScreenLocalParticipant = localParticipant.callParticipantId == fullScreenParticipant.callParticipantId - CallGrid( - items = callParticipantsPagerState.callParticipants, - modifier = Modifier - .fillMaxSize() - .clickable( - onClick = { - scope.launch { - callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS) - } - }, - enabled = false - ), - itemKey = { it.callParticipantId } - ) { participant, itemModifier -> RemoteParticipantContent( - participant = participant, - renderInPip = callParticipantsPagerState.isRenderInPip, + participant = fullScreenParticipant, + renderInPip = true, raiseHandAllowed = false, onInfoMoreInfoClick = null, - modifier = itemModifier + mirrorVideo = isFullScreenLocalParticipant, + modifier = Modifier.fillMaxSize() + ) + + if (!isFullScreenLocalParticipant) { + val localAspectRatio = rememberParticipantAspectRatio(localParticipant.videoSink) + val isLocalLandscape = localAspectRatio?.let { it > 1f } ?: savedLocalParticipantLandscape + val (selfPipWidth, selfPipHeight) = if (isLocalLandscape) { + PIP_METRICS_SELF_LANDSCAPE_WIDTH to PIP_METRICS_SELF_LANDSCAPE_HEIGHT + } else { + PIP_METRICS_SELF_PORTRAIT_WIDTH to PIP_METRICS_SELF_PORTRAIT_HEIGHT + } + + PictureInPictureSelfPip( + localParticipant = localParticipant, + modifier = Modifier + .padding(10.dp) + .size( + width = selfPipWidth, + height = selfPipHeight + ) + .align(Alignment.BottomEnd) + ) + + val handRaiseCount = (callParticipantsPagerState.callParticipants + localParticipant).count { it.isHandRaised } + AnimatedInfoPillsRow( + handRaiseCount = handRaiseCount, + pendingParticipantsCount = pendingParticipantsCount, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) + ) + } + } +} + +private enum class InfoPillType { + HAND_RAISE, + PENDING_PARTICIPANTS +} + +@Composable +private fun AnimatedInfoPillsRow( + handRaiseCount: Int, + pendingParticipantsCount: Int, + modifier: Modifier = Modifier +) { + val visiblePills = remember(handRaiseCount, pendingParticipantsCount) { + buildList { + if (handRaiseCount > 0) add(InfoPillType.HAND_RAISE to handRaiseCount) + if (pendingParticipantsCount > 0) add(InfoPillType.PENDING_PARTICIPANTS to pendingParticipantsCount) + } + } + + LazyRow( + horizontalArrangement = spacedBy(4.dp), + modifier = modifier + ) { + items( + count = visiblePills.size, + key = { visiblePills[it].first } + ) { index -> + val (type, count) = visiblePills[index] + InfoPill( + icon = ImageVector.vectorResource( + when (type) { + InfoPillType.HAND_RAISE -> R.drawable.symbol_raise_hand_24 + InfoPillType.PENDING_PARTICIPANTS -> R.drawable.symbol_person_24 + } + ), + count = count, + modifier = Modifier.animateItem() + ) + } + } +} + +@Composable +private fun InfoPill( + icon: ImageVector, + count: Int, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .background(color = colorResource(R.color.signal_dark_colorSurface3), shape = RoundedCornerShape(percent = 50)) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + + Text( + text = "$count", + color = Color.White ) } } + +@Composable +private fun PictureInPictureSelfPip( + localParticipant: CallParticipant, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(shape = RoundedCornerShape(8.dp)) + .background(color = Color.Black.copy(alpha = 0.7f)) + ) { + if (localParticipant.isVideoEnabled) { + SelfPipContent( + participant = localParticipant, + selfPipMode = SelfPipMode.OVERLAY_SELF_PIP, + isMoreThanOneCameraAvailable = false, + onSwitchCameraClick = null, + showAudioIndicator = false, + modifier = Modifier.fillMaxSize() + ) + } else { + BlurredBackgroundAvatar( + recipient = localParticipant.recipient, + modifier = Modifier.fillMaxSize() + ) + + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_slash_fill_24), + contentDescription = null, + tint = Color.White, + modifier = Modifier + .padding(10.dp) + .size(28.dp) + .background(color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), shape = CircleShape) + .padding(6.dp) + .align(Alignment.TopCenter) + ) + } + + AudioIndicator( + participant = localParticipant, + modifier = Modifier + .padding(10.dp) + .size(28.dp) + .background(color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), shape = CircleShape) + .padding(6.dp) + .align(Alignment.BottomCenter) + ) + } +} + +@NightPreview +@Composable +private fun PictureInPictureCallScreenPreview() { + Previews.Preview { + val participants = remember { + (2..4).map { + CallParticipant( + callParticipantId = CallParticipantId(0, RecipientId.from(it.toLong())), + recipient = Recipient( + isResolving = false, + systemContactName = "Contact $it" + ), + handRaisedTimestamp = 1L + ) + } + } + + val localParticipant = rememberLocalParticipantForPreview() + + val state = remember { + CallParticipantsPagerState( + callParticipants = participants, + focusedParticipant = participants.first(), + isRenderInPip = true, + hideAvatar = false + ) + } + + PictureInPictureCallScreen( + localParticipant = localParticipant, + pendingParticipantsCount = 2, + callParticipantsPagerState = state + ) + } +} + +@NightPreview +@Composable +private fun PictureInPictureCallScreenLocalOnlyPreview() { + Previews.Preview { + val localParticipant = rememberLocalParticipantForPreview() + + val state = remember { + CallParticipantsPagerState( + callParticipants = listOf(CallParticipant.EMPTY), + focusedParticipant = CallParticipant.EMPTY, + isRenderInPip = true, + hideAvatar = false + ) + } + + PictureInPictureCallScreen( + localParticipant = localParticipant, + pendingParticipantsCount = 0, + callParticipantsPagerState = state + ) + } +} + +@NightPreview +@Composable +private fun PictureInPictureCallScreenBlockedPreview() { + Previews.Preview { + val participants = remember { + listOf( + CallParticipant( + callParticipantId = CallParticipantId(0, RecipientId.from(2L)), + recipient = Recipient( + isResolving = false, + systemContactName = "Blocked Contact", + isBlocked = true + ) + ) + ) + } + + val localParticipant = rememberLocalParticipantForPreview() + + val state = remember { + CallParticipantsPagerState( + callParticipants = participants, + focusedParticipant = participants.first(), + isRenderInPip = true, + hideAvatar = false + ) + } + + PictureInPictureCallScreen( + localParticipant = localParticipant, + pendingParticipantsCount = 0, + callParticipantsPagerState = state + ) + } +} + +@NightPreview +@Composable +private fun PictureInPictureCallScreenVideoErrorPreview() { + Previews.Preview { + val participants = remember { + listOf( + CallParticipant( + callParticipantId = CallParticipantId(0, RecipientId.from(2L)), + recipient = Recipient( + isResolving = false, + systemContactName = "Error Contact" + ), + isMediaKeysReceived = false, + addedToCallTime = System.currentTimeMillis() - 10000 + ) + ) + } + + val localParticipant = rememberLocalParticipantForPreview() + + val state = remember { + CallParticipantsPagerState( + callParticipants = participants, + focusedParticipant = participants.first(), + isRenderInPip = true, + hideAvatar = false + ) + } + + PictureInPictureCallScreen( + localParticipant = localParticipant, + pendingParticipantsCount = 0, + callParticipantsPagerState = state + ) + } +} + +@NightPreview +@Composable +private fun InfoPillPreview() { + Previews.Preview { + InfoPill( + icon = ImageVector.vectorResource(id = R.drawable.symbol_person_24), + count = 5 + ) + } +} + +@NightPreview +@Composable +private fun AnimatedInfoPillsRowPreview() { + Previews.Preview { + var handRaiseCount by remember { mutableIntStateOf(0) } + var pendingCount by remember { mutableIntStateOf(0) } + + Column { + AnimatedInfoPillsRow( + handRaiseCount = handRaiseCount, + pendingParticipantsCount = pendingCount, + modifier = Modifier.fillMaxWidth() + ) + + Row( + horizontalArrangement = spacedBy(8.dp), + modifier = Modifier + .padding(16.dp) + ) { + TextButton( + onClick = { handRaiseCount = if (handRaiseCount > 0) 0 else 3 } + ) { + Text( + text = if (handRaiseCount > 0) "Hide Hands" else "Show Hands", + color = Color.White + ) + } + TextButton( + onClick = { pendingCount = if (pendingCount > 0) 0 else 2 } + ) { + Text( + text = if (pendingCount > 0) "Hide Pending" else "Show Pending", + color = Color.White + ) + } + } + } + } +} + +@NightPreview +@Composable +private fun PictureInPictureSelfPipPreview() { + Previews.Preview { + val localParticipant = rememberLocalParticipantForPreview() + + PictureInPictureSelfPip( + localParticipant = localParticipant, + modifier = Modifier.size( + width = PIP_METRICS_SELF_PORTRAIT_WIDTH, + height = PIP_METRICS_SELF_PORTRAIT_HEIGHT + ) + ) + } +} + +private fun rememberLocalParticipantForPreview(): CallParticipant { + return CallParticipant( + callParticipantId = CallParticipantId(0, RecipientId.from(1L)), + recipient = Recipient( + isResolving = false, + isSelf = true, + systemContactName = "Local User" + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt index 5ddbd93461..08506fdaca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt @@ -12,7 +12,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration -import android.hardware.display.DisplayManager import android.media.AudioManager import android.os.Build import android.os.Bundle @@ -24,8 +23,6 @@ import android.view.WindowManager import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatDelegate -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService import androidx.core.util.Consumer import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -46,7 +43,6 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.LifecycleDisposable -import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.isInMultiWindowModeCompat import org.signal.core.util.logging.Log @@ -100,6 +96,8 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private const val STANDARD_DELAY_FINISH = 1000L private const val VIBRATE_DURATION = 50 private const val CUSTOM_REACTION_BOTTOM_SHEET_TAG = "CallReaction" + private const val SAVED_STATE_PIP_ASPECT_RATIO = "pip_aspect_ratio" + private const val SAVED_STATE_LOCAL_PARTICIPANT_LANDSCAPE = "local_participant_landscape" } private lateinit var callScreen: CallScreenMediator @@ -112,6 +110,8 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re private lateinit var windowInfoTrackerCallbackAdapter: WindowInfoTrackerCallbackAdapter private lateinit var requestNewSizesThrottle: ThrottledDebouncer private lateinit var pipBuilderParams: PictureInPictureParams.Builder + private var lastPipAspectRatio: Float = 0f + private var lastLocalParticipantLandscape: Boolean = false private val lifecycleDisposable = LifecycleDisposable() private var lastCallLinkDisconnectDialogShowTime: Long = 0L private var enterPipOnResume: Boolean = false @@ -125,6 +125,11 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re super.attachBaseContext(newBase) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + recreate() + } + @SuppressLint("MissingInflatedId") override fun onCreate(savedInstanceState: Bundle?) { val callIntent: CallIntent = getCallIntent() @@ -148,7 +153,12 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re initializeResources() initializeViewModel() - initializePictureInPictureParams() + + // Restore saved state if recreated while in PIP mode + val savedAspectRatio = savedInstanceState?.getFloat(SAVED_STATE_PIP_ASPECT_RATIO, 0f) ?: 0f + lastLocalParticipantLandscape = savedInstanceState?.getBoolean(SAVED_STATE_LOCAL_PARTICIPANT_LANDSCAPE, false) ?: false + viewModel.setSavedLocalParticipantLandscape(lastLocalParticipantLandscape) + initializePictureInPictureParams(savedAspectRatio) callScreen.setControlsAndInfoVisibilityListener(ControlsVisibilityListener()) @@ -197,6 +207,15 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re callScreen.onStateRestored() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + // Always save these values - Activity may recreate while entering PIP (before isInPipMode is true) + if (lastPipAspectRatio > 0f) { + outState.putFloat(SAVED_STATE_PIP_ASPECT_RATIO, lastPipAspectRatio) + } + outState.putBoolean(SAVED_STATE_LOCAL_PARTICIPANT_LANDSCAPE, lastLocalParticipantLandscape) + } + override fun onStart() { super.onStart() @@ -457,23 +476,15 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re AppDependencies.signalCallManager.orientationChanged(true, orientation.degrees) + // Update local participant landscape state for self-pip orientation + val isLandscape = orientation != Orientation.PORTRAIT_BOTTOM_EDGE + lastLocalParticipantLandscape = isLandscape + viewModel.setSavedLocalParticipantLandscape(isLandscape) + viewModel.setIsLandscapeEnabled(true) viewModel.setIsInPipMode(isInPipMode()) lifecycleScope.launch { - launch(SignalDispatchers.Unconfined) { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - val displayManager = application.getSystemService()!! - DisplayMonitor.monitor(displayManager) - .collectLatest { - val display = displayManager.getDisplay(it.displayId) ?: return@collectLatest - val orientation = Orientation.fromSurfaceRotation(display.rotation) - - AppDependencies.signalCallManager.orientationChanged(true, orientation.degrees) - } - } - } - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.microphoneEnabled.collectLatest { @@ -563,17 +574,24 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } } - private fun initializePictureInPictureParams() { + private fun initializePictureInPictureParams(savedAspectRatio: Float) { if (isSystemPipEnabledAndAvailable()) { - val orientation = resolveOrientationFromContext() - val aspectRatio = if (orientation == Orientation.PORTRAIT_BOTTOM_EDGE) { - Rational(9, 16) - } else { - Rational(16, 9) - } - pipBuilderParams = PictureInPictureParams.Builder() - pipBuilderParams.setAspectRatio(aspectRatio) + + // Use saved aspect ratio if available (recreation while in PIP), otherwise use display orientation + if (savedAspectRatio > 0f) { + lastPipAspectRatio = savedAspectRatio + pipBuilderParams.setAspectRatio(floatToRational(savedAspectRatio)) + } else { + val orientation = resolveOrientationFromContext() + val aspectRatio = if (orientation == Orientation.PORTRAIT_BOTTOM_EDGE) { + 9f / 16f + } else { + 16f / 9f + } + lastPipAspectRatio = aspectRatio + pipBuilderParams.setAspectRatio(floatToRational(aspectRatio)) + } if (Build.VERSION.SDK_INT >= 31) { lifecycleScope.launch { @@ -584,6 +602,16 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re tryToSetPictureInPictureParams() } } + + // Observe focused participant video for PIP aspect ratio updates + launch { + viewModel.callParticipantsState.collectLatest { state -> + val participant = state.allRemoteParticipants.firstOrNull() ?: state.localParticipant + if (participant.isVideoEnabled) { + observeVideoSinkAspectRatio(participant.videoSink) + } + } + } } } } else { @@ -592,6 +620,59 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } } + private suspend fun observeVideoSinkAspectRatio(videoSink: org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink?) { + if (videoSink == null) return + + kotlinx.coroutines.suspendCancellableCoroutine { continuation -> + val dimensionSink = object : org.webrtc.VideoSink { + override fun onFrame(frame: org.webrtc.VideoFrame) { + val width = frame.rotatedWidth + val height = frame.rotatedHeight + if (width > 0 && height > 0) { + val aspectRatio = width.toFloat() / height.toFloat() + updatePipAspectRatio(aspectRatio) + } + } + } + + videoSink.addSink(dimensionSink) + + continuation.invokeOnCancellation { + videoSink.removeSink(dimensionSink) + } + } + } + + @SuppressLint("NewApi") // Only called when isSystemPipEnabledAndAvailable() which requires API 26 + private fun updatePipAspectRatio(aspectRatio: Float) { + if (!isSystemPipEnabledAndAvailable()) return + if (!::pipBuilderParams.isInitialized) return + // Ignore invalid aspect ratios (uninitialized texture view, video off, etc.) + if (aspectRatio <= 0f) return + + val clampedAspectRatio = aspectRatio.coerceIn(0.41f, 2.39f) + + // Only update if aspect ratio changed meaningfully (>10%) to avoid feedback loops from noise + val changeRatio = if (lastPipAspectRatio > 0f) { + kotlin.math.abs(clampedAspectRatio - lastPipAspectRatio) / lastPipAspectRatio + } else { + 1f + } + if (changeRatio < 0.1f) return + + lastPipAspectRatio = clampedAspectRatio + val rational = floatToRational(clampedAspectRatio) + + pipBuilderParams.setAspectRatio(rational) + tryToSetPictureInPictureParams() + } + + private fun floatToRational(value: Float): Rational { + val denominator = 1000 + val numerator = (value * denominator).toInt() + return Rational(numerator, denominator) + } + private fun logIntent(callIntent: CallIntent) { Log.d(TAG, callIntent.toString()) } @@ -1008,16 +1089,11 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re } private fun resolveOrientationFromContext(): Orientation { - val displayOrientation = resources.configuration.orientation - val displayRotation = ContextCompat.getDisplayOrDefault(this).rotation - - return if (displayOrientation == Configuration.ORIENTATION_PORTRAIT) { - Orientation.PORTRAIT_BOTTOM_EDGE - } else if (displayRotation == Surface.ROTATION_270) { - Orientation.LANDSCAPE_RIGHT_EDGE - } else { - Orientation.LANDSCAPE_LEFT_EDGE - } + // Always use device display rotation, not window configuration + // This ensures correct orientation even when in PIP mode + val displayManager = getSystemService(android.content.Context.DISPLAY_SERVICE) as android.hardware.display.DisplayManager + val displayRotation = displayManager.getDisplay(android.view.Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0 + return Orientation.fromSurfaceRotation(displayRotation) } private fun tryToSetPictureInPictureParams() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt index 63a91a3f93..5250891235 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt @@ -58,6 +58,8 @@ class WebRtcCallViewModel : ViewModel() { private val internalMicrophoneEnabled = MutableStateFlow(true) private val remoteMutedBy = MutableStateFlow(null) private val isInPipMode = MutableStateFlow(false) + private val _savedLocalParticipantLandscape = MutableStateFlow(false) + val savedLocalParticipantLandscape: StateFlow = _savedLocalParticipantLandscape private val webRtcControls = MutableStateFlow(WebRtcControls.NONE) private val foldableState = MutableStateFlow(WebRtcControls.FoldableState.flat()) private val identityChangedRecipients = MutableStateFlow>(Collections.emptyList()) @@ -233,6 +235,10 @@ class WebRtcCallViewModel : ViewModel() { participantsState.update { CallParticipantsState.update(it, isInPipMode) } } + fun setSavedLocalParticipantLandscape(isLandscape: Boolean) { + _savedLocalParticipantLandscape.update { isLandscape } + } + fun setIsLandscapeEnabled(isLandscapeEnabled: Boolean) { this.isLandscapeEnabled.update { isLandscapeEnabled } } diff --git a/app/src/main/res/layout/audio_indicator_view.xml b/app/src/main/res/layout/audio_indicator_view.xml deleted file mode 100644 index d44ea85194..0000000000 --- a/app/src/main/res/layout/audio_indicator_view.xml +++ /dev/null @@ -1,12 +0,0 @@ - -