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 new file mode 100644 index 0000000000..1938f06513 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt @@ -0,0 +1,662 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.VectorConverter +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.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.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap +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.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +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.launch +import org.signal.core.ui.compose.AllNightPreviews +import org.signal.core.ui.compose.Previews +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Animation constants for CallGrid + */ +private object CallGridDefaults { + const val ANIMATION_DURATION_MS = 300L + + private val DefaultEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) + + val positionAnimationSpec: 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 sizeAnimationSpec: FiniteAnimationSpec = tween( + durationMillis = ANIMATION_DURATION_MS.toInt(), + easing = DefaultEasing + ) + + val dpAnimationSpec: FiniteAnimationSpec = tween( + durationMillis = ANIMATION_DURATION_MS.toInt(), + easing = DefaultEasing + ) +} + +/** + * Configuration for a specific grid layout + */ +@Immutable +data class GridConfig( + val rows: Int, + val columns: Int, + val itemsInLastRow: Int, + val outerPadding: Dp, + val innerSpacing: Dp, + val cornerRadius: Dp, + val aspectRatio: Float?, + val lastColumnSpansFullHeight: Boolean = false +) + +/** + * Represents a calculated cell position and size in the grid + */ +@Immutable +data class GridCell( + val index: Int, + val x: Float, + val y: Float, + val width: Float, + val height: Float +) + +/** + * Internal helper for grid layout parameters + */ +private data class GridLayoutParams( + val rows: Int, + val cols: Int, + val lastRowItems: Int, + val lastColSpans: Boolean = false +) + +/** + * Strategy for determining grid configuration based on device size + */ +sealed class CallGridStrategy(val maxTiles: Int) { + abstract fun getConfig(count: Int): GridConfig + + class SmallPortrait : CallGridStrategy(6) { + override fun getConfig(count: Int): GridConfig { + val (rows, cols, lastRowItems) = when (count) { + 1 -> GridLayoutParams(1, 1, 1) + 2 -> GridLayoutParams(2, 1, 1) + 3 -> GridLayoutParams(2, 2, 1) + 4 -> GridLayoutParams(2, 2, 2) + 5 -> GridLayoutParams(3, 2, 1) + else -> GridLayoutParams(3, 2, 2) + } + return GridConfig( + rows = rows, + columns = cols, + itemsInLastRow = lastRowItems, + outerPadding = if (count == 1) 0.dp else 16.dp, + innerSpacing = if (count == 1) 0.dp else 12.dp, + cornerRadius = if (count == 1) 0.dp else 32.dp, + aspectRatio = null + ) + } + } + + class SmallLandscape : CallGridStrategy(6) { + override fun getConfig(count: Int): GridConfig { + // For 5 items: 2 rows, 3 columns, with the last column (5th item) spanning full height + val (rows, cols, lastRowItems, lastColSpans) = when (count) { + 1 -> GridLayoutParams(1, 1, 1) + 2 -> GridLayoutParams(1, 2, 2) + 3 -> GridLayoutParams(2, 2, 1) + 4 -> GridLayoutParams(2, 2, 2) + 5 -> GridLayoutParams(2, 3, 1, lastColSpans = true) + else -> GridLayoutParams(2, 3, 3) + } + return GridConfig( + rows = rows, + columns = cols, + itemsInLastRow = lastRowItems, + outerPadding = if (count == 1) 0.dp else 16.dp, + innerSpacing = if (count == 1) 0.dp else 12.dp, + cornerRadius = if (count == 1) 0.dp else 32.dp, + aspectRatio = null, + lastColumnSpansFullHeight = lastColSpans + ) + } + } + + class Medium : CallGridStrategy(9) { + override fun getConfig(count: Int): GridConfig { + val (rows, cols, lastRowItems) = when (count) { + 1 -> GridLayoutParams(1, 1, 1) + 2 -> GridLayoutParams(2, 1, 1) + 3 -> GridLayoutParams(2, 2, 1) + 4 -> GridLayoutParams(2, 2, 2) + 5 -> GridLayoutParams(3, 2, 1) + 6 -> GridLayoutParams(3, 2, 2) + 7 -> GridLayoutParams(3, 3, 1) + 8 -> GridLayoutParams(3, 3, 2) + else -> GridLayoutParams(3, 3, 3) + } + return GridConfig( + rows = rows, + columns = cols, + itemsInLastRow = lastRowItems, + outerPadding = 24.dp, + innerSpacing = 12.dp, + cornerRadius = 32.dp, + aspectRatio = if (count == 1) 9f / 16f else 5f / 4f + ) + } + } + + class Large : CallGridStrategy(12) { + override fun getConfig(count: Int): GridConfig { + val (rows, cols, lastRowItems) = when (count) { + 1 -> GridLayoutParams(1, 1, 1) + 2 -> GridLayoutParams(1, 2, 2) + 3 -> GridLayoutParams(1, 3, 3) + 4 -> GridLayoutParams(2, 2, 2) + 5 -> GridLayoutParams(2, 3, 2) + 6 -> GridLayoutParams(2, 3, 3) + 7 -> GridLayoutParams(2, 4, 3) + 8 -> GridLayoutParams(2, 4, 4) + 9 -> GridLayoutParams(3, 4, 1) + 10 -> GridLayoutParams(3, 4, 2) + 11 -> GridLayoutParams(3, 4, 3) + else -> GridLayoutParams(3, 4, 4) + } + return GridConfig( + rows = rows, + columns = cols, + itemsInLastRow = lastRowItems, + outerPadding = 24.dp, + innerSpacing = 12.dp, + cornerRadius = 32.dp, + aspectRatio = if (count == 1) 9f / 16f else 5f / 4f + ) + } + } +} + +/** + * Remembers the appropriate CallGridStrategy based on current window size + */ +@Composable +fun rememberCallGridStrategy(): CallGridStrategy { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + + return remember(windowSizeClass) { + val isWidthExpanded = windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_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() + isWidthMedium && isHeightMedium -> CallGridStrategy.Medium() + !isHeightMedium -> CallGridStrategy.SmallLandscape() + else -> CallGridStrategy.SmallPortrait() + } + } +} + +/** + * Calculate grid cell positions and sizes. + * + * When aspectRatio is specified: Items are packed together with only inner spacing + * between them, and the entire group is centered in the container. + * + * When aspectRatio is null (compact mode): Items fill the available space, with + * partial rows stretching to fill the full width. + * + * When lastColumnSpansFullHeight is true: The last item occupies the rightmost column + * and spans the full height (all rows). + */ +private fun calculateGridCells( + config: GridConfig, + containerWidth: Float, + containerHeight: Float, + itemCount: Int +): List { + if (itemCount == 0) return emptyList() + + val padding = config.outerPadding.value + val spacing = config.innerSpacing.value + val availableWidth = containerWidth - (padding * 2) + val availableHeight = containerHeight - (padding * 2) + + if (config.lastColumnSpansFullHeight && itemCount > 1) { + return calculateGridCellsWithSpanningColumn( + config = config, + availableWidth = availableWidth, + availableHeight = availableHeight, + padding = padding, + spacing = spacing, + itemCount = itemCount + ) + } + + val maxCellWidth = (availableWidth - (spacing * (config.columns - 1))) / config.columns + val maxCellHeight = (availableHeight - (spacing * (config.rows - 1))) / config.rows + + val (itemWidth, itemHeight) = if (config.aspectRatio != null) { + val targetAspectRatio = config.aspectRatio + val cellAspectRatio = maxCellWidth / maxCellHeight + + if (cellAspectRatio > targetAspectRatio) { + val constrainedWidth = maxCellHeight * targetAspectRatio + constrainedWidth to maxCellHeight + } else { + val constrainedHeight = maxCellWidth / targetAspectRatio + maxCellWidth to constrainedHeight + } + } else { + maxCellWidth to maxCellHeight + } + + val totalGridWidth = (config.columns * itemWidth) + ((config.columns - 1) * spacing) + val totalGridHeight = (config.rows * itemHeight) + ((config.rows - 1) * spacing) + + val gridStartX = padding + (availableWidth - totalGridWidth) / 2 + val gridStartY = padding + (availableHeight - totalGridHeight) / 2 + + val cells = mutableListOf() + + var index = 0 + for (row in 0 until config.rows) { + val isLastRow = row == config.rows - 1 + val itemsInThisRow = if (isLastRow) config.itemsInLastRow else config.columns + val remainingItems = itemCount - index + + if (remainingItems <= 0) break + + val actualItemsInRow = min(itemsInThisRow, remainingItems) + val isPartialRow = actualItemsInRow < config.columns + + // Stretch items in partial rows to fill width (compact mode only) + val cellWidthForRow = if (config.aspectRatio == null && isPartialRow) { + (totalGridWidth - (spacing * (actualItemsInRow - 1))) / actualItemsInRow + } else { + itemWidth + } + + val rowWidth = (actualItemsInRow * cellWidthForRow) + ((actualItemsInRow - 1) * spacing) + val rowXOffset = (totalGridWidth - rowWidth) / 2 + + for (col in 0 until actualItemsInRow) { + val x = gridStartX + rowXOffset + col * (cellWidthForRow + spacing) + val y = gridStartY + row * (itemHeight + spacing) + + cells.add( + GridCell( + index = index, + x = x, + y = y, + width = cellWidthForRow, + height = itemHeight + ) + ) + index++ + } + } + + return cells +} + +/** + * Calculate grid cells when the last column spans full height. + * Layout: Regular grid on the left, with the last item taking the rightmost column + * and spanning all rows. + */ +private fun calculateGridCellsWithSpanningColumn( + config: GridConfig, + availableWidth: Float, + availableHeight: Float, + padding: Float, + spacing: Float, + itemCount: Int +): List { + val cells = mutableListOf() + + val columnsForRegularItems = config.columns - 1 + val regularItemCount = itemCount - 1 + + val cellWidth = (availableWidth - (spacing * (config.columns - 1))) / config.columns + val cellHeight = (availableHeight - (spacing * (config.rows - 1))) / config.rows + + val totalGridWidth = (config.columns * cellWidth) + ((config.columns - 1) * spacing) + val totalGridHeight = (config.rows * cellHeight) + ((config.rows - 1) * spacing) + + val gridStartX = padding + (availableWidth - totalGridWidth) / 2 + val gridStartY = padding + (availableHeight - totalGridHeight) / 2 + + // Place regular items in column-major order (fills columns top-to-bottom, left-to-right) + var index = 0 + for (col in 0 until columnsForRegularItems) { + for (row in 0 until config.rows) { + if (index >= regularItemCount) break + + val x = gridStartX + col * (cellWidth + spacing) + val y = gridStartY + row * (cellHeight + spacing) + + cells.add( + GridCell( + index = index, + x = x, + y = y, + width = cellWidth, + height = cellHeight + ) + ) + index++ + } + } + + // Spanning item takes full height + val spanningX = gridStartX + columnsForRegularItems * (cellWidth + spacing) + val spanningY = gridStartY + val spanningHeight = totalGridHeight + + cells.add( + GridCell( + index = regularItemCount, + x = spanningX, + y = spanningY, + width = cellWidth, + height = spanningHeight + ) + ) + + return cells +} + +/** + * An animated grid layout for call participants. + * + * Features: + * - Smooth position animations when items move + * - Fade-in animation for new items (after position animations complete) + * - 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 itemKey Function to extract a stable key from each item + * @param content Composable content for each item + */ +@Composable +fun CallGrid( + items: List, + modifier: Modifier = Modifier, + itemKey: (T) -> Any, + content: @Composable (item: T, modifier: Modifier) -> Unit +) { + val strategy = rememberCallGridStrategy() + val scope = rememberCoroutineScope() + + val positionAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } + val sizeAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } + val alphaAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } + val knownKeys = remember { mutableSetOf() } + + val displayCount = min(items.size, strategy.maxTiles) + val displayItems = items.take(displayCount) + val config = remember(strategy, displayCount) { strategy.getConfig(displayCount) } + + val animatedCornerRadius by animateDpAsState( + targetValue = config.cornerRadius, + animationSpec = CallGridDefaults.dpAnimationSpec, + label = "cornerRadius" + ) + + val currentKeys = displayItems.map { itemKey(it) }.toSet() + val newKeys = currentKeys - knownKeys + val hasExistingItems = knownKeys.isNotEmpty() + + newKeys.forEach { key -> + if (hasExistingItems) { + alphaAnimatables[key] = Animatable(0f) + } + knownKeys.add(key) + } + + val removedKeys = knownKeys - currentKeys + removedKeys.forEach { key -> + positionAnimatables.remove(key) + sizeAnimatables.remove(key) + alphaAnimatables.remove(key) + knownKeys.remove(key) + } + + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + val containerWidthPx = constraints.maxWidth.toFloat() + val containerHeightPx = constraints.maxHeight.toFloat() + + val cells = remember(config, containerWidthPx, containerHeightPx, displayCount) { + calculateGridCells( + config = config, + containerWidth = containerWidthPx, + containerHeight = containerHeightPx, + itemCount = displayCount + ) + } + + 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) + + if (cell != null) { + val widthPx = animatedSize?.width ?: cell.width.roundToInt() + val heightPx = animatedSize?.height ?: cell.height.roundToInt() + + Box( + modifier = Modifier + .layoutId(itemKeyValue) + .alpha(alpha) + ) { + content( + item, + Modifier + .size( + width = with(density) { widthPx.toDp() }, + height = with(density) { heightPx.toDp() } + ) + .clip(RoundedCornerShape(animatedCornerRadius)) + ) + } + } + } + } + } + ) { measurables, constraints -> + displayItems.forEachIndexed { index, item -> + val itemKeyValue = itemKey(item) + val cell = cells.getOrNull(index) ?: return@forEachIndexed + val targetPosition = IntOffset(cell.x.roundToInt(), cell.y.roundToInt()) + val targetSize = IntSize(cell.width.roundToInt(), cell.height.roundToInt()) + + val existingPosition = positionAnimatables[itemKeyValue] + if (existingPosition == null) { + positionAnimatables[itemKeyValue] = Animatable(targetPosition, IntOffset.VectorConverter) + if (hasExistingItems && itemKeyValue in newKeys) { + scope.launch { + delay(CallGridDefaults.ANIMATION_DURATION_MS) + alphaAnimatables[itemKeyValue]?.animateTo(1f, CallGridDefaults.alphaAnimationSpec) + } + } else { + if (alphaAnimatables[itemKeyValue] == null) { + alphaAnimatables[itemKeyValue] = Animatable(1f) + } + } + } else if (existingPosition.targetValue != targetPosition) { + scope.launch { + existingPosition.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec) + } + } + + val existingSize = sizeAnimatables[itemKeyValue] + if (existingSize == null) { + sizeAnimatables[itemKeyValue] = Animatable(targetSize, IntSize.VectorConverter) + } else if (existingSize.targetValue != targetSize) { + scope.launch { + existingSize.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec) + } + } + } + + 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) + + 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()) + } + } + + val keyToPlaceable = measurables.zip(placeables).associate { (measurable, placeable) -> + measurable.layoutId to placeable + } + + 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) + } + } + } + } + } +} + +// Preview + +@AllNightPreviews +@Composable +private fun CallGridPreview() { + Previews.Preview { + var count by remember { mutableStateOf(1) } + val items = remember(count) { (1..count).toList() } + + val colors = listOf( + Color(0xFF5E97F6), + Color(0xFF9CCC65), + Color(0xFFFFB74D), + Color(0xFFEF5350), + Color(0xFFAB47BC), + Color(0xFF26A69A), + Color(0xFF78909C), + Color(0xFFEC407A), + Color(0xFF7E57C2), + Color(0xFF29B6F6), + Color(0xFFD4E157), + Color(0xFFFF7043) + ) + + Box(modifier = Modifier.fillMaxSize()) { + CallGrid( + items = items, + modifier = Modifier.fillMaxSize(), + itemKey = { it } + ) { item, itemModifier -> + Box( + modifier = itemModifier.background(colors[(item - 1) % colors.size]), + contentAlignment = Alignment.Center + ) { + Text( + text = item.toString(), + color = Color.White + ) + } + } + + Row( + modifier = Modifier.align(Alignment.TopStart) + ) { + Button(onClick = { count = max(1, count - 1) }) { + Text("-") + } + Button(onClick = { count = min(12, count + 1) }) { + Text("+") + } + Text( + text = "Count: $count", + modifier = Modifier.padding(16.dp), + color = Color.White + ) + } + } + } +} 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 1e162cbd28..df38303d3c 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 @@ -5,32 +5,17 @@ package org.thoughtcrime.securesms.components.webrtc.v2 -import androidx.compose.foundation.layout.Arrangement.spacedBy -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.VerticalPager -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.window.core.layout.WindowHeightSizeClass -import androidx.window.core.layout.WindowWidthSizeClass import org.signal.core.ui.compose.AllNightPreviews import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette @@ -38,7 +23,6 @@ import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.CallParticipantId import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import kotlin.math.min @Composable fun CallParticipantsPager( @@ -50,6 +34,26 @@ fun CallParticipantsPager( return } + // 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 -> + CallGrid( + items = state.callParticipants, + modifier = mod, + itemKey = { it.callParticipantId } + ) { participant, itemModifier -> + RemoteParticipantContent( + participant = participant, + renderInPip = state.isRenderInPip, + raiseHandAllowed = false, + onInfoMoreInfoClick = null, + modifier = itemModifier + ) + } + } + } + if (callParticipantsPagerState.callParticipants.size > 1) { VerticalPager( state = pagerState, @@ -59,10 +63,7 @@ fun CallParticipantsPager( ) { page -> when (page) { 0 -> { - CallParticipantsLayoutComponent( - callParticipantsPagerState = callParticipantsPagerState, - modifier = Modifier.fillMaxSize() - ) + callGridContent(callParticipantsPagerState, Modifier.fillMaxSize()) } 1 -> { @@ -77,238 +78,13 @@ fun CallParticipantsPager( } } } else { - CallParticipantsLayoutComponent( - callParticipantsPagerState = callParticipantsPagerState, - modifier = modifier - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun CallParticipantsLayoutComponent( - callParticipantsPagerState: CallParticipantsPagerState, - modifier: Modifier = Modifier -) { - val layoutStrategy = rememberRemoteParticipantsLayoutStrategy() - val count = min(callParticipantsPagerState.callParticipants.size, layoutStrategy.maxDeviceCount) - - val state = remember(count) { - layoutStrategy.buildStateForCount(count) - } - - BoxWithConstraints( - contentAlignment = Alignment.Center, - modifier = modifier.padding(state.outerInsets) - ) { - val width = maxWidth - val height = maxHeight - - val nLines = (count + state.maxItemsInEachLine - 1) / state.maxItemsInEachLine - - when (layoutStrategy.lineType) { - LayoutStrategyLineType.COLUMN -> { - ColumnBasedLayout( - containerWidth = width, - containerHeight = height, - numberOfLines = nLines, - numberOfParticipants = count, - state = state, - callParticipantsPagerState = callParticipantsPagerState - ) - } - - LayoutStrategyLineType.ROW -> { - RowBasedLayout( - containerWidth = width, - containerHeight = height, - numberOfLines = nLines, - numberOfParticipants = count, - state = state, - callParticipantsPagerState = callParticipantsPagerState - ) - } - } - } -} - -@Composable -private fun RowBasedLayout( - containerWidth: Dp, - containerHeight: Dp, - numberOfLines: Int, - numberOfParticipants: Int, - state: RemoteParticipantsLayoutState, - callParticipantsPagerState: CallParticipantsPagerState -) { - Row( - horizontalArrangement = spacedBy(state.innerInsets), - verticalAlignment = Alignment.CenterVertically - ) { - val batches = callParticipantsPagerState.callParticipants - .take(numberOfParticipants) - .chunked(state.maxItemsInEachLine) { - it.reversed() - } - - val lastParticipant = batches.last().last() - - batches.forEach { batch -> - Column( - verticalArrangement = spacedBy(state.innerInsets), - horizontalAlignment = Alignment.CenterHorizontally - ) { - batch.forEach { participant -> - - AutoSizedParticipant( - lineType = LayoutStrategyLineType.ROW, - containerWidth = containerWidth, - containerHeight = containerHeight, - numberOfLines = numberOfLines, - numberOfParticipants = numberOfParticipants, - isLastParticipant = lastParticipant == participant, - isRenderInPip = callParticipantsPagerState.isRenderInPip, - state = state, - participant = participant - ) - } - } - } - } -} - -@Composable -private fun ColumnBasedLayout( - containerWidth: Dp, - containerHeight: Dp, - numberOfLines: Int, - numberOfParticipants: Int, - state: RemoteParticipantsLayoutState, - callParticipantsPagerState: CallParticipantsPagerState -) { - Column( - verticalArrangement = spacedBy(state.innerInsets), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val batches = callParticipantsPagerState.callParticipants - .take(numberOfParticipants) - .chunked(state.maxItemsInEachLine) - - val lastParticipant = batches.last().last() - - batches.forEach { batch -> - Row( - horizontalArrangement = spacedBy(state.innerInsets), - verticalAlignment = Alignment.CenterVertically - ) { - batch.forEach { participant -> - AutoSizedParticipant( - lineType = LayoutStrategyLineType.COLUMN, - containerWidth = containerWidth, - containerHeight = containerHeight, - numberOfLines = numberOfLines, - numberOfParticipants = numberOfParticipants, - isLastParticipant = lastParticipant == participant, - isRenderInPip = callParticipantsPagerState.isRenderInPip, - state = state, - participant = participant - ) - } - } - } - } -} - -@Composable -private fun AutoSizedParticipant( - lineType: LayoutStrategyLineType, - containerWidth: Dp, - containerHeight: Dp, - numberOfLines: Int, - numberOfParticipants: Int, - isLastParticipant: Boolean, - isRenderInPip: Boolean, - state: RemoteParticipantsLayoutState, - participant: CallParticipant -) { - val maxSize = when (lineType) { - LayoutStrategyLineType.COLUMN -> { - val itemMaximumHeight = (containerHeight - (state.innerInsets * (numberOfLines - 1))) / numberOfLines.toFloat() - val itemMaximumWidth = (containerWidth - (state.innerInsets * (state.maxItemsInEachLine - 1))) / state.maxItemsInEachLine.toFloat() - - DpSize(itemMaximumWidth, itemMaximumHeight) - } - - LayoutStrategyLineType.ROW -> { - val itemMaximumWidth = (containerWidth - (state.innerInsets * (numberOfLines - 1))) / numberOfLines.toFloat() - val itemMaximumHeight = (containerHeight - (state.innerInsets * (state.maxItemsInEachLine - 1))) / state.maxItemsInEachLine.toFloat() - - DpSize(itemMaximumWidth, itemMaximumHeight) - } - } - - val aspectRatio = state.aspectRatio ?: -1f - val sizeModifier = when { - aspectRatio > 0f -> - Modifier.size( - largestRectangleWithAspectRatio( - maxSize.width, - maxSize.height, - aspectRatio - ) - ) - - isLastParticipant && numberOfParticipants % 2 == 1 -> Modifier.fillMaxSize() - else -> Modifier.size(DpSize(maxSize.width, maxSize.height)) - } - - RemoteParticipantContent( - participant = participant, - renderInPip = isRenderInPip, - raiseHandAllowed = false, - onInfoMoreInfoClick = null, - modifier = sizeModifier - .clip(RoundedCornerShape(state.cornerRadius)) - ) -} - -private fun largestRectangleWithAspectRatio( - containerWidth: Dp, - containerHeight: Dp, - aspectRatio: Float -): DpSize { - val containerAspectRatio = containerWidth / containerHeight - - return if (containerAspectRatio > aspectRatio) { - DpSize( - width = containerHeight * aspectRatio, - height = containerHeight - ) - } else { - DpSize( - width = containerWidth, - height = containerWidth / aspectRatio - ) - } -} - -@Composable -private fun rememberRemoteParticipantsLayoutStrategy(): RemoteParticipantsLayoutStrategy { - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - - return remember(windowSizeClass) { - when { - windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT -> RemoteParticipantsLayoutStrategy.SmallLandscape() - windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT -> RemoteParticipantsLayoutStrategy.SmallPortrait() - windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM -> RemoteParticipantsLayoutStrategy.Medium() - else -> RemoteParticipantsLayoutStrategy.Large() - } + callGridContent(callParticipantsPagerState, modifier) } } @AllNightPreviews @Composable -private fun CallParticipantsLayoutComponentPreview() { +private fun CallParticipantsPagerPreview() { Previews.Preview { val participants = remember { (1..5).map { @@ -334,10 +110,19 @@ private fun CallParticipantsLayoutComponentPreview() { Surface( modifier = Modifier.fillMaxSize() ) { - CallParticipantsLayoutComponent( - callParticipantsPagerState = state, - modifier = Modifier.fillMaxSize() - ) + CallGrid( + items = state.callParticipants, + modifier = Modifier.fillMaxSize(), + itemKey = { it.callParticipantId } + ) { participant, itemModifier -> + RemoteParticipantContent( + participant = participant, + renderInPip = state.isRenderInPip, + raiseHandAllowed = false, + onInfoMoreInfoClick = null, + modifier = itemModifier + ) + } } } } @@ -349,76 +134,3 @@ data class CallParticipantsPagerState( val isRenderInPip: Boolean = false, val hideAvatar: Boolean = false ) - -private sealed class RemoteParticipantsLayoutStrategy( - val maxDeviceCount: Int, - val lineType: LayoutStrategyLineType = LayoutStrategyLineType.COLUMN -) { - - abstract fun buildStateForCount(count: Int): RemoteParticipantsLayoutState - - class SmallLandscape : RemoteParticipantsLayoutStrategy(6, LayoutStrategyLineType.ROW) { - override fun buildStateForCount(count: Int): RemoteParticipantsLayoutState { - return RemoteParticipantsLayoutState( - outerInsets = if (count < 2) 0.dp else 16.dp, - innerInsets = if (count < 2) 0.dp else 12.dp, - cornerRadius = if (count < 2) 0.dp else CallScreenMetrics.FocusedRendererCornerSize, - maxItemsInEachLine = if (count < 3) 1 else 2 - ) - } - } - - class SmallPortrait : RemoteParticipantsLayoutStrategy(6) { - - override fun buildStateForCount(count: Int): RemoteParticipantsLayoutState { - return RemoteParticipantsLayoutState( - outerInsets = if (count < 2) 0.dp else 16.dp, - innerInsets = if (count < 2) 0.dp else 12.dp, - cornerRadius = if (count < 2) 0.dp else CallScreenMetrics.FocusedRendererCornerSize, - maxItemsInEachLine = if (count < 3) 1 else 2 - ) - } - } - - class Medium : RemoteParticipantsLayoutStrategy(9) { - override fun buildStateForCount(count: Int): RemoteParticipantsLayoutState { - return RemoteParticipantsLayoutState( - outerInsets = 24.dp, - innerInsets = 12.dp, - cornerRadius = CallScreenMetrics.FocusedRendererCornerSize, - aspectRatio = if (count < 2) 9 / 16f else 5 / 4f, - maxItemsInEachLine = when { - count < 3 -> 1 - count < 7 -> 2 - else -> 3 - } - ) - } - } - - class Large : RemoteParticipantsLayoutStrategy(12) { - override fun buildStateForCount(count: Int): RemoteParticipantsLayoutState { - return RemoteParticipantsLayoutState( - outerInsets = 24.dp, - innerInsets = 12.dp, - cornerRadius = CallScreenMetrics.FocusedRendererCornerSize, - aspectRatio = if (count < 2) 9 / 16f else 5 / 4f, - maxItemsInEachLine = when { - count < 4 -> 3 - count == 4 -> 2 - count < 7 -> 3 - else -> 4 - } - ) - } - } -} - -@Immutable -private data class RemoteParticipantsLayoutState( - val outerInsets: Dp, - val innerInsets: Dp, - val cornerRadius: Dp, - val maxItemsInEachLine: Int, - val aspectRatio: Float? = null -) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index 4b5e3e93cc..011dcac055 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -241,7 +241,7 @@ fun CallScreen( val selfPipHorizontalPadding = 32.dp val shouldNotApplyBottomPaddingToViewPort = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) - val selfPipBottomInset: Dp = if (shouldNotApplyBottomPaddingToViewPort) { + val selfPipBottomInset: Dp = if (shouldNotApplyBottomPaddingToViewPort && localRenderState != WebRtcLocalRenderState.SMALLER_RECTANGLE) { val containerWidth = maxWidth val sheetWidth = BottomSheetDefaults.SheetMaxWidth val widthOfPip = rememberSelfPipSize(localRenderState).width @@ -261,7 +261,12 @@ fun CallScreen( 0.dp } - val reactionsAndRaisesHandBottomInset = if (shouldNotApplyBottomPaddingToViewPort) { + // Reactions/raised hands need bottom inset to stay above the bottom sheet, + // UNLESS the overflow row is present (portrait + large group call), in which case + // the reactions sit above the overflow row naturally. + val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + val hasOverflowRow = isPortrait && overflowParticipants.size > 1 + val reactionsAndRaisesHandBottomInset = if (shouldNotApplyBottomPaddingToViewPort && !hasOverflowRow) { padding } else { 0.dp @@ -592,7 +597,7 @@ private fun CallScreenPreview() { isMicEnabled = true, displayVideoToggle = true, displayGroupRingingToggle = true, - displayStartCallButton = true + displayStartCallButton = false ), callParticipantsPagerState = CallParticipantsPagerState( callParticipants = participants, @@ -609,7 +614,7 @@ private fun CallScreenPreview() { 2 ) ), - localRenderState = WebRtcLocalRenderState.FOCUSED, + localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE, callScreenDialogType = CallScreenDialogType.NONE, callInfoView = { Text(text = "Call Info View Preview", modifier = Modifier.alpha(it)) @@ -635,7 +640,7 @@ private fun CallScreenPreview() { onNavigationClick = {}, onLocalPictureInPictureClicked = {}, onLocalPictureInPictureFocusClicked = {}, - overflowParticipants = emptyList(), // participants, + overflowParticipants = participants, onControlsToggled = {}, reactions = listOf( GroupCallReactionEvent( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt index d4ab33c3d2..f33aa1c39c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPictureCallScreen.kt @@ -22,8 +22,8 @@ fun PictureInPictureCallScreen( ) { val scope = rememberCoroutineScope() - CallParticipantsLayoutComponent( - callParticipantsPagerState = callParticipantsPagerState, + CallGrid( + items = callParticipantsPagerState.callParticipants, modifier = Modifier .fillMaxSize() .clickable( @@ -33,6 +33,15 @@ fun PictureInPictureCallScreen( } }, enabled = false - ) - ) + ), + itemKey = { it.callParticipantId } + ) { participant, itemModifier -> + RemoteParticipantContent( + participant = participant, + renderInPip = callParticipantsPagerState.isRenderInPip, + raiseHandAllowed = false, + onInfoMoreInfoClick = null, + modifier = itemModifier + ) + } }