diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt index 874fee0719..eba60033de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantViewer.kt @@ -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 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 011dcac055..c9d592991c 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 @@ -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)) 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 98db96e9c0..14dfd0d74a 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 @@ -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 } ) ) 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 fd3cb046fa..e014f2c4ff 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 @@ -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( + 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( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium +) + +/** + * Spring animation spec for size changes. + */ +private val SizeAnimationSpec = spring( + 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 { - 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()