From 2af2d304b094bae032de216c7cbcaaee3e3caa88 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 11 Sep 2026 15:38:50 -0300 Subject: [PATCH] Fix a litany of call screen issues. --- .../components/webrtc/v2/CallControls.kt | 17 +- .../v2/CallControlsVisibilityListener.kt | 10 +- .../webrtc/v2/CallElementsLayout.kt | 52 ++++-- .../components/webrtc/v2/CallGrid.kt | 47 +++-- .../webrtc/v2/CallParticipantsOverflow.kt | 16 +- .../webrtc/v2/CallParticipantsPager.kt | 21 ++- .../components/webrtc/v2/CallScreen.kt | 45 +++-- .../components/webrtc/v2/CallScreenMetrics.kt | 41 +++++ .../webrtc/v2/ComposeCallScreenMediator.kt | 21 ++- .../webrtc/v2/MoveableLocalVideoRenderer.kt | 160 +++++++++++------- .../components/webrtc/v2/PictureInPicture.kt | 86 ++++++++-- .../webrtc/v2/WebRtcCallActivity.kt | 30 +++- 12 files changed, 399 insertions(+), 147 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt index c12f7d4c70..d383f090c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsVisibilityListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsVisibilityListener.kt index 4280d8bbd1..d71d98658f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsVisibilityListener.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsVisibilityListener.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt index cdf60a262d..457be8addf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallElementsLayout.kt @@ -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 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 184fe1d36a..75a523f054 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 @@ -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 { 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() @@ -397,7 +406,8 @@ private fun calculateGridCellsWithSpanningColumn( config: GridConfig, availableWidth: Float, availableHeight: Float, - padding: Float, + paddingHorizontal: Float, + paddingVertical: Float, spacing: Float, itemCount: Int ): List { @@ -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 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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt index a687314ad3..e1aa057794 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt @@ -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) } 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 0798594e3d..9a78e1bf84 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 @@ -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 -> { 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 0c5224ad9c..ff0f59cb3e 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 @@ -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() ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt index 146d2a7a22..0d700ddf06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenMetrics.kt @@ -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 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 1e89622629..648fdd237f 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 @@ -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 = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt index a873e2244e..16dc8fa3d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt index 4b0abfd580..e92854ed04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt index 5a28582f0f..3dd7490e56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt @@ -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() } }