mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 08:09:12 +01:00
CallGrid animation updates.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -530,7 +530,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
|
||||
|
||||
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), displaySmallSelfPipInLandscape);
|
||||
|
||||
if (state.isLargeVideoGroup()) {
|
||||
if (state.isLargeGroup()) {
|
||||
adjustLayoutForLargeCount();
|
||||
} else {
|
||||
adjustLayoutForSmallCount();
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user