mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-22 10:46:50 +00:00
Add new focused state for the local calling pip.
This commit is contained in:
committed by
Cody Henthorne
parent
90fe924d18
commit
2e1291b3c3
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -264,6 +264,12 @@ class WebRtcCallViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onLocalPictureInPictureFocusClicked() {
|
||||
participantsState.update {
|
||||
CallParticipantsState.setFocusLocalParticipant(it, it.localRenderState != WebRtcLocalRenderState.FOCUSED)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDismissedVideoTooltip() {
|
||||
canDisplayTooltipIfNeeded = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user