diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt index a0d6db1ac8..dc9d110d67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt @@ -261,7 +261,13 @@ data class CallParticipantsState( @JvmStatic fun setExpanded(oldState: CallParticipantsState, expanded: Boolean): CallParticipantsState { - val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isExpanded = expanded) + val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isLocalParticipantExpanded = expanded) + + return oldState.copy(localRenderState = localRenderState) + } + + fun setFocusLocalParticipant(oldState: CallParticipantsState, focused: Boolean): CallParticipantsState { + val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isLocalParticipantFocused = focused) return oldState.copy(localRenderState = localRenderState) } @@ -304,12 +310,15 @@ data class CallParticipantsState( callState: WebRtcViewModel.State = oldState.callState, numberOfRemoteParticipants: Int = oldState.allRemoteParticipants.size, isViewingFocusedParticipant: Boolean = oldState.isViewingFocusedParticipant, - isExpanded: Boolean = oldState.localRenderState == WebRtcLocalRenderState.EXPANDED + isLocalParticipantExpanded: Boolean = oldState.localRenderState == WebRtcLocalRenderState.EXPANDED, + isLocalParticipantFocused: Boolean = oldState.localRenderState == WebRtcLocalRenderState.FOCUSED ): WebRtcLocalRenderState { val displayLocal: Boolean = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled) var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE - if (!isInPip && isExpanded && localParticipant.isVideoEnabled) { + if (!isInPip && isLocalParticipantFocused && localParticipant.isVideoEnabled) { + return WebRtcLocalRenderState.FOCUSED + } else if (!isInPip && isLocalParticipantExpanded && localParticipant.isVideoEnabled) { return WebRtcLocalRenderState.EXPANDED } else if (displayLocal || showVideoForOutgoing) { if (callState == WebRtcViewModel.State.CALL_CONNECTED || callState == WebRtcViewModel.State.CALL_RECONNECTING) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java deleted file mode 100644 index b2de4757ef..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.thoughtcrime.securesms.components.webrtc; - -public enum WebRtcLocalRenderState { - GONE, - SMALL_RECTANGLE, - SMALLER_RECTANGLE, - LARGE, - LARGE_NO_VIDEO, - EXPANDED; - - public boolean isAnySmall() { - return this == SMALL_RECTANGLE || this == SMALLER_RECTANGLE; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.kt new file mode 100644 index 0000000000..38f2973f46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.components.webrtc + +enum class WebRtcLocalRenderState { + GONE, + SMALL_RECTANGLE, + SMALLER_RECTANGLE, + LARGE, + LARGE_NO_VIDEO, + EXPANDED, + FOCUSED; + + val isAnySmall: Boolean + get() = this == SMALL_RECTANGLE || this == SMALLER_RECTANGLE +} 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 65383f674a..e6f6f8e7c2 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 @@ -14,7 +14,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -30,14 +29,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -50,21 +46,14 @@ 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.draw.clip import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color 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 -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.window.core.layout.WindowHeightSizeClass -import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.signal.core.ui.compose.AllNightPreviews @@ -72,8 +61,6 @@ import org.signal.core.ui.compose.BottomSheets import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.TriggerAlignedPopupState import org.signal.core.util.DimensionUnit -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.webrtc.CallParticipantView import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette @@ -90,6 +77,7 @@ import kotlin.time.Duration.Companion.seconds private const val DRAG_HANDLE_HEIGHT = 22 private const val SHEET_TOP_PADDING = 9 private const val SHEET_BOTTOM_PADDING = 16 +private val OVERFLOW_ITEM_SIZE = 90.dp /** * In-App calling screen displaying controls, info, and participant camera feeds. @@ -124,6 +112,7 @@ fun CallScreen( raiseHandSnackbar: @Composable (Modifier) -> Unit, onNavigationClick: () -> Unit, onLocalPictureInPictureClicked: () -> Unit, + onLocalPictureInPictureFocusClicked: () -> Unit, onControlsToggled: (Boolean) -> Unit, onCallScreenDialogDismissed: () -> Unit = {} ) { @@ -244,6 +233,7 @@ fun CallScreen( callControlsState = callControlsState, callScreenState = callScreenState, onPipClick = onLocalPictureInPictureClicked, + onPipFocusClick = onLocalPictureInPictureFocusClicked, onControlsToggled = onControlsToggled, callScreenController = callScreenController, onToggleCameraDirection = callScreenControlsListener::onCameraDirectionChanged, @@ -358,6 +348,7 @@ private fun BoxScope.Viewport( callScreenState: CallScreenState, callScreenController: CallScreenController, onPipClick: () -> Unit, + onPipFocusClick: () -> Unit, onControlsToggled: (Boolean) -> Unit, onToggleCameraDirection: () -> Unit, modifier: Modifier = Modifier @@ -409,15 +400,12 @@ private fun BoxScope.Viewport( ) if (isPortrait && isLargeGroupCall) { - val overflowSize = dimensionResource(R.dimen.call_screen_overflow_item_size) - val selfPipSize = rememberTinyPortraitSize() - Row { CallParticipantsOverflow( overflowParticipants = overflowParticipants, modifier = Modifier .padding(top = 16.dp, start = 16.dp, bottom = 16.dp) - .height(overflowSize) + .height(OVERFLOW_ITEM_SIZE) .weight(1f) ) @@ -427,21 +415,18 @@ private fun BoxScope.Viewport( spacerOffset = coordinates.localToRoot(Offset.Zero) } .padding(top = 16.dp, bottom = 16.dp, end = 16.dp) - .size(selfPipSize.small) + .size(OVERFLOW_ITEM_SIZE) ) } } } if (!isPortrait && isLargeGroupCall) { - val overflowSize = dimensionResource(R.dimen.call_screen_overflow_item_size) - val selfPipSize = rememberTinyPortraitSize() - Column { CallParticipantsOverflow( overflowParticipants = overflowParticipants, modifier = Modifier - .width(overflowSize + 32.dp) + .width(OVERFLOW_ITEM_SIZE + 32.dp) .weight(1f) ) @@ -450,38 +435,20 @@ private fun BoxScope.Viewport( .onPlaced { coordinates -> spacerOffset = coordinates.localToRoot(Offset.Zero) } - .size(selfPipSize.small) + .size(OVERFLOW_ITEM_SIZE) ) } } } - - if (isLargeGroupCall) { - TinyLocalVideoRenderer( - localParticipant = localParticipant, - localRenderState = localRenderState, - modifier = Modifier - .align(Alignment.TopStart) - .padding( - start = with(LocalDensity.current) { - spacerOffset.x.toDp() - }, - top = with(LocalDensity.current) { - spacerOffset.y.toDp() - } - ), - onToggleCameraDirection = onToggleCameraDirection, - onClick = onPipClick - ) - } } - if (webRtcCallState.inOngoingCall && localParticipant.isVideoEnabled && !isLargeGroupCall) { - SmallMoveableLocalVideoRenderer( + if (webRtcCallState.inOngoingCall && localParticipant.isVideoEnabled) { + MoveableLocalVideoRenderer( localParticipant = localParticipant, localRenderState = localRenderState, onClick = onPipClick, - onToggleCameraDirection = onToggleCameraDirection, + onToggleCameraDirectionClick = onToggleCameraDirection, + onFocusLocalParticipantClick = onPipFocusClick, modifier = modifier ) } @@ -504,137 +471,6 @@ private fun LargeLocalVideoRenderer( ) } -/** - * Tiny expandable video renderer displayed when the user is in a large group call. - */ -@Composable -private fun TinyLocalVideoRenderer( - localParticipant: CallParticipant, - localRenderState: WebRtcLocalRenderState, - modifier: Modifier = Modifier, - onClick: () -> Unit, - onToggleCameraDirection: () -> Unit -) { - val (smallSize, expandedSize, padding) = rememberTinyPortraitSize() - val size = if (localRenderState == WebRtcLocalRenderState.EXPANDED) expandedSize else smallSize - - val width by animateDpAsState(label = "tiny-width", targetValue = size.width) - val height by animateDpAsState(label = "tiny-height", targetValue = size.height) - - if (LocalInspectionMode.current) { - Text( - "Test ${currentWindowAdaptiveInfo().windowSizeClass}", - modifier = modifier - .padding(padding) - .height(height) - .width(width) - .background(color = Color.Red) - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - ) - } - - CallParticipantRenderer( - callParticipant = localParticipant, - isLocalParticipant = true, - renderInPip = true, - onToggleCameraDirection = onToggleCameraDirection, - selfPipMode = if (localRenderState == WebRtcLocalRenderState.EXPANDED) { - CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP - } else { - CallParticipantView.SelfPipMode.MINI_SELF_PIP - }, - modifier = modifier - .padding(padding) - .height(height) - .width(width) - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - ) -} - -/** - * Small moveable local video renderer that displays the user's video in a draggable and expandable view. - */ -@Composable -private fun SmallMoveableLocalVideoRenderer( - localParticipant: CallParticipant, - localRenderState: WebRtcLocalRenderState, - onClick: () -> Unit, - onToggleCameraDirection: () -> Unit, - modifier: Modifier = Modifier -) { - val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT - - val smallSize = remember(isPortrait) { - if (isPortrait) DpSize(90.dp, 160.dp) else DpSize(160.dp, 90.dp) - } - val expandedSize = remember(isPortrait) { - if (isPortrait) DpSize(180.dp, 320.dp) else DpSize(320.dp, 180.dp) - } - - val size = if (localRenderState == WebRtcLocalRenderState.EXPANDED) expandedSize else smallSize - - val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = size.width, animationSpec = tween()) - val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = size.height, animationSpec = tween()) - - PictureInPicture( - contentSize = DpSize(targetWidth, targetHeight), - modifier = Modifier - .fillMaxSize() - .then(modifier) - .padding(16.dp) - .statusBarsPadding() - ) { - CallParticipantRenderer( - callParticipant = localParticipant, - isLocalParticipant = true, - renderInPip = true, - selfPipMode = if (localRenderState == WebRtcLocalRenderState.EXPANDED) { - CallParticipantView.SelfPipMode.EXPANDED_SELF_PIP - } else { - CallParticipantView.SelfPipMode.NORMAL_SELF_PIP - }, - onToggleCameraDirection = onToggleCameraDirection, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = { - onClick() - }) - ) - } -} - -@Composable -private fun rememberTinyPortraitSize(): SelfPictureInPictureDimensions { - val smallWidth = dimensionResource(R.dimen.call_screen_overflow_item_size) - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - - val smallSize = when { - windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT && !isLandscape -> DpSize(40.dp, smallWidth) - windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT && isLandscape -> DpSize(smallWidth, 40.dp) - windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED -> DpSize(124.dp, 217.dp) - else -> DpSize(smallWidth, smallWidth) - } - - val expandedSize = when { - windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT && !isLandscape -> DpSize(180.dp, 320.dp) - windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT && isLandscape -> DpSize(320.dp, 180.dp) - else -> DpSize(smallWidth, smallWidth) - } - - val padding = when { - windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT && !isLandscape -> PaddingValues(vertical = 16.dp) - else -> PaddingValues(16.dp) - } - - return remember(windowSizeClass) { - SelfPictureInPictureDimensions(smallSize, expandedSize, padding) - } -} - /** * Wrapper for a CallStateUpdate popup that animates its display on the screen, sliding up from either * above the controls or from the bottom of the screen if the controls are hidden. @@ -711,7 +547,7 @@ private fun CallScreenPreview() { 2 ) ), - localRenderState = WebRtcLocalRenderState.SMALL_RECTANGLE, + localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE, callScreenDialogType = CallScreenDialogType.NONE, callInfoView = { Text(text = "Call Info View Preview", modifier = Modifier.alpha(it)) @@ -736,6 +572,7 @@ private fun CallScreenPreview() { }, onNavigationClick = {}, onLocalPictureInPictureClicked = {}, + onLocalPictureInPictureFocusClicked = {}, overflowParticipants = participants, onControlsToggled = {}, reactions = emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt index 15f819175f..a353108539 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -166,6 +166,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo }, onNavigationClick = { activity.onBackPressedDispatcher.onBackPressed() }, onLocalPictureInPictureClicked = viewModel::onLocalPictureInPictureClicked, + onLocalPictureInPictureFocusClicked = viewModel::onLocalPictureInPictureFocusClicked, onControlsToggled = onControlsToggled, onCallScreenDialogDismissed = { callScreenViewModel.dialog.update { CallScreenDialogType.NONE } }, callParticipantUpdatePopupController = callParticipantUpdatePopupController 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 new file mode 100644 index 0000000000..92d3a43027 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/MoveableLocalVideoRenderer.kt @@ -0,0 +1,172 @@ +/* + * 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.foundation.background +import androidx.compose.foundation.clickable +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.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.NightPreview +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.CallParticipantView +import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState +import org.thoughtcrime.securesms.events.CallParticipant + +/** + * Small moveable local video renderer that displays the user's video in a draggable and expandable view. + */ +@Composable +fun MoveableLocalVideoRenderer( + localParticipant: CallParticipant, + localRenderState: WebRtcLocalRenderState, + onClick: () -> Unit, + onToggleCameraDirectionClick: () -> Unit, + onFocusLocalParticipantClick: () -> Unit, + modifier: Modifier = Modifier.Companion +) { + // 1. We need to remember our small and expanded sizes based off of the call size. + val size = remember(localRenderState) { + when (localRenderState) { + WebRtcLocalRenderState.GONE -> DpSize.Zero + WebRtcLocalRenderState.SMALL_RECTANGLE -> DpSize(90.dp, 160.dp) + WebRtcLocalRenderState.SMALLER_RECTANGLE -> DpSize(90.dp, 90.dp) + WebRtcLocalRenderState.LARGE -> DpSize.Zero + WebRtcLocalRenderState.LARGE_NO_VIDEO -> DpSize.Zero + WebRtcLocalRenderState.EXPANDED -> DpSize(170.dp, 300.dp) + WebRtcLocalRenderState.FOCUSED -> DpSize.Unspecified + } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .then(modifier) + .padding(16.dp) + .statusBarsPadding() + ) { + val targetSize = size.let { + if (it == DpSize.Unspecified) { + DpSize(maxWidth, maxHeight) + } else { + it + } + } + + val state = remember { PictureInPictureState(initialContentSize = targetSize) } + state.animateTo(targetSize) + + PictureInPicture( + state = state, + modifier = Modifier + .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 + }, + onToggleCameraDirection = onToggleCameraDirectionClick, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = { + onClick() + }) + ) + + AnimatedVisibility( + visible = localRenderState == WebRtcLocalRenderState.EXPANDED || localRenderState == WebRtcLocalRenderState.FOCUSED, + 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( + when (localRenderState) { + WebRtcLocalRenderState.FOCUSED -> R.drawable.symbol_minimize_24 + else -> R.drawable.symbol_maximize_24 + } + ), + contentDescription = stringResource( + when (localRenderState) { + WebRtcLocalRenderState.FOCUSED -> R.string.MoveableLocalVideoRenderer__shrink_local_video + else -> R.string.MoveableLocalVideoRenderer__expand_local_video + } + ) + ) + } + } + } + } +} + +@NightPreview +@Composable +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 + } + } + ) + } +} 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 d2734791aa..6a80f6859c 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 @@ -9,6 +9,7 @@ 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.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -23,6 +24,7 @@ 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.annotation.RememberInComposition import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -53,8 +55,8 @@ private const val DECELERATION_RATE = 0.99f @OptIn(ExperimentalFoundationApi::class) @Composable fun PictureInPicture( + state: PictureInPictureState, modifier: Modifier = Modifier, - contentSize: DpSize, content: @Composable () -> Unit ) { BoxWithConstraints( @@ -63,8 +65,8 @@ fun PictureInPicture( val density = LocalDensity.current val maxHeight = constraints.maxHeight val maxWidth = constraints.maxWidth - val contentWidth = with(density) { contentSize.width.toPx().roundToInt() } - val contentHeight = with(density) { contentSize.height.toPx().roundToInt() } + val contentWidth = with(density) { state.contentSize.width.toPx().roundToInt() } + val contentHeight = with(density) { state.contentSize.height.toPx().roundToInt() } val coroutineScope = rememberCoroutineScope() var isDragging by remember { @@ -75,6 +77,10 @@ fun PictureInPicture( mutableStateOf(false) } + val isContentFullScreen = remember(maxWidth, maxHeight, contentWidth, contentHeight) { + maxWidth == contentWidth && maxHeight == contentHeight + } + var offsetX by remember { mutableIntStateOf(maxWidth - contentWidth) } @@ -98,13 +104,12 @@ fun PictureInPicture( IntOffset(maxWidth - contentWidth, maxHeight - contentHeight) } - DisposableEffect(maxWidth, maxHeight, isAnimating, isDragging, contentWidth, contentHeight) { + DisposableEffect(maxWidth, maxHeight, isAnimating, isDragging, contentWidth, contentHeight, isContentFullScreen) { if (!isAnimating && !isDragging) { - val projectedCoordinate = IntOffset(offsetX, offsetY) - val closestCorner = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight) + val offset = getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight) - offsetX = closestCorner.x - offsetY = closestCorner.y + offsetX = offset.x + offsetY = offset.y } onDispose { } @@ -112,12 +117,12 @@ fun PictureInPicture( Box( modifier = Modifier - .size(contentSize) + .size(state.contentSize) .offset { IntOffset(offsetX, offsetY) } .draggable2D( - enabled = !isAnimating, + enabled = !isAnimating && !isContentFullScreen, state = rememberDraggable2DState { offset -> offsetX += offset.x.roundToInt() offsetY += offset.y.roundToInt() @@ -133,13 +138,14 @@ fun PictureInPicture( val y = offsetY + project(velocity.y) val projectedCoordinate = IntOffset(x.roundToInt(), y.roundToInt()) - val cornerCoordinate = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight) + val (corner, offset) = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight) + state.corner = corner coroutineScope.launch { animate( typeConverter = IntOffsetConverter, initialValue = IntOffset(offsetX, offsetY), - targetValue = cornerCoordinate, + targetValue = offset, initialVelocity = IntOffset(velocity.x.roundToInt(), velocity.y.roundToInt()), animationSpec = tween() ) { value, _ -> @@ -170,17 +176,49 @@ private fun project(velocity: Float): Float { return (velocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE) } -private fun getClosestCorner(coordinate: IntOffset, topLeft: IntOffset, topRight: IntOffset, bottomLeft: IntOffset, bottomRight: IntOffset): IntOffset { +private fun getClosestCorner(coordinate: IntOffset, topLeft: IntOffset, topRight: IntOffset, bottomLeft: IntOffset, bottomRight: IntOffset): Pair { val distances = mapOf( - topLeft to distance(coordinate, topLeft), - topRight to distance(coordinate, topRight), - bottomLeft to distance(coordinate, bottomLeft), - bottomRight to distance(coordinate, bottomRight) + (PictureInPictureState.Corner.TOP_LEFT to topLeft) to distance(coordinate, topLeft), + (PictureInPictureState.Corner.TOP_RIGHT to topRight) to distance(coordinate, topRight), + (PictureInPictureState.Corner.BOTTOM_LEFT to bottomLeft) to distance(coordinate, bottomLeft), + (PictureInPictureState.Corner.BOTTOM_RIGHT to bottomRight) to distance(coordinate, bottomRight) ) return distances.minBy { it.value }.key } +private fun getDesiredCornerOffset(corner: PictureInPictureState.Corner, topLeft: IntOffset, topRight: IntOffset, bottomLeft: IntOffset, bottomRight: IntOffset): IntOffset { + return when (corner) { + PictureInPictureState.Corner.TOP_LEFT -> topLeft + PictureInPictureState.Corner.TOP_RIGHT -> topRight + PictureInPictureState.Corner.BOTTOM_RIGHT -> bottomRight + PictureInPictureState.Corner.BOTTOM_LEFT -> bottomLeft + } +} + +class PictureInPictureState @RememberInComposition constructor(initialContentSize: DpSize, initialCorner: Corner = Corner.BOTTOM_RIGHT) { + + var contentSize: DpSize by mutableStateOf(initialContentSize) + private set + + var corner: Corner by mutableStateOf(initialCorner) + + enum class Corner { + TOP_LEFT, + TOP_RIGHT, + BOTTOM_RIGHT, + BOTTOM_LEFT + } + + @Composable + fun animateTo(targetSize: DpSize) { + val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = targetSize.width, animationSpec = tween()) + val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = targetSize.height, animationSpec = tween()) + + contentSize = DpSize(targetWidth, targetHeight) + } +} + private fun distance(a: IntOffset, b: IntOffset): Float { return sqrt((b.x - a.x).toDouble().pow(2) + (b.y - a.y).toDouble().pow(2)).toFloat() } @@ -190,7 +228,7 @@ private fun distance(a: IntOffset, b: IntOffset): Float { fun PictureInPicturePreview() { Previews.Preview { PictureInPicture( - contentSize = DpSize(90.dp, 160.dp), + state = remember { PictureInPictureState(initialContentSize = DpSize(90.dp, 160.dp)) }, modifier = Modifier .fillMaxSize() .padding(16.dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt index 66c1623085..63a91a3f93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt @@ -264,6 +264,12 @@ class WebRtcCallViewModel : ViewModel() { } } + fun onLocalPictureInPictureFocusClicked() { + participantsState.update { + CallParticipantsState.setFocusLocalParticipant(it, it.localRenderState != WebRtcLocalRenderState.FOCUSED) + } + } + fun onDismissedVideoTooltip() { canDisplayTooltipIfNeeded = false } diff --git a/app/src/main/res/drawable/symbol_maximize_24.xml b/app/src/main/res/drawable/symbol_maximize_24.xml new file mode 100644 index 0000000000..23e9cb0db5 --- /dev/null +++ b/app/src/main/res/drawable/symbol_maximize_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/symbol_minimize_24.xml b/app/src/main/res/drawable/symbol_minimize_24.xml new file mode 100644 index 0000000000..bf5ae7c5fa --- /dev/null +++ b/app/src/main/res/drawable/symbol_minimize_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef81ad99f5..cdf9206bc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2445,6 +2445,12 @@ To enable your video: + + + Shrink local video + + Expand local video + Signal Call Signal Video Call