Fix a litany of call screen issues.

This commit is contained in:
Alex Hart
2026-09-11 15:38:50 -03:00
committed by GitHub
parent ce1ed06bfb
commit 2af2d304b0
12 changed files with 399 additions and 147 deletions
@@ -10,15 +10,12 @@ import android.content.pm.PackageManager
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
@@ -42,6 +39,7 @@ import org.thoughtcrime.securesms.util.RemoteConfig
* Renders the button strip / start call button in the call screen
* bottom sheet.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CallControls(
displayVideoTooltip: Boolean,
@@ -55,14 +53,7 @@ fun CallControls(
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val density = LocalDensity.current
val padBottom = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() }
var bottom by remember {
mutableStateOf(padBottom)
}
if (padBottom != 0.dp) {
bottom = padBottom
}
val bottom = with(density) { WindowInsets.navigationBarsIgnoringVisibility.getBottom(density).toDp() }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -7,10 +7,16 @@ package org.thoughtcrime.securesms.components.webrtc.v2
interface CallControlsVisibilityListener {
fun onShown()
fun onHidden()
/**
* @param isFullBleedCall Whether the call renders edge to edge, and so whether the system bars should
* hide along with the controls. Decided by the Compose layer, which is the only
* place that knows the window size class.
*/
fun onHidden(isFullBleedCall: Boolean)
companion object Empty : CallControlsVisibilityListener {
override fun onShown() = Unit
override fun onHidden() = Unit
override fun onHidden(isFullBleedCall: Boolean) = Unit
}
}
@@ -9,11 +9,15 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -23,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
@@ -56,10 +61,14 @@ private class BarDimensions {
* @param callOverflowSlot Overflow participants strip.
* @param audioIndicatorSlot Participant audio indicator content.
* @param bottomInset Bottom inset used to keep content clear of anchored UI.
* @param pipBottomInset How far the pip must lift beyond the system bars, which it clears itself.
* @param isLocalVideoLandscape Whether the local video is landscape, and so whether the pip is rotated.
* @param pipMargin Inset the pip renders with. With [isLocalVideoLandscape], gives its real footprint.
* @param bottomSheetWidth Maximum width of centered bottom content.
* @param localRenderState Current local renderer mode.
* @param modifier Modifier applied to the root layout.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CallElementsLayout(
callGridSlot: @Composable () -> Unit,
@@ -70,7 +79,10 @@ fun CallElementsLayout(
callOverflowSlot: @Composable () -> Unit,
audioIndicatorSlot: @Composable () -> Unit,
bottomInset: Dp,
pipBottomInset: Dp = bottomInset,
bottomSheetWidth: Dp,
isLocalVideoLandscape: Boolean = false,
pipMargin: Dp = PipMargin,
localRenderState: WebRtcLocalRenderState,
modifier: Modifier = Modifier
) {
@@ -87,10 +99,16 @@ fun CallElementsLayout(
val density = LocalDensity.current
val pipSizePx = with(density) {
(rememberSelfPipSize(localRenderState) + DpSize(32.dp, 0.dp)).toSize()
val pipSize = rememberSelfPipSize(localRenderState).rotateForVideoOrientation(isLocalVideoLandscape)
(pipSize + DpSize(pipMargin * 2, 0.dp)).toSize()
}
val bottomInsetPx = with(density) { bottomInset.roundToPx() }
val navigationBarInsets = WindowInsets.navigationBarsIgnoringVisibility
val navigationBarBottomPx = navigationBarInsets.getBottom(density)
val navigationBarEndPx = navigationBarInsets.getRight(density, LocalLayoutDirection.current)
val statusBarTopPx = WindowInsets.statusBarsIgnoringVisibility.getTop(density)
val pipBottomInsetPx = with(density) { pipBottomInset.roundToPx() }
val bottomSheetWidthPx = with(density) {
bottomSheetWidth.roundToPx()
@@ -105,6 +123,9 @@ fun CallElementsLayout(
isFocused = isFocused,
isPortrait = isPortrait,
bottomInsetPx = bottomInsetPx,
navigationBarBottomPx = navigationBarBottomPx,
navigationBarEndPx = navigationBarEndPx,
statusBarTopPx = statusBarTopPx,
bottomSheetWidthPx = bottomSheetWidthPx,
barsSlot = { Bars() },
callGridSlot = callGridSlot,
@@ -121,7 +142,7 @@ fun CallElementsLayout(
PipLayer(
pictureInPictureSlot = pictureInPictureSlot,
localRenderState = localRenderState,
bottomInsetPx = bottomInsetPx,
bottomInsetPx = pipBottomInsetPx,
barDimensions = barDimensions,
pipSizePx = pipSizePx,
bottomSheetWidthPx = bottomSheetWidthPx
@@ -153,6 +174,9 @@ private fun BlurrableContentLayer(
isFocused: Boolean,
isPortrait: Boolean,
bottomInsetPx: Int,
navigationBarBottomPx: Int,
navigationBarEndPx: Int,
statusBarTopPx: Int,
bottomSheetWidthPx: Int,
barsSlot: @Composable () -> Unit,
callGridSlot: @Composable () -> Unit,
@@ -179,18 +203,20 @@ private fun BlurrableContentLayer(
val (overflowMeasurables, gridMeasurables, barsMeasurables, reactionsMeasurables, audioIndicatorMeasurables) = measurables
val overflowPlaceables = overflowMeasurables.map { it.measure(looseConstraints) }
val overflowConstraints = looseConstraints.offset(
horizontal = -navigationBarEndPx,
vertical = -(statusBarTopPx + navigationBarBottomPx)
)
val overflowPlaceables = overflowMeasurables.map { it.measure(overflowConstraints) }
val constrainedHeightOffset = if (isPortrait) overflowPlaceables.maxOfOrNull { it.height } ?: 0 else 0
val constrainedWidthOffset = if (isPortrait) 0 else overflowPlaceables.maxOfOrNull { it.width } ?: 0
val nonOverflowConstraints = looseConstraints.offset(horizontal = -constrainedWidthOffset, vertical = -constrainedHeightOffset)
val gridPlaceables = gridMeasurables.map { it.measure(nonOverflowConstraints) }
val barConstraints = if (bottomInsetPx > constrainedHeightOffset) {
looseConstraints.offset(-constrainedWidthOffset, -bottomInsetPx)
} else {
nonOverflowConstraints
}
// The strip sits above the nav bar, so bars must clear its top edge rather than just its height.
val overflowTopOffset = if (constrainedHeightOffset > 0) constrainedHeightOffset + navigationBarBottomPx else 0
val barConstraints = looseConstraints.offset(-constrainedWidthOffset, -maxOf(bottomInsetPx, overflowTopOffset))
val barsMaxWidth = minOf(barConstraints.maxWidth, bottomSheetWidthPx)
val barsConstrainedToSheet = barConstraints.copy(maxWidth = barsMaxWidth)
@@ -209,11 +235,11 @@ private fun BlurrableContentLayer(
layout(looseConstraints.maxWidth, looseConstraints.maxHeight) {
if (isPortrait) {
overflowPlaceables.forEach {
it.place(0, looseConstraints.maxHeight - it.height)
it.place(0, looseConstraints.maxHeight - navigationBarBottomPx - it.height)
}
} else {
overflowPlaceables.forEach {
it.place(looseConstraints.maxWidth - it.width, 0)
it.place(looseConstraints.maxWidth - navigationBarEndPx - it.width, statusBarTopPx)
}
}
@@ -234,7 +260,7 @@ private fun BlurrableContentLayer(
val gutterWidth = (looseConstraints.maxWidth - bottomSheetWidthPx) / 2
val fitsInGutter = gutterWidth >= it.width
val y = if (fitsInGutter) {
looseConstraints.maxHeight - it.height
looseConstraints.maxHeight - navigationBarBottomPx - it.height
} else {
looseConstraints.maxHeight - bottomInsetPx - barsHeightPx - it.height
}
@@ -263,8 +289,8 @@ private fun PipLayer(
val centeredContentWidthPx = maxOf(barDimensions.widthPx, bottomSheetWidthPx)
val pictureInPictureConstraints: Constraints = when (localRenderState) {
WebRtcLocalRenderState.GONE, WebRtcLocalRenderState.SMALLER_RECTANGLE, WebRtcLocalRenderState.LARGE, WebRtcLocalRenderState.LARGE_NO_VIDEO, WebRtcLocalRenderState.FOCUSED -> constraints
WebRtcLocalRenderState.SMALL_RECTANGLE, WebRtcLocalRenderState.EXPANDED -> {
WebRtcLocalRenderState.GONE, WebRtcLocalRenderState.LARGE, WebRtcLocalRenderState.LARGE_NO_VIDEO, WebRtcLocalRenderState.FOCUSED -> constraints
WebRtcLocalRenderState.SMALL_RECTANGLE, WebRtcLocalRenderState.SMALLER_RECTANGLE, WebRtcLocalRenderState.EXPANDED -> {
val spaceOnEachSide = (looseConstraints.maxWidth - centeredContentWidthPx) / 2
val shouldOffset = centeredContentWidthPx > 0 && spaceOnEachSide < pipSizePx.width
val offsetAmount = bottomInsetPx + barDimensions.heightPx
@@ -46,6 +46,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -91,7 +92,8 @@ data class GridConfig(
val rows: Int,
val columns: Int,
val itemsInLastRow: Int,
val outerPadding: Dp,
val outerPaddingHorizontal: Dp,
val outerPaddingVertical: Dp,
val innerSpacing: Dp,
val cornerRadius: Dp,
val aspectRatio: Float?,
@@ -137,7 +139,8 @@ sealed class CallGridStrategy(val maxTiles: Int) {
rows = rows,
columns = cols,
itemsInLastRow = lastRowItems,
outerPadding = if (count == 1) 0.dp else 16.dp,
outerPaddingHorizontal = if (count == 1) 0.dp else 16.dp,
outerPaddingVertical = 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
@@ -160,7 +163,8 @@ sealed class CallGridStrategy(val maxTiles: Int) {
rows = rows,
columns = cols,
itemsInLastRow = lastRowItems,
outerPadding = if (count == 1) 0.dp else 16.dp,
outerPaddingHorizontal = if (count == 1) 0.dp else 16.dp,
outerPaddingVertical = 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,
@@ -186,7 +190,8 @@ sealed class CallGridStrategy(val maxTiles: Int) {
rows = rows,
columns = cols,
itemsInLastRow = lastRowItems,
outerPadding = 24.dp,
outerPaddingHorizontal = if (count == 1) 0.dp else 24.dp,
outerPaddingVertical = 0.dp,
innerSpacing = 12.dp,
cornerRadius = 32.dp,
aspectRatio = if (count == 1) 9f / 16f else 5f / 4f
@@ -214,7 +219,8 @@ sealed class CallGridStrategy(val maxTiles: Int) {
rows = rows,
columns = cols,
itemsInLastRow = lastRowItems,
outerPadding = 24.dp,
outerPaddingHorizontal = if (count == 1) 0.dp else 24.dp,
outerPaddingVertical = 0.dp,
innerSpacing = 12.dp,
cornerRadius = 32.dp,
aspectRatio = if (count == 1) 9f / 16f else 5f / 4f
@@ -302,21 +308,24 @@ private fun calculateGridCells(
config: GridConfig,
containerWidth: Float,
containerHeight: Float,
itemCount: Int
itemCount: Int,
density: Density
): List<GridCell> {
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)
val paddingHorizontal = with(density) { config.outerPaddingHorizontal.toPx() }
val paddingVertical = with(density) { config.outerPaddingVertical.toPx() }
val spacing = with(density) { config.innerSpacing.toPx() }
val availableWidth = containerWidth - (paddingHorizontal * 2)
val availableHeight = containerHeight - (paddingVertical * 2)
if (config.lastColumnSpansFullHeight && itemCount > 1) {
return calculateGridCellsWithSpanningColumn(
config = config,
availableWidth = availableWidth,
availableHeight = availableHeight,
padding = padding,
paddingHorizontal = paddingHorizontal,
paddingVertical = paddingVertical,
spacing = spacing,
itemCount = itemCount
)
@@ -343,8 +352,8 @@ private fun calculateGridCells(
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 gridStartX = paddingHorizontal + (availableWidth - totalGridWidth) / 2
val gridStartY = paddingVertical + (availableHeight - totalGridHeight) / 2
val cells = mutableListOf<GridCell>()
@@ -397,7 +406,8 @@ private fun calculateGridCellsWithSpanningColumn(
config: GridConfig,
availableWidth: Float,
availableHeight: Float,
padding: Float,
paddingHorizontal: Float,
paddingVertical: Float,
spacing: Float,
itemCount: Int
): List<GridCell> {
@@ -412,8 +422,8 @@ private fun calculateGridCellsWithSpanningColumn(
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
val gridStartX = paddingHorizontal + (availableWidth - totalGridWidth) / 2
val gridStartY = paddingVertical + (availableHeight - totalGridHeight) / 2
var index = 0
for (col in 0 until columnsForRegularItems) {
@@ -502,13 +512,14 @@ fun <T> CallGrid(
var containerSize by remember { mutableStateOf(IntSize.Zero) }
val density = LocalDensity.current
val cells = remember(config, containerSize, displayCount) {
val cells = remember(config, containerSize, displayCount, density) {
if (containerSize == IntSize.Zero) emptyList()
else calculateGridCells(
config = config,
containerWidth = containerSize.width.toFloat(),
containerHeight = containerSize.height.toFloat(),
itemCount = displayCount
itemCount = displayCount,
density = density
)
}
@@ -5,7 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
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
@@ -44,13 +45,18 @@ fun CallParticipantsOverflow(
) {
val callScreenMetrics = rememberCallScreenMetrics()
val rendererSize = callScreenMetrics.overflowParticipantRendererSize
val edgeInset = callScreenMetrics.overflowStripEdgeInset
val itemSpacing = callScreenMetrics.overflowStripItemSpacing
// Leading slot for the self pip, which is not a cell: participants scroll under it.
val pipSlot = rendererSize + edgeInset + itemSpacing
if (lineType == LayoutStrategyLineType.ROW) {
LazyRow(
reverseLayout = true,
modifier = Modifier.fillMaxWidth().then(modifier),
contentPadding = PaddingValues(start = 16.dp, end = rendererSize + 32.dp),
horizontalArrangement = spacedBy(4.dp)
contentPadding = PaddingValues(start = edgeInset, end = pipSlot),
horizontalArrangement = Arrangement.Absolute.spacedBy(itemSpacing, Alignment.End)
) {
appendItems(rendererSize, overflowParticipants)
}
@@ -58,8 +64,8 @@ fun CallParticipantsOverflow(
LazyColumn(
reverseLayout = true,
modifier = Modifier.fillMaxHeight().then(modifier),
contentPadding = PaddingValues(top = 16.dp, bottom = rendererSize + 32.dp),
verticalArrangement = spacedBy(4.dp)
contentPadding = PaddingValues(top = edgeInset, bottom = pipSlot),
verticalArrangement = Arrangement.spacedBy(itemSpacing, Alignment.Bottom)
) {
appendItems(rendererSize, overflowParticipants)
}
@@ -6,9 +6,12 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsIgnoringVisibility
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.material3.Surface
@@ -32,6 +35,7 @@ import org.thoughtcrime.securesms.events.CallParticipantId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CallParticipantsPager(
callParticipantsPagerState: CallParticipantsPagerState,
@@ -51,11 +55,20 @@ fun CallParticipantsPager(
callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink
)
// Inset so that CallGrid's outer padding is measured from the system bars, not the screen edge.
val isFullBleed = rememberIsFullBleedCall(callParticipantsPagerState.callParticipants.size)
val insetModifier = if (isFullBleed) {
Modifier
} else {
Modifier
.displayCutoutPadding()
.windowInsetsPadding(WindowInsets.systemBarsIgnoringVisibility)
}
VerticalPager(
state = pagerState,
modifier = modifier
.displayCutoutPadding()
.statusBarsPadding()
modifier = modifier.then(insetModifier)
) { page ->
when (page) {
0 -> {
@@ -18,14 +18,19 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -94,7 +99,7 @@ private const val SHEET_BOTTOM_PADDING = 16
/**
* In-App calling screen displaying controls, info, and participant camera feeds.
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun CallScreen(
callRecipient: Recipient,
@@ -272,6 +277,19 @@ fun CallScreen(
label = "animate-as-state"
)
// With the bars left up, content still has to clear the nav bar once the sheet is gone.
val isFullBleed = rememberIsFullBleedCall(callParticipantsPagerState.callParticipants.size)
val navigationBarInset = WindowInsets.navigationBarsIgnoringVisibility.asPaddingValues().calculateBottomPadding()
val bottomInset = if (isFullBleed) padding else maxOf(padding, navigationBarInset)
// The pip clears the system bars itself, so exclude what the nav bar already covers.
val pipBottomInset = (padding - navigationBarInset).coerceAtLeast(0.dp)
val isLocalVideoLandscape = rememberIsLocalVideoLandscape(localParticipant)
val callScreenMetrics = rememberCallScreenMetrics()
// The pip lines up with the overflow strip when there is one. CallElementsLayout needs the same value.
val pipMargin = if (overflowParticipants.isEmpty()) PipMargin else callScreenMetrics.overflowStripEdgeInset
val onCallInfoClick: () -> Unit = {
scope.launch {
if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) {
@@ -403,6 +421,8 @@ fun CallScreen(
onClick = onLocalPictureInPictureClicked,
onToggleCameraDirectionClick = callScreenControlsListener::onCameraDirectionChanged,
onFocusLocalParticipantClick = onLocalPictureInPictureFocusClicked,
isVideoLandscape = isLocalVideoLandscape,
margin = pipMargin,
modifier = Modifier.fillMaxSize()
)
},
@@ -426,7 +446,7 @@ fun CallScreen(
PendingParticipantsInternal(modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp))
},
callOverflowSlot = {
val metrics = rememberCallScreenMetrics()
val metrics = callScreenMetrics
if (overflowParticipants.isNotEmpty()) {
val lineType = if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
LayoutStrategyLineType.COLUMN
@@ -440,12 +460,12 @@ fun CallScreen(
modifier = when (lineType) {
LayoutStrategyLineType.COLUMN ->
Modifier
.padding(horizontal = 16.dp)
.padding(end = metrics.overflowStripEdgeInset)
.width(metrics.overflowParticipantRendererSize)
LayoutStrategyLineType.ROW ->
Modifier
.padding(vertical = 16.dp)
.padding(top = metrics.overflowStripGridGap, bottom = metrics.overflowStripEdgeInset)
.height(metrics.overflowParticipantRendererSize)
}
)
@@ -460,8 +480,11 @@ fun CallScreen(
)
}
},
bottomInset = padding,
bottomInset = bottomInset,
pipBottomInset = pipBottomInset,
bottomSheetWidth = CallScreenMetrics.SheetMaxWidth,
isLocalVideoLandscape = isLocalVideoLandscape,
pipMargin = pipMargin,
localRenderState = localRenderState,
modifier = Modifier.fillMaxSize()
)
@@ -484,7 +507,7 @@ fun CallScreen(
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = padding)
.padding(bottom = bottomInset)
) {
AnimatedCallStateUpdate(
callControlsChange = callScreenState.callControlsChange,
@@ -500,7 +523,7 @@ fun CallScreen(
CallParticipantUpdatePopup(
controller = callParticipantUpdatePopupController,
modifier = Modifier
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility)
.fillMaxWidth()
)
}
@@ -509,7 +532,7 @@ fun CallScreen(
visible = callScreenState.displayWifiToCellularPopup,
onDismiss = onWifiToCellularPopupDismissed,
modifier = Modifier
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility)
.fillMaxWidth()
)
@@ -517,7 +540,7 @@ fun CallScreen(
hintType = callScreenState.swipeHint,
onDismiss = onSwipeToSpeakerHintDismissed,
modifier = Modifier
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility)
.fillMaxWidth()
)
@@ -525,7 +548,7 @@ fun CallScreen(
message = callScreenState.remoteMuteToastMessage,
onDismiss = onRemoteMuteToastDismissed,
modifier = Modifier
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility)
.fillMaxWidth()
)
@@ -50,6 +50,35 @@ class CallScreenMetrics @RememberInComposition constructor(
medium = 56.dp
)
/**
* Number of other participants at which the self pip shrinks to the overflow renderer size.
*/
val selfPipShrinkThreshold: Int = forWindowSizeClass(
compact = 5,
medium = 9
)
/** Inset of the overflow strip from the safe area, along and at the end of the strip. */
val overflowStripEdgeInset: Dp = forWindowSizeClass(
compact = 16.dp,
medium = 24.dp
)
/** Gap between cells in the overflow strip. */
val overflowStripItemSpacing: Dp = forWindowSizeClass(
compact = 10.dp,
medium = 12.dp
)
/**
* Extra gap between the grid and a horizontal overflow strip, making up the difference where CallGrid's
* vertical outer padding is zero. A vertical strip needs none: horizontal outer padding never is.
*/
val overflowStripGridGap: Dp = forWindowSizeClass(
compact = 0.dp,
medium = 16.dp
)
val overflowInfoIconSize: Dp = forWindowSizeClass(
compact = 24.dp,
medium = 28.dp
@@ -113,6 +142,18 @@ class CallScreenMetrics @RememberInComposition constructor(
}
}
/**
* Whether the call renders edge to edge: a single remote participant on a compact window, where the grid is
* full bleed and the system bars hide with the controls. The grid, call screen and activity must agree.
*/
@Composable
fun rememberIsFullBleedCall(remoteParticipantCount: Int): Boolean {
val callGridStrategy = rememberCallGridStrategy()
val isCompact = callGridStrategy is CallGridStrategy.SmallPortrait || callGridStrategy is CallGridStrategy.SmallLandscape
return isCompact && remoteParticipantCount == 1
}
@Composable
fun rememberCallScreenMetrics(): CallScreenMetrics {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
@@ -14,6 +14,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
@@ -118,6 +120,20 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
val overflowParticipants = remember(callParticipantsState.allRemoteParticipants, callGridStrategy) {
callParticipantsState.allRemoteParticipants.drop(callGridStrategy.maxTiles)
}
// The state machine shrinks the pip past one other participant; the spec holds the normal size until
// the grid is crowded. Focusing a participant still shrinks it regardless of count.
val callScreenMetrics = rememberCallScreenMetrics()
val isFullBleedCall = rememberIsFullBleedCall(callParticipantsState.allRemoteParticipants.size)
val shouldShrinkPip = callParticipantsState.isViewingFocusedParticipant ||
callParticipantsState.allRemoteParticipants.size >= callScreenMetrics.selfPipShrinkThreshold
val localRenderState = if (callParticipantsState.localRenderState == WebRtcLocalRenderState.SMALLER_RECTANGLE && !shouldShrinkPip) {
WebRtcLocalRenderState.SMALL_RECTANGLE
} else {
callParticipantsState.localRenderState
}
val callParticipantsPagerState = remember(gridParticipants, callParticipantsState) {
CallParticipantsPagerState(
callParticipants = gridParticipants,
@@ -145,12 +161,13 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
val callControlsVisibilityListener by controlsVisibilityListener.collectAsStateWithLifecycle()
val isFullBleedCallState = rememberUpdatedState(isFullBleedCall)
val onControlsToggled: (Boolean) -> Unit = remember(controlsVisibilityListener) {
{
if (it) {
callControlsVisibilityListener.onShown()
} else {
callControlsVisibilityListener.onHidden()
callControlsVisibilityListener.onHidden(isFullBleedCallState.value)
}
}
}
@@ -207,7 +224,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
pendingParticipantsListener = pendingParticipantsListener,
overflowParticipants = overflowParticipants,
localParticipant = callParticipantsState.localParticipant,
localRenderState = callParticipantsState.localRenderState,
localRenderState = localRenderState,
reactions = callParticipantsState.reactions,
callScreenDialogType = dialog,
callInfoView = {
@@ -8,16 +8,21 @@ package org.thoughtcrime.securesms.components.webrtc.v2
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
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.layout.systemBarsIgnoringVisibility
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
@@ -51,9 +56,13 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.events.CallParticipant
import org.signal.core.ui.R as CoreUiR
/** Default inset of the pip from the safe area. The overflow strip's inset replaces it when on screen. */
internal val PipMargin = 24.dp
/**
* Small moveable local video renderer that displays the user's video in a draggable and expandable view.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MoveableLocalVideoRenderer(
localParticipant: CallParticipant,
@@ -62,25 +71,31 @@ fun MoveableLocalVideoRenderer(
onClick: () -> Unit,
onToggleCameraDirectionClick: () -> Unit,
onFocusLocalParticipantClick: () -> Unit,
isVideoLandscape: Boolean = rememberIsLocalVideoLandscape(localParticipant),
margin: Dp = PipMargin,
modifier: Modifier = Modifier
) {
val size = rememberSelfPipSize(localRenderState)
val isFocused = localRenderState == WebRtcLocalRenderState.FOCUSED
val localAspectRatio = rememberParticipantAspectRatio(localParticipant.videoSink)
val configurationLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val isVideoLandscape = localAspectRatio?.let { it > 1f } ?: configurationLandscape
// GONE, LARGE and LARGE_NO_VIDEO report a zero size: not a pip at all. Animating to that zero would
// slide the pip toward the corner as it shrank, so hold the last real size and fade.
val isPipVisible = size != DpSize.Zero
var lastVisibleSize by remember { mutableStateOf(size) }
if (isPipVisible && size != DpSize.Unspecified) {
lastVisibleSize = size
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.then(modifier)
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.systemBarsIgnoringVisibility)
.displayCutoutPadding()
) {
val focusedSize = remember(maxWidth, maxHeight, isVideoLandscape) {
val desiredWidth = maxWidth - 32.dp
val desiredHeight = maxHeight - 32.dp
val focusedSize = remember(maxWidth, maxHeight, isVideoLandscape, margin) {
val desiredWidth = maxWidth - margin * 2
val desiredHeight = maxHeight - margin * 2
val aspectRatio = if (isVideoLandscape) {
16f / 9f
@@ -98,7 +113,7 @@ fun MoveableLocalVideoRenderer(
}
}
val targetSize = if (isFocused) focusedSize else size.rotateForVideoOrientation(isVideoLandscape)
val targetSize = if (isFocused) focusedSize else lastVisibleSize.rotateForVideoOrientation(isVideoLandscape)
val state = remember { PictureInPictureState(initialContentSize = targetSize) }
state.animateTo(targetSize)
@@ -113,65 +128,72 @@ fun MoveableLocalVideoRenderer(
val clip by animateClip(localRenderState)
val showFocusButton = localRenderState == WebRtcLocalRenderState.EXPANDED || isFocused
PictureInPicture(
state = state,
isFocused = isFocused,
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
AnimatedVisibility(
visible = isPipVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize()
) {
SelfPipContent(
participant = localParticipant,
selfPipMode = selfPipMode,
isMoreThanOneCameraAvailable = localParticipant.cameraState.cameraCount > 1,
onSwitchCameraClick = onToggleCameraDirectionClick,
PictureInPicture(
state = state,
isFocused = isFocused,
modifier = Modifier
.padding(margin)
.fillMaxSize()
.dropShadow(
shape = RoundedCornerShape(clip),
shadow = Shadow(
radius = 32.dp,
color = Color.Black.copy(alpha = 0.12f),
offset = DpOffset(x = 0.dp, y = 4.dp)
)
)
.dropShadow(
shape = RoundedCornerShape(clip),
shadow = Shadow(
radius = 12.dp,
color = Color.Black.copy(alpha = 0.32f),
offset = androidx.compose.ui.unit.DpOffset(x = 0.dp, y = 4.dp)
)
)
.clip(RoundedCornerShape(clip))
.clickable(onClick = onClick)
)
AnimatedVisibility(
visible = showFocusButton,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(48.dp)
) {
IconButton(
onClick = onFocusLocalParticipantClick,
SelfPipContent(
participant = localParticipant,
selfPipMode = selfPipMode,
isMoreThanOneCameraAvailable = localParticipant.cameraState.cameraCount > 1,
onSwitchCameraClick = onToggleCameraDirectionClick,
modifier = Modifier
.background(color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape)
) {
Icon(
imageVector = ImageVector.vectorResource(
if (isFocused) R.drawable.symbol_minimize_24 else CoreUiR.drawable.symbol_maximize_24
),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
contentDescription = stringResource(
if (isFocused) {
R.string.MoveableLocalVideoRenderer__shrink_local_video
} else {
R.string.MoveableLocalVideoRenderer__expand_local_video
}
.fillMaxSize()
.dropShadow(
shape = RoundedCornerShape(clip),
shadow = Shadow(
radius = 32.dp,
color = Color.Black.copy(alpha = 0.12f),
offset = DpOffset(x = 0.dp, y = 4.dp)
)
)
)
.dropShadow(
shape = RoundedCornerShape(clip),
shadow = Shadow(
radius = 12.dp,
color = Color.Black.copy(alpha = 0.32f),
offset = androidx.compose.ui.unit.DpOffset(x = 0.dp, y = 4.dp)
)
)
.clip(RoundedCornerShape(clip))
.clickable(onClick = onClick)
)
AnimatedVisibility(
visible = showFocusButton,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(48.dp)
) {
IconButton(
onClick = onFocusLocalParticipantClick,
modifier = Modifier
.background(color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape)
) {
Icon(
imageVector = ImageVector.vectorResource(
if (isFocused) R.drawable.symbol_minimize_24 else CoreUiR.drawable.symbol_maximize_24
),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
contentDescription = stringResource(
if (isFocused) {
R.string.MoveableLocalVideoRenderer__shrink_local_video
} else {
R.string.MoveableLocalVideoRenderer__expand_local_video
}
)
)
}
}
}
}
@@ -240,6 +262,18 @@ private fun MoveableLocalVideoRendererPreview() {
}
}
/**
* Whether the local video is landscape, falling back to device orientation until the first frame arrives.
* Hoisted so callers share one result: this attaches a dimension sink to the video track.
*/
@Composable
fun rememberIsLocalVideoLandscape(localParticipant: CallParticipant): Boolean {
val localAspectRatio = rememberParticipantAspectRatio(localParticipant.videoSink)
val configurationLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
return localAspectRatio?.let { it > 1f } ?: configurationLandscape
}
@Composable
fun rememberSelfPipSize(
localRenderState: WebRtcLocalRenderState
@@ -266,7 +300,7 @@ fun rememberSelfPipSize(
*
* @param isVideoLandscape Whether the video is in landscape orientation (width > height)
*/
private fun DpSize.rotateForVideoOrientation(isVideoLandscape: Boolean): DpSize {
internal fun DpSize.rotateForVideoOrientation(isVideoLandscape: Boolean): DpSize {
return if (isVideoLandscape) {
DpSize(this.height, this.width)
} else {
@@ -143,20 +143,60 @@ fun PictureInPicture(
Animatable(initialOffset, IntOffset.VectorConverter)
}
// Animate position when focused state changes or when constraints/corner changes
LaunchedEffect(maxWidth, maxHeight, targetContentWidth, targetContentHeight, state.corner, isFocused, baseOffsetX) {
if (!isDragging) {
val targetOffset = if (isFocused) {
centerOffset
} else {
getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
}
val anchor = PipAnchor(
corner = state.corner,
isFocused = isFocused,
contentWidth = targetContentWidth,
contentHeight = targetContentHeight
)
// Animate to new position (don't snap)
var previousAnchor by remember { mutableStateOf(anchor) }
// False while a move is in flight. Not offsetAnimatable.isRunning: every measurement pass relaunches
// the effect below, cancelling the animation and clearing that flag before the new launch reads it.
var isSettled by remember { mutableStateOf(true) }
// Animatable zeroes its velocity when an animation ends, cancellation included, so carry it by hand.
var lastVelocity by remember { mutableStateOf(IntOffset.Zero) }
// Animate position when the anchor changes, and track the bounding box directly otherwise.
LaunchedEffect(anchor, maxWidth, maxHeight, baseOffsetX) {
// Recorded even while dragging: a stale record would make the first box change after the drag look
// like an anchor change.
val anchorChanged = anchor != previousAnchor
previousAnchor = anchor
if (isDragging) {
return@LaunchedEffect
}
val targetOffset = if (isFocused) {
centerOffset
} else {
getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
}
if (anchorChanged) {
isSettled = false
}
if (isSettled) {
// Only the box moved, and whoever moved it is already animating it -- the controls sheet, say.
// Follow it frame for frame instead of springing towards it.
offsetAnimatable.snapTo(targetOffset)
} else {
// Mid-move: the box changing means the destination moved, not that the move is over. Retarget at
// the current speed -- restarting from a standstill stalls the spring, snapping teleports the pip.
offsetAnimatable.animateTo(
targetValue = targetOffset,
animationSpec = PositionAnimationSpec
)
animationSpec = PositionAnimationSpec,
initialVelocity = lastVelocity
) {
lastVelocity = this.velocity
}
isSettled = true
lastVelocity = IntOffset.Zero
}
}
@@ -192,12 +232,23 @@ fun PictureInPicture(
val (corner, targetOffset) = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight)
state.corner = corner
// A fling is a move like any other, including when released over the corner it started from
// and the anchor never changed. Seed the velocity: a new corner relaunches the effect above,
// which can cancel this fling before its first frame records anything.
isSettled = false
lastVelocity = IntOffset(velocity.x.roundToInt(), velocity.y.roundToInt())
coroutineScope.launch {
offsetAnimatable.animateTo(
targetValue = targetOffset,
initialVelocity = IntOffset(velocity.x.roundToInt(), velocity.y.roundToInt()),
animationSpec = FlingAnimationSpec
)
) {
lastVelocity = this.velocity
}
isSettled = true
lastVelocity = IntOffset.Zero
}
}
)
@@ -207,6 +258,17 @@ fun PictureInPicture(
}
}
/**
* Where the pip wants to sit, independent of the size of its bounding box. Changes to these
* properties are animated, whereas changes to the bounding box are tracked directly.
*/
private data class PipAnchor(
val corner: PictureInPictureState.Corner,
val isFocused: Boolean,
val contentWidth: Int,
val contentHeight: Int
)
private fun project(velocity: Float): Float {
return (velocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE)
}
@@ -40,8 +40,10 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@@ -581,6 +583,19 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
viewModel.getCallParticipantListUpdate().collectLatest(callScreen::onParticipantListUpdate)
}
launch {
viewModel.callParticipantsState
.map { it.allRemoteParticipants.size > 1 }
.distinctUntilChanged()
.collectLatest { hasMultipleRemoteParticipants ->
// A call that grows past a single remote participant can no longer be edge to edge, even if
// we already went immersive back when it was one-to-one.
if (hasMultipleRemoteParticipants) {
FullscreenHelper.showSystemUI(window)
}
}
}
launch {
viewModel.getSafetyNumberChangeEvent().collect { handleSafetyNumberChangeEvent(it) }
}
@@ -1293,12 +1308,19 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
fullScreenHelper.showSystemUI()
}
override fun onHidden() {
override fun onHidden(isFullBleedCall: Boolean) {
val controlState = viewModel.getWebRtcControls().value
if (!controlState.displayErrorControls()) {
fullScreenHelper.hideSystemUI()
videoTooltip?.dismiss()
if (controlState.displayErrorControls()) {
return
}
// Only an edge-to-edge call goes immersive with the controls. Anything else keeps the system bars
// up and pads around them instead, so the grid doesn't reflow every time the controls fade.
if (isFullBleedCall) {
fullScreenHelper.hideSystemUI()
}
videoTooltip?.dismiss()
}
}