diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt index dc9d110d67..1003ef4afb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt @@ -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 = 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 get() { val results = allRemoteParticipants.asSequence() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java index a874d477a2..de16f51030 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java index 40ad48e692..0f5ad952f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -530,7 +530,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout { updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), displaySmallSelfPipInLandscape); - if (state.isLargeVideoGroup()) { + if (state.isLargeGroup()) { adjustLayoutForLargeCount(); } else { adjustLayoutForSmallCount(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt index 1938f06513..ec318545eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt @@ -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 = tween( + private inline fun defaultTween(): FiniteAnimationSpec = tween( durationMillis = ANIMATION_DURATION_MS.toInt(), easing = DefaultEasing ) - val alphaAnimationSpec: FiniteAnimationSpec = tween( - durationMillis = ANIMATION_DURATION_MS.toInt(), - easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f) - ) + val positionAnimationSpec: FiniteAnimationSpec = defaultTween() + val alphaAnimationSpec: FiniteAnimationSpec = defaultTween() + val sizeAnimationSpec: FiniteAnimationSpec = defaultTween() + val dpAnimationSpec: FiniteAnimationSpec = defaultTween() + val scaleAnimationSpec: FiniteAnimationSpec = defaultTween() - val sizeAnimationSpec: FiniteAnimationSpec = tween( - durationMillis = ANIMATION_DURATION_MS.toInt(), - easing = DefaultEasing - ) - - val dpAnimationSpec: FiniteAnimationSpec = 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(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( + 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 CallGrid( items: List, modifier: Modifier = Modifier, + singleParticipantAspectRatio: Float? = null, itemKey: (T) -> Any, content: @Composable (item: T, modifier: Modifier) -> Unit ) { @@ -444,11 +504,20 @@ fun CallGrid( val positionAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } val sizeAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } val alphaAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } + val scaleAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } val knownKeys = remember { mutableSetOf() } + var exitingItems: List> by remember { mutableStateOf(emptyList()) } + val previousItems = remember { mutableStateMapOf() } 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 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 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>) { + 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 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 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 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>) { + 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 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") + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt index 5d2e8383d1..e07f145e3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt @@ -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(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) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt index df38303d3c..a0f9e9dec4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt index 251e74e112..0ca4001aa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -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,