| // Copyright 2019 The Chromium Authors. All rights reserved. |
| // 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.ActivityManager; |
| 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.RequiresApi; |
| import androidx.annotation.VisibleForTesting; |
| |
| import org.chromium.base.ApplicationStatus; |
| import org.chromium.base.BuildInfo; |
| import org.chromium.base.ContextUtils; |
| import org.chromium.base.MathUtils; |
| import org.chromium.base.annotations.CalledByNative; |
| import org.chromium.base.annotations.NativeMethods; |
| import org.chromium.chrome.browser.init.AsyncInitializationActivity; |
| 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.browser_ui.media.R; |
| 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.lang.reflect.InvocationTargetException; |
| import java.lang.reflect.Method; |
| 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 verify Pre-T that the broadcast sender was Chrome. This extra can be removed when the |
| // min supported version is Android T. |
| private static final String EXTRA_RECEIVER_TOKEN = |
| "org.chromium.chrome.browser.media.PictureInPictureActivity.ReceiverToken"; |
| |
| // 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 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 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() { |
| mPreviousTrack = createRemoteAction(MediaSessionAction.PREVIOUS_TRACK, |
| R.drawable.ic_skip_previous_white_24dp, R.string.accessibility_previous_track); |
| mPlay = createRemoteAction(MediaSessionAction.PLAY, R.drawable.ic_play_arrow_white_24dp, |
| R.string.accessibility_play); |
| mPause = createRemoteAction(MediaSessionAction.PAUSE, R.drawable.ic_pause_white_24dp, |
| R.string.accessibility_pause); |
| mReplay = createRemoteAction(MediaSessionAction.PLAY, R.drawable.ic_replay_white_24dp, |
| R.string.accessibility_replay); |
| mNextTrack = createRemoteAction(MediaSessionAction.NEXT_TRACK, |
| R.drawable.ic_skip_next_white_24dp, R.string.accessibility_next_track); |
| mHangUp = createRemoteAction(MediaSessionAction.HANG_UP, |
| R.drawable.ic_call_end_white_24dp, R.string.accessibility_hang_up); |
| mMicrophone = new ToggleRemoteAction( |
| createRemoteAction(MediaSessionAction.TOGGLE_MICROPHONE, |
| R.drawable.ic_mic_white_24dp, R.string.accessibility_mute_microphone), |
| createRemoteAction(MediaSessionAction.TOGGLE_MICROPHONE, |
| R.drawable.ic_mic_off_white_24dp, |
| R.string.accessibility_unmute_microphone)); |
| mCamera = new ToggleRemoteAction( |
| createRemoteAction(MediaSessionAction.TOGGLE_CAMERA, |
| R.drawable.ic_videocam_24dp, R.string.accessibility_turn_off_camera), |
| createRemoteAction(MediaSessionAction.TOGGLE_CAMERA, |
| R.drawable.ic_videocam_off_white_24dp, |
| R.string.accessibility_turn_on_camera)); |
| |
| mPlaybackState = PlaybackState.END_OF_VIDEO; |
| mVisibleActions = new HashSet<>(); |
| } |
| |
| @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) |
| @SuppressLint("NewApi") |
| ArrayList<RemoteAction> getActionsForPictureInPictureParams() { |
| ArrayList<RemoteAction> actions = new ArrayList<>(); |
| |
| boolean shouldShowPreviousNextTrack = |
| mVisibleActions.contains(MediaSessionAction.PREVIOUS_TRACK) |
| || mVisibleActions.contains(MediaSessionAction.NEXT_TRACK); |
| if (shouldShowPreviousNextTrack) { |
| mPreviousTrack.setEnabled( |
| mVisibleActions.contains(MediaSessionAction.PREVIOUS_TRACK)); |
| actions.add(mPreviousTrack); |
| } |
| |
| 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 (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 dummy 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 dummyAction = 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)); |
| dummyAction.setEnabled(false); |
| actions.add(dummyAction); |
| } |
| |
| 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 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. |
| */ |
| @SuppressLint("NewApi") |
| private RemoteAction createRemoteAction( |
| int action, int iconResourceId, int titleResourceId) { |
| Intent intent = new Intent(MEDIA_ACTION); |
| intent.putExtra(EXTRA_RECEIVER_TOKEN, mMediaSessionReceiver.hashCode()); |
| intent.putExtra(CONTROL_TYPE, action); |
| intent.putExtra(NATIVE_POINTER_KEY, mNativeOverlayWindowAndroid); |
| PendingIntent pendingIntent = |
| PendingIntent.getBroadcast(getApplicationContext(), action, 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) { |
| long nativeOverlayWindowAndroid = intent.getLongExtra(NATIVE_POINTER_KEY, 0); |
| if (nativeOverlayWindowAndroid != mNativeOverlayWindowAndroid |
| || mNativeOverlayWindowAndroid == 0) { |
| return; |
| } |
| |
| if (intent.getAction() == null || !intent.getAction().equals(MEDIA_ACTION)) return; |
| if (!intent.hasExtra(EXTRA_RECEIVER_TOKEN) |
| || intent.getIntExtra(EXTRA_RECEIVER_TOKEN, 0) != this.hashCode()) { |
| return; |
| } |
| |
| switch (intent.getIntExtra(CONTROL_TYPE, -1)) { |
| case MediaSessionAction.PLAY: |
| case MediaSessionAction.PAUSE: |
| // TODO(crbug.com/1345956): Play/pause state might get out of sync. |
| PictureInPictureActivityJni.get().togglePlayPause(nativeOverlayWindowAndroid); |
| return; |
| case MediaSessionAction.PREVIOUS_TRACK: |
| PictureInPictureActivityJni.get().previousTrack(nativeOverlayWindowAndroid); |
| return; |
| case MediaSessionAction.NEXT_TRACK: |
| PictureInPictureActivityJni.get().nextTrack(nativeOverlayWindowAndroid); |
| return; |
| case MediaSessionAction.TOGGLE_MICROPHONE: |
| PictureInPictureActivityJni.get().toggleMicrophone(nativeOverlayWindowAndroid); |
| return; |
| case MediaSessionAction.TOGGLE_CAMERA: |
| PictureInPictureActivityJni.get().toggleCamera(nativeOverlayWindowAndroid); |
| return; |
| case MediaSessionAction.HANG_UP: |
| PictureInPictureActivityJni.get().hangUp(nativeOverlayWindowAndroid); |
| return; |
| default: |
| return; |
| } |
| } |
| }; |
| |
| private class InitiatorTabObserver extends EmptyTabObserver { |
| @Override |
| public void onDestroyed(Tab tab) { |
| if (tab.isClosing()) PictureInPictureActivity.this.finish(); |
| } |
| |
| @Override |
| public void onCrash(Tab tab) { |
| PictureInPictureActivity.this.finish(); |
| } |
| } |
| |
| /** |
| * 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 (!BuildInfo.isAtLeastT()) return null; |
| |
| Bundle optionsBundle = null; |
| final Rational aspectRatio = new Rational(bounds.width(), bounds.height()); |
| final PictureInPictureParams params = new PictureInPictureParams.Builder() |
| .setSourceRectHint(bounds) |
| .setAspectRatio(aspectRatio) |
| .build(); |
| // Use reflection to access ActivityOptions#makeLaunchIntoPip |
| // TODO(crbug.com/1331593): Do not use reflection, with a new sdk. |
| try { |
| Method methodMakeEnterContentPip = ActivityOptions.class.getMethod( |
| "makeLaunchIntoPip", PictureInPictureParams.class); |
| ActivityOptions opts = (ActivityOptions) methodMakeEnterContentPip.invoke( |
| ActivityOptions.class, params); |
| optionsBundle = (opts != null) ? opts.toBundle() : null; |
| } catch (NoSuchMethodException e) { |
| e.printStackTrace(); |
| } catch (IllegalAccessException e) { |
| e.printStackTrace(); |
| } catch (InvocationTargetException e) { |
| e.printStackTrace(); |
| } |
| return optionsBundle; |
| } |
| }; |
| |
| @Override |
| protected void triggerLayoutInflation() { |
| onInitialLayoutInflationComplete(); |
| } |
| |
| @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 |
| || !isInitiatorTabAlive()) { |
| this.finish(); |
| 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 |
| public void onStop() { |
| super.onStop(); |
| if (mCompositorView != null) mCompositorView.destroy(); |
| } |
| |
| @Override |
| public void onDestroy() { |
| super.onDestroy(); |
| |
| if (mMediaSessionReceiver != null) { |
| unregisterReceiver(mMediaSessionReceiver); |
| mMediaSessionReceiver = null; |
| } |
| |
| if (mInitiatorTab != null) { |
| mInitiatorTab.removeObserver(mTabObserver); |
| mInitiatorTab = null; |
| } |
| mTabObserver = null; |
| } |
| |
| @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); |
| this.finish(); |
| } |
| |
| @Override |
| protected ActivityWindowAndroid createWindowAndroid() { |
| return new ActivityWindowAndroid( |
| this, /* listenToActivityState= */ true, getIntentRequestTracker()); |
| } |
| |
| @SuppressLint("NewApi") |
| private boolean isInitiatorTabAlive() { |
| if (mInitiatorTab == null) return false; |
| |
| ActivityManager activityManager = |
| (ActivityManager) ContextUtils.getApplicationContext().getSystemService( |
| Context.ACTIVITY_SERVICE); |
| for (ActivityManager.AppTask appTask : activityManager.getAppTasks()) { |
| if (appTask.getTaskInfo().id == TabUtils.getActivity(mInitiatorTab).getTaskId()) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| @CalledByNative |
| public void close() { |
| 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(); |
| } |
| |
| @CalledByNative |
| private void setMicrophoneMuted(boolean muted) { |
| mMediaActionsButtonsManager.setMicrophoneMuted(muted); |
| updatePictureInPictureParams(); |
| } |
| |
| @CalledByNative |
| private 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); |
| |
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |
| 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.finish(); |
| } |
| } |
| } |
| |
| // 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; |
| } |
| |
| @VisibleForTesting |
| /* 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); |
| void nextTrack(long nativeOverlayWindowAndroid); |
| void previousTrack(long nativeOverlayWindowAndroid); |
| void toggleMicrophone(long nativeOverlayWindowAndroid); |
| void toggleCamera(long nativeOverlayWindowAndroid); |
| void hangUp(long nativeOverlayWindowAndroid); |
| |
| void compositorViewCreated(long nativeOverlayWindowAndroid, CompositorView compositorView); |
| |
| void onViewSizeChanged(long nativeOverlayWindowAndroid, int width, int height); |
| void onBackToTab(long nativeOverlayWindowAndroid); |
| } |
| } |