Add new focused state for the local calling pip.

This commit is contained in:
Alex Hart
2025-11-13 09:54:21 -04:00
committed by Cody Henthorne
parent 90fe924d18
commit 2e1291b3c3
11 changed files with 305 additions and 212 deletions

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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(),

View File

@@ -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

View File

@@ -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
}
}
)
}
}

View File

@@ -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<PictureInPictureState.Corner, IntOffset> {
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)

View File

@@ -264,6 +264,12 @@ class WebRtcCallViewModel : ViewModel() {
}
}
fun onLocalPictureInPictureFocusClicked() {
participantsState.update {
CallParticipantsState.setFocusLocalParticipant(it, it.localRenderState != WebRtcLocalRenderState.FOCUSED)
}
}
fun onDismissedVideoTooltip() {
canDisplayTooltipIfNeeded = false
}