Add call participants overflow to calling v2 screen.

This commit is contained in:
Alex Hart 2024-08-23 09:58:32 -03:00 committed by Nicholas Tinsley
parent 204fcc28c7
commit 3f71f90234
9 changed files with 370 additions and 90 deletions

View file

@ -15,12 +15,12 @@ import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.webrtc.RendererCommon;
class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter<CallParticipant, WebRtcCallParticipantsRecyclerAdapter.ViewHolder> {
public class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter<CallParticipant, WebRtcCallParticipantsRecyclerAdapter.ViewHolder> {
private static final int PARTICIPANT = 0;
private static final int EMPTY = 1;
protected WebRtcCallParticipantsRecyclerAdapter() {
public WebRtcCallParticipantsRecyclerAdapter() {
super(new DiffCallback());
}
@ -43,7 +43,7 @@ class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter<CallParticipant,
return getItem(position) == CallParticipant.EMPTY ? EMPTY : PARTICIPANT;
}
static class ViewHolder extends RecyclerView.ViewHolder {
public static class ViewHolder extends RecyclerView.ViewHolder {
ViewHolder(@NonNull View itemView) {
super(itemView);
}

View file

@ -134,6 +134,7 @@ class CallActivity : BaseActivity(), CallControlsCallback {
isRenderInPip = callParticipantsState.isInPipMode,
hideAvatar = callParticipantsState.hideAvatar
),
overflowParticipants = callParticipantsState.listParticipants,
localParticipant = callParticipantsState.localParticipant,
localRenderState = callParticipantsState.localRenderState,
callInfoView = {

View file

@ -1,43 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.webrtc.RendererCommon
/**
* Displays video for the given participant if attachVideoSink is true.
*/
@Composable
fun CallParticipantVideoRenderer(
callParticipant: CallParticipant,
attachVideoSink: Boolean,
modifier: Modifier = Modifier
) {
AndroidView(
factory = ::TextureViewRenderer,
modifier = modifier,
onRelease = { it.release() }
) { renderer ->
renderer.setMirror(callParticipant.cameraDirection == CameraState.Direction.FRONT)
renderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
callParticipant.videoSink.lockableEglBase.performWithValidEglBase {
renderer.init(it)
}
if (attachVideoSink) {
renderer.attachBroadcastVideoSink(callParticipant.videoSink)
} else {
renderer.attachBroadcastVideoSink(null)
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallParticipantsRecyclerAdapter
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.util.visible
/**
* Wrapper composable for the CallParticipants overflow recycler view.
*
* Displays a scrollable list of users that are in the call but are not displayed in the primary grid.
*/
@Composable
fun CallParticipantsOverflow(
overflowParticipants: List<CallParticipant>,
modifier: Modifier = Modifier
) {
val adapter = remember { WebRtcCallParticipantsRecyclerAdapter() }
AndroidView(
factory = {
val view = LayoutInflater.from(it).inflate(R.layout.webrtc_call_participant_overflow_recycler, FrameLayout(it), false) as RecyclerView
view.adapter = adapter
view
},
modifier = modifier,
update = {
it.visible = true
adapter.submitList(overflowParticipants)
}
)
}

View file

@ -15,14 +15,20 @@ 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.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
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
@ -76,6 +82,7 @@ fun CallScreen(
callControlsState: CallControlsState,
callControlsCallback: CallControlsCallback = CallControlsCallback.Empty,
callParticipantsPagerState: CallParticipantsPagerState,
overflowParticipants: List<CallParticipant>,
localParticipant: CallParticipant,
localRenderState: WebRtcLocalRenderState,
callInfoView: @Composable (Float) -> Unit,
@ -159,6 +166,7 @@ fun CallScreen(
localRenderState = localRenderState,
webRtcCallState = webRtcCallState,
callParticipantsPagerState = callParticipantsPagerState,
overflowParticipants = overflowParticipants,
scaffoldState = scaffoldState,
callControlsState = callControlsState,
onPipClick = onLocalPictureInPictureClicked
@ -176,6 +184,7 @@ fun CallScreen(
localRenderState = localRenderState,
webRtcCallState = webRtcCallState,
callParticipantsPagerState = callParticipantsPagerState,
overflowParticipants = overflowParticipants,
scaffoldState = scaffoldState,
callControlsState = callControlsState,
onPipClick = onLocalPictureInPictureClicked
@ -229,44 +238,85 @@ fun CallScreen(
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Viewport(
private fun BoxScope.Viewport(
localParticipant: CallParticipant,
localRenderState: WebRtcLocalRenderState,
webRtcCallState: WebRtcViewModel.State,
callParticipantsPagerState: CallParticipantsPagerState,
overflowParticipants: List<CallParticipant>,
scaffoldState: BottomSheetScaffoldState,
callControlsState: CallControlsState,
onPipClick: () -> Unit
) {
LargeLocalVideoRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState
)
if (webRtcCallState.isPassedPreJoin) {
val scope = rememberCoroutineScope()
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.extraLarge)
.clickable(
onClick = {
scope.launch {
if (scaffoldState.bottomSheetState.isVisible) {
scaffoldState.bottomSheetState.hide()
} else {
scaffoldState.bottomSheetState.show()
}
}
},
enabled = !callControlsState.skipHiddenState
)
if (webRtcCallState.isPreJoinOrNetworkUnavailable) {
LargeLocalVideoRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState
)
}
if (webRtcCallState.inOngoingCall && localParticipant.isVideoEnabled) {
val isLargeGroupCall = overflowParticipants.size > 1
if (webRtcCallState.isPassedPreJoin) {
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val scope = rememberCoroutineScope()
Row {
Column(
modifier = Modifier.weight(1f)
) {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(MaterialTheme.shapes.extraLarge)
.clickable(
onClick = {
scope.launch {
if (scaffoldState.bottomSheetState.isVisible) {
scaffoldState.bottomSheetState.hide()
} else {
scaffoldState.bottomSheetState.show()
}
}
},
enabled = !callControlsState.skipHiddenState
)
)
if (isPortrait && isLargeGroupCall) {
CallParticipantsOverflow(
overflowParticipants = overflowParticipants,
modifier = Modifier
.padding(16.dp)
.height(72.dp)
.fillMaxWidth()
)
}
}
if (!isPortrait && isLargeGroupCall) {
CallParticipantsOverflow(
overflowParticipants = overflowParticipants,
modifier = Modifier
.padding(16.dp)
.width(72.dp)
.fillMaxHeight()
)
}
}
if (isLargeGroupCall) {
TinyLocalVideoRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState,
modifier = Modifier.align(Alignment.BottomEnd),
onClick = onPipClick
)
}
}
if (webRtcCallState.inOngoingCall && localParticipant.isVideoEnabled && !isLargeGroupCall) {
val padBottom: Dp = if (scaffoldState.bottomSheetState.isVisible) {
0.dp
} else {
@ -283,20 +333,60 @@ private fun Viewport(
}
}
/**
* Full-screen local video renderer displayed when the user is in pre-call state.
*/
@Composable
private fun LargeLocalVideoRenderer(
localParticipant: CallParticipant,
localRenderState: WebRtcLocalRenderState
) {
CallParticipantVideoRenderer(
callParticipant = localParticipant,
attachVideoSink = localRenderState == WebRtcLocalRenderState.LARGE,
LocalParticipantRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.extraLarge)
)
}
/**
* 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
) {
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val smallSize = remember(isPortrait) {
if (isPortrait) DpSize(40.dp, 72.dp) else DpSize(72.dp, 40.dp)
}
val largeSize = remember(isPortrait) {
if (isPortrait) DpSize(180.dp, 320.dp) else DpSize(320.dp, 180.dp)
}
val width by animateDpAsState(label = "tiny-width", targetValue = if (localRenderState == WebRtcLocalRenderState.EXPANDED) largeSize.width else smallSize.width)
val height by animateDpAsState(label = "tiny-height", targetValue = if (localRenderState == WebRtcLocalRenderState.EXPANDED) largeSize.height else smallSize.height)
LocalParticipantRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState,
modifier = modifier
.padding(16.dp)
.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,
@ -304,8 +394,14 @@ private fun SmallMoveableLocalVideoRenderer(
extraPadBottom: Dp,
onClick: () -> Unit
) {
val smallSize = DpSize(90.dp, 160.dp)
val largeSize = DpSize(180.dp, 320.dp)
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 largeSize = remember(isPortrait) {
if (isPortrait) DpSize(180.dp, 320.dp) else DpSize(320.dp, 180.dp)
}
val size = if (localRenderState == WebRtcLocalRenderState.SMALL_RECTANGLE) smallSize else largeSize
@ -321,19 +417,23 @@ private fun SmallMoveableLocalVideoRenderer(
.statusBarsPadding()
.padding(bottom = bottomPadding)
) {
CallParticipantVideoRenderer(
callParticipant = localParticipant,
attachVideoSink = localRenderState == WebRtcLocalRenderState.SMALL_RECTANGLE || localRenderState == WebRtcLocalRenderState.EXPANDED,
LocalParticipantRenderer(
localParticipant = localParticipant,
localRenderState = localRenderState,
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.medium)
.clickable {
.clickable(onClick = {
onClick()
}
})
)
}
}
/**
* 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.
*/
@Composable
private fun AnimatedCallStateUpdate(
callControlsChange: CallControlsChange?,
@ -366,7 +466,7 @@ private fun AnimatedCallStateUpdate(
private fun CallScreenPreview() {
Previews.Preview {
CallScreen(
callRecipient = Recipient.UNKNOWN,
callRecipient = Recipient(systemContactName = "Test User"),
webRtcCallState = WebRtcViewModel.State.CALL_CONNECTED,
callScreenState = CallScreenState(),
callControlsState = CallControlsState(
@ -376,14 +476,15 @@ private fun CallScreenPreview() {
displayGroupRingingToggle = true,
displayStartCallButton = true
),
callParticipantsPagerState = CallParticipantsPagerState(),
localParticipant = CallParticipant(),
localRenderState = WebRtcLocalRenderState.LARGE,
callInfoView = {
Text(text = "Call Info View Preview", modifier = Modifier.alpha(it))
},
localParticipant = CallParticipant(),
localRenderState = WebRtcLocalRenderState.LARGE,
callParticipantsPagerState = CallParticipantsPagerState(),
onNavigationClick = {},
onLocalPictureInPictureClicked = {}
onLocalPictureInPictureClicked = {},
overflowParticipants = (1..5).map { CallParticipant() }
)
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.viewinterop.AndroidView
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable
import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.compose.GlideImage
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.webrtc.RendererCommon
/**
* Displays video for the local participant or an appropriate avatar.
*/
@Composable
fun LocalParticipantRenderer(
localParticipant: CallParticipant,
localRenderState: WebRtcLocalRenderState,
modifier: Modifier = Modifier
) {
BoxWithConstraints(
modifier = modifier
) {
val maxWidth = constraints.maxWidth
val maxHeight = constraints.maxHeight
val density = LocalDensity.current
val size = with(density) {
DpSize(
width = maxWidth.toDp(),
height = maxHeight.toDp()
)
}
val model = remember {
ProfileContactPhoto(Recipient.self())
}
val context = LocalContext.current
val fallback = remember {
FallbackAvatarDrawable(context, Recipient.self().getFallbackAvatar())
}
GlideImage(
model = model,
imageSize = size,
fallback = fallback,
modifier = Modifier.fillMaxSize()
)
if (localParticipant.isVideoEnabled) {
AndroidView(
factory = ::TextureViewRenderer,
modifier = Modifier.fillMaxSize(),
onRelease = { it.release() }
) { renderer ->
renderer.setMirror(localParticipant.cameraDirection == CameraState.Direction.FRONT)
renderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
localParticipant.videoSink.lockableEglBase.performWithValidEglBase {
renderer.init(it)
}
renderer.attachBroadcastVideoSink(localParticipant.videoSink)
}
}
}
}

View file

@ -0,0 +1,94 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.compose
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.DisposableEffectResult
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
/**
* Our very own GlideImage.
*/
@Composable
fun <T> GlideImage(
model: T?,
modifier: Modifier = Modifier,
imageSize: DpSize? = null,
fallback: Drawable? = null,
error: Drawable? = fallback,
diskCacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL
) {
var bitmap by remember {
mutableStateOf<ImageBitmap?>(null)
}
val target = remember {
object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
bitmap = resource.asImageBitmap()
}
override fun onLoadCleared(placeholder: Drawable?) {
bitmap = null
}
}
}
val density = LocalDensity.current
val context = LocalContext.current
DisposableEffect(model, fallback, error, diskCacheStrategy, density, imageSize) {
val builder = Glide.with(context)
.asBitmap()
.load(model)
.fallback(fallback)
.error(error)
.diskCacheStrategy(diskCacheStrategy)
.fitCenter()
if (imageSize != null) {
with(density) {
builder.override(imageSize.width.toPx().toInt(), imageSize.height.toPx().toInt()).into(target)
}
} else {
builder.into(target)
}
object : DisposableEffectResult {
override fun dispose() {
Glide.with(context).clear(target)
bitmap = null
}
}
}
val bm = bitmap
if (bm != null) {
Image(
bitmap = bm,
contentDescription = null,
contentScale = if (model == null) ContentScale.Inside else ContentScale.Crop,
modifier = modifier
)
}
}

View file

@ -480,7 +480,7 @@ public class CommunicationActions {
}
private static Class<? extends Activity> getCallActivityClass() {
return RemoteConfig.useNewCallApi() ? CallActivity.class : WebRtcCallActivity.class;
return RemoteConfig.newCallUi() ? CallActivity.class : WebRtcCallActivity.class;
}
private interface CallContext {

View file

@ -1103,7 +1103,7 @@ object RemoteConfig {
)
@JvmStatic
@get:JvmName("useNewCallApi")
@get:JvmName("newCallUi")
val newCallUi: Boolean by remoteBoolean(
key = "android.newCallUi",
defaultValue = false,