blob: 36eab394931848035f2f77648ef53fc06398bc04 [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.systemui.media.controls.ui;
import static android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS;
import static com.android.systemui.media.controls.models.recommendation.SmartspaceMediaDataKt.NUM_REQUIRED_RECOMMENDATIONS;
import android.animation.Animator;
import android.animation.AnimatorInflater;
import android.animation.AnimatorSet;
import android.app.PendingIntent;
import android.app.WallpaperColors;
import android.app.smartspace.SmartspaceAction;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.res.ColorStateList;
import android.graphics.Bitmap;
import android.graphics.BlendMode;
import android.graphics.Color;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.Icon;
import android.graphics.drawable.LayerDrawable;
import android.graphics.drawable.TransitionDrawable;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Process;
import android.os.Trace;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Interpolator;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.constraintlayout.widget.ConstraintSet;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.graphics.ColorUtils;
import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.logging.InstanceId;
import com.android.internal.widget.CachingIconView;
import com.android.settingslib.widget.AdaptiveIcon;
import com.android.systemui.ActivityIntentHelper;
import com.android.systemui.R;
import com.android.systemui.animation.ActivityLaunchAnimator;
import com.android.systemui.animation.GhostedViewLaunchAnimatorController;
import com.android.systemui.animation.Interpolators;
import com.android.systemui.bluetooth.BroadcastDialogController;
import com.android.systemui.broadcast.BroadcastSender;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.flags.FeatureFlags;
import com.android.systemui.flags.Flags;
import com.android.systemui.media.controls.models.GutsViewHolder;
import com.android.systemui.media.controls.models.player.MediaAction;
import com.android.systemui.media.controls.models.player.MediaButton;
import com.android.systemui.media.controls.models.player.MediaData;
import com.android.systemui.media.controls.models.player.MediaDeviceData;
import com.android.systemui.media.controls.models.player.MediaViewHolder;
import com.android.systemui.media.controls.models.player.SeekBarObserver;
import com.android.systemui.media.controls.models.player.SeekBarViewModel;
import com.android.systemui.media.controls.models.recommendation.RecommendationViewHolder;
import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData;
import com.android.systemui.media.controls.pipeline.MediaDataManager;
import com.android.systemui.media.controls.util.MediaDataUtils;
import com.android.systemui.media.controls.util.MediaUiEventLogger;
import com.android.systemui.media.controls.util.SmallHash;
import com.android.systemui.media.dialog.MediaOutputDialogFactory;
import com.android.systemui.monet.ColorScheme;
import com.android.systemui.monet.Style;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.plugins.FalsingManager;
import com.android.systemui.shared.system.SysUiStatsLog;
import com.android.systemui.statusbar.NotificationLockscreenUserManager;
import com.android.systemui.statusbar.policy.KeyguardStateController;
import com.android.systemui.surfaceeffects.ripple.MultiRippleController;
import com.android.systemui.surfaceeffects.ripple.MultiRippleView;
import com.android.systemui.surfaceeffects.ripple.RippleAnimation;
import com.android.systemui.surfaceeffects.ripple.RippleAnimationConfig;
import com.android.systemui.surfaceeffects.ripple.RippleShader;
import com.android.systemui.surfaceeffects.turbulencenoise.TurbulenceNoiseAnimationConfig;
import com.android.systemui.surfaceeffects.turbulencenoise.TurbulenceNoiseController;
import com.android.systemui.util.ColorUtilKt;
import com.android.systemui.util.animation.TransitionLayout;
import com.android.systemui.util.concurrency.DelayableExecutor;
import com.android.systemui.util.time.SystemClock;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import javax.inject.Inject;
import dagger.Lazy;
import kotlin.Triple;
import kotlin.Unit;
/**
* A view controller used for Media Playback.
*/
public class MediaControlPanel {
protected static final String TAG = "MediaControlPanel";
private static final float DISABLED_ALPHA = 0.38f;
private static final String EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME = "com.google"
+ ".android.apps.gsa.staticplugins.opa.smartspace.ExportedSmartspaceTrampolineActivity";
private static final String EXTRAS_SMARTSPACE_INTENT =
"com.google.android.apps.gsa.smartspace.extra.SMARTSPACE_INTENT";
private static final String KEY_SMARTSPACE_ARTIST_NAME = "artist_name";
private static final String KEY_SMARTSPACE_OPEN_IN_FOREGROUND = "KEY_OPEN_IN_FOREGROUND";
// Event types logged by smartspace
private static final int SMARTSPACE_CARD_CLICK_EVENT = 760;
protected static final int SMARTSPACE_CARD_DISMISS_EVENT = 761;
private static final float REC_MEDIA_COVER_SCALE_FACTOR = 1.25f;
private static final float MEDIA_SCRIM_START_ALPHA = 0.25f;
private static final float MEDIA_REC_SCRIM_START_ALPHA = 0.15f;
private static final float MEDIA_PLAYER_SCRIM_END_ALPHA = 1.0f;
private static final float MEDIA_REC_SCRIM_END_ALPHA = 1.0f;
private static final Intent SETTINGS_INTENT = new Intent(ACTION_MEDIA_CONTROLS_SETTINGS);
// Buttons to show in small player when using semantic actions
private static final List<Integer> SEMANTIC_ACTIONS_COMPACT = List.of(
R.id.actionPlayPause,
R.id.actionPrev,
R.id.actionNext
);
// Buttons that should get hidden when we're scrubbing (they will be replaced with the views
// showing scrubbing time)
private static final List<Integer> SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING = List.of(
R.id.actionPrev,
R.id.actionNext
);
// Buttons to show in small player when using semantic actions
private static final List<Integer> SEMANTIC_ACTIONS_ALL = List.of(
R.id.actionPlayPause,
R.id.actionPrev,
R.id.actionNext,
R.id.action0,
R.id.action1
);
// Time in millis for playing turbulence noise that is played after a touch ripple.
@VisibleForTesting static final long TURBULENCE_NOISE_PLAY_DURATION = 7500L;
private final SeekBarViewModel mSeekBarViewModel;
private SeekBarObserver mSeekBarObserver;
protected final Executor mBackgroundExecutor;
private final DelayableExecutor mMainExecutor;
private final ActivityStarter mActivityStarter;
private final BroadcastSender mBroadcastSender;
private Context mContext;
private MediaViewHolder mMediaViewHolder;
private RecommendationViewHolder mRecommendationViewHolder;
private String mKey;
private MediaData mMediaData;
private SmartspaceMediaData mRecommendationData;
private MediaViewController mMediaViewController;
private MediaSession.Token mToken;
private MediaController mController;
private Lazy<MediaDataManager> mMediaDataManagerLazy;
// Uid for the media app.
protected int mUid = Process.INVALID_UID;
private int mSmartspaceMediaItemsCount;
private MediaCarouselController mMediaCarouselController;
private final MediaOutputDialogFactory mMediaOutputDialogFactory;
private final FalsingManager mFalsingManager;
private MetadataAnimationHandler mMetadataAnimationHandler;
private ColorSchemeTransition mColorSchemeTransition;
private Drawable mPrevArtwork = null;
private boolean mIsArtworkBound = false;
private int mArtworkBoundId = 0;
private int mArtworkNextBindRequestId = 0;
private final KeyguardStateController mKeyguardStateController;
private final ActivityIntentHelper mActivityIntentHelper;
private final NotificationLockscreenUserManager mLockscreenUserManager;
// Used for logging.
protected boolean mIsImpressed = false;
private SystemClock mSystemClock;
private MediaUiEventLogger mLogger;
private InstanceId mInstanceId;
protected int mSmartspaceId = -1;
private String mPackageName;
private boolean mIsScrubbing = false;
private boolean mIsSeekBarEnabled = false;
private final SeekBarViewModel.ScrubbingChangeListener mScrubbingChangeListener =
this::setIsScrubbing;
private final SeekBarViewModel.EnabledChangeListener mEnabledChangeListener =
this::setIsSeekBarEnabled;
private final BroadcastDialogController mBroadcastDialogController;
private boolean mIsCurrentBroadcastedApp = false;
private boolean mShowBroadcastDialogButton = false;
private String mCurrentBroadcastApp;
private MultiRippleController mMultiRippleController;
private TurbulenceNoiseController mTurbulenceNoiseController;
private final FeatureFlags mFeatureFlags;
private TurbulenceNoiseAnimationConfig mTurbulenceNoiseAnimationConfig;
@VisibleForTesting
MultiRippleController.Companion.RipplesFinishedListener mRipplesFinishedListener;
/**
* Initialize a new control panel
*
* @param backgroundExecutor background executor, used for processing artwork
* @param mainExecutor main thread executor, used if we receive callbacks on the background
* thread that then trigger UI changes.
* @param activityStarter activity starter
*/
@Inject
public MediaControlPanel(
Context context,
@Background Executor backgroundExecutor,
@Main DelayableExecutor mainExecutor,
ActivityStarter activityStarter,
BroadcastSender broadcastSender,
MediaViewController mediaViewController,
SeekBarViewModel seekBarViewModel,
Lazy<MediaDataManager> lazyMediaDataManager,
MediaOutputDialogFactory mediaOutputDialogFactory,
MediaCarouselController mediaCarouselController,
FalsingManager falsingManager,
SystemClock systemClock,
MediaUiEventLogger logger,
KeyguardStateController keyguardStateController,
ActivityIntentHelper activityIntentHelper,
NotificationLockscreenUserManager lockscreenUserManager,
BroadcastDialogController broadcastDialogController,
FeatureFlags featureFlags
) {
mContext = context;
mBackgroundExecutor = backgroundExecutor;
mMainExecutor = mainExecutor;
mActivityStarter = activityStarter;
mBroadcastSender = broadcastSender;
mSeekBarViewModel = seekBarViewModel;
mMediaViewController = mediaViewController;
mMediaDataManagerLazy = lazyMediaDataManager;
mMediaOutputDialogFactory = mediaOutputDialogFactory;
mMediaCarouselController = mediaCarouselController;
mFalsingManager = falsingManager;
mSystemClock = systemClock;
mLogger = logger;
mKeyguardStateController = keyguardStateController;
mActivityIntentHelper = activityIntentHelper;
mLockscreenUserManager = lockscreenUserManager;
mBroadcastDialogController = broadcastDialogController;
mSeekBarViewModel.setLogSeek(() -> {
if (mPackageName != null && mInstanceId != null) {
mLogger.logSeek(mUid, mPackageName, mInstanceId);
}
logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT);
return Unit.INSTANCE;
});
mFeatureFlags = featureFlags;
}
/**
* Clean up seekbar and controller when panel is destroyed
*/
public void onDestroy() {
if (mSeekBarObserver != null) {
mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver);
}
mSeekBarViewModel.removeScrubbingChangeListener(mScrubbingChangeListener);
mSeekBarViewModel.removeEnabledChangeListener(mEnabledChangeListener);
mSeekBarViewModel.onDestroy();
mMediaViewController.onDestroy();
}
/**
* Get the view holder used to display media controls.
*
* @return the media view holder
*/
@Nullable
public MediaViewHolder getMediaViewHolder() {
return mMediaViewHolder;
}
/**
* Get the recommendation view holder used to display Smartspace media recs.
* @return the recommendation view holder
*/
@Nullable
public RecommendationViewHolder getRecommendationViewHolder() {
return mRecommendationViewHolder;
}
/**
* Get the view controller used to display media controls
*
* @return the media view controller
*/
@NonNull
public MediaViewController getMediaViewController() {
return mMediaViewController;
}
/**
* Sets the listening state of the player.
* <p>
* Should be set to true when the QS panel is open. Otherwise, false. This is a signal to avoid
* unnecessary work when the QS panel is closed.
*
* @param listening True when player should be active. Otherwise, false.
*/
public void setListening(boolean listening) {
mSeekBarViewModel.setListening(listening);
}
@VisibleForTesting
public boolean getListening() {
return mSeekBarViewModel.getListening();
}
/** Sets whether the user is touching the seek bar to change the track position. */
private void setIsScrubbing(boolean isScrubbing) {
if (mMediaData == null || mMediaData.getSemanticActions() == null) {
return;
}
if (isScrubbing == this.mIsScrubbing) {
return;
}
this.mIsScrubbing = isScrubbing;
mMainExecutor.execute(() ->
updateDisplayForScrubbingChange(mMediaData.getSemanticActions()));
}
private void setIsSeekBarEnabled(boolean isSeekBarEnabled) {
if (isSeekBarEnabled == this.mIsSeekBarEnabled) {
return;
}
this.mIsSeekBarEnabled = isSeekBarEnabled;
updateSeekBarVisibility();
}
/**
* Get the context
*
* @return context
*/
public Context getContext() {
return mContext;
}
/** Attaches the player to the player view holder. */
public void attachPlayer(MediaViewHolder vh) {
mMediaViewHolder = vh;
TransitionLayout player = vh.getPlayer();
mSeekBarObserver = new SeekBarObserver(vh);
mSeekBarViewModel.getProgress().observeForever(mSeekBarObserver);
mSeekBarViewModel.attachTouchHandlers(vh.getSeekBar());
mSeekBarViewModel.setScrubbingChangeListener(mScrubbingChangeListener);
mSeekBarViewModel.setEnabledChangeListener(mEnabledChangeListener);
mMediaViewController.attach(player, MediaViewController.TYPE.PLAYER);
vh.getPlayer().setOnLongClickListener(v -> {
if (mFalsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)) return true;
if (!mMediaViewController.isGutsVisible()) {
openGuts();
return true;
} else {
closeGuts();
return true;
}
});
// AlbumView uses a hardware layer so that clipping of the foreground is handled
// with clipping the album art. Otherwise album art shows through at the edges.
mMediaViewHolder.getAlbumView().setLayerType(View.LAYER_TYPE_HARDWARE, null);
TextView titleText = mMediaViewHolder.getTitleText();
TextView artistText = mMediaViewHolder.getArtistText();
CachingIconView explicitIndicator = mMediaViewHolder.getExplicitIndicator();
AnimatorSet enter = loadAnimator(R.anim.media_metadata_enter,
Interpolators.EMPHASIZED_DECELERATE, titleText, artistText, explicitIndicator);
AnimatorSet exit = loadAnimator(R.anim.media_metadata_exit,
Interpolators.EMPHASIZED_ACCELERATE, titleText, artistText, explicitIndicator);
MultiRippleView multiRippleView = vh.getMultiRippleView();
mMultiRippleController = new MultiRippleController(multiRippleView);
mTurbulenceNoiseController = new TurbulenceNoiseController(vh.getTurbulenceNoiseView());
if (mFeatureFlags.isEnabled(Flags.UMO_TURBULENCE_NOISE)) {
mRipplesFinishedListener = () -> {
if (mTurbulenceNoiseAnimationConfig == null) {
mTurbulenceNoiseAnimationConfig = createTurbulenceNoiseAnimation();
}
// Color will be correctly updated in ColorSchemeTransition.
mTurbulenceNoiseController.play(mTurbulenceNoiseAnimationConfig);
mMainExecutor.executeDelayed(
mTurbulenceNoiseController::finish, TURBULENCE_NOISE_PLAY_DURATION);
};
mMultiRippleController.addRipplesFinishedListener(mRipplesFinishedListener);
}
mColorSchemeTransition = new ColorSchemeTransition(
mContext, mMediaViewHolder, mMultiRippleController, mTurbulenceNoiseController);
mMetadataAnimationHandler = new MetadataAnimationHandler(exit, enter);
}
@VisibleForTesting
protected AnimatorSet loadAnimator(int animId, Interpolator motionInterpolator,
View... targets) {
ArrayList<Animator> animators = new ArrayList<>();
for (View target : targets) {
AnimatorSet animator = (AnimatorSet) AnimatorInflater.loadAnimator(mContext, animId);
animator.getChildAnimations().get(0).setInterpolator(motionInterpolator);
animator.setTarget(target);
animators.add(animator);
}
AnimatorSet result = new AnimatorSet();
result.playTogether(animators);
return result;
}
/** Attaches the recommendations to the recommendation view holder. */
public void attachRecommendation(RecommendationViewHolder vh) {
mRecommendationViewHolder = vh;
TransitionLayout recommendations = vh.getRecommendations();
mMediaViewController.attach(recommendations, MediaViewController.TYPE.RECOMMENDATION);
mRecommendationViewHolder.getRecommendations().setOnLongClickListener(v -> {
if (mFalsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)) return true;
if (!mMediaViewController.isGutsVisible()) {
openGuts();
return true;
} else {
closeGuts();
return true;
}
});
}
/** Bind this player view based on the data given. */
public void bindPlayer(@NonNull MediaData data, String key) {
if (mMediaViewHolder == null) {
return;
}
if (Trace.isEnabled()) {
Trace.traceBegin(Trace.TRACE_TAG_APP, "MediaControlPanel#bindPlayer<" + key + ">");
}
mKey = key;
mMediaData = data;
MediaSession.Token token = data.getToken();
mPackageName = data.getPackageName();
mUid = data.getAppUid();
// Only assigns instance id if it's unassigned.
if (mSmartspaceId == -1) {
mSmartspaceId = SmallHash.hash(mUid + (int) mSystemClock.currentTimeMillis());
}
mInstanceId = data.getInstanceId();
if (mToken == null || !mToken.equals(token)) {
mToken = token;
}
if (mToken != null) {
mController = new MediaController(mContext, mToken);
} else {
mController = null;
}
// Click action
PendingIntent clickIntent = data.getClickIntent();
if (clickIntent != null) {
mMediaViewHolder.getPlayer().setOnClickListener(v -> {
if (mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) return;
if (mMediaViewController.isGutsVisible()) return;
mLogger.logTapContentView(mUid, mPackageName, mInstanceId);
logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT);
boolean showOverLockscreen = mKeyguardStateController.isShowing()
&& mActivityIntentHelper.wouldPendingShowOverLockscreen(clickIntent,
mLockscreenUserManager.getCurrentUserId());
if (showOverLockscreen) {
try {
clickIntent.send();
} catch (PendingIntent.CanceledException e) {
Log.e(TAG, "Pending intent for " + key + " was cancelled");
}
} else {
mActivityStarter.postStartActivityDismissingKeyguard(clickIntent,
buildLaunchAnimatorController(mMediaViewHolder.getPlayer()));
}
});
}
// Seek Bar
if (data.getResumption() && data.getResumeProgress() != null) {
double progress = data.getResumeProgress();
mSeekBarViewModel.updateStaticProgress(progress);
} else {
final MediaController controller = getController();
mBackgroundExecutor.execute(() -> mSeekBarViewModel.updateController(controller));
}
// Show the broadcast dialog button only when the le audio is enabled.
mShowBroadcastDialogButton =
data.getDevice() != null && data.getDevice().getShowBroadcastButton();
bindOutputSwitcherAndBroadcastButton(mShowBroadcastDialogButton, data);
bindGutsMenuForPlayer(data);
bindPlayerContentDescription(data);
bindScrubbingTime(data);
bindActionButtons(data);
boolean isSongUpdated = bindSongMetadata(data);
bindArtworkAndColors(data, key, isSongUpdated);
// TODO: We don't need to refresh this state constantly, only if the state actually changed
// to something which might impact the measurement
// State refresh interferes with the translation animation, only run it if it's not running.
if (!mMetadataAnimationHandler.isRunning()) {
mMediaViewController.refreshState();
}
Trace.endSection();
}
private void bindOutputSwitcherAndBroadcastButton(boolean showBroadcastButton, MediaData data) {
ViewGroup seamlessView = mMediaViewHolder.getSeamless();
seamlessView.setVisibility(View.VISIBLE);
ImageView iconView = mMediaViewHolder.getSeamlessIcon();
TextView deviceName = mMediaViewHolder.getSeamlessText();
final MediaDeviceData device = data.getDevice();
final boolean isTapEnabled;
final boolean useDisabledAlpha;
final int iconResource;
CharSequence deviceString;
if (showBroadcastButton) {
// TODO(b/233698402): Use the package name instead of app label to avoid the
// unexpected result.
mIsCurrentBroadcastedApp = device != null
&& TextUtils.equals(device.getName(),
mContext.getString(R.string.broadcasting_description_is_broadcasting));
useDisabledAlpha = !mIsCurrentBroadcastedApp;
// Always be enabled if the broadcast button is shown
isTapEnabled = true;
// Defaults for broadcasting state
deviceString = mContext.getString(R.string.bt_le_audio_broadcast_dialog_unknown_name);
iconResource = R.drawable.settings_input_antenna;
} else {
// Disable clicking on output switcher for invalid devices and resumption controls
useDisabledAlpha = (device != null && !device.getEnabled()) || data.getResumption();
isTapEnabled = !useDisabledAlpha;
// Defaults for non-broadcasting state
deviceString = mContext.getString(R.string.media_seamless_other_device);
iconResource = R.drawable.ic_media_home_devices;
}
mMediaViewHolder.getSeamlessButton().setAlpha(useDisabledAlpha ? DISABLED_ALPHA : 1.0f);
seamlessView.setEnabled(isTapEnabled);
if (device != null) {
Drawable icon = device.getIcon();
if (icon instanceof AdaptiveIcon) {
AdaptiveIcon aIcon = (AdaptiveIcon) icon;
aIcon.setBackgroundColor(mColorSchemeTransition.getBgColor());
iconView.setImageDrawable(aIcon);
} else {
iconView.setImageDrawable(icon);
}
if (device.getName() != null) {
deviceString = device.getName();
}
} else {
// Set to default icon
iconView.setImageResource(iconResource);
}
deviceName.setText(deviceString);
seamlessView.setContentDescription(deviceString);
seamlessView.setOnClickListener(
v -> {
if (mFalsingManager.isFalseTap(
mFeatureFlags.isEnabled(Flags.MEDIA_FALSING_PENALTY)
? FalsingManager.MODERATE_PENALTY :
FalsingManager.LOW_PENALTY)) {
return;
}
if (showBroadcastButton) {
// If the current media app is not broadcasted and users press the outputer
// button, we should pop up the broadcast dialog to check do they want to
// switch broadcast to the other media app, otherwise we still pop up the
// media output dialog.
if (!mIsCurrentBroadcastedApp) {
mLogger.logOpenBroadcastDialog(mUid, mPackageName, mInstanceId);
mCurrentBroadcastApp = device.getName().toString();
mBroadcastDialogController.createBroadcastDialog(mCurrentBroadcastApp,
mPackageName, true, mMediaViewHolder.getSeamlessButton());
} else {
mLogger.logOpenOutputSwitcher(mUid, mPackageName, mInstanceId);
mMediaOutputDialogFactory.create(mPackageName, true,
mMediaViewHolder.getSeamlessButton());
}
} else {
mLogger.logOpenOutputSwitcher(mUid, mPackageName, mInstanceId);
if (device.getIntent() != null) {
PendingIntent deviceIntent = device.getIntent();
boolean showOverLockscreen = mKeyguardStateController.isShowing()
&& mActivityIntentHelper.wouldPendingShowOverLockscreen(
deviceIntent, mLockscreenUserManager.getCurrentUserId());
if (deviceIntent.isActivity() && !showOverLockscreen) {
mActivityStarter.postStartActivityDismissingKeyguard(deviceIntent);
} else {
try {
deviceIntent.send();
} catch (PendingIntent.CanceledException e) {
Log.e(TAG, "Device pending intent was canceled");
}
}
} else {
mMediaOutputDialogFactory.create(mPackageName, true,
mMediaViewHolder.getSeamlessButton());
}
}
});
}
private void bindGutsMenuForPlayer(MediaData data) {
Runnable onDismissClickedRunnable = () -> {
if (mKey != null) {
closeGuts();
if (!mMediaDataManagerLazy.get().dismissMediaData(mKey,
MediaViewController.GUTS_ANIMATION_DURATION + 100)) {
Log.w(TAG, "Manager failed to dismiss media " + mKey);
// Remove directly from carousel so user isn't stuck with defunct controls
mMediaCarouselController.removePlayer(mKey, false, false);
}
} else {
Log.w(TAG, "Dismiss media with null notification. Token uid="
+ data.getToken().getUid());
}
};
bindGutsMenuCommon(
/* isDismissible= */ data.isClearable(),
data.getApp(),
mMediaViewHolder.getGutsViewHolder(),
onDismissClickedRunnable);
}
private boolean bindSongMetadata(MediaData data) {
TextView titleText = mMediaViewHolder.getTitleText();
TextView artistText = mMediaViewHolder.getArtistText();
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout();
return mMetadataAnimationHandler.setNext(
new Triple(data.getSong(), data.getArtist(), data.isExplicit()),
() -> {
titleText.setText(data.getSong());
artistText.setText(data.getArtist());
setVisibleAndAlpha(expandedSet, R.id.media_explicit_indicator, data.isExplicit());
setVisibleAndAlpha(collapsedSet, R.id.media_explicit_indicator, data.isExplicit());
// refreshState is required here to resize the text views (and prevent ellipsis)
mMediaViewController.refreshState();
return Unit.INSTANCE;
},
() -> {
// After finishing the enter animation, we refresh state. This could pop if
// something is incorrectly bound, but needs to be run if other elements were
// updated while the enter animation was running
mMediaViewController.refreshState();
return Unit.INSTANCE;
});
}
// We may want to look into unifying this with bindRecommendationContentDescription if/when we
// do a refactor of this class.
private void bindPlayerContentDescription(MediaData data) {
if (mMediaViewHolder == null) {
return;
}
CharSequence contentDescription;
if (mMediaViewController.isGutsVisible()) {
contentDescription = mMediaViewHolder.getGutsViewHolder().getGutsText().getText();
} else if (data != null) {
contentDescription = mContext.getString(
R.string.controls_media_playing_item_description,
data.getSong(),
data.getArtist(),
data.getApp());
} else {
contentDescription = null;
}
mMediaViewHolder.getPlayer().setContentDescription(contentDescription);
}
private void bindRecommendationContentDescription(SmartspaceMediaData data) {
if (mRecommendationViewHolder == null) {
return;
}
CharSequence contentDescription;
if (mMediaViewController.isGutsVisible()) {
contentDescription =
mRecommendationViewHolder.getGutsViewHolder().getGutsText().getText();
} else if (data != null) {
if (mFeatureFlags.isEnabled(Flags.MEDIA_RECOMMENDATION_CARD_UPDATE)) {
contentDescription = mContext.getString(
R.string.controls_media_smartspace_rec_header);
} else {
contentDescription = mContext.getString(
R.string.controls_media_smartspace_rec_description,
data.getAppName(mContext));
}
} else {
contentDescription = null;
}
mRecommendationViewHolder.getRecommendations().setContentDescription(contentDescription);
}
private void bindArtworkAndColors(MediaData data, String key, boolean updateBackground) {
final int traceCookie = data.hashCode();
final String traceName = "MediaControlPanel#bindArtworkAndColors<" + key + ">";
Trace.beginAsyncSection(traceName, traceCookie);
final int reqId = mArtworkNextBindRequestId++;
if (updateBackground) {
mIsArtworkBound = false;
}
// Capture width & height from views in foreground for artwork scaling in background
int width = mMediaViewHolder.getAlbumView().getMeasuredWidth();
int height = mMediaViewHolder.getAlbumView().getMeasuredHeight();
mBackgroundExecutor.execute(() -> {
// Album art
ColorScheme mutableColorScheme = null;
Drawable artwork;
boolean isArtworkBound;
Icon artworkIcon = data.getArtwork();
WallpaperColors wallpaperColors = getWallpaperColor(artworkIcon);
if (wallpaperColors != null) {
mutableColorScheme = new ColorScheme(wallpaperColors, true, Style.CONTENT);
artwork = addGradientToPlayerAlbum(artworkIcon, mutableColorScheme, width, height);
isArtworkBound = true;
} else {
// If there's no artwork, use colors from the app icon
artwork = new ColorDrawable(Color.TRANSPARENT);
isArtworkBound = false;
try {
Drawable icon = mContext.getPackageManager()
.getApplicationIcon(data.getPackageName());
mutableColorScheme = new ColorScheme(WallpaperColors.fromDrawable(icon), true,
Style.CONTENT);
} catch (PackageManager.NameNotFoundException e) {
Log.w(TAG, "Cannot find icon for package " + data.getPackageName(), e);
}
}
final ColorScheme colorScheme = mutableColorScheme;
mMainExecutor.execute(() -> {
// Cancel the request if a later one arrived first
if (reqId < mArtworkBoundId) {
Trace.endAsyncSection(traceName, traceCookie);
return;
}
mArtworkBoundId = reqId;
// Transition Colors to current color scheme
boolean colorSchemeChanged = mColorSchemeTransition.updateColorScheme(colorScheme);
// Bind the album view to the artwork or a transition drawable
ImageView albumView = mMediaViewHolder.getAlbumView();
albumView.setPadding(0, 0, 0, 0);
if (updateBackground || colorSchemeChanged
|| (!mIsArtworkBound && isArtworkBound)) {
if (mPrevArtwork == null) {
albumView.setImageDrawable(artwork);
} else {
// Since we throw away the last transition, this'll pop if you backgrounds
// are cycled too fast (or the correct background arrives very soon after
// the metadata changes).
TransitionDrawable transitionDrawable = new TransitionDrawable(
new Drawable[]{mPrevArtwork, artwork});
scaleTransitionDrawableLayer(transitionDrawable, 0, width, height);
scaleTransitionDrawableLayer(transitionDrawable, 1, width, height);
transitionDrawable.setLayerGravity(0, Gravity.CENTER);
transitionDrawable.setLayerGravity(1, Gravity.CENTER);
transitionDrawable.setCrossFadeEnabled(!isArtworkBound);
albumView.setImageDrawable(transitionDrawable);
transitionDrawable.startTransition(isArtworkBound ? 333 : 80);
}
mPrevArtwork = artwork;
mIsArtworkBound = isArtworkBound;
}
// App icon - use notification icon
ImageView appIconView = mMediaViewHolder.getAppIcon();
appIconView.clearColorFilter();
if (data.getAppIcon() != null && !data.getResumption()) {
appIconView.setImageIcon(data.getAppIcon());
appIconView.setColorFilter(
mColorSchemeTransition.getAccentPrimary().getTargetColor());
} else {
// Resume players use launcher icon
appIconView.setColorFilter(getGrayscaleFilter());
try {
Drawable icon = mContext.getPackageManager()
.getApplicationIcon(data.getPackageName());
appIconView.setImageDrawable(icon);
} catch (PackageManager.NameNotFoundException e) {
Log.w(TAG, "Cannot find icon for package " + data.getPackageName(), e);
appIconView.setImageResource(R.drawable.ic_music_note);
}
}
Trace.endAsyncSection(traceName, traceCookie);
});
});
}
private void bindRecommendationArtwork(
SmartspaceAction recommendation,
String packageName,
int itemIndex
) {
final int traceCookie = recommendation.hashCode();
final String traceName =
"MediaControlPanel#bindRecommendationArtwork<" + packageName + ">";
Trace.beginAsyncSection(traceName, traceCookie);
// Capture width & height from views in foreground for artwork scaling in background
int width = mContext.getResources().getDimensionPixelSize(R.dimen.qs_media_rec_album_width);
int height = mContext.getResources().getDimensionPixelSize(
R.dimen.qs_media_rec_album_height_expanded);
mBackgroundExecutor.execute(() -> {
// Album art
ColorScheme mutableColorScheme = null;
Drawable artwork;
Icon artworkIcon = recommendation.getIcon();
WallpaperColors wallpaperColors = getWallpaperColor(artworkIcon);
if (wallpaperColors != null) {
mutableColorScheme = new ColorScheme(wallpaperColors, true, Style.CONTENT);
artwork = addGradientToRecommendationAlbum(artworkIcon, mutableColorScheme, width,
height);
} else {
artwork = new ColorDrawable(Color.TRANSPARENT);
}
mMainExecutor.execute(() -> {
// Bind the artwork drawable to media cover.
ImageView mediaCover =
mRecommendationViewHolder.getMediaCoverItems().get(itemIndex);
// Rescale media cover
Matrix coverMatrix = new Matrix(mediaCover.getImageMatrix());
coverMatrix.postScale(REC_MEDIA_COVER_SCALE_FACTOR, REC_MEDIA_COVER_SCALE_FACTOR,
0.5f * width, 0.5f * height);
mediaCover.setImageMatrix(coverMatrix);
mediaCover.setImageDrawable(artwork);
// Set up the app icon.
ImageView appIconView = mRecommendationViewHolder.getMediaAppIcons().get(itemIndex);
appIconView.clearColorFilter();
try {
Drawable icon = mContext.getPackageManager()
.getApplicationIcon(packageName);
appIconView.setImageDrawable(icon);
} catch (PackageManager.NameNotFoundException e) {
Log.w(TAG, "Cannot find icon for package " + packageName, e);
appIconView.setImageResource(R.drawable.ic_music_note);
}
Trace.endAsyncSection(traceName, traceCookie);
});
});
}
// This method should be called from a background thread. WallpaperColors.fromBitmap takes a
// good amount of time. We do that work on the background executor to avoid stalling animations
// on the UI Thread.
@VisibleForTesting
protected WallpaperColors getWallpaperColor(Icon artworkIcon) {
if (artworkIcon != null) {
if (artworkIcon.getType() == Icon.TYPE_BITMAP
|| artworkIcon.getType() == Icon.TYPE_ADAPTIVE_BITMAP) {
// Avoids extra processing if this is already a valid bitmap
Bitmap artworkBitmap = artworkIcon.getBitmap();
if (artworkBitmap.isRecycled()) {
Log.d(TAG, "Cannot load wallpaper color from a recycled bitmap");
return null;
}
return WallpaperColors.fromBitmap(artworkBitmap);
} else {
Drawable artworkDrawable = artworkIcon.loadDrawable(mContext);
if (artworkDrawable != null) {
return WallpaperColors.fromDrawable(artworkDrawable);
}
}
}
return null;
}
@VisibleForTesting
protected LayerDrawable addGradientToPlayerAlbum(Icon artworkIcon,
ColorScheme mutableColorScheme, int width, int height) {
Drawable albumArt = getScaledBackground(artworkIcon, width, height);
GradientDrawable gradient = (GradientDrawable) mContext.getDrawable(
R.drawable.qs_media_scrim).mutate();
return setupGradientColorOnDrawable(albumArt, gradient, mutableColorScheme,
MEDIA_SCRIM_START_ALPHA, MEDIA_PLAYER_SCRIM_END_ALPHA);
}
@VisibleForTesting
protected LayerDrawable addGradientToRecommendationAlbum(Icon artworkIcon,
ColorScheme mutableColorScheme, int width, int height) {
// First try scaling rec card using bitmap drawable.
// If returns null, set drawable bounds.
Drawable albumArt = getScaledRecommendationCover(artworkIcon, width, height);
if (albumArt == null) {
albumArt = getScaledBackground(artworkIcon, width, height);
}
GradientDrawable gradient = (GradientDrawable) mContext.getDrawable(
R.drawable.qs_media_rec_scrim).mutate();
return setupGradientColorOnDrawable(albumArt, gradient, mutableColorScheme,
MEDIA_REC_SCRIM_START_ALPHA, MEDIA_REC_SCRIM_END_ALPHA);
}
private LayerDrawable setupGradientColorOnDrawable(Drawable albumArt, GradientDrawable gradient,
ColorScheme mutableColorScheme, float startAlpha, float endAlpha) {
gradient.setColors(new int[] {
ColorUtilKt.getColorWithAlpha(
MediaColorSchemesKt.backgroundStartFromScheme(mutableColorScheme),
startAlpha),
ColorUtilKt.getColorWithAlpha(
MediaColorSchemesKt.backgroundEndFromScheme(mutableColorScheme),
endAlpha),
});
return new LayerDrawable(new Drawable[] { albumArt, gradient });
}
private void scaleTransitionDrawableLayer(TransitionDrawable transitionDrawable, int layer,
int targetWidth, int targetHeight) {
Drawable drawable = transitionDrawable.getDrawable(layer);
if (drawable == null) {
return;
}
int width = drawable.getIntrinsicWidth();
int height = drawable.getIntrinsicHeight();
if (width == 0 || height == 0 || targetWidth == 0 || targetHeight == 0) {
return;
}
float scale;
if ((width / (float) height) > (targetWidth / (float) targetHeight)) {
// Drawable is wider than target view, scale to match height
scale = targetHeight / (float) height;
} else {
// Drawable is taller than target view, scale to match width
scale = targetWidth / (float) width;
}
transitionDrawable.setLayerSize(layer, (int) (scale * width), (int) (scale * height));
}
private void bindActionButtons(MediaData data) {
MediaButton semanticActions = data.getSemanticActions();
List<ImageButton> genericButtons = new ArrayList<>();
for (int id : MediaViewHolder.Companion.getGenericButtonIds()) {
genericButtons.add(mMediaViewHolder.getAction(id));
}
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout();
if (semanticActions != null) {
// Hide all the generic buttons
for (ImageButton b: genericButtons) {
setVisibleAndAlpha(collapsedSet, b.getId(), false);
setVisibleAndAlpha(expandedSet, b.getId(), false);
}
for (int id : SEMANTIC_ACTIONS_ALL) {
ImageButton button = mMediaViewHolder.getAction(id);
MediaAction action = semanticActions.getActionById(id);
setSemanticButton(button, action, semanticActions);
}
} else {
// Hide buttons that only appear for semantic actions
for (int id : SEMANTIC_ACTIONS_COMPACT) {
setVisibleAndAlpha(collapsedSet, id, false);
setVisibleAndAlpha(expandedSet, id, false);
}
// Set all the generic buttons
List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact();
List<MediaAction> actions = data.getActions();
int i = 0;
for (; i < actions.size() && i < genericButtons.size(); i++) {
boolean showInCompact = actionsWhenCollapsed.contains(i);
setGenericButton(
genericButtons.get(i),
actions.get(i),
collapsedSet,
expandedSet,
showInCompact);
}
for (; i < genericButtons.size(); i++) {
// Hide any unused buttons
setGenericButton(
genericButtons.get(i),
/* mediaAction= */ null,
collapsedSet,
expandedSet,
/* showInCompact= */ false);
}
}
updateSeekBarVisibility();
}
private void updateSeekBarVisibility() {
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
expandedSet.setVisibility(R.id.media_progress_bar, getSeekBarVisibility());
expandedSet.setAlpha(R.id.media_progress_bar, mIsSeekBarEnabled ? 1.0f : 0.0f);
}
private int getSeekBarVisibility() {
if (mIsSeekBarEnabled) {
return ConstraintSet.VISIBLE;
}
// Set progress bar to INVISIBLE to keep the positions of text and buttons similar to the
// original positions when seekbar is enabled.
return ConstraintSet.INVISIBLE;
}
private void setGenericButton(
final ImageButton button,
@Nullable MediaAction mediaAction,
ConstraintSet collapsedSet,
ConstraintSet expandedSet,
boolean showInCompact) {
bindButtonCommon(button, mediaAction);
boolean visible = mediaAction != null;
setVisibleAndAlpha(expandedSet, button.getId(), visible);
setVisibleAndAlpha(collapsedSet, button.getId(), visible && showInCompact);
}
private void setSemanticButton(
final ImageButton button,
@Nullable MediaAction mediaAction,
MediaButton semanticActions) {
AnimationBindHandler animHandler;
if (button.getTag() == null) {
animHandler = new AnimationBindHandler();
button.setTag(animHandler);
} else {
animHandler = (AnimationBindHandler) button.getTag();
}
animHandler.tryExecute(() -> {
bindButtonWithAnimations(button, mediaAction, animHandler);
setSemanticButtonVisibleAndAlpha(button.getId(), mediaAction, semanticActions);
return Unit.INSTANCE;
});
}
private void bindButtonWithAnimations(
final ImageButton button,
@Nullable MediaAction mediaAction,
@NonNull AnimationBindHandler animHandler) {
if (mediaAction != null) {
if (animHandler.updateRebindId(mediaAction.getRebindId())) {
animHandler.unregisterAll();
animHandler.tryRegister(mediaAction.getIcon());
animHandler.tryRegister(mediaAction.getBackground());
bindButtonCommon(button, mediaAction);
}
} else {
animHandler.unregisterAll();
clearButton(button);
}
}
private void bindButtonCommon(final ImageButton button, @Nullable MediaAction mediaAction) {
if (mediaAction != null) {
final Drawable icon = mediaAction.getIcon();
button.setImageDrawable(icon);
button.setContentDescription(mediaAction.getContentDescription());
final Drawable bgDrawable = mediaAction.getBackground();
button.setBackground(bgDrawable);
Runnable action = mediaAction.getAction();
if (action == null) {
button.setEnabled(false);
} else {
button.setEnabled(true);
button.setOnClickListener(v -> {
if (!mFalsingManager.isFalseTap(
mFeatureFlags.isEnabled(Flags.MEDIA_FALSING_PENALTY)
? FalsingManager.MODERATE_PENALTY :
FalsingManager.LOW_PENALTY)) {
mLogger.logTapAction(button.getId(), mUid, mPackageName, mInstanceId);
logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT);
action.run();
if (mFeatureFlags.isEnabled(Flags.UMO_SURFACE_RIPPLE)) {
mMultiRippleController.play(createTouchRippleAnimation(button));
}
if (icon instanceof Animatable) {
((Animatable) icon).start();
}
if (bgDrawable instanceof Animatable) {
((Animatable) bgDrawable).start();
}
}
});
}
} else {
clearButton(button);
}
}
private RippleAnimation createTouchRippleAnimation(ImageButton button) {
float maxSize = mMediaViewHolder.getMultiRippleView().getWidth() * 2;
return new RippleAnimation(
new RippleAnimationConfig(
RippleShader.RippleShape.CIRCLE,
/* duration= */ 1500L,
/* centerX= */ button.getX() + button.getWidth() * 0.5f,
/* centerY= */ button.getY() + button.getHeight() * 0.5f,
/* maxWidth= */ maxSize,
/* maxHeight= */ maxSize,
/* pixelDensity= */ getContext().getResources().getDisplayMetrics().density,
mColorSchemeTransition.getAccentPrimary().getCurrentColor(),
/* opacity= */ 100,
/* sparkleStrength= */ 0f,
/* baseRingFadeParams= */ null,
/* sparkleRingFadeParams= */ null,
/* centerFillFadeParams= */ null,
/* shouldDistort= */ false
)
);
}
private TurbulenceNoiseAnimationConfig createTurbulenceNoiseAnimation() {
return new TurbulenceNoiseAnimationConfig(
TurbulenceNoiseAnimationConfig.DEFAULT_NOISE_GRID_COUNT,
TurbulenceNoiseAnimationConfig.DEFAULT_LUMINOSITY_MULTIPLIER,
/* noiseMoveSpeedX= */ 0f,
/* noiseMoveSpeedY= */ 0f,
TurbulenceNoiseAnimationConfig.DEFAULT_NOISE_SPEED_Z,
/* color= */ mColorSchemeTransition.getAccentPrimary().getCurrentColor(),
// We want to add (BlendMode.PLUS) the turbulence noise on top of the album art.
// Thus, set the background color with alpha 0.
/* backgroundColor= */ ColorUtils.setAlphaComponent(Color.BLACK, 0),
TurbulenceNoiseAnimationConfig.DEFAULT_OPACITY,
/* width= */ mMediaViewHolder.getMultiRippleView().getWidth(),
/* height= */ mMediaViewHolder.getMultiRippleView().getHeight(),
TurbulenceNoiseAnimationConfig.DEFAULT_MAX_DURATION_IN_MILLIS,
/* easeInDuration= */
TurbulenceNoiseAnimationConfig.DEFAULT_EASING_DURATION_IN_MILLIS,
/* easeOutDuration= */
TurbulenceNoiseAnimationConfig.DEFAULT_EASING_DURATION_IN_MILLIS,
this.getContext().getResources().getDisplayMetrics().density,
BlendMode.PLUS,
/* onAnimationEnd= */ null
);
}
private void clearButton(final ImageButton button) {
button.setImageDrawable(null);
button.setContentDescription(null);
button.setEnabled(false);
button.setBackground(null);
}
private void setSemanticButtonVisibleAndAlpha(
int buttonId,
@Nullable MediaAction mediaAction,
MediaButton semanticActions) {
ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout();
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
boolean showInCompact = SEMANTIC_ACTIONS_COMPACT.contains(buttonId);
boolean hideWhenScrubbing = SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.contains(buttonId);
boolean shouldBeHiddenDueToScrubbing =
scrubbingTimeViewsEnabled(semanticActions) && hideWhenScrubbing && mIsScrubbing;
boolean visible = mediaAction != null && !shouldBeHiddenDueToScrubbing;
int notVisibleValue;
if ((buttonId == R.id.actionPrev && semanticActions.getReservePrev())
|| (buttonId == R.id.actionNext && semanticActions.getReserveNext())) {
notVisibleValue = ConstraintSet.INVISIBLE;
} else {
notVisibleValue = ConstraintSet.GONE;
}
setVisibleAndAlpha(expandedSet, buttonId, visible, notVisibleValue);
setVisibleAndAlpha(collapsedSet, buttonId, visible && showInCompact);
}
/** Updates all the views that might change due to a scrubbing state change. */
private void updateDisplayForScrubbingChange(@NonNull MediaButton semanticActions) {
// Update visibilities of the scrubbing time views and the scrubbing-dependent buttons.
bindScrubbingTime(mMediaData);
SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.forEach((id) -> setSemanticButtonVisibleAndAlpha(
id, semanticActions.getActionById(id), semanticActions));
if (!mMetadataAnimationHandler.isRunning()) {
// Trigger a state refresh so that we immediately update visibilities.
mMediaViewController.refreshState();
}
}
private void bindScrubbingTime(MediaData data) {
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
int elapsedTimeId = mMediaViewHolder.getScrubbingElapsedTimeView().getId();
int totalTimeId = mMediaViewHolder.getScrubbingTotalTimeView().getId();
boolean visible = scrubbingTimeViewsEnabled(data.getSemanticActions()) && mIsScrubbing;
setVisibleAndAlpha(expandedSet, elapsedTimeId, visible);
setVisibleAndAlpha(expandedSet, totalTimeId, visible);
// Collapsed view is always GONE as set in XML, so doesn't need to be updated dynamically
}
private boolean scrubbingTimeViewsEnabled(@Nullable MediaButton semanticActions) {
// The scrubbing time views replace the SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING action views,
// so we should only allow scrubbing times to be shown if those action views are present.
return semanticActions != null && SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.stream().allMatch(
id -> semanticActions.getActionById(id) != null
);
}
@Nullable
private ActivityLaunchAnimator.Controller buildLaunchAnimatorController(
TransitionLayout player) {
if (!(player.getParent() instanceof ViewGroup)) {
// TODO(b/192194319): Throw instead of just logging.
Log.wtf(TAG, "Skipping player animation as it is not attached to a ViewGroup",
new Exception());
return null;
}
// TODO(b/174236650): Make sure that the carousel indicator also fades out.
// TODO(b/174236650): Instrument the animation to measure jank.
return new GhostedViewLaunchAnimatorController(player,
InteractionJankMonitor.CUJ_SHADE_APP_LAUNCH_FROM_MEDIA_PLAYER) {
@Override
protected float getCurrentTopCornerRadius() {
return mContext.getResources().getDimension(R.dimen.notification_corner_radius);
}
@Override
protected float getCurrentBottomCornerRadius() {
// TODO(b/184121838): Make IlluminationDrawable support top and bottom radius.
return getCurrentTopCornerRadius();
}
};
}
/** Bind this recommendation view based on the given data. */
public void bindRecommendation(@NonNull SmartspaceMediaData data) {
if (mRecommendationViewHolder == null) {
return;
}
if (!data.isValid()) {
Log.e(TAG, "Received an invalid recommendation list; returning");
return;
}
if (Trace.isEnabled()) {
Trace.traceBegin(Trace.TRACE_TAG_APP,
"MediaControlPanel#bindRecommendation<" + data.getPackageName() + ">");
}
mRecommendationData = data;
mSmartspaceId = SmallHash.hash(data.getTargetId());
mPackageName = data.getPackageName();
mInstanceId = data.getInstanceId();
// Set up recommendation card's header.
ApplicationInfo applicationInfo;
try {
applicationInfo = mContext.getPackageManager()
.getApplicationInfo(data.getPackageName(), 0 /* flags */);
mUid = applicationInfo.uid;
} catch (PackageManager.NameNotFoundException e) {
Log.w(TAG, "Fail to get media recommendation's app info", e);
Trace.endSection();
return;
}
CharSequence appName = data.getAppName(mContext);
if (appName == null) {
Log.w(TAG, "Fail to get media recommendation's app name");
Trace.endSection();
return;
}
PackageManager packageManager = mContext.getPackageManager();
// Set up media source app's logo.
Drawable icon = packageManager.getApplicationIcon(applicationInfo);
if (!mFeatureFlags.isEnabled(Flags.MEDIA_RECOMMENDATION_CARD_UPDATE)) {
ImageView headerLogoImageView = mRecommendationViewHolder.getCardIcon();
headerLogoImageView.setImageDrawable(icon);
}
fetchAndUpdateRecommendationColors(icon);
// Set up media rec card's tap action if applicable.
TransitionLayout recommendationCard = mRecommendationViewHolder.getRecommendations();
setSmartspaceRecItemOnClickListener(recommendationCard, data.getCardAction(),
/* interactedSubcardRank */ -1);
bindRecommendationContentDescription(data);
List<ImageView> mediaCoverItems = mRecommendationViewHolder.getMediaCoverItems();
List<ViewGroup> mediaCoverContainers = mRecommendationViewHolder.getMediaCoverContainers();
List<SmartspaceAction> recommendations = data.getValidRecommendations();
boolean hasTitle = false;
boolean hasSubtitle = false;
for (int itemIndex = 0; itemIndex < NUM_REQUIRED_RECOMMENDATIONS; itemIndex++) {
SmartspaceAction recommendation = recommendations.get(itemIndex);
// Set up media item cover.
ImageView mediaCoverImageView = mediaCoverItems.get(itemIndex);
if (mFeatureFlags.isEnabled(Flags.MEDIA_RECOMMENDATION_CARD_UPDATE)) {
bindRecommendationArtwork(
recommendation,
data.getPackageName(),
itemIndex
);
} else {
mediaCoverImageView.post(
() -> mediaCoverImageView.setImageIcon(recommendation.getIcon()));
}
// Set up the media item's click listener if applicable.
ViewGroup mediaCoverContainer = mediaCoverContainers.get(itemIndex);
setSmartspaceRecItemOnClickListener(mediaCoverContainer, recommendation, itemIndex);
// Bubble up the long-click event to the card.
mediaCoverContainer.setOnLongClickListener(v -> {
if (mFalsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)) return true;
View parent = (View) v.getParent();
if (parent != null) {
parent.performLongClick();
}
return true;
});
// Set up the accessibility label for the media item.
String artistName = recommendation.getExtras()
.getString(KEY_SMARTSPACE_ARTIST_NAME, "");
if (artistName.isEmpty()) {
mediaCoverImageView.setContentDescription(
mContext.getString(
R.string.controls_media_smartspace_rec_item_no_artist_description,
recommendation.getTitle(), appName));
} else {
mediaCoverImageView.setContentDescription(
mContext.getString(
R.string.controls_media_smartspace_rec_item_description,
recommendation.getTitle(), artistName, appName));
}
// Set up title
CharSequence title = recommendation.getTitle();
hasTitle |= !TextUtils.isEmpty(title);
TextView titleView = mRecommendationViewHolder.getMediaTitles().get(itemIndex);
titleView.setText(title);
// Set up subtitle
// It would look awkward to show a subtitle if we don't have a title.
boolean shouldShowSubtitleText = !TextUtils.isEmpty(title);
CharSequence subtitle = shouldShowSubtitleText ? recommendation.getSubtitle() : "";
hasSubtitle |= !TextUtils.isEmpty(subtitle);
TextView subtitleView = mRecommendationViewHolder.getMediaSubtitles().get(itemIndex);
subtitleView.setText(subtitle);
// Set up progress bar
if (mFeatureFlags.isEnabled(Flags.MEDIA_RECOMMENDATION_CARD_UPDATE)) {
SeekBar mediaProgressBar =
mRecommendationViewHolder.getMediaProgressBars().get(itemIndex);
TextView mediaSubtitle =
mRecommendationViewHolder.getMediaSubtitles().get(itemIndex);
// show progress bar if the recommended album is played.
Double progress = MediaDataUtils.getDescriptionProgress(recommendation.getExtras());
if (progress == null || progress <= 0.0) {
mediaProgressBar.setVisibility(View.GONE);
mediaSubtitle.setVisibility(View.VISIBLE);
} else {
mediaProgressBar.setProgress((int) (progress * 100));
mediaProgressBar.setVisibility(View.VISIBLE);
mediaSubtitle.setVisibility(View.GONE);
}
}
}
mSmartspaceMediaItemsCount = NUM_REQUIRED_RECOMMENDATIONS;
// If there's no subtitles and/or titles for any of the albums, hide those views.
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
final boolean titlesVisible = hasTitle;
final boolean subtitlesVisible = hasSubtitle;
mRecommendationViewHolder.getMediaTitles().forEach((titleView) ->
setVisibleAndAlpha(expandedSet, titleView.getId(), titlesVisible));
mRecommendationViewHolder.getMediaSubtitles().forEach((subtitleView) ->
setVisibleAndAlpha(expandedSet, subtitleView.getId(), subtitlesVisible));
// Guts
Runnable onDismissClickedRunnable = () -> {
closeGuts();
mMediaDataManagerLazy.get().dismissSmartspaceRecommendation(
data.getTargetId(), MediaViewController.GUTS_ANIMATION_DURATION + 100L);
Intent dismissIntent = data.getDismissIntent();
if (dismissIntent == null) {
Log.w(TAG, "Cannot create dismiss action click action: "
+ "extras missing dismiss_intent.");
return;
}
if (dismissIntent.getComponent() != null
&& dismissIntent.getComponent().getClassName()
.equals(EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME)) {
// Dismiss the card Smartspace data through Smartspace trampoline activity.
mContext.startActivity(dismissIntent);
} else {
mBroadcastSender.sendBroadcast(dismissIntent);
}
};
bindGutsMenuCommon(
/* isDismissible= */ true,
appName.toString(),
mRecommendationViewHolder.getGutsViewHolder(),
onDismissClickedRunnable);
mController = null;
if (mMetadataAnimationHandler == null || !mMetadataAnimationHandler.isRunning()) {
mMediaViewController.refreshState();
}
Trace.endSection();
}
private void fetchAndUpdateRecommendationColors(Drawable appIcon) {
mBackgroundExecutor.execute(() -> {
ColorScheme colorScheme = new ColorScheme(
WallpaperColors.fromDrawable(appIcon), /* darkTheme= */ true);
mMainExecutor.execute(() -> setRecommendationColors(colorScheme));
});
}
private void setRecommendationColors(ColorScheme colorScheme) {
if (mRecommendationViewHolder == null) {
return;
}
int backgroundColor = MediaColorSchemesKt.surfaceFromScheme(colorScheme);
int textPrimaryColor = MediaColorSchemesKt.textPrimaryFromScheme(colorScheme);
int textSecondaryColor = MediaColorSchemesKt.textSecondaryFromScheme(colorScheme);
if (mFeatureFlags.isEnabled(Flags.MEDIA_RECOMMENDATION_CARD_UPDATE)) {
mRecommendationViewHolder.getCardTitle().setTextColor(textPrimaryColor);
}
mRecommendationViewHolder.getRecommendations()
.setBackgroundTintList(ColorStateList.valueOf(backgroundColor));
mRecommendationViewHolder.getMediaTitles().forEach(
(title) -> title.setTextColor(textPrimaryColor));
mRecommendationViewHolder.getMediaSubtitles().forEach(
(subtitle) -> subtitle.setTextColor(textSecondaryColor));
if (mFeatureFlags.isEnabled(Flags.MEDIA_RECOMMENDATION_CARD_UPDATE)) {
mRecommendationViewHolder.getMediaProgressBars().forEach(
(progressBar) -> progressBar.setProgressTintList(
ColorStateList.valueOf(textPrimaryColor))
);
}
mRecommendationViewHolder.getGutsViewHolder().setColors(colorScheme);
}
private void bindGutsMenuCommon(
boolean isDismissible,
String appName,
GutsViewHolder gutsViewHolder,
Runnable onDismissClickedRunnable) {
// Text
String text;
if (isDismissible) {
text = mContext.getString(R.string.controls_media_close_session, appName);
} else {
text = mContext.getString(R.string.controls_media_active_session);
}
gutsViewHolder.getGutsText().setText(text);
// Dismiss button
gutsViewHolder.getDismissText().setVisibility(isDismissible ? View.VISIBLE : View.GONE);
gutsViewHolder.getDismiss().setEnabled(isDismissible);
gutsViewHolder.getDismiss().setOnClickListener(v -> {
if (mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) return;
logSmartspaceCardReported(SMARTSPACE_CARD_DISMISS_EVENT);
mLogger.logLongPressDismiss(mUid, mPackageName, mInstanceId);
onDismissClickedRunnable.run();
});
// Cancel button
TextView cancelText = gutsViewHolder.getCancelText();
if (isDismissible) {
cancelText.setBackground(mContext.getDrawable(R.drawable.qs_media_outline_button));
} else {
cancelText.setBackground(mContext.getDrawable(R.drawable.qs_media_solid_button));
}
gutsViewHolder.getCancel().setOnClickListener(v -> {
if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) {
closeGuts();
}
});
gutsViewHolder.setDismissible(isDismissible);
// Settings button
gutsViewHolder.getSettings().setOnClickListener(v -> {
if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) {
mLogger.logLongPressSettings(mUid, mPackageName, mInstanceId);
mActivityStarter.startActivity(SETTINGS_INTENT, /* dismissShade= */true);
}
});
}
/**
* Close the guts for this player.
*
* @param immediate {@code true} if it should be closed without animation
*/
public void closeGuts(boolean immediate) {
if (mMediaViewHolder != null) {
mMediaViewHolder.marquee(false, mMediaViewController.GUTS_ANIMATION_DURATION);
} else if (mRecommendationViewHolder != null) {
mRecommendationViewHolder.marquee(false, mMediaViewController.GUTS_ANIMATION_DURATION);
}
mMediaViewController.closeGuts(immediate);
if (mMediaViewHolder != null) {
bindPlayerContentDescription(mMediaData);
} else if (mRecommendationViewHolder != null) {
bindRecommendationContentDescription(mRecommendationData);
}
}
private void closeGuts() {
closeGuts(false);
}
private void openGuts() {
if (mMediaViewHolder != null) {
mMediaViewHolder.marquee(true, mMediaViewController.GUTS_ANIMATION_DURATION);
} else if (mRecommendationViewHolder != null) {
mRecommendationViewHolder.marquee(true, mMediaViewController.GUTS_ANIMATION_DURATION);
}
mMediaViewController.openGuts();
if (mMediaViewHolder != null) {
bindPlayerContentDescription(mMediaData);
} else if (mRecommendationViewHolder != null) {
bindRecommendationContentDescription(mRecommendationData);
}
mLogger.logLongPressOpen(mUid, mPackageName, mInstanceId);
}
/**
* Scale artwork to fill the background of the panel
*/
@UiThread
private Drawable getScaledBackground(Icon icon, int width, int height) {
if (icon == null) {
return null;
}
Drawable drawable = icon.loadDrawable(mContext);
Rect bounds = new Rect(0, 0, width, height);
if (bounds.width() > width || bounds.height() > height) {
float offsetX = (bounds.width() - width) / 2.0f;
float offsetY = (bounds.height() - height) / 2.0f;
bounds.offset((int) -offsetX, (int) -offsetY);
}
drawable.setBounds(bounds);
return drawable;
}
/**
* Scale artwork to fill the background of media covers in recommendation card.
*/
@UiThread
private Drawable getScaledRecommendationCover(Icon artworkIcon, int width, int height) {
if (width == 0 || height == 0) {
return null;
}
if (artworkIcon != null) {
Bitmap bitmap;
if (artworkIcon.getType() == Icon.TYPE_BITMAP
|| artworkIcon.getType() == Icon.TYPE_ADAPTIVE_BITMAP) {
Bitmap artworkBitmap = artworkIcon.getBitmap();
if (artworkBitmap != null) {
bitmap = Bitmap.createScaledBitmap(artworkIcon.getBitmap(), width,
height, false);
return new BitmapDrawable(mContext.getResources(), bitmap);
}
}
}
return null;
}
/**
* Get the current media controller
*
* @return the controller
*/
public MediaController getController() {
return mController;
}
/**
* Check whether the media controlled by this player is currently playing
*
* @return whether it is playing, or false if no controller information
*/
public boolean isPlaying() {
return isPlaying(mController);
}
/**
* Check whether the given controller is currently playing
*
* @param controller media controller to check
* @return whether it is playing, or false if no controller information
*/
protected boolean isPlaying(MediaController controller) {
if (controller == null) {
return false;
}
PlaybackState state = controller.getPlaybackState();
if (state == null) {
return false;
}
return (state.getState() == PlaybackState.STATE_PLAYING);
}
private ColorMatrixColorFilter getGrayscaleFilter() {
ColorMatrix matrix = new ColorMatrix();
matrix.setSaturation(0);
return new ColorMatrixColorFilter(matrix);
}
private void setVisibleAndAlpha(ConstraintSet set, int actionId, boolean visible) {
setVisibleAndAlpha(set, actionId, visible, ConstraintSet.GONE);
}
private void setVisibleAndAlpha(ConstraintSet set, int actionId, boolean visible,
int notVisibleValue) {
set.setVisibility(actionId, visible ? ConstraintSet.VISIBLE : notVisibleValue);
set.setAlpha(actionId, visible ? 1.0f : 0.0f);
}
private void setSmartspaceRecItemOnClickListener(
@NonNull View view,
@NonNull SmartspaceAction action,
int interactedSubcardRank) {
if (view == null || action == null || action.getIntent() == null
|| action.getIntent().getExtras() == null) {
Log.e(TAG, "No tap action can be set up");
return;
}
view.setOnClickListener(v -> {
if (mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) return;
if (interactedSubcardRank == -1) {
mLogger.logRecommendationCardTap(mPackageName, mInstanceId);
} else {
mLogger.logRecommendationItemTap(mPackageName, mInstanceId, interactedSubcardRank);
}
logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT,
interactedSubcardRank,
mSmartspaceMediaItemsCount);
if (shouldSmartspaceRecItemOpenInForeground(action)) {
// Request to unlock the device if the activity needs to be opened in foreground.
mActivityStarter.postStartActivityDismissingKeyguard(
action.getIntent(),
0 /* delay */,
buildLaunchAnimatorController(
mRecommendationViewHolder.getRecommendations()));
} else {
// Otherwise, open the activity in background directly.
view.getContext().startActivity(action.getIntent());
}
// Automatically scroll to the active player once the media is loaded.
mMediaCarouselController.setShouldScrollToKey(true);
});
}
/** Returns if the Smartspace action will open the activity in foreground. */
private boolean shouldSmartspaceRecItemOpenInForeground(SmartspaceAction action) {
if (action == null || action.getIntent() == null
|| action.getIntent().getExtras() == null) {
return false;
}
String intentString = action.getIntent().getExtras().getString(EXTRAS_SMARTSPACE_INTENT);
if (intentString == null) {
return false;
}
try {
Intent wrapperIntent = Intent.parseUri(intentString, Intent.URI_INTENT_SCHEME);
return wrapperIntent.getBooleanExtra(KEY_SMARTSPACE_OPEN_IN_FOREGROUND, false);
} catch (URISyntaxException e) {
Log.wtf(TAG, "Failed to create intent from URI: " + intentString);
e.printStackTrace();
}
return false;
}
/**
* Get the surface given the current end location for MediaViewController
* @return surface used for Smartspace logging
*/
protected int getSurfaceForSmartspaceLogging() {
int currentEndLocation = mMediaViewController.getCurrentEndLocation();
if (currentEndLocation == MediaHierarchyManager.LOCATION_QQS
|| currentEndLocation == MediaHierarchyManager.LOCATION_QS) {
return SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__SHADE;
} else if (currentEndLocation == MediaHierarchyManager.LOCATION_LOCKSCREEN) {
return SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__LOCKSCREEN;
} else if (currentEndLocation == MediaHierarchyManager.LOCATION_DREAM_OVERLAY) {
return SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__DREAM_OVERLAY;
}
return SysUiStatsLog.SMART_SPACE_CARD_REPORTED__DISPLAY_SURFACE__DEFAULT_SURFACE;
}
private void logSmartspaceCardReported(int eventId) {
logSmartspaceCardReported(eventId,
/* interactedSubcardRank */ 0,
/* interactedSubcardCardinality */ 0);
}
private void logSmartspaceCardReported(int eventId,
int interactedSubcardRank, int interactedSubcardCardinality) {
mMediaCarouselController.logSmartspaceCardReported(eventId,
mSmartspaceId,
mUid,
new int[]{getSurfaceForSmartspaceLogging()},
interactedSubcardRank,
interactedSubcardCardinality);
}
}