New system PiP treatment.

This commit is contained in:
Alex Hart
2026-01-15 17:36:47 -04:00
parent 3d9e12e4c1
commit 17d338f7af
16 changed files with 963 additions and 283 deletions

View File

@@ -122,7 +122,6 @@
<!-- ======================================= -->
<activity
android:name=".components.webrtc.v2.WebRtcCallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"

View File

@@ -1,149 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.service.webrtc.WebRtcActionProcessor
import org.thoughtcrime.securesms.util.visible
/**
* An indicator shown for each participant in a call which shows the state of their audio.
*/
class AudioIndicatorView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {
companion object {
private const val SIDE_BAR_SHRINK_FACTOR = 0.75f
}
private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.WHITE
}
private val barRect = RectF()
private val barWidth = DimensionUnit.DP.toPixels(3f)
private val barRadius = DimensionUnit.DP.toPixels(32f)
private val barPadding = DimensionUnit.DP.toPixels(3f)
private var middleBarAnimation: ValueAnimator? = null
private var sideBarAnimation: ValueAnimator? = null
private var showAudioLevel = false
private var lastMicrophoneEnabled: Boolean = true
private var lastAudioLevel: CallParticipant.AudioLevel? = null
init {
inflate(context, R.layout.audio_indicator_view, this)
setWillNotDraw(false)
backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.transparent_black_70))
}
private val micMuted: View = findViewById(R.id.mic_muted)
fun bind(microphoneEnabled: Boolean, level: CallParticipant.AudioLevel?) {
setBackgroundResource(R.drawable.circle_tintable)
this.visible = !microphoneEnabled || level != null
micMuted.visible = !microphoneEnabled
val wasShowingAudioLevel = showAudioLevel
showAudioLevel = microphoneEnabled && level != null
if (showAudioLevel) {
val scaleFactor = when (level!!) {
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
}
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,7 +104,7 @@ fun CallScreenJoiningOverlay(
verticalAlignment = Alignment.Bottom
) {
if (isLocalVideoEnabled) {
AudioIndicator(
ParticipantAudioIndicator(
participant = localParticipant,
selfPipMode = SelfPipMode.OVERLAY_SELF_PIP
)

View File

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

View File

@@ -134,7 +134,7 @@ fun CallScreenPreJoinOverlay(
verticalAlignment = Alignment.Bottom
) {
if (isLocalVideoEnabled) {
AudioIndicator(
ParticipantAudioIndicator(
participant = localParticipant,
selfPipMode = SelfPipMode.OVERLAY_SELF_PIP
)

View File

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

View File

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

View File

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

View File

@@ -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<DisplayManager>()!!
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<Nothing> { 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() {

View File

@@ -58,6 +58,8 @@ class WebRtcCallViewModel : ViewModel() {
private val internalMicrophoneEnabled = MutableStateFlow(true)
private val remoteMutedBy = MutableStateFlow<CallParticipant?>(null)
private val isInPipMode = MutableStateFlow(false)
private val _savedLocalParticipantLandscape = MutableStateFlow(false)
val savedLocalParticipantLandscape: StateFlow<Boolean> = _savedLocalParticipantLandscape
private val webRtcControls = MutableStateFlow(WebRtcControls.NONE)
private val foldableState = MutableStateFlow(WebRtcControls.FoldableState.flat())
private val identityChangedRecipients = MutableStateFlow<Collection<RecipientId>>(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 }
}

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.AppCompatImageView
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/mic_muted"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_gravity="center"
app:srcCompat="@drawable/symbol_mic_slash_fill_compact_16"
app:tint="@color/core_white" />