blob: 11ad290dfc2c29bdd0ce35ecc179829019658792 [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.wm.shell.pip.tv;
import static android.view.KeyEvent.ACTION_UP;
import static android.view.KeyEvent.KEYCODE_BACK;
import static android.view.KeyEvent.KEYCODE_DPAD_CENTER;
import static android.view.KeyEvent.KEYCODE_DPAD_DOWN;
import static android.view.KeyEvent.KEYCODE_DPAD_LEFT;
import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT;
import static android.view.KeyEvent.KEYCODE_DPAD_UP;
import static android.view.KeyEvent.KEYCODE_ENTER;
import static com.android.wm.shell.pip.tv.TvPipAction.ACTION_CLOSE;
import static com.android.wm.shell.pip.tv.TvPipAction.ACTION_CUSTOM;
import static com.android.wm.shell.pip.tv.TvPipAction.ACTION_CUSTOM_CLOSE;
import static com.android.wm.shell.pip.tv.TvPipAction.ACTION_EXPAND_COLLAPSE;
import static com.android.wm.shell.pip.tv.TvPipAction.ACTION_FULLSCREEN;
import static com.android.wm.shell.pip.tv.TvPipAction.ACTION_MOVE;
import android.content.Context;
import android.graphics.Rect;
import android.os.Handler;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityManager;
import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import com.android.internal.protolog.common.ProtoLog;
import com.android.internal.widget.LinearLayoutManager;
import com.android.internal.widget.RecyclerView;
import com.android.wm.shell.R;
import com.android.wm.shell.common.TvWindowMenuActionButton;
import com.android.wm.shell.pip.PipUtils;
import com.android.wm.shell.protolog.ShellProtoLogGroup;
import java.util.List;
/**
* A View that represents Pip Menu on TV. It's responsible for displaying the Pip menu actions from
* the TvPipActionsProvider as well as the buttons for manually moving the PiP.
*/
public class TvPipMenuView extends FrameLayout implements TvPipActionsProvider.Listener {
private static final String TAG = "TvPipMenuView";
private final TvPipMenuView.Listener mListener;
private final TvPipActionsProvider mTvPipActionsProvider;
private final RecyclerView mActionButtonsRecyclerView;
private final LinearLayoutManager mButtonLayoutManager;
private final RecyclerViewAdapter mRecyclerViewAdapter;
private final View mPipFrameView;
private final View mMenuFrameView;
private final View mPipView;
private final View mPipBackground;
private final View mDimLayer;
private final TvPipMenuEduTextDrawer mEduTextDrawer;
private final int mPipMenuOuterSpace;
private final int mPipMenuBorderWidth;
private final int mPipMenuFadeAnimationDuration;
private final int mResizeAnimationDuration;
private final ImageView mArrowUp;
private final ImageView mArrowRight;
private final ImageView mArrowDown;
private final ImageView mArrowLeft;
private final TvWindowMenuActionButton mA11yDoneButton;
private Rect mCurrentPipBounds;
private boolean mMoveMenuIsVisible;
private boolean mButtonMenuIsVisible;
private boolean mSwitchingOrientation;
private final AccessibilityManager mA11yManager;
private final Handler mMainHandler;
public TvPipMenuView(@NonNull Context context, @NonNull Handler mainHandler,
@NonNull Listener listener, TvPipActionsProvider tvPipActionsProvider) {
super(context, null, 0, 0);
inflate(context, R.layout.tv_pip_menu, this);
mMainHandler = mainHandler;
mListener = listener;
mA11yManager = context.getSystemService(AccessibilityManager.class);
mActionButtonsRecyclerView = findViewById(R.id.tv_pip_menu_action_buttons);
mButtonLayoutManager = new LinearLayoutManager(mContext);
mActionButtonsRecyclerView.setLayoutManager(mButtonLayoutManager);
mActionButtonsRecyclerView.setPreserveFocusAfterLayout(true);
mTvPipActionsProvider = tvPipActionsProvider;
mRecyclerViewAdapter = new RecyclerViewAdapter(tvPipActionsProvider.getActionsList());
mActionButtonsRecyclerView.setAdapter(mRecyclerViewAdapter);
tvPipActionsProvider.addListener(this);
mMenuFrameView = findViewById(R.id.tv_pip_menu_frame);
mPipFrameView = findViewById(R.id.tv_pip_border);
mPipView = findViewById(R.id.tv_pip);
mPipBackground = findViewById(R.id.tv_pip_menu_background);
mDimLayer = findViewById(R.id.tv_pip_menu_dim_layer);
mArrowUp = findViewById(R.id.tv_pip_menu_arrow_up);
mArrowRight = findViewById(R.id.tv_pip_menu_arrow_right);
mArrowDown = findViewById(R.id.tv_pip_menu_arrow_down);
mArrowLeft = findViewById(R.id.tv_pip_menu_arrow_left);
mA11yDoneButton = findViewById(R.id.tv_pip_menu_done_button);
mResizeAnimationDuration = context.getResources().getInteger(
R.integer.config_pipResizeAnimationDuration);
mPipMenuFadeAnimationDuration = context.getResources()
.getInteger(R.integer.tv_window_menu_fade_animation_duration);
mPipMenuOuterSpace = context.getResources()
.getDimensionPixelSize(R.dimen.pip_menu_outer_space);
mPipMenuBorderWidth = context.getResources()
.getDimensionPixelSize(R.dimen.pip_menu_border_width);
mEduTextDrawer = new TvPipMenuEduTextDrawer(mContext, mainHandler, mListener);
((FrameLayout) findViewById(R.id.tv_pip_menu_edu_text_drawer_placeholder))
.addView(mEduTextDrawer);
}
void onPipTransitionToTargetBoundsStarted(Rect targetBounds) {
if (targetBounds == null) {
return;
}
// Fade out content by fading in view on top.
if (mCurrentPipBounds != null) {
boolean ratioChanged = PipUtils.aspectRatioChanged(
mCurrentPipBounds.width() / (float) mCurrentPipBounds.height(),
targetBounds.width() / (float) targetBounds.height());
if (ratioChanged) {
mPipBackground.animate()
.alpha(1f)
.setInterpolator(TvPipInterpolators.EXIT)
.setDuration(mResizeAnimationDuration / 2)
.start();
}
}
// Update buttons.
final boolean vertical = targetBounds.height() > targetBounds.width();
final boolean orientationChanged =
vertical != (mButtonLayoutManager.getOrientation() == LinearLayoutManager.VERTICAL);
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: onPipTransitionToTargetBoundsStarted(), orientation changed %b",
TAG, orientationChanged);
if (!orientationChanged) {
return;
}
if (mButtonMenuIsVisible) {
mSwitchingOrientation = true;
mActionButtonsRecyclerView.animate()
.alpha(0)
.setInterpolator(TvPipInterpolators.EXIT)
.setDuration(mResizeAnimationDuration / 2)
.withEndAction(() -> {
mButtonLayoutManager.setOrientation(vertical
? LinearLayoutManager.VERTICAL : LinearLayoutManager.HORIZONTAL);
// Only make buttons visible again in onPipTransitionFinished to keep in
// sync with PiP content alpha animation.
});
} else {
mButtonLayoutManager.setOrientation(vertical
? LinearLayoutManager.VERTICAL : LinearLayoutManager.HORIZONTAL);
}
}
void onPipTransitionFinished(boolean enterTransition) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: onPipTransitionFinished()", TAG);
// Fade in content by fading out view on top (faded out at every aspect ratio change).
mPipBackground.animate()
.alpha(0f)
.setDuration(mResizeAnimationDuration / 2)
.setInterpolator(TvPipInterpolators.ENTER)
.start();
if (enterTransition) {
mEduTextDrawer.init();
}
if (mSwitchingOrientation) {
mActionButtonsRecyclerView.animate()
.alpha(1)
.setInterpolator(TvPipInterpolators.ENTER)
.setDuration(mResizeAnimationDuration / 2);
}
mSwitchingOrientation = false;
}
/**
* Also updates the button gravity.
*/
void updateBounds(Rect updatedBounds) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: updateLayout, width: %s, height: %s", TAG, updatedBounds.width(),
updatedBounds.height());
mCurrentPipBounds = updatedBounds;
updatePipFrameBounds();
}
Rect getPipMenuContainerBounds(Rect pipBounds) {
final Rect menuUiBounds = new Rect(pipBounds);
menuUiBounds.inset(-mPipMenuOuterSpace, -mPipMenuOuterSpace);
menuUiBounds.bottom += mEduTextDrawer.getHeight();
return menuUiBounds;
}
/**
* Update mPipFrameView's bounds according to the new pip window bounds. We can't
* make mPipFrameView match_parent, because the pip menu might contain other content around
* the pip window (e.g. edu text).
* TvPipMenuView needs to account for this so that it can draw a white border around the whole
* pip menu when it gains focus.
*/
private void updatePipFrameBounds() {
final ViewGroup.LayoutParams pipFrameParams = mPipFrameView.getLayoutParams();
if (pipFrameParams != null) {
pipFrameParams.width = mCurrentPipBounds.width() + 2 * mPipMenuBorderWidth;
pipFrameParams.height = mCurrentPipBounds.height() + 2 * mPipMenuBorderWidth;
mPipFrameView.setLayoutParams(pipFrameParams);
}
final ViewGroup.LayoutParams pipViewParams = mPipView.getLayoutParams();
if (pipViewParams != null) {
pipViewParams.width = mCurrentPipBounds.width();
pipViewParams.height = mCurrentPipBounds.height();
mPipView.setLayoutParams(pipViewParams);
}
// Keep focused button within the visible area while the PiP is changing size. Otherwise,
// the button would lose focus which would cause a need for scrolling and re-focusing after
// the animation finishes, which does not look good.
View focusedChild = mActionButtonsRecyclerView.getFocusedChild();
if (focusedChild != null) {
mActionButtonsRecyclerView.scrollToPosition(
mActionButtonsRecyclerView.getChildLayoutPosition(focusedChild));
}
}
/**
* @param gravity for the arrow hints
*/
void showMoveMenu(int gravity) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, "%s: showMoveMenu()", TAG);
showMovementHints(gravity);
setMenuButtonsVisible(false);
setFrameHighlighted(true);
animateAlphaTo(mA11yManager.isEnabled() ? 1f : 0f, mDimLayer);
mEduTextDrawer.closeIfNeeded();
}
void showButtonsMenu(boolean exitingMoveMode) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: showButtonsMenu(), exitingMoveMode %b", TAG, exitingMoveMode);
setMenuButtonsVisible(true);
hideMovementHints();
setFrameHighlighted(true);
animateAlphaTo(1f, mDimLayer);
mEduTextDrawer.closeIfNeeded();
if (exitingMoveMode) {
scrollAndRefocusButton(mTvPipActionsProvider.getFirstIndexOfAction(ACTION_MOVE),
/* alwaysScroll= */ false);
} else {
scrollAndRefocusButton(0, /* alwaysScroll= */ true);
}
}
private void scrollAndRefocusButton(int position, boolean alwaysScroll) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: scrollAndRefocusButton, target: %d", TAG, position);
if (alwaysScroll || !refocusButton(position)) {
mButtonLayoutManager.scrollToPositionWithOffset(position, 0);
mActionButtonsRecyclerView.post(() -> refocusButton(position));
}
}
/**
* @return true if focus was requested, false if focus request could not be carried out due to
* the view for the position not being available (scrolling beforehand will be necessary).
*/
private boolean refocusButton(int position) {
View itemToFocus = mButtonLayoutManager.findViewByPosition(position);
if (itemToFocus != null) {
itemToFocus.requestFocus();
itemToFocus.requestAccessibilityFocus();
}
return itemToFocus != null;
}
void hideAllUserControls() {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: hideAllUserControls()", TAG);
setMenuButtonsVisible(false);
hideMovementHints();
setFrameHighlighted(false);
animateAlphaTo(0f, mDimLayer);
}
@Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
super.onWindowFocusChanged(hasWindowFocus);
if (!hasWindowFocus) {
hideAllUserControls();
}
}
private void animateAlphaTo(float alpha, View view) {
if (view.getAlpha() == alpha) {
return;
}
view.animate()
.alpha(alpha)
.setInterpolator(alpha == 0f ? TvPipInterpolators.EXIT : TvPipInterpolators.ENTER)
.setDuration(mPipMenuFadeAnimationDuration)
.withStartAction(() -> {
if (alpha != 0) {
view.setVisibility(VISIBLE);
}
})
.withEndAction(() -> {
if (alpha == 0) {
view.setVisibility(GONE);
}
});
}
@Override
public void onActionsChanged(int added, int updated, int startIndex) {
mRecyclerViewAdapter.notifyItemRangeChanged(startIndex, updated);
if (added > 0) {
mRecyclerViewAdapter.notifyItemRangeInserted(startIndex + updated, added);
} else if (added < 0) {
mRecyclerViewAdapter.notifyItemRangeRemoved(startIndex + updated, -added);
}
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getAction() == ACTION_UP) {
if (event.getKeyCode() == KEYCODE_BACK) {
mListener.onBackPress();
return true;
}
if (mA11yManager.isEnabled()) {
return super.dispatchKeyEvent(event);
}
switch (event.getKeyCode()) {
case KEYCODE_DPAD_UP:
case KEYCODE_DPAD_DOWN:
case KEYCODE_DPAD_LEFT:
case KEYCODE_DPAD_RIGHT:
return mListener.onPipMovement(event.getKeyCode()) || super.dispatchKeyEvent(
event);
case KEYCODE_ENTER:
case KEYCODE_DPAD_CENTER:
return mListener.onExitMoveMode() || super.dispatchKeyEvent(event);
default:
break;
}
}
return super.dispatchKeyEvent(event);
}
/**
* Shows user hints for moving the PiP, e.g. arrows.
*/
public void showMovementHints(int gravity) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: showMovementHints(), position: %s", TAG, Gravity.toString(gravity));
mMoveMenuIsVisible = true;
animateAlphaTo(checkGravity(gravity, Gravity.BOTTOM) ? 1f : 0f, mArrowUp);
animateAlphaTo(checkGravity(gravity, Gravity.TOP) ? 1f : 0f, mArrowDown);
animateAlphaTo(checkGravity(gravity, Gravity.RIGHT) ? 1f : 0f, mArrowLeft);
animateAlphaTo(checkGravity(gravity, Gravity.LEFT) ? 1f : 0f, mArrowRight);
boolean a11yEnabled = mA11yManager.isEnabled();
setArrowA11yEnabled(mArrowUp, a11yEnabled, KEYCODE_DPAD_UP);
setArrowA11yEnabled(mArrowDown, a11yEnabled, KEYCODE_DPAD_DOWN);
setArrowA11yEnabled(mArrowLeft, a11yEnabled, KEYCODE_DPAD_LEFT);
setArrowA11yEnabled(mArrowRight, a11yEnabled, KEYCODE_DPAD_RIGHT);
animateAlphaTo(a11yEnabled ? 1f : 0f, mA11yDoneButton);
if (a11yEnabled) {
mA11yDoneButton.setVisibility(VISIBLE);
mA11yDoneButton.setOnClickListener(v -> {
mListener.onExitMoveMode();
});
mA11yDoneButton.requestFocus();
mA11yDoneButton.requestAccessibilityFocus();
}
}
private void setArrowA11yEnabled(View arrowView, boolean enabled, int keycode) {
arrowView.setClickable(enabled);
if (enabled) {
arrowView.setOnClickListener(v -> {
mListener.onPipMovement(keycode);
});
}
}
private boolean checkGravity(int gravity, int feature) {
return (gravity & feature) == feature;
}
/**
* Hides user hints for moving the PiP, e.g. arrows.
*/
public void hideMovementHints() {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: hideMovementHints()", TAG);
if (!mMoveMenuIsVisible) {
return;
}
mMoveMenuIsVisible = false;
animateAlphaTo(0, mArrowUp);
animateAlphaTo(0, mArrowRight);
animateAlphaTo(0, mArrowDown);
animateAlphaTo(0, mArrowLeft);
animateAlphaTo(0, mA11yDoneButton);
}
/**
* Show or hide the pip buttons menu.
*/
private void setMenuButtonsVisible(boolean visible) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: showUserActions: %b", TAG, visible);
mButtonMenuIsVisible = visible;
animateAlphaTo(visible ? 1 : 0, mActionButtonsRecyclerView);
}
private void setFrameHighlighted(boolean highlighted) {
mMenuFrameView.setActivated(highlighted);
}
private class RecyclerViewAdapter extends
RecyclerView.Adapter<RecyclerViewAdapter.ButtonViewHolder> {
private final List<TvPipAction> mActionList;
RecyclerViewAdapter(List<TvPipAction> actionList) {
this.mActionList = actionList;
}
@NonNull
@Override
public ButtonViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ButtonViewHolder(new TvWindowMenuActionButton(mContext));
}
@Override
public void onBindViewHolder(@NonNull ButtonViewHolder holder, int position) {
TvPipAction action = mActionList.get(position);
action.populateButton(holder.mButton, mMainHandler);
}
@Override
public int getItemCount() {
return mActionList.size();
}
private class ButtonViewHolder extends RecyclerView.ViewHolder implements OnClickListener {
TvWindowMenuActionButton mButton;
ButtonViewHolder(@NonNull View itemView) {
super(itemView);
mButton = (TvWindowMenuActionButton) itemView;
mButton.setOnClickListener(this);
}
@Override
public void onClick(View v) {
TvPipAction action = mActionList.get(
mActionButtonsRecyclerView.getChildLayoutPosition(v));
switch (action.getActionType()) {
case ACTION_FULLSCREEN:
mListener.onFullscreenButtonClick();
return;
case ACTION_CLOSE:
case ACTION_CUSTOM_CLOSE:
mListener.onCloseButtonClick();
return;
case ACTION_MOVE:
mListener.onEnterMoveMode();
return;
case ACTION_EXPAND_COLLAPSE:
mListener.onToggleExpandedMode();
return;
case ACTION_CUSTOM:
action.executePendingIntent();
return;
default:
ProtoLog.w(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: No action available", TAG);
}
}
}
}
interface Listener extends TvPipMenuEduTextDrawer.Listener {
void onBackPress();
void onEnterMoveMode();
/**
* Called when a button for exiting move mode was pressed.
*
* @return true if the event was handled or false if the key event should be handled by the
* next receiver.
*/
boolean onExitMoveMode();
/**
* @return whether pip movement was handled.
*/
boolean onPipMovement(int keycode);
void onCloseButtonClick();
void onFullscreenButtonClick();
void onToggleExpandedMode();
}
}