Add expandable video pip to 1:1 conversations.

This commit is contained in:
Alex Hart 2021-01-13 11:33:41 -04:00 committed by Greyson Parrelli
parent 6c94be70dc
commit cee2702fdf
8 changed files with 334 additions and 16 deletions

View file

@ -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();
}
}
}

View file

@ -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<CallParticipant> 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;

View file

@ -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();
}
}

View file

@ -222,8 +222,9 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
@Override
public boolean onSingleTapUp(MotionEvent e) {
child.performClick();
isDragging = false;
child.performClick();
return true;
}

View file

@ -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<View> incomingCallViews = new HashSet<>();
private final Set<View> 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();
}
}

View file

@ -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;
}

View file

@ -5,5 +5,6 @@ public enum WebRtcLocalRenderState {
SMALL_RECTANGLE,
SMALLER_RECTANGLE,
LARGE,
LARGE_NO_VIDEO
LARGE_NO_VIDEO,
EXPANDED
}

View file

@ -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">
<androidx.cardview.widget.CardView
android:id="@+id/call_screen_pip"
@ -110,7 +108,8 @@
android:translationY="100000dp"
android:visibility="gone"
app:cardCornerRadius="8dp"
tools:background="@color/red"
tools:translationX="0dp"
tools:translationY="0dp"
tools:visibility="visible">
<include