diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index f5bfb3f89e..641fbc3604 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -718,5 +718,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) { viewModel.setIsViewingFocusedParticipant(page); } + + @Override + public void onLocalPictureInPictureClicked() { + viewModel.onLocalPictureInPictureClicked(); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java index 3ae33fa940..842973a4be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java @@ -181,7 +181,8 @@ public final class CallParticipantsState { webRtcViewModel.getGroupState().isNotIdle(), webRtcViewModel.getState(), webRtcViewModel.getRemoteParticipants().size(), - oldState.isViewingFocusedParticipant); + oldState.isViewingFocusedParticipant, + oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); List participantsByLastSpoke = new ArrayList<>(webRtcViewModel.getRemoteParticipants()); Collections.sort(participantsByLastSpoke, ComparatorCompat.reversed((p1, p2) -> Long.compare(p1.getLastSpoke(), p2.getLastSpoke()))); @@ -207,7 +208,8 @@ public final class CallParticipantsState { oldState.getGroupCallState().isNotIdle(), oldState.callState, oldState.getAllRemoteParticipants().size(), - oldState.isViewingFocusedParticipant); + oldState.isViewingFocusedParticipant, + oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); @@ -223,6 +225,28 @@ public final class CallParticipantsState { oldState.remoteDevicesCount); } + public static @NonNull CallParticipantsState setExpanded(@NonNull CallParticipantsState oldState, boolean expanded) { + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + oldState.getGroupCallState().isNotIdle(), + oldState.callState, + oldState.getAllRemoteParticipants().size(), + oldState.isViewingFocusedParticipant, + expanded); + + return new CallParticipantsState(oldState.callState, + oldState.groupCallState, + oldState.remoteParticipants, + oldState.localParticipant, + oldState.focusedParticipant, + localRenderState, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + oldState.isViewingFocusedParticipant, + oldState.remoteDevicesCount); + } + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) { CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); @@ -232,7 +256,8 @@ public final class CallParticipantsState { oldState.getGroupCallState().isNotIdle(), oldState.callState, oldState.getAllRemoteParticipants().size(), - selectedPage == SelectedPage.FOCUSED); + selectedPage == SelectedPage.FOCUSED, + oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED); return new CallParticipantsState(oldState.callState, oldState.groupCallState, @@ -252,12 +277,15 @@ public final class CallParticipantsState { boolean isNonIdleGroupCall, @NonNull WebRtcViewModel.State callState, int numberOfRemoteParticipants, - boolean isViewingFocusedParticipant) + boolean isViewingFocusedParticipant, + boolean isExpanded) { boolean displayLocal = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled()); WebRtcLocalRenderState localRenderState = WebRtcLocalRenderState.GONE; - if (displayLocal || showVideoForOutgoing) { + if (isExpanded && (localParticipant.isVideoEnabled() || isNonIdleGroupCall)) { + return WebRtcLocalRenderState.EXPANDED; + } else if (displayLocal || showVideoForOutgoing) { if (callState == WebRtcViewModel.State.CALL_CONNECTED) { if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) { localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java new file mode 100644 index 0000000000..d216407ee3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureExpansionHelper.java @@ -0,0 +1,197 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +/** + * Helps manage the expansion and shrinking of the in-app pip. + */ +@MainThread +final class PictureInPictureExpansionHelper { + + private State state = State.IS_SHRUNKEN; + + public boolean isExpandedOrExpanding() { + return state == State.IS_EXPANDED || state == State.IS_EXPANDING; + } + + public boolean isShrunkenOrShrinking() { + return state == State.IS_SHRUNKEN || state == State.IS_SHRINKING; + } + + public void expand(@NonNull View toExpand, @NonNull Callback callback) { + if (isExpandedOrExpanding()) { + return; + } + + performExpandAnimation(toExpand, 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) { + if (isShrunkenOrShrinking()) { + return; + } + + performShrinkAnimation(toExpand, 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(); + + 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) + .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); + }); + }); + } + + enum State { + IS_EXPANDING, + IS_EXPANDED, + IS_SHRINKING, + IS_SHRUNKEN + } + + public interface Callback { + /** + * 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(); + + /** + * 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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java index 19a6ee8057..0a8391220b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java @@ -222,8 +222,9 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu @Override public boolean onSingleTapUp(MotionEvent e) { - child.performClick(); + isDragging = false; + child.performClick(); return true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java index c978aac187..3e0817acdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -1,5 +1,9 @@ package org.thoughtcrime.securesms.components.webrtc; +import android.animation.Animator; +import android.animation.AnimatorInflater; +import android.animation.AnimatorSet; +import android.animation.ValueAnimator; import android.content.Context; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; @@ -8,7 +12,13 @@ import android.util.AttributeSet; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.view.ViewPropertyAnimator; +import android.view.animation.AlphaAnimation; import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.AnimationUtils; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; @@ -102,6 +112,8 @@ public class WebRtcCallView extends FrameLayout { private WebRtcCallParticipantsPagerAdapter pagerAdapter; private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; + private PictureInPictureExpansionHelper pictureInPictureExpansionHelper; + private final Set incomingCallViews = new HashSet<>(); private final Set topViews = new HashSet<>(); @@ -211,7 +223,14 @@ public class WebRtcCallView extends FrameLayout { answer.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed)); answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed)); - pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame); + pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame); + pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper(); + + smallLocalRenderFrame.setOnClickListener(v -> { + if (controlsListener != null) { + controlsListener.onLocalPictureInPictureClicked(); + } + }); startCall.setOnClickListener(v -> { if (controlsListener != null) { @@ -301,7 +320,7 @@ public class WebRtcCallView extends FrameLayout { pagerAdapter.submitList(pages); recyclerAdapter.submitList(state.getListParticipants()); - updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant()); + updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), state.getFocusedParticipant()); if (state.isLargeVideoGroup() && !state.isInPipMode()) { layoutParticipantsForLargeCount(); @@ -310,7 +329,7 @@ public class WebRtcCallView extends FrameLayout { } } - public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, @NonNull CallParticipant localCallParticipant) { + public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, @NonNull CallParticipant localCallParticipant, @NonNull CallParticipant focusedParticipant) { smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); @@ -321,9 +340,18 @@ public class WebRtcCallView extends FrameLayout { largeLocalRender.init(localCallParticipant.getVideoSink().getEglBase()); } - smallLocalRender.setCallParticipant(localCallParticipant); - smallLocalRender.setRenderInPip(true); videoToggle.setChecked(localCallParticipant.isVideoEnabled(), false); + smallLocalRender.setRenderInPip(true); + + if (state == WebRtcLocalRenderState.EXPANDED) { + expandPip(localCallParticipant, focusedParticipant); + return; + } else if (state == WebRtcLocalRenderState.SMALL_RECTANGLE && pictureInPictureExpansionHelper.isExpandedOrExpanding()) { + shrinkPip(localCallParticipant); + return; + } else { + smallLocalRender.setCallParticipant(localCallParticipant); + } switch (state) { case GONE: @@ -559,6 +587,54 @@ public class WebRtcCallView extends FrameLayout { } } + 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); + } + + @Override + public void onPictureInPictureNotVisible() { + smallLocalRender.setCallParticipant(focusedParticipant); + } + + @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); + } + + @Override + public void onAnimationHasFinished() { + pictureInPictureGestureHelper.adjustPip(); + } + }); + } + private void animatePipToLargeRectangle() { ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, ViewUtil.dpToPx(90), ViewUtil.dpToPx(160)); animation.setDuration(PIP_RESIZE_DURATION); @@ -753,5 +829,6 @@ public class WebRtcCallView extends FrameLayout { void onAcceptCallPressed(); void onShowParticipantsList(); void onPageChanged(@NonNull CallParticipantsState.SelectedPage page); + void onLocalPictureInPictureClicked(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java index ccf6523dad..f9087da68f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -135,6 +135,16 @@ public class WebRtcCallViewModel extends ViewModel { participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), page)); } + public void onLocalPictureInPictureClicked() { + CallParticipantsState state = participantsState.getValue(); + if (state.getGroupCallState() != WebRtcViewModel.GroupCallState.IDLE) { + return; + } + + participantsState.setValue(CallParticipantsState.setExpanded(participantsState.getValue(), + state.getLocalRenderState() != WebRtcLocalRenderState.EXPANDED)); + } + public void onDismissedVideoTooltip() { canDisplayTooltipIfNeeded = false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java index 6518e99a68..f23df83ec9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java @@ -5,5 +5,6 @@ public enum WebRtcLocalRenderState { SMALL_RECTANGLE, SMALLER_RECTANGLE, LARGE, - LARGE_NO_VIDEO + LARGE_NO_VIDEO, + EXPANDED } diff --git a/app/src/main/res/layout/webrtc_call_view.xml b/app/src/main/res/layout/webrtc_call_view.xml index e0e79167ad..e2456c7c33 100644 --- a/app/src/main/res/layout/webrtc_call_view.xml +++ b/app/src/main/res/layout/webrtc_call_view.xml @@ -95,10 +95,8 @@ android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0"> + app:layout_constraintTop_toTopOf="parent">