CallGrid animation updates.

This commit is contained in:
Alex Hart
2025-12-12 16:32:25 -04:00
parent c25f6d0bc4
commit 1238151d73
7 changed files with 356 additions and 125 deletions

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import androidx.annotation.Discouraged
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.annimon.stream.OptionalLong
@@ -49,9 +50,12 @@ data class CallParticipantsState(
val allRemoteParticipants: List<CallParticipant> = remoteParticipants.allParticipants
val isFolded: Boolean = foldableState.isFolded
val isLargeVideoGroup: Boolean = allRemoteParticipants.size > SMALL_GROUP_MAX && !isInPipMode && !isFolded
val hideAvatar: Boolean = callState.isIncomingOrHandledElsewhere
@get:Discouraged("Only for backwards-compatibility with View code. Compose UI determines large group dynamically.")
val isLargeGroup: Boolean
get() = allRemoteParticipants.size > SMALL_GROUP_MAX
val raisedHands: List<GroupCallRaiseHandEvent>
get() {
val results = allRemoteParticipants.asSequence()

View File

@@ -57,11 +57,15 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf
}
public void init(@NonNull EglBase eglBase) {
init(eglBase, null);
}
public void init(@NonNull EglBase eglBase, @Nullable RendererCommon.RendererEvents rendererEvents) {
if (isInitialized) return;
isInitialized = true;
this.init(eglBase.getEglBaseContext(), null, EglBase.CONFIG_PLAIN, new GlRectDrawer());
this.init(eglBase.getEglBaseContext(), rendererEvents, EglBase.CONFIG_PLAIN, new GlRectDrawer());
}
public void init(@NonNull EglBase.Context sharedContext, @Nullable RendererCommon.RendererEvents rendererEvents, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) {

View File

@@ -530,7 +530,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), displaySmallSelfPipInLandscape);
if (state.isLargeVideoGroup()) {
if (state.isLargeGroup()) {
adjustLayoutForLargeCount();
} else {
adjustLayoutForSmallCount();

View File

@@ -16,16 +16,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -36,6 +38,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
@@ -46,41 +49,38 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import kotlin.math.max
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Animation constants for CallGrid
*/
private object CallGridDefaults {
const val ANIMATION_DURATION_MS = 300L
internal object CallGridDefaults {
const val ANIMATION_DURATION_MS = 350L
private val DefaultEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f)
private val DefaultEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)
val positionAnimationSpec: FiniteAnimationSpec<IntOffset> = tween(
private inline fun <reified T> defaultTween(): FiniteAnimationSpec<T> = tween(
durationMillis = ANIMATION_DURATION_MS.toInt(),
easing = DefaultEasing
)
val alphaAnimationSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = ANIMATION_DURATION_MS.toInt(),
easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
)
val positionAnimationSpec: FiniteAnimationSpec<IntOffset> = defaultTween()
val alphaAnimationSpec: FiniteAnimationSpec<Float> = defaultTween()
val sizeAnimationSpec: FiniteAnimationSpec<IntSize> = defaultTween()
val dpAnimationSpec: FiniteAnimationSpec<Dp> = defaultTween()
val scaleAnimationSpec: FiniteAnimationSpec<Float> = defaultTween()
val sizeAnimationSpec: FiniteAnimationSpec<IntSize> = tween(
durationMillis = ANIMATION_DURATION_MS.toInt(),
easing = DefaultEasing
)
val dpAnimationSpec: FiniteAnimationSpec<Dp> = tween(
durationMillis = ANIMATION_DURATION_MS.toInt(),
easing = DefaultEasing
)
const val ENTER_SCALE_START = 0.9f
const val ENTER_SCALE_END = 1f
const val EXIT_SCALE_END = 0.9f
}
/**
@@ -154,7 +154,7 @@ sealed class CallGridStrategy(val maxTiles: Int) {
val (rows, cols, lastRowItems, lastColSpans) = when (count) {
1 -> GridLayoutParams(1, 1, 1)
2 -> GridLayoutParams(1, 2, 2)
3 -> GridLayoutParams(2, 2, 1)
3 -> GridLayoutParams(2, 2, 1, lastColSpans = true)
4 -> GridLayoutParams(2, 2, 2)
5 -> GridLayoutParams(2, 3, 1, lastColSpans = true)
else -> GridLayoutParams(2, 3, 3)
@@ -229,17 +229,19 @@ sealed class CallGridStrategy(val maxTiles: Int) {
/**
* Remembers the appropriate CallGridStrategy based on current window size
*/
private const val WIDTH_DP_LARGE_LOWER_BOUND = 1200
@Composable
fun rememberCallGridStrategy(): CallGridStrategy {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val windowSizeClass = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass
return remember(windowSizeClass) {
val isWidthExpanded = windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)
val isWidthLarge = windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_LARGE_LOWER_BOUND)
val isWidthMedium = windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
val isHeightMedium = windowSizeClass.isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND)
when {
isWidthExpanded && isHeightMedium -> CallGridStrategy.Large()
isWidthLarge && isHeightMedium -> CallGridStrategy.Large()
isWidthMedium && isHeightMedium -> CallGridStrategy.Medium()
!isHeightMedium -> CallGridStrategy.SmallLandscape()
else -> CallGridStrategy.SmallPortrait()
@@ -247,6 +249,49 @@ fun rememberCallGridStrategy(): CallGridStrategy {
}
}
/**
* Observes a participant's video sink and returns their video's aspect ratio.
*
* This attaches a lightweight VideoSink to capture frame dimensions from the
* participant's video stream. The sink is automatically removed when the
* videoSink changes or the composable leaves composition.
*
* @param videoSink The participant's BroadcastVideoSink to observe, or null
* @return The aspect ratio (width/height) of the video, or null if not yet known
*/
@Composable
fun rememberParticipantAspectRatio(videoSink: BroadcastVideoSink?): Float? {
var aspectRatio by remember { mutableStateOf<Float?>(null) }
DisposableEffect(videoSink) {
if (videoSink == null) {
aspectRatio = null
return@DisposableEffect onDispose { }
}
val dimensionSink = object : VideoSink {
override fun onFrame(frame: VideoFrame) {
val width = frame.rotatedWidth
val height = frame.rotatedHeight
if (width > 0 && height > 0) {
val newAspectRatio = width.toFloat() / height.toFloat()
if (aspectRatio != newAspectRatio) {
aspectRatio = newAspectRatio
}
}
}
}
videoSink.addSink(dimensionSink)
onDispose {
videoSink.removeSink(dimensionSink)
}
}
return aspectRatio
}
/**
* Calculate grid cell positions and sizes.
*
@@ -417,17 +462,31 @@ private fun calculateGridCellsWithSpanningColumn(
return cells
}
/**
* State for an item that is exiting the grid with animation
*/
private data class ExitingItem<T>(
val item: T,
val key: Any,
val lastPosition: IntOffset,
val lastSize: IntSize
)
/**
* An animated grid layout for call participants.
*
* Features:
* - Smooth position animations when items move
* - Fade-in animation for new items (after position animations complete)
* - Fade-in/scale-in animation for new items (0% to 100% opacity, 90% to 100% scale)
* - Fade-out/scale-out animation for removed items (100% to 0% opacity, 100% to 90% scale)
* - Crossfade for swapped items (same position, different participant)
* - Device-aware grid configurations
*
* @param items List of items to display, each with a stable key
* @param modifier Modifier for the grid container
* @param singleParticipantAspectRatio Optional aspect ratio override for single-participant display.
* When provided and there's only one participant, this replaces the default 9:16 aspect ratio,
* allowing the video to display at its native dimensions (e.g., 16:9 for landscape video).
* @param itemKey Function to extract a stable key from each item
* @param content Composable content for each item
*/
@@ -435,6 +494,7 @@ private fun calculateGridCellsWithSpanningColumn(
fun <T> CallGrid(
items: List<T>,
modifier: Modifier = Modifier,
singleParticipantAspectRatio: Float? = null,
itemKey: (T) -> Any,
content: @Composable (item: T, modifier: Modifier) -> Unit
) {
@@ -444,11 +504,20 @@ fun <T> CallGrid(
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
val sizeAnimatables: SnapshotStateMap<Any, Animatable<IntSize, *>> = remember { mutableStateMapOf() }
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
val scaleAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
val knownKeys = remember { mutableSetOf<Any>() }
var exitingItems: List<ExitingItem<T>> by remember { mutableStateOf(emptyList()) }
val previousItems = remember { mutableStateMapOf<Any, T>() }
val displayCount = min(items.size, strategy.maxTiles)
val displayItems = items.take(displayCount)
val config = remember(strategy, displayCount) { strategy.getConfig(displayCount) }
val baseConfig = remember(strategy, displayCount) { strategy.getConfig(displayCount) }
val config = if (displayCount == 1 && singleParticipantAspectRatio != null && baseConfig.aspectRatio != null) {
baseConfig.copy(aspectRatio = singleParticipantAspectRatio)
} else {
baseConfig
}
val animatedCornerRadius by animateDpAsState(
targetValue = config.cornerRadius,
@@ -463,15 +532,48 @@ fun <T> CallGrid(
newKeys.forEach { key ->
if (hasExistingItems) {
alphaAnimatables[key] = Animatable(0f)
scaleAnimatables[key] = Animatable(CallGridDefaults.ENTER_SCALE_START)
}
knownKeys.add(key)
}
val removedKeys = knownKeys - currentKeys
removedKeys.forEach { key ->
displayItems.forEach { item ->
previousItems[itemKey(item)] = item
}
fun removeAnimationState(key: Any) {
positionAnimatables.remove(key)
sizeAnimatables.remove(key)
alphaAnimatables.remove(key)
scaleAnimatables.remove(key)
previousItems.remove(key)
}
val removedKeys = knownKeys - currentKeys
removedKeys.forEach { key ->
val exitingItem = previousItems[key]
val position = positionAnimatables[key]?.value
val size = sizeAnimatables[key]?.value
if (exitingItem != null && position != null && size != null) {
exitingItems = exitingItems + ExitingItem(
item = exitingItem,
key = key,
lastPosition = position,
lastSize = size
)
scope.launch {
coroutineScope {
launch { alphaAnimatables[key]?.animateTo(0f, CallGridDefaults.alphaAnimationSpec) }
launch { scaleAnimatables[key]?.animateTo(CallGridDefaults.EXIT_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
}
exitingItems = exitingItems.filterNot { it.key == key }
removeAnimationState(key)
}
} else {
removeAnimationState(key)
}
knownKeys.remove(key)
}
@@ -493,38 +595,67 @@ fun <T> CallGrid(
val density = LocalDensity.current
Layout(
content = {
displayItems.forEachIndexed { index, item ->
val itemKeyValue = itemKey(item)
key(itemKeyValue) {
val alpha = alphaAnimatables[itemKeyValue]?.value ?: 1f
val animatedSize = sizeAnimatables[itemKeyValue]?.value
val cell = cells.getOrNull(index)
val enteringKeys = newKeys.filter { key ->
val alpha = alphaAnimatables[key]?.value ?: 1f
alpha < 1f
}.toSet()
if (cell != null) {
val widthPx = animatedSize?.width ?: cell.width.roundToInt()
val heightPx = animatedSize?.height ?: cell.height.roundToInt()
// Internal to capture closure variables: alphaAnimatables, scaleAnimatables, density, animatedCornerRadius, content
@Composable
fun RenderItem(item: T, itemKeyValue: Any, widthPx: Int, heightPx: Int) {
val alpha = alphaAnimatables[itemKeyValue]?.value ?: 1f
val itemScale = scaleAnimatables[itemKeyValue]?.value ?: 1f
Box(
modifier = Modifier
.layoutId(itemKeyValue)
.alpha(alpha)
) {
content(
item,
Modifier
.size(
width = with(density) { widthPx.toDp() },
height = with(density) { heightPx.toDp() }
)
.clip(RoundedCornerShape(animatedCornerRadius))
)
}
}
Box(
modifier = Modifier
.layoutId(itemKeyValue)
.alpha(alpha)
.scale(itemScale)
) {
content(
item,
Modifier
.size(
width = with(density) { widthPx.toDp() },
height = with(density) { heightPx.toDp() }
)
.clip(RoundedCornerShape(animatedCornerRadius))
)
}
}
// Pre-filter items by entering status, preserving indices for cell lookup
val (enteringIndexedItems, nonEnteringIndexedItems) = displayItems
.withIndex()
.partition { (_, item) -> itemKey(item) in enteringKeys }
@Composable
fun RenderDisplayItems(indexedItems: List<IndexedValue<T>>) {
indexedItems.forEach { (index, item) ->
val itemKeyValue = itemKey(item)
key(itemKeyValue) {
val animatedSize = sizeAnimatables[itemKeyValue]?.value
val cell = cells.getOrNull(index)
if (cell != null) {
val widthPx = animatedSize?.width ?: cell.width.roundToInt()
val heightPx = animatedSize?.height ?: cell.height.roundToInt()
RenderItem(item, itemKeyValue, widthPx, heightPx)
}
}
}
}
Layout(
content = {
exitingItems.forEach { exitingItem ->
key(exitingItem.key) {
RenderItem(exitingItem.item, exitingItem.key, exitingItem.lastSize.width, exitingItem.lastSize.height)
}
}
RenderDisplayItems(enteringIndexedItems)
RenderDisplayItems(nonEnteringIndexedItems)
}
) { measurables, constraints ->
displayItems.forEachIndexed { index, item ->
val itemKeyValue = itemKey(item)
@@ -537,13 +668,18 @@ fun <T> CallGrid(
positionAnimatables[itemKeyValue] = Animatable(targetPosition, IntOffset.VectorConverter)
if (hasExistingItems && itemKeyValue in newKeys) {
scope.launch {
delay(CallGridDefaults.ANIMATION_DURATION_MS)
alphaAnimatables[itemKeyValue]?.animateTo(1f, CallGridDefaults.alphaAnimationSpec)
coroutineScope {
launch { alphaAnimatables[itemKeyValue]?.animateTo(1f, CallGridDefaults.alphaAnimationSpec) }
launch { scaleAnimatables[itemKeyValue]?.animateTo(CallGridDefaults.ENTER_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
}
}
} else {
if (alphaAnimatables[itemKeyValue] == null) {
alphaAnimatables[itemKeyValue] = Animatable(1f)
}
if (scaleAnimatables[itemKeyValue] == null) {
scaleAnimatables[itemKeyValue] = Animatable(1f)
}
}
} else if (existingPosition.targetValue != targetPosition) {
scope.launch {
@@ -561,24 +697,21 @@ fun <T> CallGrid(
}
}
val placeables = measurables.mapIndexed { index, measurable ->
val itemKeyValue = displayItems.getOrNull(index)?.let { itemKey(it) }
val animatedSize = itemKeyValue?.let { sizeAnimatables[it]?.value }
val cell = cells.getOrNull(index)
val placeables = measurables.map { measurable ->
val itemKeyValue = measurable.layoutId
val animatedSize = sizeAnimatables[itemKeyValue]?.value
val exitingItem = exitingItems.find { it.key == itemKeyValue }
if (animatedSize != null) {
measurable.measure(
Constraints.fixed(animatedSize.width, animatedSize.height)
)
} else if (cell != null) {
measurable.measure(
Constraints.fixed(
cell.width.roundToInt(),
cell.height.roundToInt()
)
)
} else {
measurable.measure(Constraints())
when {
animatedSize != null -> {
measurable.measure(Constraints.fixed(animatedSize.width, animatedSize.height))
}
exitingItem != null -> {
measurable.measure(Constraints.fixed(exitingItem.lastSize.width, exitingItem.lastSize.height))
}
else -> {
measurable.measure(Constraints())
}
}
}
@@ -587,15 +720,24 @@ fun <T> CallGrid(
}
layout(constraints.maxWidth, constraints.maxHeight) {
displayItems.forEach { item ->
val itemKeyValue = itemKey(item)
val placeable = keyToPlaceable[itemKeyValue]
val position = positionAnimatables[itemKeyValue]?.value
if (placeable != null && position != null) {
placeable.place(position.x, position.y)
fun placeDisplayItems(indexedItems: List<IndexedValue<T>>) {
indexedItems.forEach { (_, item) ->
val itemKeyValue = itemKey(item)
val placeable = keyToPlaceable[itemKeyValue]
val position = positionAnimatables[itemKeyValue]?.value
if (placeable != null && position != null) {
placeable.place(position.x, position.y)
}
}
}
exitingItems.forEach { exitingItem ->
val placeable = keyToPlaceable[exitingItem.key]
placeable?.place(exitingItem.lastPosition.x, exitingItem.lastPosition.y)
}
placeDisplayItems(enteringIndexedItems)
placeDisplayItems(nonEnteringIndexedItems)
}
}
}
@@ -607,8 +749,8 @@ fun <T> CallGrid(
@Composable
private fun CallGridPreview() {
Previews.Preview {
var count by remember { mutableStateOf(1) }
val items = remember(count) { (1..count).toList() }
var nextId by remember { mutableStateOf(2) }
val items = remember { mutableStateListOf(1) }
val colors = listOf(
Color(0xFF5E97F6),
@@ -625,7 +767,13 @@ private fun CallGridPreview() {
Color(0xFFFF7043)
)
Box(modifier = Modifier.fillMaxSize()) {
val windowSizeClass = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass
val strategy = rememberCallGridStrategy()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val widthDp = maxWidth
val heightDp = maxHeight
CallGrid(
items = items,
modifier = Modifier.fillMaxSize(),
@@ -642,20 +790,40 @@ private fun CallGridPreview() {
}
}
Text(
text = "${widthDp.value.toInt()} x ${heightDp.value.toInt()} dp\n" +
"WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
"Strategy: ${strategy::class.simpleName}",
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.TopEnd)
)
Row(
modifier = Modifier.align(Alignment.TopStart)
) {
Button(onClick = { count = max(1, count - 1) }) {
Button(onClick = { if (items.size > 1) items.removeAt(items.size - 1) }) {
Text("-")
}
Button(onClick = { count = min(12, count + 1) }) {
Button(onClick = { if (items.size < 12) { items.add(nextId); nextId++ } }) {
Text("+")
}
Text(
text = "Count: $count",
modifier = Modifier.padding(16.dp),
color = Color.White
)
Button(onClick = {
if (items.size > 1) {
val index = (0 until items.size).random()
items.removeAt(index)
}
}) {
Text("-R")
}
Button(onClick = {
if (items.size < 12) {
val index = (0..items.size).random()
items.add(index, nextId)
nextId++
}
}) {
Text("+R")
}
}
}
}

View File

@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
@@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.compose.GlideImageScaleType
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
import org.webrtc.RendererCommon
/**
* Displays a remote participant (or local participant in pre-join screen).
@@ -99,24 +101,45 @@ fun RemoteParticipantContent(
)
} else {
val hasContentToRender = participant.isVideoEnabled || participant.isScreenSharing
var isVideoReady by remember(participant.callParticipantId) { mutableStateOf(false) }
if (!hasContentToRender) {
isVideoReady = false
}
val showAvatar = !hasContentToRender || !isVideoReady
Crossfade(
targetState = showAvatar,
animationSpec = CallGridDefaults.alphaAnimationSpec,
label = "video-ready-crossfade"
) { shouldShowAvatar ->
if (shouldShowAvatar) {
if (renderInPip) {
PipAvatar(
recipient = recipient,
modifier = Modifier.fillMaxSize()
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
AvatarWithBadge(recipient = recipient)
}
}
} else {
Box(modifier = Modifier.fillMaxSize())
}
}
if (hasContentToRender) {
VideoRenderer(
participant = participant,
onFirstFrameRendered = { isVideoReady = true },
showLetterboxing = isVideoReady,
modifier = Modifier.fillMaxSize()
)
} else if (renderInPip) {
PipAvatar(
recipient = recipient,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
)
} else {
AvatarWithBadge(
recipient = recipient,
modifier = Modifier.align(Alignment.Center)
)
}
AudioIndicator(
@@ -165,15 +188,10 @@ fun SelfPipContent(
AudioIndicator(
participant = participant,
selfPipMode = selfPipMode,
modifier = Modifier.align(
when (selfPipMode) {
SelfPipMode.MINI_SELF_PIP -> Alignment.BottomCenter
else -> Alignment.BottomStart
}
)
modifier = Modifier.align(Alignment.BottomStart)
)
if (isMoreThanOneCameraAvailable && selfPipMode != SelfPipMode.MINI_SELF_PIP) {
if (isMoreThanOneCameraAvailable) {
SwitchCameraButton(
selfPipMode = selfPipMode,
onClick = onSwitchCameraClick,
@@ -356,14 +374,37 @@ private fun PipAvatar(
@Composable
private fun VideoRenderer(
participant: CallParticipant,
onFirstFrameRendered: (() -> Unit)? = null,
showLetterboxing: Boolean = true,
modifier: Modifier = Modifier
) {
var renderer by remember { mutableStateOf<TextureViewRenderer?>(null) }
val rendererEvents = remember(onFirstFrameRendered) {
if (onFirstFrameRendered != null) {
object : RendererCommon.RendererEvents {
override fun onFirstFrameRendered() {
onFirstFrameRendered()
}
override fun onFrameResolutionChanged(videoWidth: Int, videoHeight: Int, rotation: Int) {
}
}
} else {
null
}
}
val backgroundColor = if (showLetterboxing) {
android.graphics.Color.parseColor("#CC000000")
} else {
android.graphics.Color.TRANSPARENT
}
AndroidView(
factory = { context ->
FrameLayout(context).apply {
setBackgroundColor(android.graphics.Color.parseColor("#CC000000"))
setBackgroundColor(backgroundColor)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
@@ -378,7 +419,7 @@ private fun VideoRenderer(
if (participant.isVideoEnabled) {
participant.videoSink.lockableEglBase.performWithValidEglBase { eglBase ->
init(eglBase)
init(eglBase, rendererEvents)
}
attachBroadcastVideoSink(participant.videoSink)
}
@@ -388,12 +429,13 @@ private fun VideoRenderer(
addView(textureRenderer)
}
},
update = {
update = { frameLayout ->
frameLayout.setBackgroundColor(backgroundColor)
val textureRenderer = renderer
if (textureRenderer != null) {
if (participant.isVideoEnabled) {
participant.videoSink.lockableEglBase.performWithValidEglBase { eglBase ->
textureRenderer.init(eglBase)
textureRenderer.init(eglBase, rendererEvents)
}
textureRenderer.attachBroadcastVideoSink(participant.videoSink)
} else {
@@ -789,11 +831,12 @@ private fun SelfPipMiniPreview() {
SelfPipContent(
participant = CallParticipant.EMPTY.copy(
recipient = Recipient(isResolving = false, systemContactName = "You", isSelf = true),
isVideoEnabled = true,
isMicrophoneEnabled = false
),
selfPipMode = SelfPipMode.MINI_SELF_PIP,
isMoreThanOneCameraAvailable = false,
onSwitchCameraClick = null,
isMoreThanOneCameraAvailable = true,
onSwitchCameraClick = {},
modifier = Modifier.size(rememberCallScreenMetrics().overflowParticipantRendererDpSize)
)
}

View File

@@ -34,12 +34,17 @@ fun CallParticipantsPager(
return
}
val firstParticipantAR = rememberParticipantAspectRatio(
callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink
)
// Use movableContentOf to preserve CallGrid state when switching between
// single participant (no pager) and multiple participants (with pager)
val callGridContent = remember {
movableContentOf { state: CallParticipantsPagerState, mod: Modifier ->
movableContentOf { state: CallParticipantsPagerState, mod: Modifier, aspectRatio: Float? ->
CallGrid(
items = state.callParticipants,
singleParticipantAspectRatio = aspectRatio,
modifier = mod,
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
@@ -63,7 +68,7 @@ fun CallParticipantsPager(
) { page ->
when (page) {
0 -> {
callGridContent(callParticipantsPagerState, Modifier.fillMaxSize())
callGridContent(callParticipantsPagerState, Modifier.fillMaxSize(), firstParticipantAR)
}
1 -> {
@@ -78,7 +83,7 @@ fun CallParticipantsPager(
}
}
} else {
callGridContent(callParticipantsPagerState, modifier)
callGridContent(callParticipantsPagerState, modifier, firstParticipantAR)
}
}

View File

@@ -102,9 +102,16 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
val callControlsState by viewModel.getCallControlsState().collectAsStateWithLifecycle(CallControlsState())
val callParticipantsViewState by callScreenViewModel.callParticipantsViewState.collectAsStateWithLifecycle()
val callParticipantsState = remember(callParticipantsViewState) { callParticipantsViewState.callParticipantsState }
val callParticipantsPagerState = remember(callParticipantsState) {
val callGridStrategy = rememberCallGridStrategy()
val gridParticipants = remember(callParticipantsState.allRemoteParticipants, callGridStrategy) {
callParticipantsState.allRemoteParticipants.take(callGridStrategy.maxTiles)
}
val overflowParticipants = remember(callParticipantsState.allRemoteParticipants, callGridStrategy) {
callParticipantsState.allRemoteParticipants.drop(callGridStrategy.maxTiles)
}
val callParticipantsPagerState = remember(gridParticipants, callParticipantsState) {
CallParticipantsPagerState(
callParticipants = callParticipantsState.gridParticipants,
callParticipants = gridParticipants,
focusedParticipant = callParticipantsState.focusedParticipant,
isRenderInPip = callParticipantsState.isInPipMode,
hideAvatar = callParticipantsState.hideAvatar
@@ -168,7 +175,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
callScreenSheetDisplayListener = callScreenSheetDisplayListener,
callParticipantsPagerState = callParticipantsPagerState,
pendingParticipantsListener = pendingParticipantsListener,
overflowParticipants = callParticipantsState.listParticipants,
overflowParticipants = overflowParticipants,
localParticipant = callParticipantsState.localParticipant,
localRenderState = callParticipantsState.localRenderState,
reactions = callParticipantsState.reactions,