Fix PictureInPicture fling behavior and simplify focused mode.

This commit is contained in:
Alex Hart
2025-12-11 14:04:27 -04:00
parent 4d8ed34d94
commit c9d0a11e85
4 changed files with 170 additions and 128 deletions

View File

@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -35,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
@@ -460,19 +462,28 @@ private fun SwitchCameraButton(
}
val iconInset by animateDpAsState(targetIconInset)
val hasActiveClick = (selfPipMode == SelfPipMode.EXPANDED_SELF_PIP || selfPipMode == SelfPipMode.FOCUSED_SELF_PIP) && onClick != null
val clickModifier = if ((selfPipMode == SelfPipMode.EXPANDED_SELF_PIP || selfPipMode == SelfPipMode.FOCUSED_SELF_PIP) && onClick != null) {
val clickModifier = if (hasActiveClick) {
Modifier.clickable { onClick() }
} else {
Modifier
}
val background by animateColorAsState(
if (hasActiveClick) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)
}
)
Box(
modifier = modifier
.padding(end = margin, bottom = margin)
.size(size)
.background(
color = Color(0xFF383838),
color = background,
shape = CircleShape
)
.padding(iconInset)
@@ -606,6 +617,42 @@ enum class SelfPipMode {
FOCUSED_SELF_PIP
}
@NightPreview
@Composable
private fun SwitchCameraButtonOnPreview() {
Previews.Preview {
Box(
modifier = Modifier.background(
brush = Brush.linearGradient(colors = listOf(Color.Green, Color.Blue))
)
) {
SwitchCameraButton(
selfPipMode = SelfPipMode.EXPANDED_SELF_PIP,
onClick = {},
modifier = Modifier
)
}
}
}
@NightPreview
@Composable
private fun SwitchCameraButtonOffPreview() {
Previews.Preview {
Box(
modifier = Modifier.background(
brush = Brush.linearGradient(colors = listOf(Color.Green, Color.Blue))
)
) {
SwitchCameraButton(
selfPipMode = SelfPipMode.NORMAL_SELF_PIP,
onClick = {},
modifier = Modifier
)
}
}
}
// region Remote Participant Previews
@NightPreview

View File

@@ -47,6 +47,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
@@ -174,6 +175,7 @@ fun CallScreen(
sheetDragHandle = null,
sheetPeekHeight = peekHeight.dp,
sheetContainerColor = SignalTheme.colors.colorSurface1,
containerColor = Color.Black,
sheetMaxWidth = 540.dp,
sheetContent = {
BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally))

View File

@@ -65,6 +65,7 @@ fun MoveableLocalVideoRenderer(
modifier: Modifier = Modifier
) {
val size = rememberSelfPipSize(localRenderState)
val isFocused = localRenderState == WebRtcLocalRenderState.FOCUSED
BoxWithConstraints(
modifier = Modifier
@@ -73,60 +74,47 @@ fun MoveableLocalVideoRenderer(
.statusBarsPadding()
.displayCutoutPadding()
) {
val targetSize = size.let {
if (it == DpSize.Unspecified) {
val orientation = LocalConfiguration.current.orientation
val desiredWidth = maxWidth - 32.dp
val desiredHeight = maxHeight - 32.dp
val orientation = LocalConfiguration.current.orientation
val focusedSize = remember(maxWidth, maxHeight, orientation) {
val desiredWidth = maxWidth - 32.dp
val desiredHeight = maxHeight - 32.dp
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
16f / 9f
} else {
9f / 16f
}
val widthFromHeight = desiredHeight * aspectRatio
val heightFromWidth = desiredWidth / aspectRatio
val size: DpSize = if (widthFromHeight <= desiredWidth) {
DpSize(widthFromHeight, desiredHeight)
} else {
DpSize(desiredWidth, heightFromWidth)
}
size
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
16f / 9f
} else {
it.rotateForConfiguration()
9f / 16f
}
val widthFromHeight = desiredHeight * aspectRatio
val heightFromWidth = desiredWidth / aspectRatio
if (widthFromHeight <= desiredWidth) {
DpSize(widthFromHeight, desiredHeight)
} else {
DpSize(desiredWidth, heightFromWidth)
}
}
val targetSize = if (isFocused) focusedSize else size.rotateForConfiguration()
val state = remember { PictureInPictureState(initialContentSize = targetSize) }
state.animateTo(targetSize)
val selfPipMode = when (localRenderState) {
WebRtcLocalRenderState.EXPANDED -> {
SelfPipMode.EXPANDED_SELF_PIP
}
WebRtcLocalRenderState.FOCUSED -> {
SelfPipMode.FOCUSED_SELF_PIP
}
WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
SelfPipMode.MINI_SELF_PIP
}
else -> {
SelfPipMode.NORMAL_SELF_PIP
}
WebRtcLocalRenderState.EXPANDED -> SelfPipMode.EXPANDED_SELF_PIP
WebRtcLocalRenderState.FOCUSED -> SelfPipMode.FOCUSED_SELF_PIP
WebRtcLocalRenderState.SMALLER_RECTANGLE -> SelfPipMode.MINI_SELF_PIP
else -> SelfPipMode.NORMAL_SELF_PIP
}
val clip by animateClip(localRenderState)
val shadow by animateShadow(localRenderState)
val showFocusButton = localRenderState == WebRtcLocalRenderState.EXPANDED || isFocused
PictureInPicture(
centerContent = size == DpSize.Unspecified,
state = state,
isFocused = isFocused,
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
@@ -143,13 +131,11 @@ fun MoveableLocalVideoRenderer(
shadow = shadow
)
.clip(RoundedCornerShape(clip))
.clickable(onClick = {
onClick()
})
.clickable(onClick = onClick)
)
AnimatedVisibility(
visible = localRenderState == WebRtcLocalRenderState.EXPANDED || localRenderState == WebRtcLocalRenderState.FOCUSED,
visible = showFocusButton,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
@@ -162,15 +148,14 @@ fun MoveableLocalVideoRenderer(
) {
Icon(
imageVector = ImageVector.vectorResource(
when (localRenderState) {
WebRtcLocalRenderState.FOCUSED -> R.drawable.symbol_minimize_24
else -> R.drawable.symbol_maximize_24
}
if (isFocused) R.drawable.symbol_minimize_24 else R.drawable.symbol_maximize_24
),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
contentDescription = stringResource(
when (localRenderState) {
WebRtcLocalRenderState.FOCUSED -> R.string.MoveableLocalVideoRenderer__shrink_local_video
else -> R.string.MoveableLocalVideoRenderer__expand_local_video
if (isFocused) {
R.string.MoveableLocalVideoRenderer__shrink_local_video
} else {
R.string.MoveableLocalVideoRenderer__expand_local_video
}
)
)

View File

@@ -5,18 +5,17 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.AnimationVector2D
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.draggable2D
import androidx.compose.foundation.gestures.rememberDraggable2DState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
@@ -24,10 +23,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -36,6 +34,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -48,18 +47,44 @@ import kotlin.math.sqrt
private const val DECELERATION_RATE = 0.99f
/**
* Spring animation spec that mimics the View system's ViscousFluidInterpolator.
* Uses no bounce with medium-low stiffness for natural physics-based fling deceleration.
*/
private val FlingAnimationSpec = spring<IntOffset>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow
)
/**
* Spring animation spec for position changes (corner to corner, or to/from center).
* Uses a medium stiffness for responsive but smooth transitions.
*/
private val PositionAnimationSpec = spring<IntOffset>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
/**
* Spring animation spec for size changes.
*/
private val SizeAnimationSpec = spring<Dp>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
/**
* Displays moveable content in a bounding box and allows the user to drag it to
* the four corners. Automatically adjusts itself as the bounding box and content
* size changes.
* size changes. When [isFocused] is true, the content centers and dragging is disabled.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PictureInPicture(
centerContent: Boolean,
state: PictureInPictureState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
isFocused: Boolean = false,
content: @Composable BoxScope.() -> Unit
) {
BoxWithConstraints(
modifier = modifier
@@ -67,8 +92,6 @@ fun PictureInPicture(
val density = LocalDensity.current
val maxHeight = constraints.maxHeight
val maxWidth = constraints.maxWidth
val contentWidth = with(density) { state.contentSize.width.toPx().roundToInt() }
val contentHeight = with(density) { state.contentSize.height.toPx().roundToInt() }
val targetContentWidth = with(density) { state.targetSize.width.toPx().roundToInt() }
val targetContentHeight = with(density) { state.targetSize.height.toPx().roundToInt() }
val coroutineScope = rememberCoroutineScope()
@@ -77,17 +100,6 @@ fun PictureInPicture(
mutableStateOf(false)
}
var isAnimating by remember {
mutableStateOf(false)
}
var offsetX by remember {
mutableIntStateOf(maxWidth - contentWidth)
}
var offsetY by remember {
mutableIntStateOf(maxHeight - contentHeight)
}
val topLeft = remember {
IntOffset(0, 0)
}
@@ -104,70 +116,76 @@ fun PictureInPicture(
IntOffset(maxWidth - targetContentWidth, maxHeight - targetContentHeight)
}
DisposableEffect(maxWidth, maxHeight, isAnimating, isDragging, targetContentWidth, targetContentHeight, centerContent) {
if (!isAnimating && !isDragging) {
if (centerContent) {
offsetX = (maxWidth / 2f).roundToInt() - (targetContentWidth / 2f).roundToInt()
offsetY = (maxHeight / 2f).roundToInt() - (targetContentHeight / 2f).roundToInt()
} else {
val offset = getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
offsetX = offset.x
offsetY = offset.y
}
}
onDispose { }
val centerOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
IntOffset(
(maxWidth - targetContentWidth) / 2,
(maxHeight - targetContentHeight) / 2
)
}
val animatedOffset by animateIntOffsetAsState(
targetValue = IntOffset(offsetX, offsetY),
animationSpec = tween()
)
val initialOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
}
val offsetAnimatable = remember {
Animatable(initialOffset, IntOffset.VectorConverter)
}
// Animate position when focused state changes or when constraints/corner changes
LaunchedEffect(maxWidth, maxHeight, targetContentWidth, targetContentHeight, state.corner, isFocused) {
if (!isDragging) {
val targetOffset = if (isFocused) {
centerOffset
} else {
getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
}
// Animate to new position (don't snap)
offsetAnimatable.animateTo(
targetValue = targetOffset,
animationSpec = PositionAnimationSpec
)
}
}
Box(
modifier = Modifier
.size(state.contentSize)
.offset {
if (isDragging) {
IntOffset(offsetX, offsetY)
} else {
animatedOffset
}
offsetAnimatable.value
}
.draggable2D(
enabled = !isAnimating && !centerContent,
enabled = !offsetAnimatable.isRunning && !isFocused,
state = rememberDraggable2DState { offset ->
offsetX += offset.x.roundToInt()
offsetY += offset.y.roundToInt()
coroutineScope.launch {
offsetAnimatable.snapTo(
IntOffset(
offsetAnimatable.value.x + offset.x.roundToInt(),
offsetAnimatable.value.y + offset.y.roundToInt()
)
)
}
},
onDragStarted = {
isDragging = true
},
onDragStopped = { velocity ->
isDragging = false
isAnimating = true
val x = offsetX + project(velocity.x)
val y = offsetY + project(velocity.y)
val currentOffset = offsetAnimatable.value
val x = currentOffset.x + project(velocity.x)
val y = currentOffset.y + project(velocity.y)
val projectedCoordinate = IntOffset(x.roundToInt(), y.roundToInt())
val (corner, offset) = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight)
val (corner, targetOffset) = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight)
state.corner = corner
coroutineScope.launch {
animate(
typeConverter = IntOffsetConverter,
initialValue = IntOffset(offsetX, offsetY),
targetValue = offset,
offsetAnimatable.animateTo(
targetValue = targetOffset,
initialVelocity = IntOffset(velocity.x.roundToInt(), velocity.y.roundToInt()),
animationSpec = tween()
) { value, _ ->
offsetX = value.x
offsetY = value.y
}
isAnimating = false
animationSpec = FlingAnimationSpec
)
}
}
)
@@ -177,15 +195,6 @@ fun PictureInPicture(
}
}
private object IntOffsetConverter : TwoWayConverter<IntOffset, AnimationVector2D> {
override val convertFromVector: (AnimationVector2D) -> IntOffset = { animationVector ->
IntOffset(animationVector.v1.roundToInt(), animationVector.v2.roundToInt())
}
override val convertToVector: (IntOffset) -> AnimationVector2D = { intOffset ->
AnimationVector(intOffset.x.toFloat(), intOffset.y.toFloat())
}
}
private fun project(velocity: Float): Float {
return (velocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE)
}
@@ -231,8 +240,8 @@ class PictureInPictureState @RememberInComposition constructor(initialContentSiz
fun animateTo(newTargetSize: DpSize) {
targetSize = newTargetSize
val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = newTargetSize.width, animationSpec = tween())
val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = newTargetSize.height, animationSpec = tween())
val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = newTargetSize.width, animationSpec = SizeAnimationSpec)
val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = newTargetSize.height, animationSpec = SizeAnimationSpec)
contentSize = DpSize(targetWidth, targetHeight)
}
@@ -247,7 +256,6 @@ private fun distance(a: IntOffset, b: IntOffset): Float {
fun PictureInPicturePreview() {
Previews.Preview {
PictureInPicture(
centerContent = false,
state = remember { PictureInPictureState(initialContentSize = DpSize(90.dp, 160.dp)) },
modifier = Modifier
.fillMaxSize()