Refactored the Media Player management
Previously all media instances had their own management
and it could easily happen that certain players
would get out of sync as a result. We now have
a unified architecture to listen to media notifications
and inflating a singular UI
Test: atest SystemUiTests
Bug: 154137987
Change-Id: I9f807e6431dd7cb54ca9b6d983379d770a281f31
diff --git a/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt b/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt
new file mode 100644
index 0000000..f60c012
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt
@@ -0,0 +1,67 @@
+/*
+ * 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
+
+import android.view.View
+import com.android.internal.util.ContrastColorUtil
+import com.android.systemui.statusbar.NotificationMediaManager
+import com.android.systemui.statusbar.notification.stack.MediaHeaderView
+import com.android.systemui.statusbar.phone.KeyguardBypassController
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * A class that controls the media notifications on the lock screen, handles its visibility and
+ * is responsible for the embedding of he media experience.
+ */
+@Singleton
+class KeyguardMediaController @Inject constructor(
+ private val mediaHierarchyManager: MediaHierarchyManager,
+ private val notifMediaManager: NotificationMediaManager,
+ private val bypassController: KeyguardBypassController
+) {
+ private var view: MediaHeaderView? = null
+
+ init {
+ notifMediaManager.addCallback(object : NotificationMediaManager.MediaListener {
+ override fun onMediaDataLoaded(key: String, data: MediaData) {
+ updateVisibility()
+ }
+
+ override fun onMediaDataRemoved(key: String) {
+ updateVisibility()
+ }
+ })
+ }
+
+ /**
+ * Attach this controller to a media view, initializing its state
+ */
+ fun attach(mediaControlsView: MediaHeaderView) {
+ view = mediaControlsView
+ val hostView = mediaHierarchyManager.createMediaHost(
+ MediaHierarchyManager.LOCATION_LOCKSCREEN)
+ mediaControlsView.setMediaHost(hostView)
+ updateVisibility()
+ }
+
+ private fun updateVisibility() {
+ val shouldBeVisible = notifMediaManager.hasActiveMedia() && !bypassController.bypassEnabled
+ view?.visibility = if (shouldBeVisible) View.VISIBLE else View.GONE
+ }
+}
+
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index 9adfe7d..0297c90 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -16,6 +16,8 @@
package com.android.systemui.media;
+import static com.android.systemui.util.SysuiLifecycle.viewAttachLifecycle;
+
import android.annotation.LayoutRes;
import android.app.PendingIntent;
import android.content.ComponentName;
@@ -28,9 +30,9 @@
import android.content.res.ColorStateList;
import android.graphics.Bitmap;
import android.graphics.ImageDecoder;
+import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
-import android.graphics.drawable.Icon;
import android.graphics.drawable.RippleDrawable;
import android.media.MediaDescription;
import android.media.MediaMetadata;
@@ -50,10 +52,12 @@
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
+import android.widget.SeekBar;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.constraintlayout.motion.widget.MotionLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
@@ -65,6 +69,9 @@
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.qs.QSMediaBrowser;
import com.android.systemui.util.Assert;
+import com.android.systemui.util.concurrency.DelayableExecutor;
+
+import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.List;
@@ -76,6 +83,18 @@
public class MediaControlPanel {
private static final String TAG = "MediaControlPanel";
@Nullable private final LocalMediaManager mLocalMediaManager;
+
+ // Button IDs for QS controls
+ static final int[] ACTION_IDS = {
+ R.id.action0,
+ R.id.action1,
+ R.id.action2,
+ R.id.action3,
+ R.id.action4
+ };
+
+ private final SeekBarViewModel mSeekBarViewModel;
+ private final SeekBarObserver mSeekBarObserver;
private final Executor mForegroundExecutor;
protected final Executor mBackgroundExecutor;
private final ActivityStarter mActivityStarter;
@@ -92,8 +111,6 @@
private boolean mIsRegistered = false;
private String mKey;
- private final int[] mActionIds;
-
public static final String MEDIA_PREFERENCES = "media_control_prefs";
public static final String MEDIA_PREFERENCE_KEY = "browser_components";
private SharedPreferences mSharedPrefs;
@@ -101,15 +118,6 @@
private boolean mIsRemotePlayback;
private QSMediaBrowser mQSMediaBrowser;
- // Button IDs used in notifications
- protected static final int[] NOTIF_ACTION_IDS = {
- com.android.internal.R.id.action0,
- com.android.internal.R.id.action1,
- com.android.internal.R.id.action2,
- com.android.internal.R.id.action3,
- com.android.internal.R.id.action4
- };
-
// URI fields to try loading album art from
private static final String[] ART_URIS = {
MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
@@ -183,12 +191,11 @@
* @param activityStarter activity starter
*/
public MediaControlPanel(Context context, ViewGroup parent,
- @Nullable LocalMediaManager routeManager, @LayoutRes int layoutId, int[] actionIds,
- Executor foregroundExecutor, Executor backgroundExecutor,
- ActivityStarter activityStarter) {
+ @Nullable LocalMediaManager routeManager, Executor foregroundExecutor,
+ DelayableExecutor backgroundExecutor, ActivityStarter activityStarter) {
mContext = context;
LayoutInflater inflater = LayoutInflater.from(mContext);
- mMediaNotifView = (MotionLayout) inflater.inflate(layoutId, parent, false);
+ mMediaNotifView = (MotionLayout) inflater.inflate(R.layout.qs_media_panel, parent, false);
// TODO(b/150854549): removeOnAttachStateChangeListener when this doesn't inflate views
// mStateListener shouldn't need to be unregistered since this object shares the same
// lifecycle with the inflated view. It would be better, however, if this controller used an
@@ -196,21 +203,42 @@
// mStateListener to be unregistered in detach.
mMediaNotifView.addOnAttachStateChangeListener(mStateListener);
mLocalMediaManager = routeManager;
- mActionIds = actionIds;
mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor;
mActivityStarter = activityStarter;
+ mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
+ mSeekBarObserver = new SeekBarObserver(getView());
+ // Can't use the viewAttachLifecycle of media player because remove/add is used to adjust
+ // priority of players. As soon as it is removed, the lifecycle will end and the seek bar
+ // will stop updating. So, use the lifecycle of the parent instead.
+ // TODO: this parent is also detached, need to fix that
+ mSeekBarViewModel.getProgress().observe(viewAttachLifecycle(parent), mSeekBarObserver);
+ SeekBar bar = getView().findViewById(R.id.media_progress_bar);
+ bar.setOnSeekBarChangeListener(mSeekBarViewModel.getSeekBarListener());
+ bar.setOnTouchListener(mSeekBarViewModel.getSeekBarTouchListener());
}
/**
* Get the view used to display media controls
* @return the view
*/
- public View getView() {
+ public MotionLayout getView() {
return mMediaNotifView;
}
/**
+ * Sets the listening state of the player.
+ *
+ * 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);
+ }
+
+ /**
* Get the context
* @return context
*/
@@ -219,20 +247,12 @@
}
/**
- * Update the media panel view for the given media session
- * @param token
- * @param iconDrawable
- * @param largeIcon
- * @param iconColor
- * @param bgColor
- * @param contentIntent
- * @param appNameString
- * @param key
+ * Bind this view based on the data given
*/
- public void setMediaSession(MediaSession.Token token, Drawable iconDrawable, Icon largeIcon,
- int iconColor, int bgColor, PendingIntent contentIntent, String appNameString,
- String key) {
- // Ensure that component names are updated if token has changed
+ public void bind(@NotNull MediaData data) {
+ mToken = data.getToken();
+ mForegroundColor = data.getForegroundColor();
+ mBackgroundColor = data.getBackgroundColor();
if (mToken == null || !mToken.equals(token)) {
if (mQSMediaBrowser != null) {
Log.d(TAG, "Disconnecting old media browser");
@@ -244,8 +264,6 @@
mCheckedForResumption = false;
}
- mForegroundColor = iconColor;
- mBackgroundColor = bgColor;
mController = new MediaController(mContext, mToken);
mKey = key;
@@ -258,6 +276,7 @@
PackageManager pm = mContext.getPackageManager();
Intent resumeIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE);
List<ResolveInfo> resumeInfo = pm.queryIntentServices(resumeIntent, 0);
+ // TODO: look into this resumption
if (resumeInfo != null) {
for (ResolveInfo inf : resumeInfo) {
if (inf.serviceInfo.packageName.equals(mController.getPackageName())) {
@@ -272,20 +291,33 @@
mController.registerCallback(mSessionCallback);
+ albumView.setImageDrawable(data.getArtwork());
+
mMediaNotifView.setBackgroundTintList(ColorStateList.valueOf(mBackgroundColor));
// Click action
- if (contentIntent != null) {
+ PendingIntent clickIntent = data.getClickIntent();
+ if (clickIntent != null) {
mMediaNotifView.setOnClickListener(v -> {
- mActivityStarter.postStartActivityDismissingKeyguard(contentIntent);
+ mActivityStarter.postStartActivityDismissingKeyguard(clickIntent);
});
}
// App icon
- ImageView appIcon = mMediaNotifView.findViewById(R.id.icon);
+ ImageView appIcon = mMediaNotifView.requireViewById(R.id.icon);
+ // TODO: look at iconDrawable
+ Drawable iconDrawable = data.getAppIcon();
iconDrawable.setTint(mForegroundColor);
appIcon.setImageDrawable(iconDrawable);
+ TextView titleText = mMediaNotifView.requireViewById(R.id.header_title);
+ titleText.setText(data.getSong());
+ TextView appName = mMediaNotifView.requireViewById(R.id.app_name);
+ appName.setText(data.getApp());
+ appName.setTextColor(mForegroundColor);
+ TextView artistText = mMediaNotifView.requireViewById(R.id.header_artist);
+ artistText.setText(data.getArtist());
+ artistText.setTextColor(mForegroundColor);
// Transfer chip
mSeamless = mMediaNotifView.findViewById(R.id.media_seamless);
if (mSeamless != null) {
@@ -313,6 +345,54 @@
mIsRemotePlayback = false;
}
+ ConstraintSet expandedSet = mMediaNotifView.getConstraintSet(R.id.expanded);
+ ConstraintSet collapsedSet = mMediaNotifView.getConstraintSet(R.id.collapsed);
+ List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact();
+ // Media controls
+ int i = 0;
+ List<MediaAction> actionIcons = data.getActions();
+ for (; i < actionIcons.size() && i < ACTION_IDS.length; i++) {
+ final ImageButton button = mMediaNotifView.findViewById(ACTION_IDS[i]);
+ MediaAction mediaAction = actionIcons.get(i);
+ button.setImageDrawable(mediaAction.getDrawable());
+ button.setContentDescription(mediaAction.getContentDescription());
+ button.setImageTintList(ColorStateList.valueOf(mForegroundColor));
+ PendingIntent actionIntent = mediaAction.getIntent();
+
+ if (mMediaNotifView.getBackground() instanceof IlluminationDrawable) {
+ ((IlluminationDrawable) mMediaNotifView.getBackground())
+ .setupTouch(button, mMediaNotifView);
+ }
+
+ button.setOnClickListener(v -> {
+ if (actionIntent != null) {
+ try {
+ actionIntent.send();
+ } catch (PendingIntent.CanceledException e) {
+ e.printStackTrace();
+ }
+ }
+ });
+ boolean visibleInCompat = actionsWhenCollapsed.contains(i);
+ collapsedSet.setVisibility(ACTION_IDS[i],
+ visibleInCompat ? ConstraintSet.VISIBLE : ConstraintSet.GONE);
+ expandedSet.setVisibility(ACTION_IDS[i], ConstraintSet.VISIBLE);
+ }
+
+ // Hide any unused buttons
+ for (; i < ACTION_IDS.length; i++) {
+ expandedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
+ collapsedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
+ }
+
+ // Seek Bar
+ final MediaController controller = new MediaController(getContext(), data.getToken());
+ mBackgroundExecutor.execute(
+ () -> mSeekBarViewModel.updateController(controller, data.getForegroundColor()));
+
+ // Set up long press menu
+ // TODO: b/156036025 bring back media guts
+
makeActive();
// App title (not in mini player)
@@ -614,15 +694,16 @@
*/
protected void resetButtons() {
// Hide all the old buttons
- for (int i = 0; i < mActionIds.length; i++) {
- ImageButton thisBtn = mMediaNotifView.findViewById(mActionIds[i]);
- if (thisBtn != null) {
- thisBtn.setVisibility(View.GONE);
- }
+
+ ConstraintSet expandedSet = mMediaNotifView.getConstraintSet(R.id.expanded);
+ ConstraintSet collapsedSet = mMediaNotifView.getConstraintSet(R.id.collapsed);
+ for (int i = 1; i < ACTION_IDS.length; i++) {
+ expandedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
+ collapsedSet.setVisibility(ACTION_IDS[i], ConstraintSet.GONE);
}
// Add a restart button
- ImageButton btn = mMediaNotifView.findViewById(mActionIds[0]);
+ ImageButton btn = mMediaNotifView.findViewById(ACTION_IDS[0]);
btn.setOnClickListener(v -> {
Log.d(TAG, "Attempting to restart session");
if (mQSMediaBrowser != null) {
@@ -644,7 +725,20 @@
});
btn.setImageDrawable(mContext.getResources().getDrawable(R.drawable.lb_ic_play));
btn.setImageTintList(ColorStateList.valueOf(mForegroundColor));
- btn.setVisibility(View.VISIBLE);
+ expandedSet.setVisibility(ACTION_IDS[0], ConstraintSet.VISIBLE);
+ collapsedSet.setVisibility(ACTION_IDS[0], ConstraintSet.VISIBLE);
+
+ mSeekBarViewModel.clearController();
+ // TODO: fix guts
+ // View guts = mMediaNotifView.findViewById(R.id.media_guts);
+ View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options);
+
+ mMediaNotifView.setOnLongClickListener(v -> {
+ // Replace player view with close/cancel view
+// guts.setVisibility(View.GONE);
+ options.setVisibility(View.VISIBLE);
+ return true; // consumed click
+ });
}
private void makeActive() {
@@ -668,7 +762,6 @@
mIsRegistered = false;
}
}
-
/**
* Verify that we can connect to the given component with a MediaBrowser, and if so, add that
* component to the list of resumption components
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
new file mode 100644
index 0000000..8165362
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
+
+import android.app.PendingIntent
+import android.graphics.drawable.Drawable
+import android.media.session.MediaSession
+
+/** State of a media view. */
+data class MediaData(
+ val initialized: Boolean = false,
+ val foregroundColor: Int,
+ val backgroundColor: Int,
+ val app: String?,
+ val appIcon: Drawable?,
+ val artist: String?,
+ val song: String?,
+ val artwork: Drawable?,
+ val actions: List<MediaAction>,
+ val actionsToShowInCompact: List<Int>,
+ val packageName: String?,
+ val token: MediaSession.Token?,
+ val clickIntent: PendingIntent?
+)
+
+/** State of a media action. */
+data class MediaAction(
+ val drawable: Drawable?,
+ val intent: PendingIntent?,
+ val contentDescription: CharSequence?
+)
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
new file mode 100644
index 0000000..298a5fd
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
@@ -0,0 +1,206 @@
+/*
+ * 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
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.media.MediaMetadata
+import android.media.session.MediaSession
+import android.provider.Settings
+import android.service.notification.StatusBarNotification
+import androidx.core.graphics.drawable.RoundedBitmapDrawable
+import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
+import com.android.internal.util.ContrastColorUtil
+import com.android.systemui.R
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.statusbar.NotificationMediaManager
+import com.android.systemui.statusbar.notification.MediaNotificationProcessor
+import java.util.*
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.collections.LinkedHashMap
+
+/**
+ * A class that facilitates management and loading of Media Data, ready for binding.
+ */
+@Singleton
+class MediaDataManager @Inject constructor(
+ private val context: Context,
+ private val mediaControllerFactory: MediaControllerFactory,
+ @Background private val backgroundExecutor: Executor,
+ @Main private val foregroundExcecutor: Executor
+) {
+
+ lateinit var listener: NotificationMediaManager.MediaListener
+ private var albumArtSize: Int = 0
+ private var albumArtRadius: Int = 0
+ private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
+
+ init {
+ loadDimens()
+ }
+
+ private fun loadDimens() {
+ albumArtRadius = context.resources.getDimensionPixelSize(R.dimen.qs_media_corner_radius)
+ albumArtSize = context.resources.getDimensionPixelSize(R.dimen.qs_media_album_size)
+ }
+
+ fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
+ if (isMediaNotification(sbn)) {
+ if (!mediaEntries.containsKey(key)) {
+ mediaEntries.put(key, LOADING)
+ }
+ loadMediaData(key, sbn)
+ } else {
+ onNotificationRemoved(key)
+ }
+ }
+
+ private fun loadMediaData(key: String, sbn: StatusBarNotification) {
+ backgroundExecutor.execute {
+ loadMediaDataInBg(key, sbn)
+ }
+ }
+
+ private fun loadMediaDataInBg(key: String, sbn: StatusBarNotification) {
+ val token = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
+ as MediaSession.Token?
+ val metadata = mediaControllerFactory.create(token).metadata
+
+ if (metadata == null) {
+ // TODO: handle this better, removing media notification
+ return
+ }
+
+ // Foreground and Background colors computed from album art
+ val notif: Notification = sbn.notification
+ var fgColor = notif.color
+ var bgColor = -1
+ var artworkBitmap = metadata.getBitmap(MediaMetadata.METADATA_KEY_ART)
+ if (artworkBitmap == null) {
+ artworkBitmap = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
+ }
+ // TODO: load media data from uri
+ if (artworkBitmap != null) {
+ // If we have art, get colors from that
+ val p = MediaNotificationProcessor.generateArtworkPaletteBuilder(artworkBitmap)
+ .generate()
+ val swatch = MediaNotificationProcessor.findBackgroundSwatch(p)
+ bgColor = swatch.rgb
+ fgColor = MediaNotificationProcessor.selectForegroundColor(bgColor, p)
+ }
+ // Make sure colors will be legible
+ val isDark = !ContrastColorUtil.isColorLight(bgColor)
+ fgColor = ContrastColorUtil.resolveContrastColor(context, fgColor, bgColor,
+ isDark)
+ fgColor = ContrastColorUtil.ensureTextContrast(fgColor, bgColor, isDark)
+
+ // Album art
+ var artwork: RoundedBitmapDrawable? = null
+ if (artworkBitmap != null) {
+ val original = artworkBitmap.copy(Bitmap.Config.ARGB_8888, true)
+ val scaled = Bitmap.createScaledBitmap(original, albumArtSize, albumArtSize,
+ false)
+ artwork = RoundedBitmapDrawableFactory.create(context.resources, scaled)
+ artwork.cornerRadius = albumArtRadius.toFloat()
+ }
+
+ // App name
+ val builder = Notification.Builder.recoverBuilder(context, notif)
+ val app = builder.loadHeaderAppName()
+
+ // App Icon
+ val smallIconDrawable: Drawable = sbn.notification.smallIcon.loadDrawable(context)
+
+ // Song name
+ val song: String = metadata.getString(MediaMetadata.METADATA_KEY_TITLE)
+
+ // Artist name
+ val artist: String = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
+
+ // Control buttons
+ val actionIcons: MutableList<MediaAction> = ArrayList()
+ val actions = notif.actions
+ val actionsToShowCollapsed = notif.extras.getIntArray(
+ Notification.EXTRA_COMPACT_ACTIONS)?.toList() ?: emptyList()
+ // TODO: b/153736623 look into creating actions when this isn't a media style notification
+
+ val packageContext: Context = sbn.getPackageContext(context)
+ for (action in actions) {
+ val mediaAction = MediaAction(
+ action.getIcon().loadDrawable(packageContext),
+ action.actionIntent,
+ action.title)
+ actionIcons.add(mediaAction)
+ }
+
+ foregroundExcecutor.execute {
+ onMediaDataLoaded(key, MediaData(true, fgColor, bgColor, app, smallIconDrawable, artist,
+ song, artwork, actionIcons, actionsToShowCollapsed, sbn.packageName, token,
+ notif.contentIntent))
+ }
+
+ }
+
+ fun onMediaDataLoaded(key: String, data: MediaData) {
+ if (mediaEntries.containsKey(key)) {
+ // Otherwise this was removed already
+ mediaEntries.put(key, data)
+ listener.onMediaDataLoaded(key, data)
+ }
+ }
+
+ fun onNotificationRemoved(key: String) {
+ val removed = mediaEntries.remove(key)
+ if (removed != null) {
+ listener.onMediaDataRemoved(key)
+ }
+ }
+
+ private fun isMediaNotification(sbn: StatusBarNotification) : Boolean {
+ if (!useUniversalMediaPlayer()) {
+ return false
+ }
+ if (!sbn.notification.hasMediaSession()) {
+ return false
+ }
+ val notificationStyle = sbn.notification.notificationStyle
+ if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle)
+ || Notification.MediaStyle::class.java.equals(notificationStyle)) {
+ return true
+ }
+ return false
+ }
+
+ /**
+ * are we using the universal media player
+ */
+ private fun useUniversalMediaPlayer()
+ = Settings.System.getInt(context.contentResolver, "qs_media_player", 1) > 0
+
+ /**
+ * Are there any media notifications active?
+ */
+ fun hasActiveMedia() = mediaEntries.size > 0
+}
+
+private val LOADING = MediaData(false, 0, 0, null, null, null, null, null,
+ emptyList(), emptyList(), null, null, null)
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt
new file mode 100644
index 0000000..4a650ab
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt
@@ -0,0 +1,216 @@
+/*
+ * 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
+
+import android.annotation.IntDef
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.LinearLayout
+
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.settingslib.media.InfoMediaManager
+import com.android.settingslib.media.LocalMediaManager
+import com.android.systemui.R
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.plugins.statusbar.StatusBarStateController
+import com.android.systemui.statusbar.NotificationMediaManager
+import com.android.systemui.statusbar.StatusBarState
+import com.android.systemui.statusbar.notification.VisualStabilityManager
+import com.android.systemui.statusbar.phone.KeyguardBypassController
+import com.android.systemui.util.animation.UniqueObjectHost
+import com.android.systemui.util.concurrency.DelayableExecutor
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * This manager is responsible for placement of the unique media view between the different hosts
+ * and animate the positions of the views to achieve seamless transitions.
+ */
+@Singleton
+class MediaHierarchyManager @Inject constructor(
+ private val context: Context,
+ @Main private val foregroundExecutor: Executor,
+ @Background private val backgroundExecutor: DelayableExecutor,
+ private val localBluetoothManager: LocalBluetoothManager?,
+ private val visualStabilityManager: VisualStabilityManager,
+ private val statusBarStateController: StatusBarStateController,
+ private val bypassController: KeyguardBypassController,
+ mediaManager: NotificationMediaManager
+) {
+ private val mediaCarousel: ViewGroup
+ private val mediaContent: ViewGroup
+ private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
+ private val mediaHosts = arrayOfNulls<ViewGroup>(LOCATION_LOCKSCREEN + 1)
+ private val visualStabilityCallback = ::reorderAllPlayers
+ private var currentAttachmentLocation = -1
+
+ var shouldListen = true
+ set(value) {
+ field = value
+ for (player in mediaPlayers.values) {
+ player.setListening(shouldListen)
+ }
+ }
+
+ init {
+ mediaCarousel = inflateMediaCarousel()
+ mediaContent = mediaCarousel.findViewById(R.id.media_carousel)
+ mediaManager.addCallback(object : NotificationMediaManager.MediaListener {
+ override fun onMediaDataLoaded(key: String, data: MediaData) {
+ updateView(key, data)
+ }
+
+ override fun onMediaDataRemoved(key: String) {
+ val removed = mediaPlayers.remove(key)
+ removed?.apply {
+ mediaContent.removeView(removed.view)
+ }
+ }
+ })
+ }
+
+ private fun inflateMediaCarousel(): ViewGroup {
+ return LayoutInflater.from(context).inflate(
+ R.layout.media_carousel, UniqueObjectHost(context), false) as ViewGroup
+ }
+
+ private fun reorderAllPlayers() {
+ for (mediaPlayer in mediaPlayers.values) {
+ val view = mediaPlayer.view
+ if (mediaPlayer.isPlaying && mediaContent.indexOfChild(view) != 0) {
+ mediaContent.removeView(view)
+ mediaContent.addView(view, 0)
+ }
+ }
+ }
+
+ private fun updateView(key: String, data: MediaData) {
+ var existingPlayer = mediaPlayers[key]
+ if (existingPlayer == null) {
+ // Set up listener for device changes
+ // TODO: integrate with MediaTransferManager?
+ val imm = InfoMediaManager(context, data.packageName,
+ null /* notification */, localBluetoothManager)
+ val routeManager = LocalMediaManager(context, localBluetoothManager,
+ imm, data.packageName)
+
+ existingPlayer = MediaControlPanel(context, mediaContent, routeManager,
+ foregroundExecutor, backgroundExecutor)
+ mediaPlayers[key] = existingPlayer
+ val padding = context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
+ val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT)
+ lp.marginStart = padding
+ lp.marginEnd = padding
+ existingPlayer.view.setLayoutParams(lp)
+ existingPlayer.setListening(shouldListen)
+ if (existingPlayer.isPlaying) {
+ mediaContent.addView(existingPlayer.view, 0)
+ } else {
+ mediaContent.addView(existingPlayer.view)
+ }
+ } else if (existingPlayer.isPlaying &&
+ mediaContent.indexOfChild(existingPlayer.view) != 0) {
+ if (visualStabilityManager.isReorderingAllowed) {
+ mediaContent.removeView(existingPlayer.view)
+ mediaContent.addView(existingPlayer.view, 0)
+ } else {
+ visualStabilityManager.addReorderingAllowedCallback(visualStabilityCallback)
+ }
+ }
+ existingPlayer.bind(data)
+ }
+
+ /**
+ * Create a media host view which can be attached to a view hierarchy and where the player
+ * will be placed in when the host is the currently desired state.
+ *
+ * @return the hostView associated with this location
+ */
+ fun createMediaHost(@MediaLocation location: Int) : ViewGroup {
+ val viewHost = UniqueObjectHost(context)
+ mediaHosts[location] = viewHost
+ if (location == currentAttachmentLocation) {
+ // In case we are overriding a view that is already visible, make sure we attach it
+ // to this new host view in the below call
+ currentAttachmentLocation = -1
+ }
+ updateAttachmentLocation(animate = false)
+ return viewHost
+ }
+
+ private fun updateAttachmentLocation(animate: Boolean) {
+ var desiredLocation = calculateLocation()
+ if (desiredLocation != currentAttachmentLocation) {
+ val host = mediaHosts[desiredLocation]
+ host?.apply {
+ if (currentAttachmentLocation >= 0) {
+ val currentHost = mediaHosts[currentAttachmentLocation]
+ currentHost?.removeView(mediaCarousel)
+ }
+ host.addView(mediaCarousel)
+ // TODO: handle animation / repositioning etc
+ currentAttachmentLocation = desiredLocation
+ }
+ }
+ }
+
+ @MediaLocation
+ private fun calculateLocation() : Int {
+ val onLockscreen = (!bypassController.bypassEnabled
+ && (statusBarStateController.state == StatusBarState.KEYGUARD
+ || statusBarStateController.state == StatusBarState.FULLSCREEN_USER_SWITCHER))
+ return when {
+ qsExpansion > 0.0f -> LOCATION_QS
+ onLockscreen -> LOCATION_LOCKSCREEN
+ else -> LOCATION_QQS
+ }
+ }
+
+ /**
+ * The expansion of quick settings
+ */
+ var qsExpansion: Float = 0.0f
+ set(value) {
+ field = value
+ updateAttachmentLocation(animate = false)
+ }
+
+ @IntDef(prefix = ["LOCATION_"], value = [LOCATION_QS, LOCATION_QQS, LOCATION_LOCKSCREEN])
+ @Retention(AnnotationRetention.SOURCE)
+ annotation class MediaLocation
+
+ companion object {
+ /**
+ * Attached in expanded quick settings
+ */
+ const val LOCATION_QS = 0
+
+ /**
+ * Attached in the collapsed QS
+ */
+ const val LOCATION_QQS = 1
+
+ /**
+ * Attached on the lock screen
+ */
+ const val LOCATION_LOCKSCREEN = 2
+ }
+}
\ No newline at end of file