diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java index 9b2fe9b857..fe03fb2c2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java @@ -15,12 +15,12 @@ import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.util.ViewUtil; import org.webrtc.RendererCommon; -class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter { +public class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter { 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 - 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) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt new file mode 100644 index 0000000000..4101b1b9ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsOverflow.kt @@ -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, + 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) + } + ) +} 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 2472bdf646..8e630be6e5 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 @@ -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, 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, 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() } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/LocalParticipantRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/LocalParticipantRenderer.kt new file mode 100644 index 0000000000..064babe832 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/LocalParticipantRenderer.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/compose/GlideImage.kt b/app/src/main/java/org/thoughtcrime/securesms/compose/GlideImage.kt new file mode 100644 index 0000000000..1cde8c96a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/compose/GlideImage.kt @@ -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 GlideImage( + model: T?, + modifier: Modifier = Modifier, + imageSize: DpSize? = null, + fallback: Drawable? = null, + error: Drawable? = fallback, + diskCacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL +) { + var bitmap by remember { + mutableStateOf(null) + } + + val target = remember { + object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 546d939856..df86473a63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -480,7 +480,7 @@ public class CommunicationActions { } private static Class getCallActivityClass() { - return RemoteConfig.useNewCallApi() ? CallActivity.class : WebRtcCallActivity.class; + return RemoteConfig.newCallUi() ? CallActivity.class : WebRtcCallActivity.class; } private interface CallContext { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index d9f9e39668..7f769d632e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1103,7 +1103,7 @@ object RemoteConfig { ) @JvmStatic - @get:JvmName("useNewCallApi") + @get:JvmName("newCallUi") val newCallUi: Boolean by remoteBoolean( key = "android.newCallUi", defaultValue = false,