// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.media;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.app.PictureInPictureParams;
import android.app.RemoteAction;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.Bundle;
import android.util.Rational;
import android.util.Size;
import android.view.View;
import android.view.View.OnLayoutChangeListener;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import org.jni_zero.CalledByNative;
import org.jni_zero.NativeMethods;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.MathUtils;
import org.chromium.base.supplier.OneshotSupplier;
import org.chromium.base.supplier.OneshotSupplierImpl;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.init.AsyncInitializationActivity;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileProvider;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabUtils;
import org.chromium.components.thinwebview.CompositorView;
import org.chromium.components.thinwebview.CompositorViewFactory;
import org.chromium.components.thinwebview.ThinWebViewConstraints;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.overlay_window.PlaybackState;
import org.chromium.media_session.mojom.MediaSessionAction;
import org.chromium.ui.base.ActivityWindowAndroid;
import org.chromium.ui.base.WindowAndroid;
import java.util.ArrayList;
import java.util.HashSet;
/**
* A picture in picture activity which get created when requesting
* PiP from web API. The activity will connect to web API through
* OverlayWindowAndroid.
*/
public class PictureInPictureActivity extends AsyncInitializationActivity {
// Used to filter media buttons' remote action intents.
private static final String MEDIA_ACTION =
"org.chromium.chrome.browser.media.PictureInPictureActivity.MediaAction";
// Used to determine which action button has been touched.
private static final String CONTROL_TYPE =
"org.chromium.chrome.browser.media.PictureInPictureActivity.ControlType";
// Used to determine the media controls state. (e.g. microphone on/off)
private static final String CONTROL_STATE =
"org.chromium.chrome.browser.media.PictureInPictureActivity.ControlState";
// Use for passing unique window id to each PictureInPictureActivity instance.
private static final String NATIVE_POINTER_KEY =
"org.chromium.chrome.browser.media.PictureInPictureActivity.NativePointer";
// Used for passing webcontents to PictureInPictureActivity.
private static final String WEB_CONTENTS_KEY =
"org.chromium.chrome.browser.media.PictureInPicture.WebContents";
// If present, it indicates that the intent launches into PiP.
public static final String LAUNCHED_KEY = "com.android.chrome.pictureinpicture.launched";
// If present, these provide our aspect ratio hint.
private static final String SOURCE_WIDTH_KEY =
"org.chromium.chrome.browser.media.PictureInPictureActivity.source.width";
private static final String SOURCE_HEIGHT_KEY =
"org.chromium.chrome.browser.media.PictureInPictureActivity.source.height";
private static final float MAX_ASPECT_RATIO = 2.39f;
private static final float MIN_ASPECT_RATIO = 1 / 2.39f;
private static long sPendingNativeOverlayWindowAndroid;
private long mNativeOverlayWindowAndroid;
private Tab mInitiatorTab;
private InitiatorTabObserver mTabObserver;
private CompositorView mCompositorView;
// If present, this is the video's aspect ratio.
private Rational mAspectRatio;
// Maximum pip width, in pixels, to prevent resizes that are too big.
private int mMaxWidth;
private MediaSessionBroadcastReceiver mMediaSessionReceiver;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
MediaActionButtonsManager mMediaActionsButtonsManager;
/** A helper class for managing media action buttons in PictureInPicture window. */
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
class MediaActionButtonsManager {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mPreviousSlide;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mPreviousTrack;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mPlay;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mPause;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mReplay;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mNextTrack;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mNextSlide;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final RemoteAction mHangUp;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final ToggleRemoteAction mMicrophone;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
final ToggleRemoteAction mCamera;
private @PlaybackState int mPlaybackState;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
class ToggleRemoteAction {
private final RemoteAction mActionOn;
private final RemoteAction mActionOff;
private boolean mState;
private ToggleRemoteAction(RemoteAction actionOn, RemoteAction actionOff) {
mActionOn = actionOn;
mActionOff = actionOff;
mState = false;
}
private void setState(boolean on) {
mState = on;
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
RemoteAction getAction() {
return mState ? mActionOn : mActionOff;
}
}
/** A set of {@link MediaSessionAction}. */
private HashSet<Integer> mVisibleActions;
private MediaActionButtonsManager() {
int requestCode = 0;
mPreviousTrack =
createRemoteAction(
requestCode++,
MediaSessionAction.PREVIOUS_TRACK,
R.drawable.ic_skip_previous_white_24dp,
R.string.accessibility_previous_track,
/**controlState=*/
null);
mPreviousSlide =
createRemoteAction(
requestCode++,
MediaSessionAction.PREVIOUS_SLIDE,
R.drawable.ic_skip_previous_white_24dp,
R.string.accessibility_previous_slide,
/**controlState=*/
null);
mPlay =
createRemoteAction(
requestCode++,
MediaSessionAction.PLAY,
R.drawable.ic_play_arrow_white_24dp,
R.string.accessibility_play,
/**controlState=*/
null);
mPause =
createRemoteAction(
requestCode++,
MediaSessionAction.PAUSE,
R.drawable.ic_pause_white_24dp,
R.string.accessibility_pause,
/**controlState=*/
null);
mReplay =
createRemoteAction(
requestCode++,
MediaSessionAction.PLAY,
R.drawable.ic_replay_white_24dp,
R.string.accessibility_replay,
/**controlState=*/
null);
mNextTrack =
createRemoteAction(
requestCode++,
MediaSessionAction.NEXT_TRACK,
R.drawable.ic_skip_next_white_24dp,
R.string.accessibility_next_track,
/**controlState=*/
null);
mNextSlide =
createRemoteAction(
requestCode++,
MediaSessionAction.NEXT_SLIDE,
R.drawable.ic_skip_next_white_24dp,
R.string.accessibility_next_slide,
/**controlState=*/
null);
mHangUp =
createRemoteAction(
requestCode++,
MediaSessionAction.HANG_UP,
R.drawable.ic_call_end_white_24dp,
R.string.accessibility_hang_up,
/**controlState=*/
null);
mMicrophone =
new ToggleRemoteAction(
createRemoteAction(
requestCode++,
MediaSessionAction.TOGGLE_MICROPHONE,
R.drawable.ic_mic_white_24dp,
R.string.accessibility_mute_microphone,
/**controlState=*/
true),
createRemoteAction(
requestCode++,
MediaSessionAction.TOGGLE_MICROPHONE,
R.drawable.ic_mic_off_white_24dp,
R.string.accessibility_unmute_microphone,
/**controlState=*/
false));
mCamera =
new ToggleRemoteAction(
createRemoteAction(
requestCode++,
MediaSessionAction.TOGGLE_CAMERA,
R.drawable.ic_videocam_24dp,
R.string.accessibility_turn_off_camera,
/**controlState=*/
true),
createRemoteAction(
requestCode++,
MediaSessionAction.TOGGLE_CAMERA,
R.drawable.ic_videocam_off_white_24dp,
R.string.accessibility_turn_on_camera,
/**controlState=*/
false));
mPlaybackState = PlaybackState.END_OF_VIDEO;
mVisibleActions = new HashSet<>();
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@SuppressLint("NewApi")
ArrayList<RemoteAction> getActionsForPictureInPictureParams() {
ArrayList<RemoteAction> actions = new ArrayList<>();
final boolean shouldShowPreviousNextTrack =
mVisibleActions.contains(MediaSessionAction.PREVIOUS_TRACK)
|| mVisibleActions.contains(MediaSessionAction.NEXT_TRACK);
if (shouldShowPreviousNextTrack) {
mPreviousTrack.setEnabled(
mVisibleActions.contains(MediaSessionAction.PREVIOUS_TRACK));
actions.add(mPreviousTrack);
}
final boolean shouldShowPreviousNextSlide =
mVisibleActions.contains(MediaSessionAction.PREVIOUS_SLIDE)
|| mVisibleActions.contains(MediaSessionAction.NEXT_SLIDE);
if (shouldShowPreviousNextSlide) {
mPreviousSlide.setEnabled(
mVisibleActions.contains(MediaSessionAction.PREVIOUS_SLIDE));
actions.add(mPreviousSlide);
}
if (mVisibleActions.contains(MediaSessionAction.PLAY)) {
switch (mPlaybackState) {
case PlaybackState.PLAYING:
actions.add(mPause);
break;
case PlaybackState.PAUSED:
actions.add(mPlay);
break;
case PlaybackState.END_OF_VIDEO:
actions.add(mReplay);
break;
}
}
if (shouldShowPreviousNextTrack) {
mNextTrack.setEnabled(mVisibleActions.contains(MediaSessionAction.NEXT_TRACK));
actions.add(mNextTrack);
}
if (shouldShowPreviousNextSlide) {
mNextSlide.setEnabled(mVisibleActions.contains(MediaSessionAction.NEXT_SLIDE));
actions.add(mNextSlide);
}
if (mVisibleActions.contains(MediaSessionAction.TOGGLE_MICROPHONE)) {
actions.add(mMicrophone.getAction());
}
if (mVisibleActions.contains(MediaSessionAction.TOGGLE_CAMERA)) {
actions.add(mCamera.getAction());
}
if (mVisibleActions.contains(MediaSessionAction.HANG_UP)) {
actions.add(mHangUp);
}
// Insert a disabled placeholder remote action with transparent icon if action list is
// empty. This is a workaround of the issue that android picture-in-picture will
// fallback to default MediaSession when action list given is empty.
// TODO (jazzhsu): Remove this when android picture-in-picture can accept empty list and
// not fallback to default MediaSession.
if (actions.isEmpty()) {
RemoteAction placeholderAction =
new RemoteAction(
Icon.createWithBitmap(
Bitmap.createBitmap(
new int[] {Color.TRANSPARENT},
1,
1,
Bitmap.Config.ARGB_8888)),
"",
"",
PendingIntent.getBroadcast(
getApplicationContext(),
-1,
new Intent(MEDIA_ACTION),
PendingIntent.FLAG_IMMUTABLE));
placeholderAction.setEnabled(false);
actions.add(placeholderAction);
}
return actions;
}
/**
* Update visible actions.
*
* @param visibleActions A set of available {@link MediaSessionAction}.
*/
private void updateVisibleActions(HashSet<Integer> visibleActions) {
mVisibleActions = visibleActions;
}
private void updatePlaybackState(@PlaybackState int playbackState) {
mPlaybackState = playbackState;
}
private void setMicrophoneMuted(boolean muted) {
mMicrophone.setState(!muted);
}
private void setCameraOn(boolean cameraOn) {
mCamera.setState(cameraOn);
}
/**
* Create a remote action for picture-in-picture window.
*
* @param requestCode unique id for pending intent.
* @param action {@link MediaSessionAction} that the action button is corresponding to.
* @param iconResourceId used for getting icon associated with the id.
* @param titleResourceId used for getting accessibility title associated with the id.
* @param controlState indicate the action's state. (e.g. microphone on/off) Null if not
* applicable
*/
@SuppressLint("NewApi")
private RemoteAction createRemoteAction(
int requestCode,
int action,
int iconResourceId,
int titleResourceId,
Boolean controlState) {
Intent intent = new Intent(MEDIA_ACTION);
intent.setPackage(getApplicationContext().getPackageName());
IntentUtils.addTrustedIntentExtras(intent);
intent.putExtra(CONTROL_TYPE, action);
intent.putExtra(NATIVE_POINTER_KEY, mNativeOverlayWindowAndroid);
if (controlState != null) {
intent.putExtra(CONTROL_STATE, controlState);
}
PendingIntent pendingIntent =
PendingIntent.getBroadcast(
getApplicationContext(),
requestCode,
intent,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
return new RemoteAction(
Icon.createWithResource(getApplicationContext(), iconResourceId),
getApplicationContext().getResources().getText(titleResourceId),
"",
pendingIntent);
}
}
private class MediaSessionBroadcastReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (!IntentUtils.isTrustedIntentFromSelf(intent)) return;
long nativeOverlayWindowAndroid = intent.getLongExtra(NATIVE_POINTER_KEY, 0);
if (nativeOverlayWindowAndroid != mNativeOverlayWindowAndroid
|| mNativeOverlayWindowAndroid == 0) {
return;
}
if (intent.getAction() == null || !intent.getAction().equals(MEDIA_ACTION)) return;
Boolean controlState =
intent.hasExtra(CONTROL_STATE)
? intent.getBooleanExtra(CONTROL_STATE, true)
: null;
switch (intent.getIntExtra(CONTROL_TYPE, -1)) {
case MediaSessionAction.PLAY:
PictureInPictureActivityJni.get()
.togglePlayPause(
nativeOverlayWindowAndroid,
/**toggleOn=*/
true);
return;
case MediaSessionAction.PAUSE:
PictureInPictureActivityJni.get()
.togglePlayPause(
nativeOverlayWindowAndroid,
/**toggleOn=*/
false);
return;
case MediaSessionAction.PREVIOUS_TRACK:
PictureInPictureActivityJni.get().previousTrack(nativeOverlayWindowAndroid);
return;
case MediaSessionAction.NEXT_TRACK:
PictureInPictureActivityJni.get().nextTrack(nativeOverlayWindowAndroid);
return;
case MediaSessionAction.PREVIOUS_SLIDE:
PictureInPictureActivityJni.get().previousSlide(nativeOverlayWindowAndroid);
return;
case MediaSessionAction.NEXT_SLIDE:
PictureInPictureActivityJni.get().nextSlide(nativeOverlayWindowAndroid);
return;
case MediaSessionAction.TOGGLE_MICROPHONE:
PictureInPictureActivityJni.get()
.toggleMicrophone(nativeOverlayWindowAndroid, !controlState);
return;
case MediaSessionAction.TOGGLE_CAMERA:
PictureInPictureActivityJni.get()
.toggleCamera(nativeOverlayWindowAndroid, !controlState);
return;
case MediaSessionAction.HANG_UP:
PictureInPictureActivityJni.get().hangUp(nativeOverlayWindowAndroid);
return;
default:
return;
}
}
}
private class InitiatorTabObserver extends EmptyTabObserver {
@Override
public void onClosingStateChanged(Tab tab, boolean closing) {
if (closing) {
PictureInPictureActivity.this.onExitPictureInPicture(/* closeByNative= */ false);
}
}
@Override
public void onDestroyed(Tab tab) {
if (tab.isClosing()) {
PictureInPictureActivity.this.onExitPictureInPicture(/* closeByNative= */ false);
}
}
@Override
public void onCrash(Tab tab) {
PictureInPictureActivity.this.onExitPictureInPicture(/* closeByNative= */ false);
}
}
/**
* Interface to abstract makeLaunchIntoPip, which is only available in android T+.
* Implementations of this API should expect to be called even on older version of Android, and
* do nothing. This allows tests to mock out the behavior.
*/
interface LaunchIntoPipHelper {
// Return a bundle to launch Picture in picture with `bounds` as the source rectangle.
// May return null if the bundle could not be constructed.
Bundle build(Context activityContext, Rect bounds);
}
// Default implementation that tries to `makeLaunchIntoPiP` via reflection. Does nothing,
// successfully, if this is not Android T or later.
static LaunchIntoPipHelper sLaunchIntoPipHelper =
new LaunchIntoPipHelper() {
@Override
public Bundle build(final Context activityContext, final Rect bounds) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null;
final Rational aspectRatio = new Rational(bounds.width(), bounds.height());
final PictureInPictureParams params =
new PictureInPictureParams.Builder()
.setSourceRectHint(bounds)
.setAspectRatio(aspectRatio)
.build();
return ActivityOptions.makeLaunchIntoPip(params).toBundle();
}
};
@Override
protected void triggerLayoutInflation() {
onInitialLayoutInflationComplete();
}
@Override
protected OneshotSupplier<ProfileProvider> createProfileProvider() {
OneshotSupplierImpl<ProfileProvider> supplier = new OneshotSupplierImpl<>();
ProfileProvider profileProvider =
new ProfileProvider() {
@NonNull
@Override
public Profile getOriginalProfile() {
return mInitiatorTab.getProfile().getOriginalProfile();
}
@Nullable
@Override
public Profile getOffTheRecordProfile(boolean createIfNeeded) {
if (!mInitiatorTab.getProfile().isOffTheRecord()) {
throw new IllegalStateException(
"Attempting to access invalid incognito profile from PiP");
}
return mInitiatorTab.getProfile();
}
@Override
public boolean hasOffTheRecordProfile() {
return mInitiatorTab.isIncognito();
}
};
supplier.set(profileProvider);
return supplier;
}
@Override
public void finishNativeInitialization() {
super.finishNativeInitialization();
// Compute a somewhat arbitrary cut-off of 90% of the window's display width. The PiP
// window can't be anywhere near this big, so the exact value doesn't matter. We'll ignore
// resizes messages that are above it, since they're spurious.
mMaxWidth = (int) ((getWindowAndroid().getDisplay().getDisplayWidth()) * 0.95);
mCompositorView =
CompositorViewFactory.create(
this, getWindowAndroid(), new ThinWebViewConstraints());
addContentView(
mCompositorView.getView(),
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
mCompositorView
.getView()
.addOnLayoutChangeListener(
new OnLayoutChangeListener() {
@Override
public void onLayoutChange(
View v,
int left,
int top,
int right,
int bottom,
int oldLeft,
int oldTop,
int oldRight,
int oldBottom) {
if (mNativeOverlayWindowAndroid == 0) return;
// We sometimes get an initial update of zero before getting
// something reasonable.
if (top == bottom || left == right) return;
// On close, sometimes we get a size update that's almost the entire
// display width.
// Pip window's can't be that big, so ignore it.
final int width = right - left;
if (width > mMaxWidth) return;
PictureInPictureActivityJni.get()
.onViewSizeChanged(
mNativeOverlayWindowAndroid, width, bottom - top);
}
});
PictureInPictureActivityJni.get()
.compositorViewCreated(mNativeOverlayWindowAndroid, mCompositorView);
}
@Override
public boolean shouldStartGpuProcess() {
return true;
}
@Override
@SuppressLint("NewAPI") // Picture-in-Picture API will not be enabled for oldver versions.
public void onStart() {
super.onStart();
final Intent intent = getIntent();
mNativeOverlayWindowAndroid = intent.getLongExtra(NATIVE_POINTER_KEY, 0);
intent.setExtrasClassLoader(WebContents.class.getClassLoader());
mInitiatorTab = TabUtils.fromWebContents(intent.getParcelableExtra(WEB_CONTENTS_KEY));
// Finish the activity if OverlayWindowAndroid has already been destroyed
// or InitiatorTab has been destroyed by user or crashed.
if (mNativeOverlayWindowAndroid != sPendingNativeOverlayWindowAndroid
|| TabUtils.getActivity(mInitiatorTab) == null) {
onExitPictureInPicture(/* closeByNative= */ false);
return;
}
sPendingNativeOverlayWindowAndroid = 0;
mTabObserver = new InitiatorTabObserver();
mInitiatorTab.addObserver(mTabObserver);
mMediaSessionReceiver = new MediaSessionBroadcastReceiver();
ContextUtils.registerNonExportedBroadcastReceiver(
this, mMediaSessionReceiver, new IntentFilter(MEDIA_ACTION));
mMediaActionsButtonsManager = new MediaActionButtonsManager();
PictureInPictureActivityJni.get()
.onActivityStart(mNativeOverlayWindowAndroid, this, getWindowAndroid());
// See if there are PiP hints in the extras.
Size size =
new Size(
intent.getIntExtra(SOURCE_WIDTH_KEY, 0),
intent.getIntExtra(SOURCE_HEIGHT_KEY, 0));
if (size.getWidth() > 0 && size.getHeight() > 0) {
clampAndStoreAspectRatio(size.getWidth(), size.getHeight());
}
// If the key is not present, then we need to launch into PiP now. Otherwise, the intent
// did that for us.
if (!getIntent().hasExtra(LAUNCHED_KEY)) {
enterPictureInPictureMode(getPictureInPictureParams());
}
}
@Override
@RequiresApi(api = Build.VERSION_CODES.O)
public void onPictureInPictureModeChanged(
boolean isInPictureInPictureMode, Configuration newConfig) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
if (isInPictureInPictureMode) return;
PictureInPictureActivityJni.get().onBackToTab(mNativeOverlayWindowAndroid);
onExitPictureInPicture(/* closeByNative= */ false);
}
@Override
protected ActivityWindowAndroid createWindowAndroid() {
return new ActivityWindowAndroid(
this, /* listenToActivityState= */ true, getIntentRequestTracker());
}
@CalledByNative
public void close() {
onExitPictureInPicture(/* closeByNative= */ true);
}
private void onExitPictureInPicture(boolean closeByNative) {
if (!closeByNative && mNativeOverlayWindowAndroid != 0) {
PictureInPictureActivityJni.get().destroy(mNativeOverlayWindowAndroid);
}
if (mCompositorView != null) {
mCompositorView.destroy();
mCompositorView = null;
}
if (mMediaSessionReceiver != null) {
unregisterReceiver(mMediaSessionReceiver);
mMediaSessionReceiver = null;
}
if (mInitiatorTab != null) {
mInitiatorTab.removeObserver(mTabObserver);
mInitiatorTab = null;
}
mTabObserver = null;
// If called by `closeByNative`, it means that the native side will be freed at some point
// after this returns. If `!closeByNative`, then we called destroyed on our native side (if
// we have one). Either way, we shouldn't refer to the native side after this.
// See b/40063137 for details.
mNativeOverlayWindowAndroid = 0;
this.finish();
}
@SuppressLint("NewApi")
private PictureInPictureParams getPictureInPictureParams() {
PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
builder.setActions(mMediaActionsButtonsManager.getActionsForPictureInPictureParams());
builder.setAspectRatio(mAspectRatio);
return builder.build();
}
@SuppressLint("NewApi")
private void updatePictureInPictureParams() {
setPictureInPictureParams(getPictureInPictureParams());
}
@CalledByNative
@SuppressLint("NewApi")
private void updateVideoSize(int width, int height) {
clampAndStoreAspectRatio(width, height);
updatePictureInPictureParams();
}
// Clamp the aspect ratio, and return the width assuming we allow the height. If it's not
// clamped, then it'll return the original width. This is safe if `height` is zero.
private static int clampAspectRatioAndRecomputeWidth(int width, int height) {
if (height == 0) return width;
final float aspectRatio =
MathUtils.clamp(width / (float) height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO);
return (int) (height * aspectRatio);
}
private void clampAndStoreAspectRatio(int width, int height) {
width = clampAspectRatioAndRecomputeWidth(width, height);
mAspectRatio = new Rational(width, height);
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
void setPlaybackState(@PlaybackState int playbackState) {
mMediaActionsButtonsManager.updatePlaybackState(playbackState);
updatePictureInPictureParams();
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
void setMicrophoneMuted(boolean muted) {
mMediaActionsButtonsManager.setMicrophoneMuted(muted);
updatePictureInPictureParams();
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
void setCameraState(boolean turnedOn) {
mMediaActionsButtonsManager.setCameraOn(turnedOn);
updatePictureInPictureParams();
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
void updateVisibleActions(int[] actions) {
HashSet<Integer> visibleActions = new HashSet<>();
for (int action : actions) visibleActions.add(action);
mMediaActionsButtonsManager.updateVisibleActions(visibleActions);
updatePictureInPictureParams();
}
private long getNativeOverlayWindowAndroid() {
return mNativeOverlayWindowAndroid;
}
private void resetNativeOverlayWindowAndroid() {
mNativeOverlayWindowAndroid = 0;
}
@VisibleForTesting
/* package */ Rational getAspectRatio() {
return mAspectRatio;
}
@CalledByNative
public static void createActivity(
long nativeOverlayWindowAndroid,
Object initiatorTab,
int sourceX,
int sourceY,
int sourceWidth,
int sourceHeight) {
// Dissociate OverlayWindowAndroid if there is one already.
if (sPendingNativeOverlayWindowAndroid != 0) {
PictureInPictureActivityJni.get().destroy(sPendingNativeOverlayWindowAndroid);
}
sPendingNativeOverlayWindowAndroid = nativeOverlayWindowAndroid;
Context activityContext = null;
final WindowAndroid window = ((Tab) initiatorTab).getWindowAndroid();
if (window != null) {
activityContext = window.getActivity().get();
}
Context context =
(activityContext != null) ? activityContext : ContextUtils.getApplicationContext();
Intent intent = new Intent(context, PictureInPictureActivity.class);
intent.putExtra(WEB_CONTENTS_KEY, ((Tab) initiatorTab).getWebContents());
intent.putExtra(NATIVE_POINTER_KEY, nativeOverlayWindowAndroid);
Bundle optionsBundle = null;
// Clamp the aspect ratio, which is okay even if they're unspecified. We do this first in
// case the width clamps to 0. In that case, it's ignored as if it weren't given.
sourceWidth = clampAspectRatioAndRecomputeWidth(sourceWidth, sourceHeight);
if (sourceWidth > 0 && sourceHeight > 0) {
// Auto-enter PiP if supported. This requires an Activity context.
final Rect bounds =
new Rect(sourceX, sourceY, sourceX + sourceWidth, sourceY + sourceHeight);
// Try to build the options bundle to launch into PiP.
// Trivially out of bounds values indicate that they bounds are to be ignored.
if (activityContext != null && bounds.left >= 0 && bounds.top >= 0) {
optionsBundle = sLaunchIntoPipHelper.build(activityContext, bounds);
}
if (optionsBundle != null) {
// That particular value doesn't matter, as long as the key is present.
intent.putExtra(LAUNCHED_KEY, true);
}
// Add the aspect ratio parameters if we have them, so that we can enter pip with them
// correctly immediately.
intent.putExtra(SOURCE_WIDTH_KEY, sourceWidth);
intent.putExtra(SOURCE_HEIGHT_KEY, sourceHeight);
}
context.startActivity(intent, optionsBundle);
}
@CalledByNative
private static void onWindowDestroyed(long nativeOverlayWindowAndroid) {
if (nativeOverlayWindowAndroid == sPendingNativeOverlayWindowAndroid) {
sPendingNativeOverlayWindowAndroid = 0;
}
for (Activity activity : ApplicationStatus.getRunningActivities()) {
if (!(activity instanceof PictureInPictureActivity)) continue;
PictureInPictureActivity pipActivity = (PictureInPictureActivity) activity;
if (nativeOverlayWindowAndroid == pipActivity.getNativeOverlayWindowAndroid()) {
pipActivity.resetNativeOverlayWindowAndroid();
pipActivity.onExitPictureInPicture(/* closeByNative= */ true);
}
}
}
// Allow tests to mock out our LaunchIntoPipHelper. Returns the outgoing one.
@VisibleForTesting
/* package */ static LaunchIntoPipHelper setLaunchIntoPipHelper(LaunchIntoPipHelper helper) {
LaunchIntoPipHelper original = sLaunchIntoPipHelper;
sLaunchIntoPipHelper = helper;
return original;
}
/* package */ View getViewForTesting() {
return mCompositorView.getView();
}
@NativeMethods
public interface Natives {
void onActivityStart(
long nativeOverlayWindowAndroid,
PictureInPictureActivity self,
WindowAndroid window);
void destroy(long nativeOverlayWindowAndroid);
void togglePlayPause(long nativeOverlayWindowAndroid, boolean toggleOn);
void nextTrack(long nativeOverlayWindowAndroid);
void previousTrack(long nativeOverlayWindowAndroid);
void nextSlide(long nativeOverlayWindowAndroid);
void previousSlide(long nativeOverlayWindowAndroid);
void toggleMicrophone(long nativeOverlayWindowAndroid, boolean toggleOn);
void toggleCamera(long nativeOverlayWindowAndroid, boolean toggleOn);
void hangUp(long nativeOverlayWindowAndroid);
void compositorViewCreated(long nativeOverlayWindowAndroid, CompositorView compositorView);
void onViewSizeChanged(long nativeOverlayWindowAndroid, int width, int height);
void onBackToTab(long nativeOverlayWindowAndroid);
}
}