mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-03-01 14:16:49 +00:00
Receive calling reactions support and control ux refactor.
Co-authored-by: Nicholas <nicholas@signal.org>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user