Receive calling reactions support and control ux refactor.
Co-authored-by: Nicholas <nicholas@signal.org>
This commit is contained in:
parent
7ce2991b0f
commit
a678555d8d
36 changed files with 1852 additions and 747 deletions
|
@ -1,14 +1,13 @@
|
|||
package com.google.android.material.bottomsheet
|
||||
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Manually adjust the nested scrolling child for a given [BottomSheetBehavior].
|
||||
*/
|
||||
object BottomSheetBehaviorHack {
|
||||
fun setNestedScrollingChild(behavior: BottomSheetBehavior<FrameLayout>, view: View) {
|
||||
fun <T : View> setNestedScrollingChild(behavior: BottomSheetBehavior<T>, view: View) {
|
||||
behavior.nestedScrollingChildRef = WeakReference(view)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import android.media.AudioManager;
|
|||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Rational;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
|
||||
|
@ -53,8 +54,8 @@ import org.greenrobot.eventbus.ThreadMode;
|
|||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
|
||||
|
@ -73,6 +74,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
|||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController;
|
||||
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
|
||||
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
|
@ -152,6 +154,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
private PictureInPictureParams.Builder pipBuilderParams;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private long lastCallLinkDisconnectDialogShowTime;
|
||||
private ControlsAndInfoController controlsAndInfo;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
|
@ -161,7 +164,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
super.attachBaseContext(newBase);
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@SuppressLint({ "SourceLockedOrientationActivity", "MissingInflatedId" })
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
|
@ -189,6 +192,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
initializeViewModel(isLandscapeEnabled);
|
||||
initializePictureInPictureParams();
|
||||
|
||||
controlsAndInfo = new ControlsAndInfoController(callScreen, viewModel);
|
||||
controlsAndInfo.addVisibilityListener(new FadeCallback());
|
||||
|
||||
fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.webrtc_call_view_toolbar_text), findViewById(R.id.webrtc_call_view_toolbar_no_text));
|
||||
|
||||
lifecycleDisposable.add(controlsAndInfo);
|
||||
|
||||
logIntent(getIntent());
|
||||
|
||||
if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) {
|
||||
|
@ -431,7 +441,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
|
||||
viewModel.setIsInPipMode(isInPipMode());
|
||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
|
||||
viewModel.getWebRtcControls().observe(this, controls -> {
|
||||
callScreen.setWebRtcControls(controls);
|
||||
controlsAndInfo.updateControls(controls);
|
||||
});
|
||||
viewModel.getEvents().observe(this, this::handleViewModelEvent);
|
||||
|
||||
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
|
||||
|
@ -522,7 +535,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
.setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video)
|
||||
.setOnDismissListener(() -> viewModel.onDismissedVideoTooltip())
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
return;
|
||||
}
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) {
|
||||
if (videoTooltip != null) {
|
||||
|
@ -912,7 +924,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
|
||||
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
||||
if (event.getGroupState().isNotIdle()) {
|
||||
callScreen.setStatusFromGroupCallState(event.getGroupState());
|
||||
callScreen.setRingGroup(event.shouldRingGroup());
|
||||
|
||||
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
|
||||
|
@ -946,10 +957,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onControlsFadeOut() {
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
}
|
||||
public void toggleControls() {
|
||||
controlsAndInfo.toggleControls();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -1060,7 +1069,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
if (liveRecipient.get().isCallLink()) {
|
||||
CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId());
|
||||
} else {
|
||||
CallParticipantsListDialog.show(getSupportFragmentManager());
|
||||
controlsAndInfo.showCallInfo();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1124,4 +1133,20 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FadeCallback implements ControlsAndInfoController.BottomSheetVisibilityListener {
|
||||
|
||||
@Override
|
||||
public void onShown() {
|
||||
fullscreenHelper.showSystemUI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHidden() {
|
||||
fullscreenHelper.hideSystemUI();
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.thoughtcrime.securesms.animation;
|
||||
|
||||
import android.graphics.Point;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.Animation;
|
||||
|
@ -16,6 +17,10 @@ public class ResizeAnimation extends Animation {
|
|||
private int startWidth;
|
||||
private int startHeight;
|
||||
|
||||
public ResizeAnimation(@NonNull View target, @NonNull Point dimension) {
|
||||
this(target, dimension.x, dimension.y);
|
||||
}
|
||||
|
||||
public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) {
|
||||
this.target = target;
|
||||
this.targetWidthPx = targetWidthPx;
|
||||
|
|
|
@ -51,13 +51,14 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
|||
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
}
|
||||
|
||||
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
|
||||
protected val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
|
||||
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
|
||||
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
|
||||
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
|
||||
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
|
||||
|
||||
private val listeners: MutableList<KeyboardStateListener> = mutableListOf()
|
||||
private val windowInsetsListeners: MutableSet<WindowInsetsListener> = mutableSetOf()
|
||||
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
|
||||
private val keyboardAnimator = KeyboardInsetAnimator()
|
||||
private val displayMetrics = DisplayMetrics()
|
||||
private var overridingKeyboard: Boolean = false
|
||||
|
@ -82,20 +83,35 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
fun addKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
listeners += listener
|
||||
keyboardStateListeners += listener
|
||||
}
|
||||
|
||||
fun removeKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
listeners.remove(listener)
|
||||
keyboardStateListeners.remove(listener)
|
||||
}
|
||||
|
||||
fun addWindowInsetsListener(listener: WindowInsetsListener) {
|
||||
windowInsetsListeners += listener
|
||||
}
|
||||
|
||||
fun removeWindowInsetsListener(listener: WindowInsetsListener) {
|
||||
windowInsetsListeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
statusBarGuideline?.setGuidelineBegin(windowInsets.top)
|
||||
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom)
|
||||
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right)
|
||||
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left)
|
||||
val statusBar = windowInsets.top
|
||||
val navigationBar = windowInsets.bottom
|
||||
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
|
||||
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
|
||||
|
||||
statusBarGuideline?.setGuidelineBegin(statusBar)
|
||||
navigationBarGuideline?.setGuidelineEnd(navigationBar)
|
||||
parentStartGuideline?.setGuidelineBegin(parentStart)
|
||||
parentEndGuideline?.setGuidelineEnd(parentEnd)
|
||||
|
||||
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
|
||||
|
||||
if (keyboardInsets.bottom > 0) {
|
||||
setKeyboardHeight(keyboardInsets.bottom)
|
||||
|
@ -113,7 +129,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
if (previousKeyboardHeight != keyboardInsets.bottom) {
|
||||
listeners.forEach {
|
||||
keyboardStateListeners.forEach {
|
||||
if (previousKeyboardHeight <= 0) {
|
||||
it.onKeyboardShown()
|
||||
} else {
|
||||
|
@ -191,6 +207,10 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
|||
fun onKeyboardHidden()
|
||||
}
|
||||
|
||||
interface WindowInsetsListener {
|
||||
fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.recyclerview
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* Ignores all touch events, purely for rendering views in a recyclable manner.
|
||||
*/
|
||||
class NoTouchingRecyclerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : RecyclerView(context, attrs, defStyleAttr) {
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(e: MotionEvent?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -51,6 +51,7 @@ import androidx.lifecycle.toLiveData
|
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.signal.core.ui.BottomSheets
|
||||
|
@ -121,10 +122,6 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
|
|||
@Composable
|
||||
override fun SheetContent() {
|
||||
val callLinkDetailsState by callLinkDetailsViewModel.state
|
||||
val callParticipantsState by webRtcCallViewModel.callParticipantsState
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.toLiveData()
|
||||
.observeAsState()
|
||||
|
||||
val participants: List<CallParticipant> by webRtcCallViewModel.callParticipantsState
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
|
@ -332,7 +329,7 @@ private fun CallLinkMemberRow(
|
|||
.fillMaxWidth()
|
||||
.padding(Rows.defaultPadding())
|
||||
) {
|
||||
val recipient by Recipient.observable(callParticipant.recipient.id)
|
||||
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id)))
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.toLiveData()
|
||||
.observeAsState(initial = callParticipant.recipient)
|
||||
|
|
|
@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.R
|
|||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal
|
||||
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
@ -28,12 +29,14 @@ data class CallParticipantsState(
|
|||
val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false),
|
||||
val focusedParticipant: CallParticipant = CallParticipant.EMPTY,
|
||||
val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE,
|
||||
val reactions: List<GroupCallReactionEvent> = emptyList(),
|
||||
val isInPipMode: Boolean = false,
|
||||
private val showVideoForOutgoing: Boolean = false,
|
||||
val isViewingFocusedParticipant: Boolean = false,
|
||||
val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
|
||||
private val foldableState: FoldableState = FoldableState.flat(),
|
||||
val isInOutgoingRingingMode: Boolean = false,
|
||||
val recipient: Recipient = Recipient.UNKNOWN,
|
||||
val ringGroup: Boolean = false,
|
||||
val ringerRecipient: Recipient = Recipient.UNKNOWN,
|
||||
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(),
|
||||
|
@ -223,6 +226,7 @@ data class CallParticipantsState(
|
|||
focusedParticipant = getFocusedParticipant(webRtcViewModel.remoteParticipants),
|
||||
localRenderState = localRenderState,
|
||||
showVideoForOutgoing = newShowVideoForOutgoing,
|
||||
recipient = webRtcViewModel.recipient,
|
||||
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
|
||||
ringGroup = webRtcViewModel.ringGroup,
|
||||
isInOutgoingRingingMode = isInOutgoingRingingMode,
|
||||
|
@ -269,7 +273,8 @@ data class CallParticipantsState(
|
|||
return oldState.copy(
|
||||
remoteParticipants = oldState.remoteParticipants.map { p -> p.copy(audioLevel = ephemeralState.remoteAudioLevels[p.callParticipantId]) },
|
||||
localParticipant = oldState.localParticipant.copy(audioLevel = ephemeralState.localAudioLevel),
|
||||
focusedParticipant = oldState.focusedParticipant.copy(audioLevel = ephemeralState.remoteAudioLevels[oldState.focusedParticipant.callParticipantId])
|
||||
focusedParticipant = oldState.focusedParticipant.copy(audioLevel = ephemeralState.remoteAudioLevels[oldState.focusedParticipant.callParticipantId]),
|
||||
reactions = ephemeralState.getUnexpiredReactions()
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -287,7 +292,7 @@ data class CallParticipantsState(
|
|||
val displayLocal: Boolean = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled)
|
||||
var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE
|
||||
|
||||
if (!isInPip && isExpanded && (localParticipant.isVideoEnabled || isNonIdleGroupCall)) {
|
||||
if (!isInPip && isExpanded && localParticipant.isVideoEnabled) {
|
||||
return WebRtcLocalRenderState.EXPANDED
|
||||
} else if (displayLocal || showVideoForOutgoing) {
|
||||
if (callState == WebRtcViewModel.State.CALL_CONNECTED || callState == WebRtcViewModel.State.CALL_RECONNECTING) {
|
||||
|
|
|
@ -84,7 +84,7 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
|
|||
|
||||
measureChild()
|
||||
|
||||
val anchor: View = ViewCompat.requireViewById(parent, R.id.call_screen_footer_gradient_barrier)
|
||||
val anchor: View = ViewCompat.requireViewById(parent, R.id.call_screen_above_controls_guideline)
|
||||
val pill: View = ViewCompat.requireViewById(contentView, R.id.call_state_pill)
|
||||
|
||||
// 54 is the top margin of the contentView (30) plus the desired padding (24)
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/** Constraints to apply for different call sizes */
|
||||
enum class LayoutPositions(
|
||||
@JvmField @IdRes val participantBottomViewId: Int,
|
||||
@JvmField @Dimension val participantBottomMargin: Int,
|
||||
@JvmField @IdRes val reactionBottomViewId: Int,
|
||||
@JvmField @Dimension val reactionBottomMargin: Int
|
||||
) {
|
||||
/** 1:1 or small calls anchor full screen or controls */
|
||||
SMALL_GROUP(
|
||||
participantBottomViewId = ConstraintSet.PARENT_ID,
|
||||
participantBottomMargin = 0,
|
||||
reactionBottomViewId = R.id.call_screen_above_controls_guideline,
|
||||
reactionBottomMargin = 8
|
||||
),
|
||||
|
||||
/** Large calls have a participant rail to anchor to */
|
||||
LARGE_GROUP(
|
||||
participantBottomViewId = R.id.call_screen_participants_recycler,
|
||||
participantBottomMargin = 16,
|
||||
reactionBottomViewId = R.id.call_screen_participants_recycler,
|
||||
reactionBottomMargin = 20
|
||||
);
|
||||
|
||||
@JvmField
|
||||
val participantBottomViewEndSide: Int = if (participantBottomViewId == ConstraintSet.PARENT_ID) ConstraintSet.BOTTOM else ConstraintSet.TOP
|
||||
}
|
|
@ -1,18 +1,38 @@
|
|||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.graphics.Point;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.Animation;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.animation.ResizeAnimation;
|
||||
import org.thoughtcrime.securesms.mediasend.SimpleAnimationListener;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* Helps manage the expansion and shrinking of the in-app pip.
|
||||
*/
|
||||
@MainThread
|
||||
final class PictureInPictureExpansionHelper {
|
||||
|
||||
private static final int PIP_RESIZE_DURATION_MS = 300;
|
||||
private static final int EXPANDED_PIP_WIDTH_DP = 170;
|
||||
private static final int EXPANDED_PIP_HEIGHT_DP = 300;
|
||||
|
||||
private final View selfPip;
|
||||
private final Point expandedDimensions;
|
||||
|
||||
private State state = State.IS_SHRUNKEN;
|
||||
private Point defaultDimensions;
|
||||
|
||||
public PictureInPictureExpansionHelper(@NonNull View selfPip) {
|
||||
this.selfPip = selfPip;
|
||||
this.defaultDimensions = new Point(selfPip.getLayoutParams().width, selfPip.getLayoutParams().height);
|
||||
this.expandedDimensions = new Point(ViewUtil.dpToPx(EXPANDED_PIP_WIDTH_DP), ViewUtil.dpToPx(EXPANDED_PIP_HEIGHT_DP));
|
||||
}
|
||||
|
||||
public boolean isExpandedOrExpanding() {
|
||||
return state == State.IS_EXPANDED || state == State.IS_EXPANDING;
|
||||
|
@ -22,144 +42,79 @@ final class PictureInPictureExpansionHelper {
|
|||
return state == State.IS_SHRUNKEN || state == State.IS_SHRINKING;
|
||||
}
|
||||
|
||||
public void expand(@NonNull View toExpand, @NonNull Callback callback) {
|
||||
public void setDefaultSize(@NonNull Point dimensions, @NonNull Callback callback) {
|
||||
if (defaultDimensions.equals(dimensions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultDimensions = dimensions;
|
||||
|
||||
if (isExpandedOrExpanding()) {
|
||||
return;
|
||||
}
|
||||
|
||||
performExpandAnimation(toExpand, new Callback() {
|
||||
ViewGroup.LayoutParams layoutParams = selfPip.getLayoutParams();
|
||||
if (layoutParams.width == defaultDimensions.x && layoutParams.height == defaultDimensions.y) {
|
||||
callback.onAnimationHasFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
resizeSelfPip(defaultDimensions, callback);
|
||||
}
|
||||
|
||||
public void expand() {
|
||||
if (isExpandedOrExpanding()) {
|
||||
return;
|
||||
}
|
||||
|
||||
resizeSelfPip(expandedDimensions, new Callback() {
|
||||
@Override
|
||||
public void onAnimationWillStart() {
|
||||
state = State.IS_EXPANDING;
|
||||
callback.onAnimationWillStart();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureExpanded() {
|
||||
callback.onPictureInPictureExpanded();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureNotVisible() {
|
||||
callback.onPictureInPictureNotVisible();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationHasFinished() {
|
||||
state = State.IS_EXPANDED;
|
||||
callback.onAnimationHasFinished();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void shrink(@NonNull View toExpand, @NonNull Callback callback) {
|
||||
public void shrink() {
|
||||
if (isShrunkenOrShrinking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
performShrinkAnimation(toExpand, new Callback() {
|
||||
resizeSelfPip(defaultDimensions, new Callback() {
|
||||
@Override
|
||||
public void onAnimationWillStart() {
|
||||
state = State.IS_SHRINKING;
|
||||
callback.onAnimationWillStart();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureExpanded() {
|
||||
callback.onPictureInPictureExpanded();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureNotVisible() {
|
||||
callback.onPictureInPictureNotVisible();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationHasFinished() {
|
||||
state = State.IS_SHRUNKEN;
|
||||
callback.onAnimationHasFinished();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void performExpandAnimation(@NonNull View target, @NonNull Callback callback) {
|
||||
ViewGroup parent = (ViewGroup) target.getParent();
|
||||
private void resizeSelfPip(@NonNull Point dimension, @NonNull Callback callback) {
|
||||
ResizeAnimation resizeAnimation = new ResizeAnimation(selfPip, dimension);
|
||||
resizeAnimation.setDuration(PIP_RESIZE_DURATION_MS);
|
||||
resizeAnimation.setAnimationListener(new SimpleAnimationListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animation animation) {
|
||||
callback.onAnimationWillStart();
|
||||
}
|
||||
|
||||
float x = target.getX();
|
||||
float y = target.getY();
|
||||
float scaleX = parent.getMeasuredWidth() / (float) target.getMeasuredWidth();
|
||||
float scaleY = parent.getMeasuredHeight() / (float) target.getMeasuredHeight();
|
||||
float scale = Math.max(scaleX, scaleY);
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
callback.onAnimationHasFinished();
|
||||
}
|
||||
});
|
||||
|
||||
callback.onAnimationWillStart();
|
||||
|
||||
target.animate()
|
||||
.setDuration(200)
|
||||
.x((parent.getMeasuredWidth() - target.getMeasuredWidth()) / 2f)
|
||||
.y((parent.getMeasuredHeight() - target.getMeasuredHeight()) / 2f)
|
||||
.scaleX(scale)
|
||||
.scaleY(scale)
|
||||
.withEndAction(() -> {
|
||||
callback.onPictureInPictureExpanded();
|
||||
target.animate()
|
||||
.setDuration(100)
|
||||
.alpha(0f)
|
||||
.withEndAction(() -> {
|
||||
callback.onPictureInPictureNotVisible();
|
||||
|
||||
target.setX(x);
|
||||
target.setY(y);
|
||||
target.setScaleX(0f);
|
||||
target.setScaleY(0f);
|
||||
target.setAlpha(1f);
|
||||
|
||||
target.animate()
|
||||
.setDuration(200)
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.withEndAction(callback::onAnimationHasFinished);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void performShrinkAnimation(@NonNull View target, @NonNull Callback callback) {
|
||||
ViewGroup parent = (ViewGroup) target.getParent();
|
||||
|
||||
float x = target.getX();
|
||||
float y = target.getY();
|
||||
float scaleX = parent.getMeasuredWidth() / (float) target.getMeasuredWidth();
|
||||
float scaleY = parent.getMeasuredHeight() / (float) target.getMeasuredHeight();
|
||||
float scale = Math.max(scaleX, scaleY);
|
||||
|
||||
callback.onAnimationWillStart();
|
||||
|
||||
target.animate()
|
||||
.setDuration(200)
|
||||
.scaleX(0f)
|
||||
.scaleY(0f)
|
||||
.withEndAction(() -> {
|
||||
target.setX((parent.getMeasuredWidth() - target.getMeasuredWidth()) / 2f);
|
||||
target.setY((parent.getMeasuredHeight() - target.getMeasuredHeight()) / 2f);
|
||||
target.setAlpha(0f);
|
||||
target.setScaleX(scale);
|
||||
target.setScaleY(scale);
|
||||
|
||||
callback.onPictureInPictureNotVisible();
|
||||
|
||||
target.animate()
|
||||
.setDuration(100)
|
||||
.alpha(1f)
|
||||
.withEndAction(() -> {
|
||||
callback.onPictureInPictureExpanded();
|
||||
|
||||
target.animate()
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.x(x)
|
||||
.y(y)
|
||||
.withEndAction(callback::onAnimationHasFinished);
|
||||
});
|
||||
});
|
||||
selfPip.clearAnimation();
|
||||
selfPip.startAnimation(resizeAnimation);
|
||||
}
|
||||
|
||||
enum State {
|
||||
|
@ -174,24 +129,12 @@ final class PictureInPictureExpansionHelper {
|
|||
* Called when an animation (shrink or expand) will begin. This happens before any animation
|
||||
* is executed.
|
||||
*/
|
||||
void onAnimationWillStart();
|
||||
|
||||
/**
|
||||
* Called when the PiP is covering the whole screen. This is when any staging / teardown of the
|
||||
* large local renderer should occur.
|
||||
*/
|
||||
void onPictureInPictureExpanded();
|
||||
|
||||
/**
|
||||
* Called when the PiP is not visible on the screen anymore. This is when any staging / teardown
|
||||
* of the pip should occur.
|
||||
*/
|
||||
void onPictureInPictureNotVisible();
|
||||
default void onAnimationWillStart() {}
|
||||
|
||||
/**
|
||||
* Called when the animation is complete. Useful for e.g. adjusting the pip's final location to
|
||||
* make sure it is respecting the screen space available.
|
||||
*/
|
||||
void onAnimationHasFinished();
|
||||
default void onAnimationHasFinished() {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.graphics.Point;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.Gravity;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.VelocityTracker;
|
||||
import android.view.View;
|
||||
|
@ -11,19 +11,16 @@ import android.view.ViewConfiguration;
|
|||
import android.view.ViewGroup;
|
||||
import android.view.animation.AccelerateDecelerateInterpolator;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.view.GestureDetectorCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Queue;
|
||||
|
||||
public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener {
|
||||
|
||||
|
@ -31,18 +28,15 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
private static final Interpolator FLING_INTERPOLATOR = new ViscousFluidInterpolator();
|
||||
private static final Interpolator ADJUST_INTERPOLATOR = new AccelerateDecelerateInterpolator();
|
||||
|
||||
private final ViewGroup parent;
|
||||
private final View child;
|
||||
private final int framePadding;
|
||||
private final Queue<Runnable> runAfterFling;
|
||||
private final ViewGroup parent;
|
||||
private final View child;
|
||||
private final int framePadding;
|
||||
|
||||
private int pipWidth;
|
||||
private int pipHeight;
|
||||
private int activePointerId = MotionEvent.INVALID_POINTER_ID;
|
||||
private int activePointerId = MotionEvent.INVALID_POINTER_ID;
|
||||
private float lastTouchX;
|
||||
private float lastTouchY;
|
||||
private boolean isDragging;
|
||||
private boolean isAnimating;
|
||||
private int extraPaddingTop;
|
||||
private int extraPaddingBottom;
|
||||
private double projectionX;
|
||||
|
@ -51,6 +45,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
private int maximumFlingVelocity;
|
||||
private boolean isLockedToBottomEnd;
|
||||
private Interpolator interpolator;
|
||||
private Corner currentCornerPosition = Corner.BOTTOM_RIGHT;
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
public static PictureInPictureGestureHelper applyTo(@NonNull View child) {
|
||||
|
@ -111,7 +106,6 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
this.pipWidth = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_width);
|
||||
this.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height);
|
||||
this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity();
|
||||
this.runAfterFling = new LinkedList<>();
|
||||
this.interpolator = ADJUST_INTERPOLATOR;
|
||||
}
|
||||
|
||||
|
@ -122,10 +116,18 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
|
||||
public void setTopVerticalBoundary(int topBoundary) {
|
||||
extraPaddingTop = topBoundary - parent.getTop();
|
||||
|
||||
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) child.getLayoutParams();
|
||||
layoutParams.setMargins(layoutParams.leftMargin, extraPaddingTop + framePadding, layoutParams.rightMargin, layoutParams.bottomMargin);
|
||||
child.setLayoutParams(layoutParams);
|
||||
}
|
||||
|
||||
public void setBottomVerticalBoundary(int bottomBoundary) {
|
||||
extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary;
|
||||
|
||||
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) child.getLayoutParams();
|
||||
layoutParams.setMargins(layoutParams.leftMargin, layoutParams.topMargin, layoutParams.rightMargin, extraPaddingBottom + framePadding);
|
||||
child.setLayoutParams(layoutParams);
|
||||
}
|
||||
|
||||
private boolean onGestureFinished(MotionEvent e) {
|
||||
|
@ -139,43 +141,20 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
return false;
|
||||
}
|
||||
|
||||
public void adjustPip() {
|
||||
pipWidth = child.getMeasuredWidth();
|
||||
pipHeight = child.getMeasuredHeight();
|
||||
|
||||
if (isAnimating) {
|
||||
interpolator = ADJUST_INTERPOLATOR;
|
||||
|
||||
fling();
|
||||
} else if (!isDragging) {
|
||||
interpolator = ADJUST_INTERPOLATOR;
|
||||
|
||||
onFling(null, null, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void lockToBottomEnd() {
|
||||
isLockedToBottomEnd = true;
|
||||
fling();
|
||||
}
|
||||
|
||||
public void enableCorners() {
|
||||
isLockedToBottomEnd = false;
|
||||
}
|
||||
|
||||
public void performAfterFling(@NonNull Runnable runnable) {
|
||||
if (isAnimating) {
|
||||
runAfterFling.add(runnable);
|
||||
} else {
|
||||
runnable.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e) {
|
||||
activePointerId = e.getPointerId(0);
|
||||
lastTouchX = e.getX(0) + child.getX();
|
||||
lastTouchY = e.getY(0) + child.getY();
|
||||
isDragging = true;
|
||||
pipWidth = child.getMeasuredWidth();
|
||||
pipHeight = child.getMeasuredHeight();
|
||||
interpolator = FLING_INTERPOLATOR;
|
||||
|
@ -185,6 +164,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
|
||||
@Override
|
||||
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
|
||||
if (isLockedToBottomEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int pointerIndex = e2.findPointerIndex(activePointerId);
|
||||
|
||||
if (pointerIndex == -1) {
|
||||
|
@ -192,10 +175,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
return false;
|
||||
}
|
||||
|
||||
float x = e2.getX(pointerIndex) + child.getX();
|
||||
float y = e2.getY(pointerIndex) + child.getY();
|
||||
float dx = x - lastTouchX;
|
||||
float dy = y - lastTouchY;
|
||||
float x = e2.getX(pointerIndex) + child.getX();
|
||||
float y = e2.getY(pointerIndex) + child.getY();
|
||||
float dx = x - lastTouchX;
|
||||
float dy = y - lastTouchY;
|
||||
|
||||
child.setTranslationX(child.getTranslationX() + dx);
|
||||
child.setTranslationY(child.getTranslationY() + dy);
|
||||
|
@ -208,6 +191,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
|
||||
@Override
|
||||
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
|
||||
if (isLockedToBottomEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (velocityTracker != null) {
|
||||
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
|
||||
|
||||
|
@ -225,92 +212,75 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e) {
|
||||
isDragging = false;
|
||||
|
||||
child.performClick();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void fling() {
|
||||
Point projection = new Point((int) projectionX, (int) projectionY);
|
||||
Point nearestCornerPosition = findNearestCornerPosition(projection);
|
||||
Corner nearestCornerPosition = findNearestCornerPosition(projection);
|
||||
|
||||
isAnimating = true;
|
||||
isDragging = false;
|
||||
FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) child.getLayoutParams();
|
||||
layoutParams.gravity = nearestCornerPosition.gravity;
|
||||
|
||||
if (currentCornerPosition != null && currentCornerPosition != nearestCornerPosition) {
|
||||
adjustTranslationFrameOfReference(child, currentCornerPosition, nearestCornerPosition);
|
||||
}
|
||||
currentCornerPosition = nearestCornerPosition;
|
||||
|
||||
child.setLayoutParams(layoutParams);
|
||||
|
||||
child.animate()
|
||||
.translationX(getTranslationXForPoint(nearestCornerPosition))
|
||||
.translationY(getTranslationYForPoint(nearestCornerPosition))
|
||||
.translationX(0)
|
||||
.translationY(0)
|
||||
.setDuration(250)
|
||||
.setInterpolator(interpolator)
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
isAnimating = false;
|
||||
|
||||
Iterator<Runnable> afterFlingRunnables = runAfterFling.iterator();
|
||||
while (afterFlingRunnables.hasNext()) {
|
||||
Runnable runnable = afterFlingRunnables.next();
|
||||
|
||||
runnable.run();
|
||||
afterFlingRunnables.remove();
|
||||
}
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
private Point findNearestCornerPosition(Point projection) {
|
||||
private Corner findNearestCornerPosition(Point projection) {
|
||||
if (isLockedToBottomEnd) {
|
||||
return ViewUtil.isLtr(parent) ? calculateBottomRightCoordinates(parent)
|
||||
: calculateBottomLeftCoordinates(parent);
|
||||
return ViewUtil.isLtr(parent) ? Corner.BOTTOM_RIGHT
|
||||
: Corner.BOTTOM_LEFT;
|
||||
}
|
||||
|
||||
Point maxPoint = null;
|
||||
double maxDistance = Double.MAX_VALUE;
|
||||
CornerPoint maxPoint = null;
|
||||
double maxDistance = Double.MAX_VALUE;
|
||||
|
||||
for (Point point : Arrays.asList(calculateTopLeftCoordinates(),
|
||||
calculateTopRightCoordinates(parent),
|
||||
calculateBottomLeftCoordinates(parent),
|
||||
calculateBottomRightCoordinates(parent)))
|
||||
{
|
||||
double distance = distance(point, projection);
|
||||
for (CornerPoint cornerPoint : Arrays.asList(calculateTopLeftCoordinates(),
|
||||
calculateTopRightCoordinates(parent),
|
||||
calculateBottomLeftCoordinates(parent),
|
||||
calculateBottomRightCoordinates(parent))) {
|
||||
double distance = distance(cornerPoint.point, projection);
|
||||
|
||||
if (distance < maxDistance) {
|
||||
maxDistance = distance;
|
||||
maxPoint = point;
|
||||
maxPoint = cornerPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return maxPoint;
|
||||
//noinspection DataFlowIssue
|
||||
return maxPoint.corner;
|
||||
}
|
||||
|
||||
private float getTranslationXForPoint(Point destination) {
|
||||
return destination.x - child.getLeft();
|
||||
private CornerPoint calculateTopLeftCoordinates() {
|
||||
return new CornerPoint(new Point(framePadding, framePadding + extraPaddingTop),
|
||||
Corner.TOP_LEFT);
|
||||
}
|
||||
|
||||
private float getTranslationYForPoint(Point destination) {
|
||||
return destination.y - child.getTop();
|
||||
private CornerPoint calculateTopRightCoordinates(@NonNull ViewGroup parent) {
|
||||
return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, framePadding + extraPaddingTop),
|
||||
Corner.TOP_RIGHT);
|
||||
}
|
||||
|
||||
private Point calculateTopLeftCoordinates() {
|
||||
return new Point(framePadding,
|
||||
framePadding + extraPaddingTop);
|
||||
private CornerPoint calculateBottomLeftCoordinates(@NonNull ViewGroup parent) {
|
||||
return new CornerPoint(new Point(framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom),
|
||||
Corner.BOTTOM_LEFT);
|
||||
}
|
||||
|
||||
private Point calculateTopRightCoordinates(@NonNull ViewGroup parent) {
|
||||
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding,
|
||||
framePadding + extraPaddingTop);
|
||||
}
|
||||
|
||||
private Point calculateBottomLeftCoordinates(@NonNull ViewGroup parent) {
|
||||
return new Point(framePadding,
|
||||
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
|
||||
}
|
||||
|
||||
private Point calculateBottomRightCoordinates(@NonNull ViewGroup parent) {
|
||||
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding,
|
||||
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
|
||||
private CornerPoint calculateBottomRightCoordinates(@NonNull ViewGroup parent) {
|
||||
return new CornerPoint(new Point(parent.getMeasuredWidth() - pipWidth - framePadding, parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom),
|
||||
Corner.BOTTOM_RIGHT);
|
||||
}
|
||||
|
||||
private static float project(float initialVelocity) {
|
||||
|
@ -321,9 +291,80 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
|
||||
}
|
||||
|
||||
/** Borrowed from ScrollView */
|
||||
|
||||
/**
|
||||
* User drag is implemented by translating the view from the current gravity anchor (corner). When the user drags
|
||||
* to a new corner, we need to adjust the translations for the new corner so the animation of translation X/Y to 0
|
||||
* works correctly.
|
||||
*
|
||||
* For example, if in bottom right and need to move to top right, we need to calculate a new translation Y since instead
|
||||
* of being translated up from bottom it's translated down from the top.
|
||||
*/
|
||||
private void adjustTranslationFrameOfReference(@NonNull View child, @NonNull Corner previous, @NonNull Corner next) {
|
||||
TouchInterceptingFrameLayout parent = (TouchInterceptingFrameLayout) child.getParent();
|
||||
FrameLayout.LayoutParams childLayoutParams = (FrameLayout.LayoutParams) child.getLayoutParams();
|
||||
int parentWidth = parent.getWidth();
|
||||
int parentHeight = parent.getHeight();
|
||||
|
||||
if (previous.topHalf != next.topHalf) {
|
||||
int childHeight = childLayoutParams.height + childLayoutParams.topMargin + childLayoutParams.bottomMargin;
|
||||
|
||||
float adjustedTranslationY;
|
||||
if (previous.topHalf) {
|
||||
adjustedTranslationY = -(parentHeight - child.getTranslationY() - childHeight);
|
||||
} else {
|
||||
adjustedTranslationY = parentHeight + child.getTranslationY() - childHeight;
|
||||
}
|
||||
child.setTranslationY(adjustedTranslationY);
|
||||
}
|
||||
|
||||
if (previous.leftSide != next.leftSide) {
|
||||
int childWidth = childLayoutParams.width + childLayoutParams.leftMargin + childLayoutParams.rightMargin;
|
||||
|
||||
float adjustedTranslationX;
|
||||
if (previous.leftSide) {
|
||||
adjustedTranslationX = -(parentWidth - child.getTranslationX() - childWidth);
|
||||
} else {
|
||||
adjustedTranslationX = parentWidth + child.getTranslationX() - childWidth;
|
||||
}
|
||||
child.setTranslationX(adjustedTranslationX);
|
||||
}
|
||||
}
|
||||
|
||||
private static class CornerPoint {
|
||||
final Point point;
|
||||
final Corner corner;
|
||||
|
||||
public CornerPoint(@NonNull Point point, @NonNull Corner corner) {
|
||||
this.point = point;
|
||||
this.corner = corner;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Corner {
|
||||
TOP_LEFT(Gravity.TOP | Gravity.START, true, true),
|
||||
TOP_RIGHT(Gravity.TOP | Gravity.END, false, true),
|
||||
BOTTOM_LEFT(Gravity.BOTTOM | Gravity.START, true, false),
|
||||
BOTTOM_RIGHT(Gravity.BOTTOM | Gravity.END, false, false);
|
||||
|
||||
final int gravity;
|
||||
final boolean leftSide;
|
||||
final boolean topHalf;
|
||||
|
||||
Corner(int gravity, boolean leftSide, boolean topHalf) {
|
||||
this.gravity = gravity;
|
||||
this.leftSide = leftSide;
|
||||
this.topHalf = topHalf;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Borrowed from ScrollView
|
||||
*/
|
||||
private static class ViscousFluidInterpolator implements Interpolator {
|
||||
/** Controls the viscous fluid effect (how much of it). */
|
||||
/**
|
||||
* Controls the viscous fluid effect (how much of it).
|
||||
*/
|
||||
private static final float VISCOUS_FLUID_SCALE = 8.0f;
|
||||
|
||||
private static final float VISCOUS_FLUID_NORMALIZE;
|
||||
|
@ -340,10 +381,10 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
|
|||
private static float viscousFluid(float x) {
|
||||
x *= VISCOUS_FLUID_SCALE;
|
||||
if (x < 1.0f) {
|
||||
x -= (1.0f - (float)Math.exp(-x));
|
||||
x -= (1.0f - (float) Math.exp(-x));
|
||||
} else {
|
||||
float start = 0.36787944117f; // 1/e == exp(-1)
|
||||
x = 1.0f - (float)Math.exp(1.0f - x);
|
||||
x = 1.0f - (float) Math.exp(1.0f - x);
|
||||
x = start + x * (1.0f - start);
|
||||
}
|
||||
return x;
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.ColorMatrix;
|
||||
import android.graphics.ColorMatrixColorFilter;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.animation.Animation;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
@ -25,14 +28,9 @@ import androidx.constraintlayout.widget.ConstraintLayout;
|
|||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.constraintlayout.widget.Guideline;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.transition.AutoTransition;
|
||||
import androidx.transition.Transition;
|
||||
import androidx.transition.TransitionManager;
|
||||
import androidx.transition.TransitionSet;
|
||||
import androidx.viewpager2.widget.MarginPageTransformer;
|
||||
import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
|
@ -46,19 +44,19 @@ import org.signal.core.util.SetUtil;
|
|||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.ResizeAnimation;
|
||||
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.events.CallParticipant;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.mediasend.SimpleAnimationListener;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState;
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection;
|
||||
import org.thoughtcrime.securesms.stories.viewer.reply.reaction.MultiReactionBurstLayout;
|
||||
import org.thoughtcrime.securesms.util.BlurTransformation;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
@ -72,7 +70,7 @@ import java.util.HashSet;
|
|||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class WebRtcCallView extends ConstraintLayout {
|
||||
public class WebRtcCallView extends InsetAwareConstraintLayout {
|
||||
|
||||
private static final String TAG = Log.tag(WebRtcCallView.class);
|
||||
|
||||
|
@ -80,10 +78,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
|
||||
private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16;
|
||||
|
||||
public static final int FADE_OUT_DELAY = 5000;
|
||||
public static final int PIP_RESIZE_DURATION = 300;
|
||||
public static final int CONTROLS_HEIGHT = 98;
|
||||
|
||||
private WebRtcAudioOutputToggleButton audioToggle;
|
||||
private AccessibleToggleButton videoToggle;
|
||||
private AccessibleToggleButton micToggle;
|
||||
|
@ -96,7 +90,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
private TextView recipientName;
|
||||
private TextView status;
|
||||
private TextView incomingRingStatus;
|
||||
private ConstraintLayout parent;
|
||||
private ConstraintLayout participantsParent;
|
||||
private ControlsListener controlsListener;
|
||||
private RecipientId recipientId;
|
||||
|
@ -117,23 +110,25 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
private Stub<FrameLayout> groupCallSpeakerHint;
|
||||
private Stub<View> groupCallFullStub;
|
||||
private View errorButton;
|
||||
private int pagerBottomMarginDp;
|
||||
private boolean controlsVisible = true;
|
||||
private Guideline showParticipantsGuideline;
|
||||
private Guideline topFoldGuideline;
|
||||
private Guideline callScreenTopFoldGuideline;
|
||||
private AvatarImageView largeHeaderAvatar;
|
||||
private Guideline statusBarGuideline;
|
||||
private Guideline navigationBarGuideline;
|
||||
private int navBarBottomInset;
|
||||
private View fullScreenShade;
|
||||
private Toolbar collapsedToolbar;
|
||||
private Toolbar headerToolbar;
|
||||
private Stub<PendingParticipantsView> pendingParticipantsViewStub;
|
||||
private Stub<View> callLinkWarningCard;
|
||||
private RecyclerView groupReactionsFeed;
|
||||
private MultiReactionBurstLayout reactionViews;
|
||||
private Guideline aboveControlsGuideline;
|
||||
|
||||
|
||||
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
|
||||
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
|
||||
private WebRtcReactionsRecyclerAdapter reactionsAdapter;
|
||||
private PictureInPictureExpansionHelper pictureInPictureExpansionHelper;
|
||||
private PendingParticipantsView.Listener pendingParticipantsViewListener;
|
||||
|
||||
|
@ -147,12 +142,10 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
|
||||
private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS);
|
||||
private WebRtcControls controls = WebRtcControls.NONE;
|
||||
private final Runnable fadeOutRunnable = () -> {
|
||||
if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls();
|
||||
};
|
||||
|
||||
private CallParticipantsViewState lastState;
|
||||
private ContactPhoto previousLocalAvatar;
|
||||
private LayoutPositions previousLayoutPositions = null;
|
||||
|
||||
public WebRtcCallView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
|
@ -181,7 +174,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
recipientName = findViewById(R.id.call_screen_recipient_name);
|
||||
status = findViewById(R.id.call_screen_status);
|
||||
incomingRingStatus = findViewById(R.id.call_screen_incoming_ring_status);
|
||||
parent = findViewById(R.id.call_screen);
|
||||
participantsParent = findViewById(R.id.call_screen_participants_parent);
|
||||
answer = findViewById(R.id.call_screen_answer_call);
|
||||
answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label);
|
||||
|
@ -203,30 +195,36 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
topFoldGuideline = findViewById(R.id.fold_top_guideline);
|
||||
callScreenTopFoldGuideline = findViewById(R.id.fold_top_call_screen_guideline);
|
||||
largeHeaderAvatar = findViewById(R.id.call_screen_header_avatar);
|
||||
statusBarGuideline = findViewById(R.id.call_screen_status_bar_guideline);
|
||||
navigationBarGuideline = findViewById(R.id.call_screen_navigation_bar_guideline);
|
||||
fullScreenShade = findViewById(R.id.call_screen_full_shade);
|
||||
collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text);
|
||||
headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text);
|
||||
pendingParticipantsViewStub = new Stub<>(findViewById(R.id.call_screen_pending_recipients));
|
||||
callLinkWarningCard = new Stub<>(findViewById(R.id.call_screen_call_link_warning));
|
||||
groupReactionsFeed = findViewById(R.id.call_screen_reactions_feed);
|
||||
reactionViews = findViewById(R.id.call_screen_reactions_container);
|
||||
aboveControlsGuideline = findViewById(R.id.call_screen_above_controls_guideline);
|
||||
|
||||
View decline = findViewById(R.id.call_screen_decline_call);
|
||||
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
|
||||
View declineLabel = findViewById(R.id.call_screen_decline_call_label);
|
||||
View decline = findViewById(R.id.call_screen_decline_call);
|
||||
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
|
||||
View declineLabel = findViewById(R.id.call_screen_decline_call_label);
|
||||
|
||||
callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4)));
|
||||
|
||||
pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls);
|
||||
recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter();
|
||||
pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls);
|
||||
recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter();
|
||||
reactionsAdapter = new WebRtcReactionsRecyclerAdapter();
|
||||
|
||||
callParticipantsPager.setAdapter(pagerAdapter);
|
||||
callParticipantsRecycler.setAdapter(recyclerAdapter);
|
||||
groupReactionsFeed.setAdapter(reactionsAdapter);
|
||||
|
||||
DefaultItemAnimator animator = new DefaultItemAnimator();
|
||||
animator.setSupportsChangeAnimations(false);
|
||||
callParticipantsRecycler.setItemAnimator(animator);
|
||||
|
||||
groupReactionsFeed.addItemDecoration(new WebRtcReactionsAlphaItemDecoration());
|
||||
groupReactionsFeed.setItemAnimator(new WebRtcReactionsItemAnimator());
|
||||
|
||||
callParticipantsPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
||||
@Override
|
||||
public void onPageSelected(int position) {
|
||||
|
@ -287,7 +285,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
|
||||
|
||||
pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame);
|
||||
pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper();
|
||||
pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(smallLocalRenderFrame);
|
||||
|
||||
smallLocalRenderFrame.setOnClickListener(v -> {
|
||||
if (controlsListener != null) {
|
||||
|
@ -360,25 +358,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
rotatableControls.add(ringToggle);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
|
||||
if (controls.isFadeOutEnabled()) {
|
||||
scheduleFadeOut();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean fitSystemWindows(Rect insets) {
|
||||
if (insets.top != 0) {
|
||||
statusBarGuideline.setGuidelineBegin(insets.top);
|
||||
}
|
||||
navigationBarGuideline.setGuidelineEnd(insets.bottom);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
|
||||
navBarBottomInset = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
|
||||
|
@ -398,19 +377,11 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getTop());
|
||||
} else {
|
||||
pictureInPictureGestureHelper.setTopVerticalBoundary(getPipBarrier().getBottom());
|
||||
pictureInPictureGestureHelper.setBottomVerticalBoundary(videoToggle.getTop());
|
||||
pictureInPictureGestureHelper.setBottomVerticalBoundary(findViewById(R.id.call_controls_info_parent).getTop());
|
||||
}
|
||||
} else {
|
||||
pictureInPictureGestureHelper.clearVerticalBoundaries();
|
||||
}
|
||||
|
||||
pictureInPictureGestureHelper.adjustPip();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
cancelFadeOut();
|
||||
}
|
||||
|
||||
public void rotateControls(int degrees) {
|
||||
|
@ -489,21 +460,23 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
|
||||
pagerAdapter.submitList(pages);
|
||||
recyclerAdapter.submitList(state.getListParticipants());
|
||||
reactionsAdapter.submitList(state.getReactions());
|
||||
|
||||
reactionViews.displayReactions(state.getReactions());
|
||||
|
||||
boolean displaySmallSelfPipInLandscape = !isPortrait && isLandscapeEnabled;
|
||||
|
||||
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), state.getFocusedParticipant(), displaySmallSelfPipInLandscape);
|
||||
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), displaySmallSelfPipInLandscape);
|
||||
|
||||
if (state.isLargeVideoGroup() && !state.isInPipMode() && !state.isFolded()) {
|
||||
layoutParticipantsForLargeCount();
|
||||
adjustLayoutForLargeCount();
|
||||
} else {
|
||||
layoutParticipantsForSmallCount();
|
||||
adjustLayoutForSmallCount();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state,
|
||||
@NonNull CallParticipant localCallParticipant,
|
||||
@NonNull CallParticipant focusedParticipant,
|
||||
boolean displaySmallSelfPipInLandscape)
|
||||
{
|
||||
largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
|
||||
|
@ -520,17 +493,19 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
smallLocalRender.setRenderInPip(true);
|
||||
|
||||
if (state == WebRtcLocalRenderState.EXPANDED) {
|
||||
expandPip(localCallParticipant, focusedParticipant);
|
||||
smallLocalRender.setCallParticipant(focusedParticipant);
|
||||
pictureInPictureExpansionHelper.expand();
|
||||
return;
|
||||
} else if ((state == WebRtcLocalRenderState.SMALL_RECTANGLE || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) {
|
||||
shrinkPip(localCallParticipant);
|
||||
return;
|
||||
} else {
|
||||
smallLocalRender.setCallParticipant(localCallParticipant);
|
||||
smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
|
||||
} else if ((state.isAnySmall() || state == WebRtcLocalRenderState.GONE) && pictureInPictureExpansionHelper.isExpandedOrExpanding()) {
|
||||
pictureInPictureExpansionHelper.shrink();
|
||||
|
||||
if (state != WebRtcLocalRenderState.GONE) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
smallLocalRender.setCallParticipant(localCallParticipant);
|
||||
smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
|
||||
|
||||
switch (state) {
|
||||
case GONE:
|
||||
largeLocalRender.attachBroadcastVideoSink(null);
|
||||
|
@ -674,7 +649,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
topFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
|
||||
callScreenTopFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
|
||||
} else {
|
||||
showParticipantsGuideline.setGuidelineBegin(((LayoutParams) statusBarGuideline.getLayoutParams()).guideBegin);
|
||||
showParticipantsGuideline.setGuidelineBegin(((LayoutParams) getStatusBarGuideline().getLayoutParams()).guideBegin);
|
||||
showParticipantsGuideline.setGuidelineEnd(-1);
|
||||
topFoldGuideline.setGuidelineEnd(0);
|
||||
callScreenTopFoldGuideline.setGuidelineEnd(0);
|
||||
|
@ -774,21 +749,9 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
visibleViewSet.add(ringToggle);
|
||||
}
|
||||
|
||||
|
||||
if (webRtcControls.isFadeOutEnabled()) {
|
||||
if (!controls.isFadeOutEnabled()) {
|
||||
scheduleFadeOut();
|
||||
}
|
||||
} else {
|
||||
cancelFadeOut();
|
||||
|
||||
if (controlsListener != null) {
|
||||
controlsListener.showSystemUI();
|
||||
}
|
||||
}
|
||||
|
||||
if (webRtcControls.adjustForFold() && webRtcControls.isFadeOutEnabled() && !controls.adjustForFold()) {
|
||||
scheduleFadeOut();
|
||||
if (webRtcControls.displayReactions()) {
|
||||
visibleViewSet.add(reactionViews);
|
||||
visibleViewSet.add(groupReactionsFeed);
|
||||
}
|
||||
|
||||
boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold();
|
||||
|
@ -806,11 +769,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
(!webRtcControls.showSmallHeader() && largeHeaderAvatar.getVisibility() == View.GONE) ||
|
||||
forceUpdate)
|
||||
{
|
||||
|
||||
if (controlsListener != null) {
|
||||
controlsListener.showSystemUI();
|
||||
}
|
||||
|
||||
throttledDebouncer.publish(() -> fadeInNewUiState(webRtcControls.displaySmallOngoingCallButtons(), webRtcControls.showSmallHeader()));
|
||||
}
|
||||
|
||||
|
@ -831,62 +789,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
}
|
||||
}
|
||||
|
||||
private void expandPip(@NonNull CallParticipant localCallParticipant, @NonNull CallParticipant focusedParticipant) {
|
||||
pictureInPictureExpansionHelper.expand(smallLocalRenderFrame, new PictureInPictureExpansionHelper.Callback() {
|
||||
@Override
|
||||
public void onAnimationWillStart() {
|
||||
largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureExpanded() {
|
||||
largeLocalRenderFrame.setVisibility(View.VISIBLE);
|
||||
largeLocalRenderNoVideo.setVisibility(View.GONE);
|
||||
largeLocalRenderNoVideoAvatar.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureNotVisible() {
|
||||
smallLocalRender.setCallParticipant(focusedParticipant);
|
||||
smallLocalRender.setMirror(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationHasFinished() {
|
||||
pictureInPictureGestureHelper.adjustPip();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void shrinkPip(@NonNull CallParticipant localCallParticipant) {
|
||||
pictureInPictureExpansionHelper.shrink(smallLocalRenderFrame, new PictureInPictureExpansionHelper.Callback() {
|
||||
@Override
|
||||
public void onAnimationWillStart() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureExpanded() {
|
||||
largeLocalRenderFrame.setVisibility(View.GONE);
|
||||
largeLocalRender.attachBroadcastVideoSink(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureNotVisible() {
|
||||
smallLocalRender.setCallParticipant(localCallParticipant);
|
||||
smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
|
||||
|
||||
if (!localCallParticipant.isVideoEnabled()) {
|
||||
smallLocalRenderFrame.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationHasFinished() {
|
||||
pictureInPictureGestureHelper.adjustPip();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void animatePipToLargeRectangle(boolean isLandscape) {
|
||||
final Point dimens;
|
||||
if (isLandscape) {
|
||||
|
@ -895,167 +797,76 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
dimens = new Point(ViewUtil.dpToPx(90), ViewUtil.dpToPx(160));
|
||||
}
|
||||
|
||||
SimpleAnimationListener animationListener = new SimpleAnimationListener() {
|
||||
pictureInPictureExpansionHelper.setDefaultSize(dimens, new PictureInPictureExpansionHelper.Callback() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
public void onAnimationHasFinished() {
|
||||
pictureInPictureGestureHelper.enableCorners();
|
||||
pictureInPictureGestureHelper.adjustPip();
|
||||
}
|
||||
};
|
||||
|
||||
ViewGroup.LayoutParams layoutParams = smallLocalRenderFrame.getLayoutParams();
|
||||
if (layoutParams.width == dimens.x && layoutParams.height == dimens.y) {
|
||||
animationListener.onAnimationEnd(null);
|
||||
return;
|
||||
}
|
||||
|
||||
ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, dimens.x, dimens.y);
|
||||
animation.setDuration(PIP_RESIZE_DURATION);
|
||||
animation.setAnimationListener(animationListener);
|
||||
|
||||
smallLocalRenderFrame.startAnimation(animation);
|
||||
});
|
||||
}
|
||||
|
||||
private void animatePipToSmallRectangle() {
|
||||
pictureInPictureGestureHelper.lockToBottomEnd();
|
||||
|
||||
pictureInPictureGestureHelper.performAfterFling(() -> {
|
||||
ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, ViewUtil.dpToPx(54), ViewUtil.dpToPx(72));
|
||||
animation.setDuration(PIP_RESIZE_DURATION);
|
||||
animation.setAnimationListener(new SimpleAnimationListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animation animation) {
|
||||
pictureInPictureGestureHelper.adjustPip();
|
||||
}
|
||||
});
|
||||
|
||||
smallLocalRenderFrame.startAnimation(animation);
|
||||
pictureInPictureExpansionHelper.setDefaultSize(new Point(ViewUtil.dpToPx(54), ViewUtil.dpToPx(72)), new PictureInPictureExpansionHelper.Callback() {
|
||||
@Override
|
||||
public void onAnimationHasFinished() {
|
||||
pictureInPictureGestureHelper.lockToBottomEnd();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void toggleControls() {
|
||||
if (controls.isFadeOutEnabled() && largeHeader.getVisibility() == VISIBLE) {
|
||||
fadeOutControls();
|
||||
} else {
|
||||
fadeInControls();
|
||||
}
|
||||
controlsListener.toggleControls();
|
||||
}
|
||||
|
||||
private void fadeOutControls() {
|
||||
fadeControls(ConstraintSet.GONE);
|
||||
controlsListener.onControlsFadeOut();
|
||||
private void adjustLayoutForSmallCount() {
|
||||
adjustLayoutPositions(LayoutPositions.SMALL_GROUP);
|
||||
}
|
||||
|
||||
private void fadeInControls() {
|
||||
fadeControls(ConstraintSet.VISIBLE);
|
||||
|
||||
scheduleFadeOut();
|
||||
private void adjustLayoutForLargeCount() {
|
||||
adjustLayoutPositions(LayoutPositions.LARGE_GROUP);
|
||||
}
|
||||
|
||||
private void layoutParticipantsForSmallCount() {
|
||||
pagerBottomMarginDp = 0;
|
||||
|
||||
layoutParticipants();
|
||||
}
|
||||
|
||||
private void layoutParticipantsForLargeCount() {
|
||||
pagerBottomMarginDp = 104;
|
||||
|
||||
layoutParticipants();
|
||||
}
|
||||
|
||||
private int withControlsHeight(int margin) {
|
||||
if (margin == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (controlsVisible || controls.adjustForFold()) ? margin + CONTROLS_HEIGHT : margin;
|
||||
}
|
||||
|
||||
private void layoutParticipants() {
|
||||
int desiredMargin = ViewUtil.dpToPx(withControlsHeight(pagerBottomMarginDp));
|
||||
if (ViewKt.getMarginBottom(callParticipantsPager) == desiredMargin) {
|
||||
private void adjustLayoutPositions(@NonNull LayoutPositions layoutPositions) {
|
||||
if (previousLayoutPositions == layoutPositions) {
|
||||
return;
|
||||
}
|
||||
|
||||
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
|
||||
|
||||
TransitionManager.beginDelayedTransition(participantsParent, transition);
|
||||
previousLayoutPositions = layoutPositions;
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(participantsParent);
|
||||
constraintSet.clone(this);
|
||||
|
||||
constraintSet.setMargin(R.id.call_screen_participants_pager, ConstraintSet.BOTTOM, desiredMargin);
|
||||
constraintSet.applyTo(participantsParent);
|
||||
}
|
||||
constraintSet.connect(R.id.call_screen_participants_parent,
|
||||
ConstraintSet.BOTTOM,
|
||||
layoutPositions.participantBottomViewId,
|
||||
layoutPositions.participantBottomViewEndSide,
|
||||
ViewUtil.dpToPx(layoutPositions.participantBottomMargin));
|
||||
|
||||
private void fadeControls(int visibility) {
|
||||
controlsVisible = visibility == VISIBLE;
|
||||
constraintSet.connect(R.id.call_screen_reactions_feed,
|
||||
ConstraintSet.BOTTOM,
|
||||
layoutPositions.reactionBottomViewId,
|
||||
ConstraintSet.TOP,
|
||||
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
|
||||
|
||||
Transition transition = new AutoTransition().setOrdering(TransitionSet.ORDERING_TOGETHER)
|
||||
.setDuration(TRANSITION_DURATION_MILLIS);
|
||||
|
||||
TransitionManager.endTransitions(parent);
|
||||
|
||||
if (controlsListener != null) {
|
||||
if (controlsVisible) {
|
||||
controlsListener.showSystemUI();
|
||||
} else {
|
||||
controlsListener.hideSystemUI();
|
||||
}
|
||||
}
|
||||
|
||||
TransitionManager.beginDelayedTransition(parent, transition);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(parent);
|
||||
|
||||
for (View view : controlsToFade()) {
|
||||
constraintSet.setVisibility(view.getId(), visibility);
|
||||
}
|
||||
|
||||
adjustParticipantsRecycler(constraintSet);
|
||||
|
||||
constraintSet.applyTo(parent);
|
||||
|
||||
layoutParticipants();
|
||||
}
|
||||
|
||||
private Set<View> controlsToFade() {
|
||||
if (controls.adjustForFold()) {
|
||||
return Sets.intersection(topViews, visibleViewSet);
|
||||
} else {
|
||||
return visibleViewSet;
|
||||
}
|
||||
constraintSet.applyTo(this);
|
||||
}
|
||||
|
||||
private void fadeInNewUiState(boolean useSmallMargins, boolean showSmallHeader) {
|
||||
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
|
||||
|
||||
TransitionManager.beginDelayedTransition(parent, transition);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(parent);
|
||||
|
||||
for (View view : SetUtil.difference(allTimeVisibleViews, visibleViewSet)) {
|
||||
constraintSet.setVisibility(view.getId(), ConstraintSet.GONE);
|
||||
view.setVisibility(GONE);
|
||||
}
|
||||
|
||||
for (View view : visibleViewSet) {
|
||||
constraintSet.setVisibility(view.getId(), ConstraintSet.VISIBLE);
|
||||
view.setVisibility(VISIBLE);
|
||||
|
||||
if (adjustableMarginsSet.contains(view)) {
|
||||
constraintSet.setMargin(view.getId(),
|
||||
ConstraintSet.END,
|
||||
ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP
|
||||
: LARGE_ONGOING_CALL_BUTTON_MARGIN_DP));
|
||||
MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams();
|
||||
params.setMarginEnd(ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP
|
||||
: LARGE_ONGOING_CALL_BUTTON_MARGIN_DP));
|
||||
view.setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
|
||||
adjustParticipantsRecycler(constraintSet);
|
||||
|
||||
constraintSet.applyTo(parent);
|
||||
|
||||
if (showSmallHeader) {
|
||||
collapsedToolbar.setEnabled(true);
|
||||
collapsedToolbar.setAlpha(1);
|
||||
|
@ -1073,28 +884,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
}
|
||||
}
|
||||
|
||||
private void adjustParticipantsRecycler(@NonNull ConstraintSet constraintSet) {
|
||||
if (controlsVisible || controls.adjustForFold()) {
|
||||
constraintSet.connect(R.id.call_screen_participants_recycler, ConstraintSet.BOTTOM, R.id.call_screen_video_toggle, ConstraintSet.TOP);
|
||||
} else {
|
||||
constraintSet.connect(R.id.call_screen_participants_recycler, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
|
||||
}
|
||||
|
||||
constraintSet.setHorizontalBias(R.id.call_screen_participants_recycler, controls.adjustForFold() ? 0.5f : 1f);
|
||||
}
|
||||
|
||||
private void scheduleFadeOut() {
|
||||
cancelFadeOut();
|
||||
|
||||
if (getHandler() == null) return;
|
||||
getHandler().postDelayed(fadeOutRunnable, FADE_OUT_DELAY);
|
||||
}
|
||||
|
||||
private void cancelFadeOut() {
|
||||
if (getHandler() == null) return;
|
||||
getHandler().removeCallbacks(fadeOutRunnable);
|
||||
}
|
||||
|
||||
private static <T> void runIfNonNull(@Nullable T listener, @NonNull Consumer<T> listenerConsumer) {
|
||||
if (listener != null) {
|
||||
listenerConsumer.accept(listener);
|
||||
|
@ -1133,10 +922,15 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
ringToggle.setActivated(enabled);
|
||||
}
|
||||
|
||||
public void onControlTopChanged(int top) {
|
||||
pictureInPictureGestureHelper.setBottomVerticalBoundary(top);
|
||||
|
||||
aboveControlsGuideline.setGuidelineBegin(top);
|
||||
}
|
||||
|
||||
public interface ControlsListener {
|
||||
void onStartCall(boolean isVideoCall);
|
||||
void onCancelStartCall();
|
||||
void onControlsFadeOut();
|
||||
void showSystemUI();
|
||||
void hideSystemUI();
|
||||
void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput);
|
||||
|
@ -1154,5 +948,6 @@ public class WebRtcCallView extends ConstraintLayout {
|
|||
void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed);
|
||||
void onCallInfoClicked();
|
||||
void onNavigateUpClicked();
|
||||
void toggleControls();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -253,10 +253,6 @@ public class WebRtcCallViewModel extends ViewModel {
|
|||
|
||||
public void onLocalPictureInPictureClicked() {
|
||||
CallParticipantsState state = participantsState.getValue();
|
||||
if (state.getGroupCallState() != WebRtcViewModel.GroupCallState.IDLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
participantsState.onNext(CallParticipantsState.setExpanded(participantsState.getValue(),
|
||||
state.getLocalRenderState() != WebRtcLocalRenderState.EXPANDED));
|
||||
}
|
||||
|
|
|
@ -100,23 +100,28 @@ public final class WebRtcControls {
|
|||
isCallLink);
|
||||
}
|
||||
|
||||
boolean displayErrorControls() {
|
||||
/** This is only true at the very start of a call and will then never be true again */
|
||||
public boolean hideControlsSheetInitially() {
|
||||
return displayIncomingCallButtons() || callState == CallState.NONE;
|
||||
}
|
||||
|
||||
public boolean displayErrorControls() {
|
||||
return isError();
|
||||
}
|
||||
|
||||
boolean displayStartCallControls() {
|
||||
public boolean displayStartCallControls() {
|
||||
return isPreJoin();
|
||||
}
|
||||
|
||||
boolean adjustForFold() {
|
||||
public boolean adjustForFold() {
|
||||
return foldableState.isFolded();
|
||||
}
|
||||
|
||||
@Px int getFold() {
|
||||
public @Px int getFold() {
|
||||
return foldableState.getFoldPoint();
|
||||
}
|
||||
|
||||
@StringRes int getStartCallButtonText() {
|
||||
public @StringRes int getStartCallButtonText() {
|
||||
if (isGroupCall()) {
|
||||
if (groupCallState == GroupCallState.FULL) {
|
||||
return R.string.WebRtcCallView__call_is_full;
|
||||
|
@ -127,86 +132,90 @@ public final class WebRtcControls {
|
|||
return R.string.WebRtcCallView__start_call;
|
||||
}
|
||||
|
||||
boolean isStartCallEnabled() {
|
||||
public boolean isStartCallEnabled() {
|
||||
return groupCallState != GroupCallState.FULL;
|
||||
}
|
||||
|
||||
boolean displayGroupCallFull() {
|
||||
public boolean displayGroupCallFull() {
|
||||
return groupCallState == GroupCallState.FULL;
|
||||
}
|
||||
|
||||
@NonNull String getGroupCallFullMessage(@NonNull Context context) {
|
||||
public @NonNull String getGroupCallFullMessage(@NonNull Context context) {
|
||||
if (participantLimit != null) {
|
||||
return context.getString(R.string.WebRtcCallView__the_maximum_number_of_d_participants_has_been_Reached_for_this_call, participantLimit);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
boolean displayGroupMembersButton() {
|
||||
public boolean displayGroupMembersButton() {
|
||||
return (groupCallState.isAtLeast(GroupCallState.CONNECTING) && hasAtLeastOneRemote) || groupCallState.isAtLeast(GroupCallState.FULL);
|
||||
}
|
||||
|
||||
boolean displayEndCall() {
|
||||
public boolean displayEndCall() {
|
||||
return isAtLeastOutgoing() || callState == CallState.RECONNECTING;
|
||||
}
|
||||
|
||||
boolean displayMuteAudio() {
|
||||
public boolean displayMuteAudio() {
|
||||
return isPreJoin() || isAtLeastOutgoing();
|
||||
}
|
||||
|
||||
boolean displayVideoToggle() {
|
||||
public boolean displayVideoToggle() {
|
||||
return isPreJoin() || isAtLeastOutgoing();
|
||||
}
|
||||
|
||||
boolean displayAudioToggle() {
|
||||
public boolean displayAudioToggle() {
|
||||
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothHeadsetAvailableForAudioToggle() || isWiredHeadsetAvailableForAudioToggle());
|
||||
}
|
||||
|
||||
boolean displayCameraToggle() {
|
||||
public boolean displayCameraToggle() {
|
||||
return (isPreJoin() || isAtLeastOutgoing()) && isLocalVideoEnabled && isMoreThanOneCameraAvailable;
|
||||
}
|
||||
|
||||
boolean displayRemoteVideoRecycler() {
|
||||
public boolean displayRemoteVideoRecycler() {
|
||||
return isOngoing();
|
||||
}
|
||||
|
||||
boolean displayAnswerWithoutVideo() {
|
||||
public boolean displayAnswerWithoutVideo() {
|
||||
return isIncoming() && isRemoteVideoEnabled;
|
||||
}
|
||||
|
||||
boolean displayIncomingCallButtons() {
|
||||
public boolean displayIncomingCallButtons() {
|
||||
return isIncoming();
|
||||
}
|
||||
|
||||
boolean isEarpieceAvailableForAudioToggle() {
|
||||
public boolean isEarpieceAvailableForAudioToggle() {
|
||||
return !isLocalVideoEnabled;
|
||||
}
|
||||
|
||||
boolean isBluetoothHeadsetAvailableForAudioToggle() {
|
||||
public boolean isBluetoothHeadsetAvailableForAudioToggle() {
|
||||
return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH);
|
||||
}
|
||||
|
||||
boolean isWiredHeadsetAvailableForAudioToggle() {
|
||||
public boolean isWiredHeadsetAvailableForAudioToggle() {
|
||||
return availableDevices.contains(SignalAudioManager.AudioDevice.WIRED_HEADSET);
|
||||
}
|
||||
|
||||
boolean isFadeOutEnabled() {
|
||||
public boolean isFadeOutEnabled() {
|
||||
return isAtLeastOutgoing() && isRemoteVideoEnabled && callState != CallState.RECONNECTING;
|
||||
}
|
||||
|
||||
boolean displaySmallOngoingCallButtons() {
|
||||
public boolean displaySmallOngoingCallButtons() {
|
||||
return isAtLeastOutgoing() && displayAudioToggle() && displayCameraToggle();
|
||||
}
|
||||
|
||||
boolean displayLargeOngoingCallButtons() {
|
||||
public boolean displayLargeOngoingCallButtons() {
|
||||
return isAtLeastOutgoing() && !(displayAudioToggle() && displayCameraToggle());
|
||||
}
|
||||
|
||||
boolean displayTopViews() {
|
||||
public boolean displayTopViews() {
|
||||
return !isInPipMode;
|
||||
}
|
||||
|
||||
@NonNull WebRtcAudioOutput getAudioOutput() {
|
||||
public boolean displayReactions() {
|
||||
return !isInPipMode;
|
||||
}
|
||||
|
||||
public @NonNull WebRtcAudioOutput getAudioOutput() {
|
||||
switch (activeDevice) {
|
||||
case SPEAKER_PHONE:
|
||||
return WebRtcAudioOutput.SPEAKER;
|
||||
|
@ -219,15 +228,15 @@ public final class WebRtcControls {
|
|||
}
|
||||
}
|
||||
|
||||
boolean showSmallHeader() {
|
||||
public boolean showSmallHeader() {
|
||||
return isAtLeastOutgoing();
|
||||
}
|
||||
|
||||
boolean showFullScreenShade() {
|
||||
public boolean showFullScreenShade() {
|
||||
return isPreJoin() || isIncoming();
|
||||
}
|
||||
|
||||
boolean displayRingToggle() {
|
||||
public boolean displayRingToggle() {
|
||||
return isPreJoin() && isGroupCall() && !isCallLink && !hasAtLeastOneRemote;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,5 +6,9 @@ public enum WebRtcLocalRenderState {
|
|||
SMALLER_RECTANGLE,
|
||||
LARGE,
|
||||
LARGE_NO_VIDEO,
|
||||
EXPANDED
|
||||
EXPANDED;
|
||||
|
||||
public boolean isAnySmall() {
|
||||
return this == SMALL_RECTANGLE || this == SMALLER_RECTANGLE;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.graphics.Canvas
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
||||
|
||||
/**
|
||||
* This fades the top 2 reactions slightly inside their recyclerview.
|
||||
*/
|
||||
class WebRtcReactionsAlphaItemDecoration : ItemDecoration() {
|
||||
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
for (i in 0..parent.childCount) {
|
||||
val child = parent.getChildAt(i) ?: continue
|
||||
when (parent.getChildAdapterPosition(child)) {
|
||||
WebRtcReactionsRecyclerAdapter.MAX_REACTION_NUMBER - 1 -> child.alpha = 0.7f
|
||||
WebRtcReactionsRecyclerAdapter.MAX_REACTION_NUMBER - 2 -> child.alpha = 0.9f
|
||||
else -> child.alpha = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ValueAnimator
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* Reactions item animator based on [ConversationItemAnimator]
|
||||
*/
|
||||
class WebRtcReactionsItemAnimator : RecyclerView.ItemAnimator() {
|
||||
|
||||
private data class TweeningInfo(
|
||||
val startValue: Float,
|
||||
val endValue: Float
|
||||
) {
|
||||
fun lerp(progress: Float): Float {
|
||||
return startValue + progress * (endValue - startValue)
|
||||
}
|
||||
}
|
||||
|
||||
private data class AnimationInfo(
|
||||
val sharedAnimator: ValueAnimator,
|
||||
val tweeningInfo: TweeningInfo
|
||||
)
|
||||
|
||||
private val pendingSlideAnimations: MutableMap<RecyclerView.ViewHolder, TweeningInfo> = mutableMapOf()
|
||||
private val slideAnimations: MutableMap<RecyclerView.ViewHolder, AnimationInfo> = mutableMapOf()
|
||||
|
||||
override fun animateDisappearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo?): Boolean {
|
||||
if (!pendingSlideAnimations.containsKey(viewHolder) &&
|
||||
!slideAnimations.containsKey(viewHolder)
|
||||
) {
|
||||
pendingSlideAnimations[viewHolder] = TweeningInfo(0f, viewHolder.itemView.height.toFloat())
|
||||
dispatchAnimationStarted(viewHolder)
|
||||
return true
|
||||
}
|
||||
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
if (viewHolder.absoluteAdapterPosition > 1) {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
return animateSlide(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||
}
|
||||
|
||||
private fun animateSlide(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
if (slideAnimations.containsKey(viewHolder)) {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
val translationY = if (preLayoutInfo == null) {
|
||||
postLayoutInfo.bottom - postLayoutInfo.top
|
||||
} else {
|
||||
preLayoutInfo.top - postLayoutInfo.top
|
||||
}.toFloat()
|
||||
|
||||
if (translationY == 0f) {
|
||||
viewHolder.itemView.translationY = 0f
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
viewHolder.itemView.translationY = translationY
|
||||
|
||||
pendingSlideAnimations[viewHolder] = TweeningInfo(translationY, 0f)
|
||||
dispatchAnimationStarted(viewHolder)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
return if (pendingSlideAnimations.contains(viewHolder) || slideAnimations.containsKey(viewHolder)) {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
false
|
||||
} else {
|
||||
animateSlide(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||
}
|
||||
}
|
||||
|
||||
override fun animateChange(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
if (oldHolder != newHolder) {
|
||||
dispatchAnimationFinished(oldHolder)
|
||||
}
|
||||
|
||||
return animatePersistence(newHolder, preLayoutInfo, postLayoutInfo)
|
||||
}
|
||||
|
||||
override fun runPendingAnimations() {
|
||||
Log.d(TAG, "Starting ${pendingSlideAnimations.size} animations.")
|
||||
runPendingSlideAnimations()
|
||||
}
|
||||
|
||||
private fun runPendingSlideAnimations() {
|
||||
val animators: MutableList<Animator> = mutableListOf()
|
||||
for ((viewHolder, tweeningInfo) in pendingSlideAnimations) {
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f)
|
||||
slideAnimations[viewHolder] = AnimationInfo(animator, tweeningInfo)
|
||||
animator.duration = 150L
|
||||
animator.addUpdateListener {
|
||||
if (viewHolder in slideAnimations) {
|
||||
viewHolder.itemView.translationY = tweeningInfo.lerp(it.animatedFraction)
|
||||
(viewHolder.itemView.parent as RecyclerView?)?.invalidate()
|
||||
}
|
||||
}
|
||||
animator.doOnEnd {
|
||||
if (viewHolder in slideAnimations) {
|
||||
handleAnimationEnd(viewHolder)
|
||||
}
|
||||
}
|
||||
animators.add(animator)
|
||||
}
|
||||
|
||||
AnimatorSet().apply {
|
||||
playTogether(animators)
|
||||
start()
|
||||
}
|
||||
|
||||
pendingSlideAnimations.clear()
|
||||
}
|
||||
|
||||
private fun handleAnimationEnd(viewHolder: RecyclerView.ViewHolder) {
|
||||
viewHolder.itemView.translationY = 0f
|
||||
slideAnimations.remove(viewHolder)
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
dispatchFinishedWhenDone()
|
||||
}
|
||||
|
||||
override fun endAnimation(item: RecyclerView.ViewHolder) {
|
||||
endSlideAnimation(item)
|
||||
}
|
||||
|
||||
override fun endAnimations() {
|
||||
endSlideAnimations()
|
||||
dispatchAnimationsFinished()
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean {
|
||||
return slideAnimations.values.any { it.sharedAnimator.isRunning }
|
||||
}
|
||||
|
||||
override fun onAnimationFinished(viewHolder: RecyclerView.ViewHolder) {
|
||||
val parent = (viewHolder.itemView.parent as? RecyclerView)
|
||||
parent?.post { parent.invalidate() }
|
||||
}
|
||||
|
||||
private fun endSlideAnimation(item: RecyclerView.ViewHolder) {
|
||||
slideAnimations[item]?.sharedAnimator?.cancel()
|
||||
}
|
||||
|
||||
private fun endSlideAnimations() {
|
||||
slideAnimations.values.map { it.sharedAnimator }.forEach {
|
||||
it.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchFinishedWhenDone() {
|
||||
if (!isRunning) {
|
||||
Log.d(TAG, "Finished running animations.")
|
||||
dispatchAnimationsFinished()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(WebRtcReactionsItemAnimator::class.java)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
|
||||
|
||||
/**
|
||||
* RecyclerView adapter for the reactions feed. This takes in a list of [GroupCallReactionEvent] and renders them onto the screen.
|
||||
* This adapter also encapsulates logic for whether the reaction should be displayed, such as expiration and maximum visible count.
|
||||
*/
|
||||
class WebRtcReactionsRecyclerAdapter : ListAdapter<GroupCallReactionEvent, WebRtcReactionsRecyclerAdapter.ViewHolder>(DiffCallback()) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.webrtc_call_reaction_recycler_item, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.bind(item)
|
||||
}
|
||||
|
||||
override fun submitList(list: MutableList<GroupCallReactionEvent>?) {
|
||||
if (list == null) {
|
||||
super.submitList(null)
|
||||
} else {
|
||||
super.submitList(
|
||||
list.filter { it.getExpirationTimestamp() > System.currentTimeMillis() }
|
||||
.sortedBy { it.timestamp }
|
||||
.takeLast(MAX_REACTION_NUMBER)
|
||||
.reversed()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(val itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val emojiView: EmojiImageView = itemView.findViewById(R.id.webrtc_call_reaction_emoji_view)
|
||||
private val textView: EmojiTextView = itemView.findViewById(R.id.webrtc_call_reaction_name_textview)
|
||||
fun bind(item: GroupCallReactionEvent) {
|
||||
emojiView.setImageEmoji(item.reaction)
|
||||
textView.text = item.sender.getRecipientDisplayNameDeviceAgnostic(itemView.context)
|
||||
itemView.isClickable = false
|
||||
textView.isClickable = false
|
||||
emojiView.isClickable = false
|
||||
}
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<GroupCallReactionEvent>() {
|
||||
override fun areItemsTheSame(oldItem: GroupCallReactionEvent, newItem: GroupCallReactionEvent): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: GroupCallReactionEvent, newItem: GroupCallReactionEvent): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_REACTION_NUMBER = 5
|
||||
}
|
||||
}
|
|
@ -0,0 +1,342 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.controls
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rxjava3.subscribeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.toLiveData
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Renders information about a call (1:1, group, or call link) and provides actions available for
|
||||
* said call (e.g., raise hand, kick, etc)
|
||||
*/
|
||||
object CallInfoView {
|
||||
|
||||
@Composable
|
||||
fun View(webRtcCallViewModel: WebRtcCallViewModel, modifier: Modifier) {
|
||||
val state: ParticipantsState by webRtcCallViewModel.callParticipantsState
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.map { state ->
|
||||
ParticipantsState(
|
||||
inCallLobby = state.callState == WebRtcViewModel.State.CALL_PRE_JOIN,
|
||||
ringGroup = state.ringGroup,
|
||||
includeSelf = state.groupCallState === WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED || state.groupCallState === WebRtcViewModel.GroupCallState.IDLE,
|
||||
participantCount = if (state.participantCount.isPresent) state.participantCount.asLong.toInt() else 0,
|
||||
remoteParticipants = state.allRemoteParticipants.sortedBy { it.callParticipantId.recipientId },
|
||||
localParticipant = state.localParticipant,
|
||||
groupMembers = state.groupMembers.filterNot { it.member.isSelf },
|
||||
callRecipient = state.recipient
|
||||
)
|
||||
}
|
||||
.subscribeAsState(ParticipantsState())
|
||||
|
||||
SignalTheme(
|
||||
isDarkMode = true
|
||||
) {
|
||||
Surface {
|
||||
CallInfo(state = state, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CallInfoPreview() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
CallInfo(
|
||||
state = ParticipantsState(remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallInfo(
|
||||
state: ParticipantsState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CallLinkInfoSheet__call_info),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 52.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
text = getCallSheetLabel(state),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.inCallLobby || state.isOngoing()) {
|
||||
items(
|
||||
items = state.participantsForList,
|
||||
key = { it.callParticipantId },
|
||||
contentType = { null }
|
||||
) {
|
||||
CallParticipantRow(
|
||||
callParticipant = it,
|
||||
isSelfAdmin = false,
|
||||
onBlockClicked = {}
|
||||
)
|
||||
}
|
||||
} else if (state.isGroupCall()) {
|
||||
items(
|
||||
items = state.groupMembers,
|
||||
key = { it.member.id.toLong() },
|
||||
contentType = { null }
|
||||
) {
|
||||
GroupMemberRow(
|
||||
groupMember = it,
|
||||
isSelfAdmin = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
CallParticipantRow(
|
||||
initialRecipient = state.callRecipient,
|
||||
name = state.callRecipient.getShortDisplayName(LocalContext.current),
|
||||
showIcons = false,
|
||||
isVideoEnabled = false,
|
||||
isMicrophoneEnabled = false,
|
||||
isSelfAdmin = false,
|
||||
onBlockClicked = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getCallSheetLabel(state: ParticipantsState): String {
|
||||
return if (!state.inCallLobby || state.isOngoing()) {
|
||||
pluralStringResource(id = R.plurals.CallParticipantsListDialog_in_this_call, count = state.participantCountForDisplay, state.participantCountForDisplay)
|
||||
} else if (state.isGroupCall()) {
|
||||
val groupSize = state.groupMembers.size
|
||||
if (state.ringGroup) {
|
||||
pluralStringResource(id = R.plurals.CallParticipantsListDialog__signal_will_ring, count = groupSize, groupSize)
|
||||
} else {
|
||||
pluralStringResource(id = R.plurals.CallParticipantsListDialog__signal_will_notify, count = groupSize, groupSize)
|
||||
}
|
||||
} else {
|
||||
pluralStringResource(id = R.plurals.CallParticipantsListDialog__signal_will_ring, count = 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CallParticipantRowPreview() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
CallParticipantRow(
|
||||
CallParticipant(recipient = Recipient.UNKNOWN),
|
||||
isSelfAdmin = true
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallParticipantRow(
|
||||
callParticipant: CallParticipant,
|
||||
isSelfAdmin: Boolean,
|
||||
onBlockClicked: (CallParticipant) -> Unit
|
||||
) {
|
||||
CallParticipantRow(
|
||||
initialRecipient = callParticipant.recipient,
|
||||
name = callParticipant.getShortRecipientDisplayName(LocalContext.current),
|
||||
showIcons = true,
|
||||
isVideoEnabled = callParticipant.isVideoEnabled,
|
||||
isMicrophoneEnabled = callParticipant.isMicrophoneEnabled,
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
onBlockClicked = { onBlockClicked(callParticipant) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallParticipantRow(
|
||||
initialRecipient: Recipient,
|
||||
name: String,
|
||||
showIcons: Boolean,
|
||||
isVideoEnabled: Boolean,
|
||||
isMicrophoneEnabled: Boolean,
|
||||
isSelfAdmin: Boolean,
|
||||
onBlockClicked: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Rows.defaultPadding())
|
||||
) {
|
||||
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(initialRecipient.id)))
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.toLiveData()
|
||||
.observeAsState(initial = initialRecipient)
|
||||
|
||||
if (LocalInspectionMode.current) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(color = Color.Red, shape = CircleShape)
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
it.setAvatarUsingProfile(recipient)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
|
||||
if (showIcons && !isVideoEnabled) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_slash_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
|
||||
if (showIcons && !isMicrophoneEnabled) {
|
||||
if (!isVideoEnabled) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_mic_slash_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
|
||||
if (showIcons && isSelfAdmin && !recipient.isSelf) {
|
||||
if (!isMicrophoneEnabled) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onBlockClicked)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupMemberRow(
|
||||
groupMember: GroupMemberEntry.FullMember,
|
||||
isSelfAdmin: Boolean
|
||||
) {
|
||||
CallParticipantRow(
|
||||
initialRecipient = groupMember.member,
|
||||
name = groupMember.member.getShortDisplayName(LocalContext.current),
|
||||
showIcons = false,
|
||||
isVideoEnabled = false,
|
||||
isMicrophoneEnabled = false,
|
||||
isSelfAdmin = isSelfAdmin
|
||||
) {}
|
||||
}
|
||||
|
||||
private data class ParticipantsState(
|
||||
val inCallLobby: Boolean = false,
|
||||
val ringGroup: Boolean = true,
|
||||
val includeSelf: Boolean = false,
|
||||
val participantCount: Int = 0,
|
||||
val remoteParticipants: List<CallParticipant> = emptyList(),
|
||||
val localParticipant: CallParticipant? = null,
|
||||
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(),
|
||||
val callRecipient: Recipient = Recipient.UNKNOWN
|
||||
) {
|
||||
|
||||
val participantsForList: List<CallParticipant> = if (includeSelf && localParticipant != null) {
|
||||
listOf(localParticipant) + remoteParticipants
|
||||
} else {
|
||||
remoteParticipants
|
||||
}
|
||||
|
||||
val participantCountForDisplay: Int = if (participantCount == 0) {
|
||||
participantsForList.size
|
||||
} else {
|
||||
participantCount
|
||||
}
|
||||
|
||||
fun isGroupCall(): Boolean {
|
||||
return groupMembers.isNotEmpty()
|
||||
}
|
||||
|
||||
fun isOngoing(): Boolean {
|
||||
return remoteParticipants.isNotEmpty()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.controls
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Handler
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehaviorHack
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
|
||||
import org.thoughtcrime.securesms.util.padding
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Brain for rendering the call controls and info within a bottom sheet.
|
||||
*/
|
||||
class ControlsAndInfoController(
|
||||
private val webRtcCallView: WebRtcCallView,
|
||||
private val viewModel: WebRtcCallViewModel
|
||||
) : Disposable {
|
||||
|
||||
companion object {
|
||||
private const val CONTROL_FADE_OUT_START = 0f
|
||||
private const val CONTROL_FADE_OUT_DONE = 0.23f
|
||||
private const val INFO_FADE_IN_START = CONTROL_FADE_OUT_DONE
|
||||
private const val INFO_FADE_IN_DONE = 0.8f
|
||||
|
||||
private val HIDE_CONTROL_DELAY = 5.seconds.inWholeMilliseconds
|
||||
}
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val coordinator: CoordinatorLayout
|
||||
private val frame: FrameLayout
|
||||
private val behavior: BottomSheetBehavior<View>
|
||||
private val callInfoComposeView: ComposeView
|
||||
private val callControls: ConstraintLayout
|
||||
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
|
||||
private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() }
|
||||
private val handler: Handler?
|
||||
get() = webRtcCallView.handler
|
||||
|
||||
private var previousCallControlHeight = 0
|
||||
private var controlPeakHeight = 0
|
||||
private var controlState: WebRtcControls = WebRtcControls.NONE
|
||||
|
||||
init {
|
||||
val infoTranslationDistance = 24f.dp
|
||||
coordinator = webRtcCallView.findViewById(R.id.call_controls_info_coordinator)
|
||||
frame = webRtcCallView.findViewById(R.id.call_controls_info_parent)
|
||||
behavior = BottomSheetBehavior.from(frame)
|
||||
callInfoComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
|
||||
callControls = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
|
||||
|
||||
callInfoComposeView.apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
val nestedScrollInterop = rememberNestedScrollInteropConnection()
|
||||
CallInfoView.View(viewModel, Modifier.nestedScroll(nestedScrollInterop))
|
||||
}
|
||||
}
|
||||
|
||||
callInfoComposeView.alpha = 0f
|
||||
callInfoComposeView.translationY = infoTranslationDistance
|
||||
|
||||
frame.background = MaterialShapeDrawable(
|
||||
ShapeAppearanceModel.builder()
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
|
||||
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
|
||||
.build()
|
||||
).apply {
|
||||
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallView.context, R.color.signal_colorSurface))
|
||||
}
|
||||
|
||||
behavior.isHideable = true
|
||||
behavior.peekHeight = 0
|
||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
BottomSheetBehaviorHack.setNestedScrollingChild(behavior, callInfoComposeView)
|
||||
|
||||
coordinator.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
|
||||
val guidelineTop = max(frame.top, (bottom - top) - behavior.peekHeight)
|
||||
webRtcCallView.post { webRtcCallView.onControlTopChanged(guidelineTop) }
|
||||
}
|
||||
|
||||
callControls.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
if (callControls.height > 0 && callControls.height != previousCallControlHeight) {
|
||||
previousCallControlHeight = callControls.height
|
||||
controlPeakHeight = callControls.height + callControls.y.toInt()
|
||||
behavior.peekHeight = controlPeakHeight
|
||||
frame.minimumHeight = coordinator.height / 2
|
||||
behavior.maxHeight = (coordinator.height.toFloat() * 0.66f).toInt()
|
||||
|
||||
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
|
||||
webRtcCallView.post { webRtcCallView.onControlTopChanged(guidelineTop) }
|
||||
}
|
||||
}
|
||||
|
||||
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
if (controlState.isFadeOutEnabled) {
|
||||
hide(delay = HIDE_CONTROL_DELAY)
|
||||
}
|
||||
} else if (newState == BottomSheetBehavior.STATE_EXPANDED || newState == BottomSheetBehavior.STATE_DRAGGING) {
|
||||
cancelScheduledHide()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
callControls.alpha = alphaControls(slideOffset)
|
||||
callControls.visible = callControls.alpha > 0f
|
||||
|
||||
callInfoComposeView.alpha = alphaCallInfo(slideOffset)
|
||||
callInfoComposeView.translationY = infoTranslationDistance - (infoTranslationDistance * callInfoComposeView.alpha)
|
||||
|
||||
webRtcCallView.onControlTopChanged(max(frame.top, coordinator.height - behavior.peekHeight))
|
||||
}
|
||||
})
|
||||
|
||||
webRtcCallView.addWindowInsetsListener(object : InsetAwareConstraintLayout.WindowInsetsListener {
|
||||
override fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int) {
|
||||
if (navigationBar > 0) {
|
||||
callControls.padding(bottom = navigationBar)
|
||||
callInfoComposeView.padding(bottom = navigationBar)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun addVisibilityListener(listener: BottomSheetVisibilityListener): Boolean {
|
||||
return bottomSheetVisibilityListeners.add(listener)
|
||||
}
|
||||
|
||||
fun showCallInfo() {
|
||||
cancelScheduledHide()
|
||||
behavior.isHideable = false
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
fun showControls() {
|
||||
cancelScheduledHide()
|
||||
behavior.isHideable = false
|
||||
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
|
||||
bottomSheetVisibilityListeners.forEach { it.onShown() }
|
||||
}
|
||||
|
||||
private fun hide(delay: Long = 0L) {
|
||||
if (delay == 0L) {
|
||||
if (controlState.isFadeOutEnabled || controlState == WebRtcControls.PIP) {
|
||||
behavior.isHideable = true
|
||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
bottomSheetVisibilityListeners.forEach { it.onHidden() }
|
||||
}
|
||||
} else {
|
||||
cancelScheduledHide()
|
||||
handler?.postDelayed(scheduleHideControlsRunnable, delay)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleControls() {
|
||||
if (behavior.state == BottomSheetBehavior.STATE_EXPANDED || behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
showControls()
|
||||
} else {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateControls(newControlState: WebRtcControls) {
|
||||
val previousState = controlState
|
||||
controlState = newControlState
|
||||
|
||||
if (controlState == WebRtcControls.PIP) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
if (controlState.hideControlsSheetInitially()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (previousState.hideControlsSheetInitially() && (previousState != WebRtcControls.PIP)) {
|
||||
showControls()
|
||||
return
|
||||
}
|
||||
|
||||
if (controlState.isFadeOutEnabled) {
|
||||
if (!previousState.isFadeOutEnabled) {
|
||||
hide(delay = HIDE_CONTROL_DELAY)
|
||||
}
|
||||
} else {
|
||||
cancelScheduledHide()
|
||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
showControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScheduledHide() {
|
||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED && !isDisposed) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelScheduledHide() {
|
||||
handler?.removeCallbacks(scheduleHideControlsRunnable)
|
||||
}
|
||||
|
||||
private fun alphaControls(slideOffset: Float): Float {
|
||||
return if (slideOffset <= CONTROL_FADE_OUT_START) {
|
||||
1f
|
||||
} else if (slideOffset >= CONTROL_FADE_OUT_DONE) {
|
||||
0f
|
||||
} else {
|
||||
1f - (1f * (slideOffset - CONTROL_FADE_OUT_START) / (CONTROL_FADE_OUT_DONE - CONTROL_FADE_OUT_START))
|
||||
}
|
||||
}
|
||||
|
||||
private fun alphaCallInfo(slideOffset: Float): Float {
|
||||
return if (slideOffset >= INFO_FADE_IN_DONE) {
|
||||
1f
|
||||
} else if (slideOffset <= INFO_FADE_IN_START) {
|
||||
0f
|
||||
} else {
|
||||
(1f * (slideOffset - INFO_FADE_IN_START) / (INFO_FADE_IN_DONE - INFO_FADE_IN_START))
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
override fun isDisposed(): Boolean {
|
||||
return disposables.isDisposed
|
||||
}
|
||||
|
||||
interface BottomSheetVisibilityListener {
|
||||
fun onShown()
|
||||
fun onHidden()
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ public class CallParticipantsListHeader implements MappingModel<CallParticipants
|
|||
}
|
||||
|
||||
@NonNull String getHeader(@NonNull Context context) {
|
||||
return context.getResources().getQuantityString(R.plurals.CallParticipantsListDialog_in_this_call_d_people, participantCount, participantCount);
|
||||
return context.getResources().getQuantityString(R.plurals.CallParticipantsListDialog_in_this_call, participantCount, participantCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -47,6 +47,14 @@ data class CallParticipant constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun getRecipientDisplayNameDeviceAgnostic(context: Context): String {
|
||||
return if (recipient.isSelf) {
|
||||
context.getString(R.string.CallParticipant__you)
|
||||
} else {
|
||||
recipient.getDisplayName(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun getShortRecipientDisplayName(context: Context): String {
|
||||
return if (recipient.isSelf && isPrimary) {
|
||||
context.getString(R.string.CallParticipant__you)
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.events
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* This is a data class to represent a reaction coming in over the wire in the format we need (mapped to a [CallParticipant]) in a way that can be easily
|
||||
* compared across Rx streams.
|
||||
*/
|
||||
data class GroupCallReactionEvent(val sender: CallParticipant, val reaction: String, val timestamp: Long) {
|
||||
fun getExpirationTimestamp(): Long {
|
||||
return timestamp + TimeUnit.SECONDS.toMillis(LIFESPAN_SECONDS)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LIFESPAN_SECONDS = 4L
|
||||
}
|
||||
}
|
|
@ -95,7 +95,8 @@ public class ConnectedCallActionProcessor extends DeviceAwareActionProcessor {
|
|||
return ephemeralState.copy(
|
||||
CallParticipant.AudioLevel.fromRawAudioLevel(localLevel),
|
||||
callParticipantId.map(participantId -> Collections.singletonMap(participantId, CallParticipant.AudioLevel.fromRawAudioLevel(remoteLevel)))
|
||||
.orElse(Collections.emptyMap())
|
||||
.orElse(Collections.emptyMap()),
|
||||
ephemeralState.getUnexpiredReactions()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import org.signal.ringrtc.GroupCall;
|
|||
import org.signal.ringrtc.PeekInfo;
|
||||
import org.thoughtcrime.securesms.events.CallParticipant;
|
||||
import org.thoughtcrime.securesms.events.CallParticipantId;
|
||||
import org.thoughtcrime.securesms.events.GroupCallReactionEvent;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
@ -25,7 +26,10 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
|
|||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Process actions for when the call has at least once been connected and joined.
|
||||
|
@ -135,7 +139,7 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
return ephemeralState.copy(localAudioLevel, remoteAudioLevels);
|
||||
return ephemeralState.copy(localAudioLevel, remoteAudioLevels, ephemeralState.getUnexpiredReactions());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -197,4 +201,30 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
|
|||
|
||||
return terminateGroupCall(currentState);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull WebRtcEphemeralState handleGroupCallReaction(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, List<GroupCall.Reaction> reactions) {
|
||||
List<GroupCallReactionEvent> reactionList = ephemeralState.getUnexpiredReactions();
|
||||
Map<CallParticipantId, CallParticipant> participants = currentState.getCallInfoState().getRemoteCallParticipantsMap();
|
||||
|
||||
for (GroupCall.Reaction reaction : reactions) {
|
||||
final GroupCallReactionEvent event = createGroupCallReaction(participants, reaction);
|
||||
if (event != null) {
|
||||
reactionList.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
return ephemeralState.copy(ephemeralState.getLocalAudioLevel(), ephemeralState.getRemoteAudioLevels(), reactionList);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private GroupCallReactionEvent createGroupCallReaction(Map<CallParticipantId, CallParticipant> participants, final GroupCall.Reaction reaction) {
|
||||
CallParticipantId participantId = participants.keySet().stream().filter(participant -> participant.getDemuxId() == reaction.demuxId).findFirst().orElse(null);
|
||||
if (participantId == null) {
|
||||
Log.v(TAG, "Could not find CallParticipantId in list of call participants based on demuxId for reaction.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GroupCallReactionEvent(participants.get(participantId), reaction.value, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -295,6 +295,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
|
|||
process((s, p) -> p.handleScreenOffChange(s));
|
||||
}
|
||||
|
||||
public void react() {
|
||||
process((s, p) -> p.handleSendGroupReact(s));
|
||||
}
|
||||
|
||||
public void postStateUpdate(@NonNull WebRtcServiceState state) {
|
||||
EventBus.getDefault().postSticky(new WebRtcViewModel(state));
|
||||
}
|
||||
|
@ -898,7 +902,9 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
|
|||
|
||||
@Override
|
||||
public void onReactions(@NonNull GroupCall groupCall, List<Reaction> reactions) {
|
||||
// TODO: Implement handling of reactions.
|
||||
if (FeatureFlags.groupCallReactions()) {
|
||||
processStateless(s -> serviceState.getActionProcessor().handleGroupCallReaction(serviceState, s, reactions));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -546,6 +546,11 @@ public abstract class WebRtcActionProcessor {
|
|||
return currentState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcServiceState handleSendGroupReact(@NonNull WebRtcServiceState currentState) {
|
||||
Log.i(tag, "react not processed");
|
||||
return currentState;
|
||||
}
|
||||
|
||||
public @NonNull WebRtcServiceState handleCameraSwitchCompleted(@NonNull WebRtcServiceState currentState, @NonNull CameraState newCameraState) {
|
||||
Log.i(tag, "handleCameraSwitchCompleted not processed");
|
||||
return currentState;
|
||||
|
@ -729,6 +734,11 @@ public abstract class WebRtcActionProcessor {
|
|||
return currentState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcEphemeralState handleGroupCallReaction(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, List<GroupCall.Reaction> reactions) {
|
||||
Log.i(tag, "handleGroupCallReaction not processed");
|
||||
return ephemeralState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHashCode) {
|
||||
Log.i(tag, "handleGroupRequestMembershipProof not processed");
|
||||
return currentState;
|
||||
|
|
|
@ -2,11 +2,18 @@ package org.thoughtcrime.securesms.service.webrtc.state
|
|||
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.CallParticipantId
|
||||
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
|
||||
|
||||
/**
|
||||
* The state of the call system which contains data which changes frequently.
|
||||
*/
|
||||
data class WebRtcEphemeralState(
|
||||
val localAudioLevel: CallParticipant.AudioLevel = CallParticipant.AudioLevel.LOWEST,
|
||||
val remoteAudioLevels: Map<CallParticipantId, CallParticipant.AudioLevel> = emptyMap()
|
||||
)
|
||||
val remoteAudioLevels: Map<CallParticipantId, CallParticipant.AudioLevel> = emptyMap(),
|
||||
private val reactions: List<GroupCallReactionEvent> = emptyList()
|
||||
) {
|
||||
|
||||
fun getUnexpiredReactions(): List<GroupCallReactionEvent> {
|
||||
return reactions.filter { System.currentTimeMillis() < it.getExpirationTimestamp() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.stories.viewer.reply.reaction
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.children
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MultiReactionBurstLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
private val cooldownTimes = mutableMapOf<String, Long>()
|
||||
|
||||
private var nextViewIndex = 0
|
||||
|
||||
init {
|
||||
repeat(MAX_SIMULTANEOUS_REACTIONS) {
|
||||
addView(OnReactionSentView(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun displayReactions(reactions: List<GroupCallReactionEvent>) {
|
||||
if (children.count() == 0) {
|
||||
throw IllegalStateException("You must add views before displaying reactions!")
|
||||
}
|
||||
|
||||
reactions.filter {
|
||||
if (it.getExpirationTimestamp() < System.currentTimeMillis()) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
val cutoffTimestamp = cooldownTimes[EmojiUtil.getCanonicalRepresentation(it.reaction)] ?: return@filter true
|
||||
|
||||
return@filter cutoffTimestamp < it.timestamp
|
||||
}
|
||||
.groupBy { EmojiUtil.getCanonicalRepresentation(it.reaction) }
|
||||
.filter { it.value.groupBy { event -> event.sender }.size >= REACTION_COUNT_THRESHOLD }
|
||||
.values
|
||||
.map { it.sortedBy { event -> event.timestamp } }
|
||||
.map { it[REACTION_COUNT_THRESHOLD - 1] }
|
||||
.sortedBy { it.timestamp }
|
||||
.take(MAX_SIMULTANEOUS_REACTIONS - cooldownTimes.filter { it.value > System.currentTimeMillis() }.size)
|
||||
.forEach {
|
||||
val reactionView = getNextReactionView()
|
||||
reactionView.playForEmoji(it.reaction)
|
||||
cooldownTimes[EmojiUtil.getCanonicalRepresentation(it.reaction)] = it.timestamp + cooldownDuration.inWholeMilliseconds
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextReactionView(): OnReactionSentView {
|
||||
val v = getChildAt(nextViewIndex) as OnReactionSentView
|
||||
|
||||
nextViewIndex = (nextViewIndex + 1) % MAX_SIMULTANEOUS_REACTIONS
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REACTION_COUNT_THRESHOLD = 3
|
||||
private const val MAX_SIMULTANEOUS_REACTIONS = 3
|
||||
private val cooldownDuration = 2.seconds
|
||||
}
|
||||
}
|
|
@ -116,6 +116,7 @@ public final class FeatureFlags {
|
|||
private static final String IDEAL_DONATIONS = "android.ideal.donations.5";
|
||||
public static final String IDEAL_ENABLED_REGIONS = "global.donations.idealEnabledRegions";
|
||||
public static final String SEPA_ENABLED_REGIONS = "global.donations.sepaEnabledRegions";
|
||||
private static final String CALLING_REACTIONS = "android.calling.reactions";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
|
@ -183,7 +184,8 @@ public final class FeatureFlags {
|
|||
SEPA_DEBIT_DONATIONS,
|
||||
IDEAL_DONATIONS,
|
||||
IDEAL_ENABLED_REGIONS,
|
||||
SEPA_ENABLED_REGIONS
|
||||
SEPA_ENABLED_REGIONS,
|
||||
CALLING_REACTIONS
|
||||
);
|
||||
|
||||
@VisibleForTesting
|
||||
|
@ -253,7 +255,8 @@ public final class FeatureFlags {
|
|||
PROMPT_BATTERY_SAVER,
|
||||
USERNAMES,
|
||||
CRASH_PROMPT_CONFIG,
|
||||
BLOCK_SSE
|
||||
BLOCK_SSE,
|
||||
CALLING_REACTIONS
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -659,6 +662,13 @@ public final class FeatureFlags {
|
|||
return getString(SEPA_ENABLED_REGIONS, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not group call reactions are enabled.
|
||||
*/
|
||||
public static boolean groupCallReactions() {
|
||||
return getBoolean(CALLING_REACTIONS, false);
|
||||
}
|
||||
|
||||
/** Only for rendering debug info. */
|
||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
|
21
app/src/main/res/drawable/symbol_mic_slash_24.xml
Normal file
21
app/src/main/res/drawable/symbol_mic_slash_24.xml
Normal file
|
@ -0,0 +1,21 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M7.5 4.14l1.4 1.4c0.23-1.5 1.53-2.67 3.1-2.67 1.73 0 3.13 1.4 3.13 3.13v5.5c0 0.09 0 0.17-0.02 0.26l1.46 1.45c0.2-0.53 0.3-1.1 0.3-1.71V6c0-2.7-2.18-4.88-4.87-4.88-2.03 0-3.77 1.25-4.5 3.02Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12.93 16.29l-1.78-1.78c-1.04-0.3-1.86-1.12-2.16-2.16l-1.87-1.87v1.02c0 2.7 2.19 4.88 4.88 4.88 0.32 0 0.63-0.04 0.93-0.1Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M14.34 17.7l1.33 1.33c-0.86 0.42-1.8 0.7-2.8 0.8v1.3h3.38c0.48 0 0.88 0.39 0.88 0.87s-0.4 0.88-0.88 0.88h-8.5c-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88h3.38v-1.29c-4.22-0.44-7.5-4-7.5-8.33v-1c0-0.48 0.39-0.88 0.87-0.88s0.88 0.4 0.88 0.88v1c0 3.66 2.96 6.63 6.62 6.63 0.82 0 1.61-0.16 2.34-0.43Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19.18 15.82l-1.29-1.29c0.47-0.9 0.73-1.94 0.73-3.03v-1c0-0.48 0.4-0.88 0.88-0.88s0.88 0.4 0.88 0.88v1c0 1.58-0.44 3.06-1.2 4.32Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4.12 2.88c-0.34-0.34-0.9-0.34-1.24 0-0.34 0.34-0.34 0.9 0 1.24l17 17c0.34 0.34 0.9 0.34 1.24 0 0.34-0.34 0.34-0.9 0-1.24l-17-17Z"/>
|
||||
</vector>
|
15
app/src/main/res/drawable/symbol_video_slash_24.xml
Normal file
15
app/src/main/res/drawable/symbol_video_slash_24.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M2.37 2.13c-0.34-0.34-0.9-0.34-1.24 0-0.34 0.34-0.34 0.9 0 1.24l18.5 18.5c0.34 0.34 0.9 0.34 1.24 0 0.34-0.34 0.34-0.9 0-1.24L2.37 2.13Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M1.17 7.26C1.2 6.75 1.3 6.29 1.49 5.86l1.43 1.42V7.4C2.87 7.86 2.87 8.45 2.87 9.3v5.4c0 0.85 0 1.44 0.03 1.9 0.04 0.45 0.1 0.69 0.2 0.86 0.2 0.4 0.53 0.73 0.93 0.93 0.17 0.1 0.41 0.16 0.86 0.2 0.46 0.03 1.05 0.04 1.9 0.04h4.9c0.85 0 1.44 0 1.9-0.04 0.23-0.02 0.4-0.05 0.55-0.08l1.33 1.32-0.22 0.12c-0.47 0.24-0.96 0.34-1.52 0.38-0.53 0.05-1.2 0.05-2 0.05H6.76c-0.8 0-1.47 0-2-0.05C4.2 20.3 3.7 20.2 3.24 19.95c-0.73-0.37-1.32-0.96-1.7-1.7-0.23-0.46-0.33-0.95-0.37-1.5-0.05-0.54-0.05-1.2-0.04-2.01V9.26c0-0.8 0-1.47 0.04-2Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M15.63 13.27V9.3c0-0.85 0-1.44-0.04-1.9-0.04-0.45-0.1-0.69-0.2-0.86-0.2-0.4-0.53-0.73-0.93-0.93-0.17-0.1-0.41-0.16-0.86-0.2-0.46-0.03-1.05-0.04-1.9-0.04H7.73L6 3.63h0.77 4.98c0.8 0 1.47 0 2 0.04 0.56 0.04 1.05 0.14 1.52 0.38 0.73 0.37 1.32 0.96 1.7 1.7 0.23 0.46 0.33 0.95 0.37 1.5 0.04 0.46 0.04 1 0.04 1.64l2.8-2.8c1.19-1.18 3.2-0.35 3.2 1.32v9.18c0 1.48-1.6 2.31-2.78 1.64l-4.96-4.96ZM17.36 12c0 0.4 0.17 0.8 0.46 1.09l3.58 3.58 0.06 0.04h0.08l0.06-0.05c0-0.01 0.02-0.03 0.02-0.07V7.4c0-0.04-0.02-0.06-0.02-0.07L21.55 7.3l-0.08-0.01s-0.03 0-0.06 0.04l-3.58 3.58c-0.3 0.29-0.45 0.68-0.45 1.09Z"/>
|
||||
</vector>
|
182
app/src/main/res/layout/webrtc_call_controls.xml
Normal file
182
app/src/main/res/layout/webrtc_call_controls.xml
Normal file
|
@ -0,0 +1,182 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/call_controls_info_coordinator"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:layout_height="match_parent"
|
||||
tools:layout_width="match_parent">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/call_controls_info_parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/signal_colorSurface"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/handle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="10dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/bottom_sheet_handle" />
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/call_info_compose"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="38dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/call_controls_constraint_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="48dp"
|
||||
android:layout_marginTop="38dp">
|
||||
|
||||
<org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutputToggleButton
|
||||
android:id="@+id/call_screen_speaker_toggle"
|
||||
android:layout_width="@dimen/webrtc_button_size"
|
||||
android:layout_height="@dimen/webrtc_button_size"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_speaker"
|
||||
android:scaleType="fitXY"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_camera_direction_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:srcCompat="@drawable/webrtc_call_screen_speaker_toggle"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/call_screen_camera_direction_toggle"
|
||||
android:layout_width="@dimen/webrtc_button_size"
|
||||
android:layout_height="@dimen/webrtc_button_size"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:clickable="false"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_camera_direction"
|
||||
android:scaleType="fitXY"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_video_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_speaker_toggle"
|
||||
app:srcCompat="@drawable/webrtc_call_screen_camera_toggle"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.AccessibleToggleButton
|
||||
android:id="@+id/call_screen_video_toggle"
|
||||
style="@style/WebRtcCallV2CompoundButton"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:background="@drawable/webrtc_call_screen_video_toggle"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_camera"
|
||||
android:stateListAnimator="@null"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_mic_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_camera_direction_toggle"
|
||||
tools:checked="true"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.AccessibleToggleButton
|
||||
android:id="@+id/call_screen_audio_mic_toggle"
|
||||
style="@style/WebRtcCallV2CompoundButton"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:background="@drawable/webrtc_call_screen_mic_toggle"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_mute"
|
||||
android:stateListAnimator="@null"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_ring_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_video_toggle"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.AccessibleToggleButton
|
||||
android:id="@+id/call_screen_audio_ring_toggle"
|
||||
style="@style/WebRtcCallV2CompoundButton"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:background="@drawable/webrtc_call_screen_ring_toggle"
|
||||
android:stateListAnimator="@null"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_end_call"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_audio_mic_toggle" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/call_screen_end_call"
|
||||
android:layout_width="@dimen/webrtc_button_size"
|
||||
android:layout_height="@dimen/webrtc_button_size"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:clickable="false"
|
||||
android:contentDescription="@string/WebRtcCallView__end_call"
|
||||
android:scaleType="fitXY"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_audio_ring_toggle"
|
||||
app:srcCompat="@drawable/webrtc_call_screen_hangup"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/call_screen_start_call_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="32dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/call_screen_start_call_start_call"
|
||||
style="@style/Widget.Signal.Button.Flat"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="56dp"
|
||||
android:minWidth="160dp"
|
||||
android:text="@string/WebRtcCallView__start_call"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/core_green_text_button"
|
||||
app:backgroundTint="@color/signal_light_colorPrimary"
|
||||
app:cornerRadius="28dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:layout_margin="8dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
||||
android:id="@+id/webrtc_call_reaction_emoji_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"/>
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/webrtc_call_reaction_name_textview"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:background="@drawable/transparent_black_pill" />
|
||||
|
||||
</LinearLayout>
|
|
@ -5,6 +5,8 @@
|
|||
tools:parentTag="org.thoughtcrime.securesms.components.webrtc.WebRtcCallView"
|
||||
tools:viewBindingIgnore="true">
|
||||
|
||||
<include layout="@layout/system_ui_guidelines" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/fold_top_guideline"
|
||||
android:layout_width="0dp"
|
||||
|
@ -12,11 +14,18 @@
|
|||
android:orientation="horizontal"
|
||||
app:layout_constraintGuide_end="0dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/call_screen_above_controls_guideline"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:orientation="horizontal"
|
||||
tools:layout_constraintGuide_end="200dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/call_screen_participants_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/fold_top_guideline"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_participants_recycler"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
@ -86,10 +95,24 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/call_screen"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<org.thoughtcrime.securesms.components.recyclerview.NoTouchingRecyclerView
|
||||
android:id="@+id/call_screen_reactions_feed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:clickable="false"
|
||||
android:orientation="vertical"
|
||||
android:scrollbars="none"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toTopOf="@+id/call_screen_above_controls_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:reverseLayout="true"
|
||||
tools:itemCount="2"
|
||||
tools:listitem="@layout/webrtc_call_reaction_recycler_item" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/call_screen_show_participants_guideline"
|
||||
|
@ -103,7 +126,7 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline"
|
||||
app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
|
||||
app:layout_constraintGuide_end="0dp" />
|
||||
|
||||
<View
|
||||
|
@ -115,20 +138,6 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/call_screen_status_bar_guideline"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
tools:layout_constraintGuide_begin="48dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/call_screen_navigation_bar_guideline"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
tools:layout_constraintGuide_end="48dp" />
|
||||
|
||||
<Space
|
||||
android:id="@+id/call_screen_footer_gradient_spacer"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -150,15 +159,16 @@
|
|||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/call_screen_participants_recycler"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:paddingStart="16dp"
|
||||
android:clipToPadding="false"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_video_toggle"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_above_controls_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
@ -183,12 +193,11 @@
|
|||
android:layout_height="@dimen/picture_in_picture_gesture_helper_pip_height"
|
||||
android:background="@null"
|
||||
android:clipChildren="true"
|
||||
android:translationX="100000dp"
|
||||
android:translationY="100000dp"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_marginStart="@dimen/picture_in_picture_gesture_helper_frame_padding"
|
||||
android:layout_marginEnd="@dimen/picture_in_picture_gesture_helper_frame_padding"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="8dp"
|
||||
tools:translationX="0dp"
|
||||
tools:translationY="0dp"
|
||||
tools:visibility="visible">
|
||||
|
||||
<include
|
||||
|
@ -205,7 +214,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:minHeight="@dimen/signal_m3_toolbar_height"
|
||||
app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline"
|
||||
app:layout_constraintTop_toTopOf="@id/status_bar_guideline"
|
||||
app:menu="@menu/webrtc_toolbar_menu"
|
||||
app:navigationIcon="@drawable/ic_arrow_left_24" />
|
||||
|
||||
|
@ -214,7 +223,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:minHeight="@dimen/signal_m3_toolbar_height"
|
||||
app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline"
|
||||
app:layout_constraintTop_toTopOf="@id/status_bar_guideline"
|
||||
app:menu="@menu/webrtc_toolbar_menu"
|
||||
app:navigationIcon="@drawable/ic_arrow_left_24"
|
||||
app:subtitleTextAppearance="@style/Signal.Text.BodyMedium"
|
||||
|
@ -228,107 +237,7 @@
|
|||
android:layout_marginEnd="64dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutputToggleButton
|
||||
android:id="@+id/call_screen_speaker_toggle"
|
||||
android:layout_width="@dimen/webrtc_button_size"
|
||||
android:layout_height="@dimen/webrtc_button_size"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_speaker"
|
||||
android:scaleType="fitXY"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_camera_direction_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:srcCompat="@drawable/webrtc_call_screen_speaker_toggle"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/call_screen_camera_direction_toggle"
|
||||
android:layout_width="@dimen/webrtc_button_size"
|
||||
android:layout_height="@dimen/webrtc_button_size"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:clickable="false"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_camera_direction"
|
||||
android:scaleType="fitXY"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_video_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_speaker_toggle"
|
||||
app:srcCompat="@drawable/webrtc_call_screen_camera_toggle"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.AccessibleToggleButton
|
||||
android:id="@+id/call_screen_video_toggle"
|
||||
style="@style/WebRtcCallV2CompoundButton"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:background="@drawable/webrtc_call_screen_video_toggle"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_camera"
|
||||
android:stateListAnimator="@null"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_mic_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_camera_direction_toggle"
|
||||
tools:checked="true"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.AccessibleToggleButton
|
||||
android:id="@+id/call_screen_audio_mic_toggle"
|
||||
style="@style/WebRtcCallV2CompoundButton"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:background="@drawable/webrtc_call_screen_mic_toggle"
|
||||
android:contentDescription="@string/WebRtcCallView__toggle_mute"
|
||||
android:stateListAnimator="@null"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_audio_ring_toggle"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_video_toggle"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.AccessibleToggleButton
|
||||
android:id="@+id/call_screen_audio_ring_toggle"
|
||||
style="@style/WebRtcCallV2CompoundButton"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:background="@drawable/webrtc_call_screen_ring_toggle"
|
||||
android:stateListAnimator="@null"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_end_call"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_audio_mic_toggle" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/call_screen_end_call"
|
||||
android:layout_width="@dimen/webrtc_button_size"
|
||||
android:layout_height="@dimen/webrtc_button_size"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="30dp"
|
||||
android:clickable="false"
|
||||
android:contentDescription="@string/WebRtcCallView__end_call"
|
||||
android:scaleType="fitXY"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_start_call_controls"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_audio_ring_toggle"
|
||||
app:srcCompat="@drawable/webrtc_call_screen_hangup"
|
||||
tools:visibility="visible" />
|
||||
app:layout_constraintTop_toTopOf="@id/status_bar_guideline" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/call_screen_decline_call"
|
||||
|
@ -338,7 +247,7 @@
|
|||
android:layout_marginBottom="65dp"
|
||||
android:contentDescription="@string/WebRtcCallScreen__decline"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline"
|
||||
app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
|
||||
app:layout_constraintEnd_toStartOf="@id/call_screen_answer_call"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
@ -367,7 +276,7 @@
|
|||
android:layout_marginBottom="65dp"
|
||||
android:contentDescription="@string/WebRtcCallScreen__answer"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline"
|
||||
app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toEndOf="@id/call_screen_decline_call"
|
||||
|
@ -415,34 +324,16 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/call_screen_start_call_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="32dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/call_screen_start_call_start_call"
|
||||
style="@style/Widget.Signal.Button.Flat"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="56dp"
|
||||
android:minWidth="160dp"
|
||||
android:text="@string/WebRtcCallView__start_call"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/core_green_text_button"
|
||||
app:backgroundTint="@color/signal_light_colorPrimary"
|
||||
app:cornerRadius="28dp" />
|
||||
|
||||
</LinearLayout>
|
||||
<org.thoughtcrime.securesms.stories.viewer.reply.reaction.MultiReactionBurstLayout
|
||||
android:id="@+id/call_screen_reactions_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
app:layout_constraintBottom_toBottomOf="@id/call_screen_reactions_feed"
|
||||
app:layout_constraintEnd_toEndOf="@id/call_screen_reactions_feed"
|
||||
app:layout_constraintStart_toStartOf="@id/call_screen_reactions_feed"
|
||||
app:layout_constraintTop_toTopOf="@id/call_screen_reactions_feed" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/call_screen_error_cancel"
|
||||
|
@ -458,7 +349,7 @@
|
|||
android:textColor="@color/core_white"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/transparent_white_40"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_navigation_bar_guideline"
|
||||
app:layout_constraintBottom_toTopOf="@id/navigation_bar_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:visibility="gone" />
|
||||
|
@ -505,5 +396,5 @@
|
|||
app:barrierDirection="top"
|
||||
app:constraint_referenced_ids="call_screen_answer_call,call_screen_decline_call,call_screen_audio_mic_toggle,call_screen_camera_direction_toggle,call_screen_video_toggle,call_screen_answer_without_video,call_screen_speaker_toggle,call_screen_end_call" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<include layout="@layout/webrtc_call_controls"/>
|
||||
</merge>
|
|
@ -1848,9 +1848,17 @@
|
|||
<string name="WebRtcAudioOutputBottomSheet__earpiece_icon_content_description">An icon representing a device\'s earpiece.</string>
|
||||
|
||||
<!-- CallParticipantsListDialog -->
|
||||
<plurals name="CallParticipantsListDialog_in_this_call_d_people">
|
||||
<item quantity="one">In this call · %1$d person</item>
|
||||
<item quantity="other">In this call · %1$d people</item>
|
||||
<plurals name="CallParticipantsListDialog_in_this_call">
|
||||
<item quantity="one">In this call (%1$d)</item>
|
||||
<item quantity="other">In this call (%1$d)</item>
|
||||
</plurals>
|
||||
<plurals name="CallParticipantsListDialog__signal_will_ring">
|
||||
<item quantity="one">Signal will Ring (%1$d)</item>
|
||||
<item quantity="other">Signal will Ring (%1$d)</item>
|
||||
</plurals>
|
||||
<plurals name="CallParticipantsListDialog__signal_will_notify">
|
||||
<item quantity="one">Signal will Notify (%1$d)</item>
|
||||
<item quantity="other">Signal will Notify (%1$d)</item>
|
||||
</plurals>
|
||||
|
||||
<!-- CallParticipantView -->
|
||||
|
|
Loading…
Add table
Reference in a new issue