From be600f769dcf7530f1bdab374f1129defbdc5c7d Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 13 Nov 2025 13:19:44 -0400 Subject: [PATCH] Add blurring for focused state for local call pip. --- .../components/webrtc/v2/BlurOverlay.kt | 118 +++++++++++++ .../components/webrtc/v2/CallScreen.kt | 100 +++++------ .../webrtc/v2/MoveableLocalVideoRenderer.kt | 157 ++++++++++++++---- 3 files changed, 287 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt new file mode 100644 index 0000000000..6fb264a226 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/BlurOverlay.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.NightPreview +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import kotlin.time.Duration.Companion.seconds + +/** + * Blurrable container. Wrap whatever you want blurred in this. + */ +@Composable +fun BlurContainer( + isBlurred: Boolean, + modifier: Modifier = Modifier, + blurRadius: Dp = 20.dp, + skipOverlay: Boolean = false, + content: @Composable BoxScope.() -> Unit +) { + val blurRadius = if (isBlurred) blurRadius else 0.dp + val blur by animateDpAsState(blurRadius) + + Box( + modifier = modifier.blur(blur, edgeTreatment = BlurredEdgeTreatment.Unbounded) + ) { + content() + + if (!skipOverlay) { + BlurOverlay(visible = isBlurred, modifier = Modifier.fillMaxSize()) + } + } +} + +/** + * 'Glass' blur overlay. + * ``` + */ +@Composable +private fun BlurOverlay(visible: Boolean, modifier: Modifier) { + AnimatedVisibility( + visible = visible, + modifier = modifier + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color(0xB3252525)) + .background(color = Color(0x109C9C9C)) + ) + } +} + +@NightPreview +@Composable +fun BlurContainerPreview() { + Previews.Preview { + var isBlurred by remember { mutableStateOf(false) } + + LaunchedEffect(isBlurred) { + if (isBlurred) { + delay(3.seconds) + isBlurred = false + } + } + + BlurContainer( + isBlurred = isBlurred, + modifier = Modifier.fillMaxSize() + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Image( + painter = painterResource(R.drawable.ic_add_a_profile_megaphone_image), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + + Buttons.LargeTonal( + onClick = { + isBlurred = true + } + ) { + Text("Blur") + } + } + } + } +} 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 e6f6f8e7c2..daed43cc41 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 @@ -16,7 +16,6 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -38,7 +37,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -46,9 +44,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.geometry.Offset import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalConfiguration @@ -337,7 +333,7 @@ fun CallScreen( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun BoxScope.Viewport( +private fun Viewport( localParticipant: CallParticipant, localRenderState: WebRtcLocalRenderState, webRtcCallState: WebRtcViewModel.State, @@ -377,68 +373,64 @@ private fun BoxScope.Viewport( } } - var spacerOffset by remember { mutableStateOf(Offset.Zero) } + BlurContainer( + isBlurred = localRenderState == WebRtcLocalRenderState.FOCUSED, + modifier = modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.weight(1f) + ) { + CallParticipantsPager( + callParticipantsPagerState = callParticipantsPagerState, + pagerState = callScreenController.callParticipantsVerticalPagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clickable( + onClick = { + scope.launch { + callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS) + } + }, + enabled = !callControlsState.skipHiddenState + ) + ) - Row(modifier = modifier.fillMaxWidth()) { - Column( - modifier = Modifier.weight(1f) - ) { - CallParticipantsPager( - callParticipantsPagerState = callParticipantsPagerState, - pagerState = callScreenController.callParticipantsVerticalPagerState, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .clickable( - onClick = { - scope.launch { - callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS) - } - }, - enabled = !callControlsState.skipHiddenState - ) - ) + if (isPortrait && isLargeGroupCall) { + Row { + CallParticipantsOverflow( + overflowParticipants = overflowParticipants, + modifier = Modifier + .padding(top = 16.dp, start = 16.dp, bottom = 16.dp) + .height(OVERFLOW_ITEM_SIZE) + .weight(1f) + ) - if (isPortrait && isLargeGroupCall) { - Row { + Spacer( + modifier = Modifier + .padding(top = 16.dp, bottom = 16.dp, end = 16.dp) + .size(OVERFLOW_ITEM_SIZE) + ) + } + } + } + + if (!isPortrait && isLargeGroupCall) { + Column { CallParticipantsOverflow( overflowParticipants = overflowParticipants, modifier = Modifier - .padding(top = 16.dp, start = 16.dp, bottom = 16.dp) - .height(OVERFLOW_ITEM_SIZE) + .width(OVERFLOW_ITEM_SIZE + 32.dp) .weight(1f) ) Spacer( - modifier = Modifier - .onPlaced { coordinates -> - spacerOffset = coordinates.localToRoot(Offset.Zero) - } - .padding(top = 16.dp, bottom = 16.dp, end = 16.dp) - .size(OVERFLOW_ITEM_SIZE) + modifier = Modifier.size(OVERFLOW_ITEM_SIZE) ) } } } - - if (!isPortrait && isLargeGroupCall) { - Column { - CallParticipantsOverflow( - overflowParticipants = overflowParticipants, - modifier = Modifier - .width(OVERFLOW_ITEM_SIZE + 32.dp) - .weight(1f) - ) - - Spacer( - modifier = Modifier - .onPlaced { coordinates -> - spacerOffset = coordinates.localToRoot(Offset.Zero) - } - .size(OVERFLOW_ITEM_SIZE) - ) - } - } } } 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 92d3a43027..83c023d215 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 @@ -6,28 +6,42 @@ package org.thoughtcrime.securesms.components.webrtc.v2 import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf 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.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import org.signal.core.ui.compose.NightPreview @@ -66,12 +80,11 @@ fun MoveableLocalVideoRenderer( modifier = Modifier .fillMaxSize() .then(modifier) - .padding(16.dp) .statusBarsPadding() ) { val targetSize = size.let { if (it == DpSize.Unspecified) { - DpSize(maxWidth, maxHeight) + DpSize(maxWidth - 32.dp, maxHeight - 32.dp) } else { it } @@ -80,24 +93,40 @@ fun MoveableLocalVideoRenderer( val state = remember { PictureInPictureState(initialContentSize = targetSize) } state.animateTo(targetSize) + val selfPipMode = when (localRenderState) { + WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED -> { + CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP + } + WebRtcLocalRenderState.SMALLER_RECTANGLE -> { + CallParticipantView.SelfPipMode.MINI_SELF_PIP + } + else -> { + CallParticipantView.SelfPipMode.NORMAL_SELF_PIP + } + } + + val clip by animateClip(localRenderState) + val shadow by animateShadow(localRenderState) + PictureInPicture( state = state, modifier = Modifier + .padding(16.dp) .fillMaxSize() ) { CallParticipantRenderer( callParticipant = localParticipant, isLocalParticipant = true, renderInPip = true, - selfPipMode = if (localRenderState == WebRtcLocalRenderState.EXPANDED || localRenderState == WebRtcLocalRenderState.FOCUSED) { - CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP - } else { - CallParticipantView.SelfPipMode.NORMAL_SELF_PIP - }, + selfPipMode = selfPipMode, onToggleCameraDirection = onToggleCameraDirectionClick, modifier = Modifier .fillMaxSize() - .clip(MaterialTheme.shapes.medium) + .dropShadow( + shape = RoundedCornerShape(clip), + shadow = shadow + ) + .clip(RoundedCornerShape(clip)) .clickable(onClick = { onClick() }) @@ -135,38 +164,98 @@ fun MoveableLocalVideoRenderer( } } +@Composable +private fun animateClip(localRenderState: WebRtcLocalRenderState): State { + val targetDp = when (localRenderState) { + WebRtcLocalRenderState.FOCUSED -> 32.dp + WebRtcLocalRenderState.EXPANDED -> 28.dp + else -> 24.dp + } + + return animateDpAsState(targetValue = targetDp) +} + +@Composable +private fun animateShadow(localRenderState: WebRtcLocalRenderState): State { + val targetShadowRadius = when (localRenderState) { + WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> { + 14.dp + } + else -> { + 0.dp + } + } + + val targetShadowOffset = when (localRenderState) { + WebRtcLocalRenderState.EXPANDED, WebRtcLocalRenderState.FOCUSED, WebRtcLocalRenderState.SMALLER_RECTANGLE -> { + 4.dp + } + else -> { + 0.dp + } + } + + val shadowRadius by animateDpAsState(targetShadowRadius) + val shadowOffset by animateDpAsState(targetShadowOffset) + return remember { + derivedStateOf { Shadow(radius = shadowRadius, offset = DpOffset(0.dp, shadowOffset)) } + } +} + @NightPreview @Composable -fun MoveableLocalVideoRendererPreview() { +private fun MoveableLocalVideoRendererPreview() { var localRenderState by remember { mutableStateOf(WebRtcLocalRenderState.SMALL_RECTANGLE) } Previews.Preview { - MoveableLocalVideoRenderer( - localParticipant = remember { - CallParticipant() - }, - localRenderState = localRenderState, - onClick = { - localRenderState = when (localRenderState) { - WebRtcLocalRenderState.SMALL_RECTANGLE -> { - WebRtcLocalRenderState.EXPANDED - } - - WebRtcLocalRenderState.EXPANDED -> { - WebRtcLocalRenderState.SMALL_RECTANGLE - } - - else -> localRenderState - } - }, - onToggleCameraDirectionClick = {}, - onFocusLocalParticipantClick = { - localRenderState = if (localRenderState == WebRtcLocalRenderState.FOCUSED) { - WebRtcLocalRenderState.EXPANDED - } else { - WebRtcLocalRenderState.FOCUSED - } + val blur by animateDpAsState( + if (localRenderState == WebRtcLocalRenderState.FOCUSED) { + 20.dp + } else { + 0.dp } ) + + Box(modifier = Modifier.background(color = Color.Green)) { + BlurContainer( + isBlurred = localRenderState == WebRtcLocalRenderState.FOCUSED + ) { + Image( + painter = painterResource(R.drawable.ic_add_a_profile_megaphone_image), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .blur(blur) + ) + } + + MoveableLocalVideoRenderer( + localParticipant = remember { + CallParticipant() + }, + localRenderState = localRenderState, + onClick = { + localRenderState = when (localRenderState) { + WebRtcLocalRenderState.SMALL_RECTANGLE -> { + WebRtcLocalRenderState.EXPANDED + } + + WebRtcLocalRenderState.EXPANDED -> { + WebRtcLocalRenderState.SMALL_RECTANGLE + } + + else -> localRenderState + } + }, + onToggleCameraDirectionClick = {}, + onFocusLocalParticipantClick = { + localRenderState = if (localRenderState == WebRtcLocalRenderState.FOCUSED) { + WebRtcLocalRenderState.EXPANDED + } else { + WebRtcLocalRenderState.FOCUSED + } + } + ) + } } }