mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 16:54:28 +01:00
Fix a litany of call screen issues.
This commit is contained in:
@@ -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,
|
||||
|
||||
+8
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+39
-13
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -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)
|
||||
}
|
||||
|
||||
+17
-4
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
+41
@@ -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
|
||||
|
||||
+19
-2
@@ -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 = {
|
||||
|
||||
+97
-63
@@ -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 {
|
||||
|
||||
+74
-12
@@ -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)
|
||||
}
|
||||
|
||||
+26
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user