mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-14 23:18:43 +00:00
New system PiP treatment.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -104,7 +104,7 @@ fun CallScreenJoiningOverlay(
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (isLocalVideoEnabled) {
|
||||
AudioIndicator(
|
||||
ParticipantAudioIndicator(
|
||||
participant = localParticipant,
|
||||
selfPipMode = SelfPipMode.OVERLAY_SELF_PIP
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -134,7 +134,7 @@ fun CallScreenPreJoinOverlay(
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (isLocalVideoEnabled) {
|
||||
AudioIndicator(
|
||||
ParticipantAudioIndicator(
|
||||
participant = localParticipant,
|
||||
selfPipMode = SelfPipMode.OVERLAY_SELF_PIP
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user